@master0071/dsh-systemone 0.4.4 → 0.6.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/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: '(空 = 只用内置场景)',
@@ -61,6 +61,9 @@ window.__ModuleLoader__.load({
61
61
  providerMock: '本地模拟',
62
62
  apiKey: 'API Key',
63
63
  apiKeyHint: '也可用环境变量 UNISOUND_API_KEY / SYSTEMONE_API_KEY。出于安全,已保存的 Key 不会回显。',
64
+ apiKeyRef: '凭证引用名(推荐)',
65
+ apiKeyRefHint: '从 ~/.dsh/.credentials.yaml 的 refs.<名字> 读取密钥,配置里只留名字、不落明文。留空则依次尝试 SYSTEMONE_API_KEY / UNISOUND_API_KEY / TYPESAFE_API_KEY。',
66
+ apiKeyRefPlaceholder: '例如 SYSTEMONE_API_KEY',
64
67
  apiKeyPlaceholder: '留空表示不修改已保存的值',
65
68
  baseUrl: 'API 基础地址',
66
69
  baseUrlHint: '默认 https://maas-api.unisound.com/v1',
@@ -72,12 +75,20 @@ window.__ModuleLoader__.load({
72
75
  timeoutMsHint: '单次决策请求的最长等待时间',
73
76
  minConfidence: '置信度阈值',
74
77
  minConfidenceHint: '任一答案低于该值时,结果会标记为建议人工复核(0~1)',
78
+ redact: '发送前脱敏',
79
+ redactHint: '开启后把 state 里的手机号/身份证/邮箱/银行卡号替换为 [类型] 标签再发给决策服务,判断语义保留',
80
+ redactOn: '开启(含隐私数据时建议)',
81
+ redactOff: '关闭',
75
82
  autoScenario: '固定场景(可选)',
76
- autoScenarioHint: '点右侧箭头可从 10 个内置场景里选,也可手填场景 id 或别名(如 software_dev / 开发);留空则每次自动选场景(多一次路由请求)',
83
+ autoScenarioHint: '点右侧箭头可从 11 个内置场景里选,也可手填场景 id 或别名(如 software_dev / 开发);留空则每次自动选场景(多一次路由请求)',
77
84
  autoInject: '注入通道',
78
85
  autoInjectHint: 'message=同一步生效;context=走宿主动态上下文,不写入会话历史',
79
86
  autoInjectMessage: '同步注入(message)',
80
87
  autoInjectContext: '动态上下文(context)',
88
+ autoFailMode: '失败语义',
89
+ autoFailModeHint: 'open=决策失败时放行(注入用途的正确默认);closed=决策失败时否决本轮(供将来做闸门用;仅对真失败生效,寒暄/低置信度等主动跳过不受影响;仅 message 通道会拦截)',
90
+ autoFailModeOpen: '放行(open)',
91
+ autoFailModeClosed: '拦截本轮(closed)',
81
92
  autoTimeoutMs: '自动决策超时(毫秒)',
82
93
  autoTimeoutMsHint: '超时即放行,绝不阻塞会话',
83
94
  autoMaxMessages: '捕获历史条数',
@@ -112,7 +123,7 @@ window.__ModuleLoader__.load({
112
123
  tabCustom: 'Custom scenarios',
113
124
  tabCustomHint: 'Define your own scenarios in JSON (including general intent recognition). Saving rebuilds the library immediately.',
114
125
  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.',
126
+ 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
127
  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
128
  insertTemplate: 'Insert intent template',
118
129
  jsonEmpty: '(empty = built-in scenarios only)',
@@ -131,6 +142,9 @@ window.__ModuleLoader__.load({
131
142
  providerMock: 'Local mock',
132
143
  apiKey: 'API key',
133
144
  apiKeyHint: 'UNISOUND_API_KEY / SYSTEMONE_API_KEY also work. A saved key is never echoed back.',
145
+ apiKeyRef: 'Credential ref (recommended)',
146
+ apiKeyRefHint: 'Read the key from refs.<name> in ~/.dsh/.credentials.yaml, so only a name lands in the config. Empty tries SYSTEMONE_API_KEY / UNISOUND_API_KEY / TYPESAFE_API_KEY in order.',
147
+ apiKeyRefPlaceholder: 'e.g. SYSTEMONE_API_KEY',
134
148
  apiKeyPlaceholder: 'Leave empty to keep the saved value',
135
149
  baseUrl: 'Base URL',
136
150
  baseUrlHint: 'Defaults to https://maas-api.unisound.com/v1',
@@ -142,12 +156,20 @@ window.__ModuleLoader__.load({
142
156
  timeoutMsHint: 'Longest wait for one decision request',
143
157
  minConfidence: 'Confidence threshold',
144
158
  minConfidenceHint: 'Below this value a result is flagged for human review (0~1)',
159
+ redact: 'Redact before send',
160
+ redactHint: 'Replace phone/ID-card/email/bank-card numbers in state with [type] tags before sending to the decision service; semantics preserved',
161
+ redactOn: 'On (recommended with PII)',
162
+ redactOff: 'Off',
145
163
  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)',
164
+ 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
165
  autoInject: 'Injection channel',
148
166
  autoInjectHint: 'message=applies in the same step; context=host dynamic context, not written to history',
149
167
  autoInjectMessage: 'Same step (message)',
150
168
  autoInjectContext: 'Dynamic context (context)',
169
+ autoFailMode: 'Failure semantics',
170
+ autoFailModeHint: 'open=pass through when the decision fails (correct default for injection); closed=veto the turn on failure (reserved for future gates; only real failures, deliberate skips still pass; message channel only)',
171
+ autoFailModeOpen: 'Pass through (open)',
172
+ autoFailModeClosed: 'Veto the turn (closed)',
151
173
  autoTimeoutMs: 'Auto decision timeout (ms)',
152
174
  autoTimeoutMsHint: 'On timeout the step proceeds — never blocks the session',
153
175
  autoMaxMessages: 'Captured history messages',
@@ -268,7 +290,7 @@ window.__ModuleLoader__.load({
268
290
  /**
269
291
  * 内置场景候选(供「固定场景」输入框的下拉提示)。
270
292
  *
271
- * 用 `<datalist>` 而不是 `<select>`:既能把 10 个内置场景列出来点选,
293
+ * 用 `<datalist>` 而不是 `<select>`:既能把 11 个内置场景列出来点选,
272
294
  * 又允许手填自定义场景 id 或别名(别名见 README 的场景表)。
273
295
  * 这里只放 id + 中文标题;id 用别名也能命中(findScenario 会按别名匹配)。
274
296
  */
@@ -283,16 +305,19 @@ window.__ModuleLoader__.load({
283
305
  { id: 'education', label: '教育内容 · 知识点、难度与合规' },
284
306
  { id: 'requirements', label: '需求与变更 · 优先级、风险与派发' },
285
307
  { id: 'software_dev', label: '软件开发 · 任务类型、复杂度与处置' },
308
+ { id: 'requirement_clarity', label: '需求预检 · 明确度与澄清点' },
286
309
  ]
287
310
 
288
311
  const SERVICE_FIELDS = [
289
312
  { key: 'provider', kind: 'select', options: ['unisound', 'http', 'mock'] },
313
+ { key: 'apiKeyRef', kind: 'text' },
290
314
  { key: 'apiKey', kind: 'password' },
291
315
  { key: 'baseUrl', kind: 'text' },
292
316
  { key: 'endpoint', kind: 'text' },
293
317
  { key: 'model', kind: 'text' },
294
318
  { key: 'timeoutMs', kind: 'number' },
295
319
  { key: 'minConfidence', kind: 'number' },
320
+ { key: 'redact', kind: 'select', options: ['false', 'true'] },
296
321
  ]
297
322
 
298
323
  /** 自定义场景编辑区(JSON)。 */
@@ -328,35 +353,57 @@ window.__ModuleLoader__.load({
328
353
  return { ok: false, error: `item:${at}`, ids, count: 0 }
329
354
  }
330
355
  const issues = []
356
+ const isPatch = spec.patch === true
331
357
  if (typeof spec.id !== 'string' || spec.id.trim() === '') issues.push('缺少 id')
332
- if (typeof spec.title !== 'string' || spec.title.trim() === '') issues.push('缺少 title')
358
+ if (!isPatch && (typeof spec.title !== 'string' || spec.title.trim() === '')) issues.push('缺少 title')
333
359
  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
360
+ if (!isPatch || questions !== undefined) {
361
+ if (!questions || typeof questions !== 'object' || Array.isArray(questions)) {
362
+ issues.push(isPatch ? 'patch 的 questions 必须是对象' : '缺少 questions 对象')
363
+ } else if (isPatch) {
364
+ // patch 的问题允许省略 type/instructions/criteria(继承目标场景的同名字段),
365
+ // 深层合法性由宿主对「合并结果」统一校验;这里只拦立刻能发现的结构错误
366
+ for (const [qid, q] of Object.entries(questions)) {
367
+ if (qid === '__proto__' || qid === 'constructor' || qid === 'prototype') {
368
+ issues.push(`问题 id ${qid} 不能使用(与对象原型机制冲突),请换一个 id`)
369
+ continue
370
+ }
371
+ if (!q || typeof q !== 'object') { issues.push(`问题 ${qid} 必须是对象`); continue }
372
+ if (q.type !== undefined && !['choice', 'noul', 'score'].includes(q.type)) {
373
+ issues.push(`问题 ${qid} 的 type 必须是 choice/noul/score`)
374
+ }
345
375
  }
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 的数组`)
376
+ } else {
377
+ const qids = Object.keys(questions)
378
+ if (qids.length === 0) issues.push('questions 至少 1 个')
379
+ if (qids.length > 16) issues.push(`questions 最多 16 个(当前 ${qids.length})`)
380
+ for (const [qid, q] of Object.entries(questions)) {
381
+ // 与宿主侧 validateScenario 对齐:这类键会被宿主拒绝,
382
+ // 这里不拦的话配置页会显示「JSON 有效」、场景却静默不生效
383
+ if (qid === '__proto__' || qid === 'constructor' || qid === 'prototype') {
384
+ issues.push(`问题 id ${qid} 不能使用(与对象原型机制冲突),请换一个 id`)
385
+ continue
386
+ }
387
+ if (!q || typeof q !== 'object') { issues.push(`问题 ${qid} 必须是对象`); continue }
388
+ if (!['choice', 'noul', 'score'].includes(q.type)) {
389
+ issues.push(`问题 ${qid} 的 type 必须是 choice/noul/score`)
390
+ continue
391
+ }
392
+ if (typeof q.instructions !== 'string' || q.instructions.trim() === '') {
393
+ issues.push(`问题 ${qid} 缺少 instructions`)
394
+ }
395
+ if (q.type === 'choice') {
396
+ const keys = q.criteria && typeof q.criteria === 'object' && !Array.isArray(q.criteria)
397
+ ? Object.keys(q.criteria)
398
+ : null
399
+ if (keys === null) issues.push(`问题 ${qid}(choice)需要 criteria 对象`)
400
+ else if (keys.length < 2) issues.push(`问题 ${qid}(choice)至少 2 个选项`)
401
+ else if (keys.length > 26) issues.push(`问题 ${qid}(choice)选项不能超过 26 个(当前 ${keys.length})`)
402
+ }
403
+ if (q.type === 'score'
404
+ && (!Array.isArray(q.criteria) || q.criteria.length < 2)) {
405
+ issues.push(`问题 ${qid}(score)criteria 需为长度 ≥2 的数组`)
406
+ }
360
407
  }
361
408
  }
362
409
  }
@@ -371,6 +418,7 @@ window.__ModuleLoader__.load({
371
418
  const AUTO_FIELDS = [
372
419
  { key: 'autoScenario', kind: 'datalist', options: SCENARIO_OPTIONS },
373
420
  { key: 'autoInject', kind: 'select', options: ['message', 'context'] },
421
+ { key: 'autoFailMode', kind: 'select', options: ['open', 'closed'] },
374
422
  { key: 'autoTimeoutMs', kind: 'number' },
375
423
  { key: 'autoMaxMessages', kind: 'number' },
376
424
  { key: 'autoCacheTtlMs', kind: 'number' },
@@ -390,6 +438,13 @@ window.__ModuleLoader__.load({
390
438
  if (value === 'message') return t('autoInjectMessage')
391
439
  if (value === 'context') return t('autoInjectContext')
392
440
  }
441
+ if (key === 'autoFailMode') {
442
+ if (value === 'open') return t('autoFailModeOpen')
443
+ if (value === 'closed') return t('autoFailModeClosed')
444
+ }
445
+ if (key === 'redact') {
446
+ return value === 'true' ? t('redactOn') : t('redactOff')
447
+ }
393
448
  return value
394
449
  }
395
450
 
@@ -499,7 +554,7 @@ window.__ModuleLoader__.load({
499
554
  inputMode: field.kind === 'number' ? 'decimal' : undefined,
500
555
  value,
501
556
  disabled,
502
- placeholder: field.key === 'apiKey' ? t('apiKeyPlaceholder') : undefined,
557
+ placeholder: field.key === 'apiKey' ? t('apiKeyPlaceholder') : field.key === 'apiKeyRef' ? t('apiKeyRefPlaceholder') : undefined,
503
558
  autoComplete: 'off',
504
559
  onChange: (event) => onChange(field.key, event.target.value),
505
560
  })
@@ -599,11 +654,11 @@ window.__ModuleLoader__.load({
599
654
  const dirty = draft !== null && Object.keys(draft).some((key) => draft[key] !== current[key])
600
655
  const canSave = dirty && !busy && writable && customCheck.ok && Object.keys(numberErrors).length === 0
601
656
 
602
- // 「固定场景」候选 = 10 个内置 + 当前生效的自定义场景 id
657
+ // 「固定场景」候选 = 11 个内置 + 当前生效的自定义场景 id(patch 条目指向已有 id,去重避免重复项)
603
658
  const scenarioOptions = [
604
659
  ...SCENARIO_OPTIONS,
605
660
  ...(customCheck.ok
606
- ? customCheck.ids
661
+ ? [...new Set(customCheck.ids)]
607
662
  .filter((id) => !SCENARIO_OPTIONS.some((option) => option.id === id))
608
663
  .map((id) => ({ id, label: id }))
609
664
  : []),
@@ -632,6 +687,8 @@ window.__ModuleLoader__.load({
632
687
  if (!Number.isFinite(parsed)) continue
633
688
  next = parsed
634
689
  }
690
+ // redact 下拉在草稿里是字符串,schema 要的是布尔
691
+ if (key === 'redact') next = value === 'true'
635
692
  ops.push({ op: 'set', path: [key], value: next })
636
693
  }
637
694
  if (ops.length === 0) {
@@ -834,6 +891,8 @@ window.__ModuleLoader__.load({
834
891
  exports.name = name
835
892
  exports.inject = inject
836
893
  exports.apply = apply
894
+ // 仅供测试:暴露纯函数(宿主只读 name/inject/apply,多余导出对宿主无影响)
895
+ exports.__internals = { validateCustomScenarios, describeCustomError }
837
896
  return module.exports
838
897
  },
839
898
  })
package/lib/format.js CHANGED
@@ -13,6 +13,7 @@ export function formatSummary({
13
13
  recommendation,
14
14
  notes = [],
15
15
  needsHumanReview = false,
16
+ reviewReasons = [],
16
17
  }) {
17
18
  const out = [
18
19
  `## ${title}`,
@@ -22,6 +23,8 @@ export function formatSummary({
22
23
  for (const note of notes) out.push(`- **注意**:${note}`)
23
24
  if (recommendation) out.push(`- **建议**:${recommendation}`)
24
25
  out.push(`- **建议复核**:${needsHumanReview ? '需要人工复核' : '无需人工复核'}`)
26
+ // 只给结论不给理由,调用方无法判断该不该信;把判据一并写出来。
27
+ for (const reason of reviewReasons) out.push(` - ${reason}`)
25
28
  if (requestId) out.push(`- **请求 ID**:${requestId}(延迟 ${latencyMs}ms)`)
26
29
  return out.join('\n')
27
30
  }
@@ -159,16 +162,110 @@ const NOUL_UNCERTAIN_HIGH = 0.55
159
162
  /** choice 返回这些取值时视为「无法判断」。 */
160
163
  const UNCERTAIN_CHOICES = new Set(['uncertain', 'unknown'])
161
164
 
162
- /** 是否需要人工复核:置信度低于阈值,或出现不确定项。 */
163
- export function computeNeedsHumanReview(answers, minConfidence) {
164
- for (const a of Object.values(answers || {})) {
165
- if (!a || typeof a !== 'object') continue
166
- if (typeof a.confidence === 'number' && a.confidence < minConfidence) return true
167
- if (a.type === 'choice' && (a.choice == null || UNCERTAIN_CHOICES.has(a.choice))) return true
168
- if (a.type === 'noul' && typeof a.noul === 'number'
169
- && a.noul > NOUL_UNCERTAIN_LOW && a.noul < NOUL_UNCERTAIN_HIGH) return true
165
+ /**
166
+ * 概率分布的"平坦度"兜底阈值:`最大概率 / (1/选项数)` 低于该值即视为模型在猜。
167
+ *
168
+ * 为什么用比值而不是绝对概率:**不同后端算 confidence 的公式不同**(TypeSafe 与
169
+ * Laya 就不一样),文献里的实测也表明这类概率**未标定**——把 0.6 这种绝对阈值
170
+ * 套在所有后端上,等于把"某个后端的经验值"当成通用标准。而"最大概率有没有明显
171
+ * 高出均匀分布"是**不依赖标定**的性质:0.5 除以 1/3 = 1.5 倍,说明只是略偏。
172
+ *
173
+ * 1.5 的取法:二选一题里对应 p≈0.75(比掷硬币强,但不足以据此自动执行);
174
+ * 三选一题里对应 p≈0.5。要更宽松/更严格可调本常量。
175
+ */
176
+ const FLATNESS_RATIO = 1.5
177
+
178
+ /**
179
+ * 概率分布的平坦度比值:最大概率相对均匀分布的倍数。
180
+ * 选项少于 2 个或除基为 0 时返回 null。choice 与 score 共用
181
+ * (同是 probabilities 分布,复核判据也相同)。
182
+ */
183
+ function flatnessRatio(probs) {
184
+ const keys = probs && typeof probs === 'object' ? Object.keys(probs) : []
185
+ if (keys.length < 2) return null
186
+ const max = Math.max(...keys.map((k) => Number(probs[k]) || 0))
187
+ const uniform = 1 / keys.length
188
+ return uniform > 0 ? max / uniform : null
189
+ }
190
+
191
+ /**
192
+ * 单题的可信度评估。
193
+ * @param {object} answer - 原始 answer(choice/noul/score)
194
+ * @returns {{ reason: string|null, ratio: number|null }} 不可信时给出原因与平坦度比值
195
+ */
196
+ function assessAnswer(answer) {
197
+ if (!answer || typeof answer !== 'object') return { reason: null, ratio: null }
198
+
199
+ if (answer.type === 'choice') {
200
+ if (answer.choice == null) return { reason: 'choice 未给出选项', ratio: null }
201
+ if (UNCERTAIN_CHOICES.has(answer.choice)) return { reason: `choice 返回「${answer.choice}」`, ratio: null }
202
+ const ratio = flatnessRatio(answer.probabilities)
203
+ if (ratio !== null && ratio < FLATNESS_RATIO) {
204
+ return { reason: `choice 的分布接近均匀(最大概率仅均匀分布的 ${ratio.toFixed(2)} 倍)`, ratio }
205
+ }
206
+ return { reason: null, ratio: null }
207
+ }
208
+
209
+ if (answer.type === 'noul') {
210
+ const v = Number(answer.noul)
211
+ if (!Number.isFinite(v)) return { reason: 'noul 未给出数值', ratio: null }
212
+ if (v > NOUL_UNCERTAIN_LOW && v < NOUL_UNCERTAIN_HIGH) {
213
+ return { reason: `noul=${v.toFixed(3)} 落在 ${NOUL_UNCERTAIN_LOW}~${NOUL_UNCERTAIN_HIGH} 的摇摆区间`, ratio: null }
214
+ }
215
+ return { reason: null, ratio: null }
170
216
  }
171
- return false
217
+
218
+ if (answer.type === 'score') {
219
+ const ratio = flatnessRatio(answer.probabilities)
220
+ if (ratio !== null && ratio < FLATNESS_RATIO) {
221
+ return { reason: `score 的分布接近均匀(最大概率仅均匀分布的 ${ratio.toFixed(2)} 倍)`, ratio }
222
+ }
223
+ if (!Number.isFinite(Number(answer.score))) return { reason: 'score 未给出分值', ratio: null }
224
+ return { reason: null, ratio: null }
225
+ }
226
+
227
+ return { reason: null, ratio: null }
228
+ }
229
+
230
+ /**
231
+ * 复核评估:判定是否需要人工复核,并给出**理由**。
232
+ *
233
+ * 判据分两类,区别很重要:
234
+ * - **分布平坦**(`assessAnswer`):概率没有明显高出均匀分布 → 模型在猜。
235
+ * 这是不依赖标定的信号,是主要判据。
236
+ * - **绝对置信度低于 `minConfidence`**:保留为次要信号以兼容既有配置,
237
+ * 但它在跨后端时意义有限(各家 confidence 公式不同,且实测未标定)。
238
+ *
239
+ * @param {object} answers - 原始 answers
240
+ * @param {number} [minConfidence] - 绝对置信度阈值;不传则只按平坦度判定
241
+ * @returns {{ needed: boolean, reasons: string[] }}
242
+ */
243
+ export function reviewAssessment(answers, minConfidence) {
244
+ const reasons = []
245
+ const threshold = Number(minConfidence)
246
+ for (const [qid, answer] of Object.entries(answers || {})) {
247
+ const { reason } = assessAnswer(answer)
248
+ if (reason) reasons.push(`${qid}:${reason}`)
249
+ if (Number.isFinite(threshold)
250
+ && typeof answer?.confidence === 'number'
251
+ && answer.confidence < threshold) {
252
+ reasons.push(`${qid}:置信度 ${(answer.confidence * 100).toFixed(1)}% 低于阈值 ${(threshold * 100).toFixed(0)}%`)
253
+ }
254
+ }
255
+ return { needed: reasons.length > 0, reasons }
256
+ }
257
+
258
+ /**
259
+ * 是否需要人工复核。
260
+ *
261
+ * 语义已从"只看绝对置信度阈值"改为"**看概率分布是否平坦**(分布接近均匀说明模型
262
+ * 在猜),并保留 `minConfidence` 作为次要信号"。理由见 reviewAssessment。
263
+ *
264
+ * @deprecated 直接用 `reviewAssessment()`——它额外给出**判定理由**,调用方不必
265
+ * 只拿到一个布尔值就无从判断该不该信。本函数保留为布尔快捷方式。
266
+ */
267
+ export function computeNeedsHumanReview(answers, minConfidence) {
268
+ return reviewAssessment(answers, minConfidence).needed
172
269
  }
173
270
 
174
271
  /* ─── 内部工具 ─────────────────────────────────────────────────────────────── */
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,10 +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
30
+ /** 发送前对 state 做内置脱敏(手机号/身份证/邮箱/银行卡号替换为类型标签) */
31
+ redact?: boolean
22
32
  /** 自定义场景:JSON 数组字符串 */
23
33
  customScenarios?: string
34
+ /** 用量台账文件路径;留空则用 `$DSH_HOME/dsh-systemone/usage.jsonl` */
35
+ usageLogPath?: string
24
36
 
25
37
  /* ── 自动决策(默认关闭) ─────────────────────────────── */
26
38
  /** 是否在每一步推理前自动捕获上下文并执行决策 */
@@ -29,6 +41,8 @@ export interface Config {
29
41
  autoScenario?: string
30
42
  /** 注入通道:message=同一步生效并去重;context=宿主动态上下文(不写历史) */
31
43
  autoInject?: string
44
+ /** 自动决策失败语义:open=放行(默认);closed=决策失败时否决本轮(供将来做闸门用,仅 message 通道) */
45
+ autoFailMode?: string
32
46
  /** 自动决策超时(毫秒) */
33
47
  autoTimeoutMs?: number
34
48
  /** 捕获的历史消息条数 */
@@ -62,6 +76,46 @@ export interface ScenarioInfo {
62
76
  questions: string[]
63
77
  }
64
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
+
65
119
  declare module '@deepseek-ai/cordis' {
66
120
  interface Context {
67
121
  /** SystemOne 决策服务。 */