@master0071/dsh-systemone 0.5.0 → 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/auto.js CHANGED
@@ -25,22 +25,48 @@
25
25
  * 安全约束(任何一条不满足都直接放行,绝不阻塞会话):
26
26
  * - 默认关闭(config.autoDecide = false)
27
27
  * - 超时(autoTimeoutMs)与失败一律 fail-open
28
- * - 相同 state 走缓存(autoCacheTtlMs),并发相同 state 走 in-flight 去重
28
+ * - 相同 state 且决策参数未变时走缓存(autoCacheTtlMs),并发相同 state 走 in-flight 去重
29
29
  * - 只在"本轮有新用户消息"时触发,工具结果步骤不触发
30
- * - 跳过斜杠命令、跳过本插件自己注入的运行时上下文
30
+ * - 跳过斜杠命令、跳过本插件自己注入的决策消息(按 INJECTED_SOURCE 识别)
31
31
  */
32
32
  import { runScenarioDecision } from './tools.js'
33
- import { findScenario } from './scenarios.js'
33
+ import { MAX_CHOICE_OPTIONS, findScenario } from './scenarios.js'
34
+ import { recordUsage } from './usage.js'
34
35
  import { readBoolean, readNumber, readString } from './volatile.js'
35
36
 
36
- /** 宿主运行时上下文使用的 source.kind(见 dsh-agent-loop 的 SOURCE 常量)。 */
37
- const RUNTIME_CONTEXT_SOURCE = 'runtime-context'
37
+ /**
38
+ * 本插件注入消息的 source.kind —— **必须用自己的 kind,绝不能复用宿主的 `runtime-context`**。
39
+ *
40
+ * 宿主 `@deepseek-ai/dsh-agent-loop` 的 RuntimeContextProjection 只凭
41
+ * `message.source.kind === 'runtime-context'` 判定"这是我自己写的动态上下文快照"
42
+ * (runtime-context.ts 的 `isOwned()`),并据此维护 `retained`(上一条自有快照的
43
+ * 序号与文本),用来决定下一步要不要再追加一条:
44
+ *
45
+ * if (event.type === 'user/message' && isOwned(event.data)) this.retained = {...}
46
+ * if (this.retained?.text === snapshot) return // 未变则不追加
47
+ * return [{ message: ..., intent: { surfaceOp: 'append' } }]
48
+ *
49
+ * 复用该 kind 的后果(本插件 v0.5.0 及更早版本的缺陷):
50
+ * 1. 每一步注入都把宿主的 retained 覆写成决策文本,于是宿主发现
51
+ * retained.text !== 自己渲染的上下文,**再追加一条冗余快照**;
52
+ * 2. 会话恢复时宿主倒序扫描,会把本插件的最新决策消息误认成自己保留的快照;
53
+ * 3. 宿主还有 CLEARED 归一化逻辑,任何按该 kind 过滤/替换的宿主代码都会误伤。
54
+ *
55
+ * 宿主 llm/message.ts 把 MessageSourceMap 定义为 merge-extensible:
56
+ * "each producer declares its own `kind` in its own module; there is no shared
57
+ * catch-all `plugin` kind"。首方同类插件(time-context / tmux-context)同样各用
58
+ * 自己的 kind。本常量同时用于**写入**注入消息与**读取**时的自我排除,两者必须一致。
59
+ */
60
+ const INJECTED_SOURCE = 'systemone-decision'
38
61
 
39
62
  /** 自动决策结果缓存的最大条目数;达到后先清过期再按插入序淘汰最旧。 */
40
63
  const MAX_AUTO_CACHE = 200
41
64
 
42
- /** 当前请求注入 state 的最大字符数(历史逐条限 2000,这里限制本轮原文)。 */
43
- const MAX_CURRENT_CHARS = 8000
65
+ /** 当前请求注入 state 的最大字符数(历史逐条限 HISTORY_CHARS_PER_MESSAGE,这里限制本轮原文)。 */
66
+ export const MAX_CURRENT_CHARS = 8000
67
+
68
+ /** 历史消息**每条**进入 state 的最大字符数(index.js 的外发声明预算同源引用)。 */
69
+ export const HISTORY_CHARS_PER_MESSAGE = 2000
44
70
 
45
71
  /** 自动决策数字配置的兜底默认值(与 lib/index.js Config schema 的 default 保持同一份来源)。 */
46
72
  export const AUTO_DEFAULTS = {
@@ -52,6 +78,11 @@ export const AUTO_DEFAULTS = {
52
78
  autoMinChars: 4,
53
79
  }
54
80
 
81
+ /** fail-closed 档判定:closed 只对"本应得到决策却没有"的真失败生效(见各调用点)。 */
82
+ function isFailClosed(config) {
83
+ return readString(config, 'autoFailMode', 'open') === 'closed'
84
+ }
85
+
55
86
  /**
56
87
  * 安装自动决策。返回卸载函数。
57
88
  *
@@ -60,7 +91,7 @@ export const AUTO_DEFAULTS = {
60
91
  * @returns {() => void}
61
92
  */
62
93
  export function installAutoDecide(ctx, deps) {
63
- const { provider, config, scenarios, logger } = deps
94
+ const { provider, config, scenarios, logger, usage } = deps
64
95
  if (!readBoolean(config, 'autoDecide', false)) return () => {}
65
96
 
66
97
  const disposers = []
@@ -70,9 +101,9 @@ export function installAutoDecide(ctx, deps) {
70
101
  const perAgent = new Map()
71
102
  /** agentId → 上次注入的决策文本:同一决策只注入一次,避免每步重复污染历史。 */
72
103
  const lastInjected = new Map()
73
- /** stateHash → { at, result }:避免同一内容重复请求。 */
104
+ /** 决策 key(state + 决策指纹)→ { at, result }:避免同一内容在同一配置下重复请求。 */
74
105
  const cache = new Map()
75
- /** stateHash → Promise:并发相同 state 只发一次上游请求。 */
106
+ /** 决策 key → Promise:并发相同 state 且同配置时只发一次上游请求。 */
76
107
  const inFlight = new Map()
77
108
  /** 已告警过的无效固定场景 id:同一错误只提示一次,避免每步刷日志。 */
78
109
  const warnedScenarios = new Set()
@@ -208,6 +239,7 @@ export function installAutoDecide(ctx, deps) {
208
239
  signal: timeout.signal,
209
240
  log,
210
241
  warnedScenarios,
242
+ usage,
211
243
  }),
212
244
  timeoutMs,
213
245
  )
@@ -215,6 +247,12 @@ export function installAutoDecide(ctx, deps) {
215
247
  timeout.dispose()
216
248
  }
217
249
  } catch (error) {
250
+ // fail-closed 档:决策管线失败 = 本轮没有可用决策,按 closed 语义否决
251
+ // (宿主把 turn 记为 blocked)。open 档维持注入语义:记 warning 放行。
252
+ if (isFailClosed(config)) {
253
+ log('warn', `自动决策失败,fail-closed 拦截本轮:${messageOf(error)}`)
254
+ return { kind: 'reject' }
255
+ }
218
256
  log('warn', `自动决策失败,已放行:${messageOf(error)}`)
219
257
  return decision
220
258
  }
@@ -259,6 +297,7 @@ export function installAutoDecide(ctx, deps) {
259
297
  signal: timeout.signal,
260
298
  log,
261
299
  warnedScenarios,
300
+ usage,
262
301
  }),
263
302
  timeoutMs,
264
303
  )
@@ -300,11 +339,62 @@ export function installAutoDecide(ctx, deps) {
300
339
  /* ─── 决策流程 ─────────────────────────────────────────────────────────────── */
301
340
 
302
341
  /**
303
- * 计算决策(带并发去重)。相同 state 只发一次上游请求。
342
+ * 决策指纹:把**影响决策结果的全部配置**压成一个短字符串。
343
+ *
344
+ * 缓存与 in-flight 去重的 key 必须是 `hashString(state + 指纹)`,不能只哈希 state
345
+ * ——否则用户在配置页改了「固定场景」、切了 model/provider、或调了两个置信度门槛后,
346
+ * 最长 autoCacheTtlMs(默认 60 秒)内仍会继续注入**旧场景的旧结论**,与
347
+ * "保存即生效、无需重启"的承诺直接冲突。
348
+ *
349
+ * 指纹含 `scenariosRef`:场景库本身也是热更新的(customScenarios),而自动路由的
350
+ * 结果是一个场景 id;若期间删除/改名了该场景,旧的路由结论会指向已不存在的场景,
351
+ * 表现为"决策突然不注入了"。把场景库指纹纳入 key 让这种变更立即失效。
352
+ *
353
+ * 刻意**不含** `autoTimeoutMs` / `autoCacheTtlMs` / `autoMaxMessages`:
354
+ * - 前两个只影响等待与缓存寿命,不影响判定内容,纳入只会让改超时白白清空缓存;
355
+ * - autoMaxMessages 已通过 state 文本体现(改变条数就会改变 state)。
356
+ *
357
+ * provider 用实例的 `name`(它本身由 provider 配置派生),model / 门槛现读现取,
358
+ * 因此本函数必须**在每次决策时调用**,而不能在装配期算一次。
359
+ *
360
+ * @param {object} config - 插件配置(volatile 字段经 readString/readNumber 脱壳)
361
+ * @param {object} provider - 当前提供商实例
362
+ * @param {string} scenariosRef - 场景库指纹(见 scenariosFingerprint)
363
+ * @returns {string} 形如 `customer_service|unisound|u2-decision|0.6|0.35|11:customer_service,…`
364
+ */
365
+ function decisionFingerprint(config, provider, scenariosRef) {
366
+ return [
367
+ readString(config, 'autoScenario', ''),
368
+ readString(provider, 'name', ''),
369
+ readString(config, 'model', ''),
370
+ String(readNumber(config, 'autoMinConfidence', AUTO_DEFAULTS.autoMinConfidence)),
371
+ String(readNumber(config, 'autoRouteMinConfidence', AUTO_DEFAULTS.autoRouteMinConfidence)),
372
+ scenariosRef,
373
+ ].join('|')
374
+ }
375
+
376
+ /**
377
+ * 场景库指纹:场景总数 + 每个场景的 `id:title`。
378
+ *
379
+ * 自定义场景是热更新的,改一个别名/加一个场景都会让自动路由的候选集变化。
380
+ * 用这个便宜字符串(远小于任何一次上游请求)换取"配置一改、缓存立即失效"。
381
+ *
382
+ * @param {object[]} scenarios - 场景库(活引用)
383
+ * @returns {string}
384
+ */
385
+ function scenariosFingerprint(scenarios) {
386
+ const list = Array.isArray(scenarios) ? scenarios : []
387
+ const parts = list.map((s) => `${s?.id ?? ''}:${s?.title ?? ''}`)
388
+ return `${list.length}:${parts.join(',')}`
389
+ }
390
+
391
+ /**
392
+ * 计算决策(带并发去重)。相同 state 且决策参数未变时只发一次上游请求。
304
393
  */
305
- async function computeDecision({ agent, newText, provider, config, scenarios, cache, inFlight, signal, log, warnedScenarios }) {
394
+ async function computeDecision({ agent, newText, provider, config, scenarios, cache, inFlight, signal, log, warnedScenarios, usage }) {
306
395
  const state = captureState(agent, newText, readNumber(config, 'autoMaxMessages', AUTO_DEFAULTS.autoMaxMessages))
307
- const key = hashString(state)
396
+ // key = state 文本 + 决策指纹:state 决定"问什么",指纹决定"按什么配置问"
397
+ const key = hashString(`${state}\u0000${decisionFingerprint(config, provider, scenariosFingerprint(scenarios))}`)
308
398
  const existing = inFlight.get(key)
309
399
  if (existing) return existing
310
400
  const promise = decide({
@@ -319,6 +409,7 @@ async function computeDecision({ agent, newText, provider, config, scenarios, ca
319
409
  signal,
320
410
  log,
321
411
  warnedScenarios,
412
+ usage,
322
413
  }).finally(() => {
323
414
  if (inFlight.get(key) === promise) inFlight.delete(key)
324
415
  })
@@ -326,7 +417,7 @@ async function computeDecision({ agent, newText, provider, config, scenarios, ca
326
417
  return promise
327
418
  }
328
419
 
329
- async function decide({ agent, newText, state, key, provider, config, scenarios, cache, signal, log, warnedScenarios }) {
420
+ async function decide({ agent, newText, state, key, provider, config, scenarios, cache, signal, log, warnedScenarios, usage }) {
330
421
  const ttl = readNumber(config, 'autoCacheTtlMs', AUTO_DEFAULTS.autoCacheTtlMs) || 0
331
422
  if (ttl > 0) {
332
423
  const hit = cache.get(key)
@@ -334,7 +425,7 @@ async function decide({ agent, newText, state, key, provider, config, scenarios,
334
425
  }
335
426
 
336
427
  // 1) 选场景:固定绑定,或让模型做一次自动路由
337
- const routed = await resolveScenario({ state, provider, config, scenarios, signal, log, warnedScenarios })
428
+ const routed = await resolveScenario({ state, provider, config, scenarios, signal, log, warnedScenarios, usage })
338
429
  if (!routed) return null
339
430
 
340
431
  // 路由置信度不足:宁可不注入,也不要塞一个瞎猜的结论
@@ -354,8 +445,14 @@ async function decide({ agent, newText, state, key, provider, config, scenarios,
354
445
  state,
355
446
  exec: { signal },
356
447
  minConfidence: readNumber(config, 'autoMinConfidence', AUTO_DEFAULTS.autoMinConfidence),
448
+ usage,
357
449
  })
358
450
  if (!result?.ok) {
451
+ // 上游明确回答"决策失败":closed 档必须上抛,让 pre-step 的 catch 按语义拦截;
452
+ // open 档维持原行为:warn + 不注入(= 放行)。
453
+ if (isFailClosed(config)) {
454
+ throw new Error(`场景 ${routed.scenario.id} 执行失败:${result?.error || '未知错误'}`)
455
+ }
359
456
  log?.('warn', `场景 ${routed.scenario.id} 执行失败,已放行:${result?.error || '未知错误'}`)
360
457
  return null
361
458
  }
@@ -384,8 +481,36 @@ async function decide({ agent, newText, state, key, provider, config, scenarios,
384
481
  return injected
385
482
  }
386
483
 
387
- /** 选择场景:固定绑定优先,否则用一次 choice 问句自动路由。 */
388
- async function resolveScenario({ state, provider, config, scenarios, signal, log, warnedScenarios }) {
484
+ /**
485
+ * 自动路由分批题的问题 id 前缀。
486
+ *
487
+ * 必须与"场景自己的问题 id"不可能相撞:场景问题 id 由用户定义,`scenario_0`
488
+ * 这种名字(正好也是 practice 里常见的场景 id 形态)会与之冲突,导致消费方
489
+ * 无法区分"这是路由题"还是"这是场景决策题"。用带命名空间的前缀 + 双下划线。
490
+ */
491
+ const ROUTE_QUESTION_PREFIX = 'systemone_route__'
492
+
493
+ /**
494
+ * 把场景清单切成每个不超过 MAX_CHOICE_OPTIONS 个选项的批次。
495
+ *
496
+ * 上游决策模型对单道 choice 的选项数有硬限制,而自动路由原本把**全部**场景塞进
497
+ * 一道题——11 个内置 + 15 个自定义即越界,且越界的问题不经过任何校验,只会被上游
498
+ * 拒绝(表现为路由静默失效)。分批后每道题都在限制内。
499
+ *
500
+ * @param {object[]} scenarios - 场景库
501
+ * @returns {object[][]} 批次数组;场景数不超限时只有一个批次
502
+ */
503
+ function chunkScenarios(scenarios) {
504
+ const size = Math.max(1, MAX_CHOICE_OPTIONS)
505
+ const batches = []
506
+ for (let i = 0; i < scenarios.length; i += size) {
507
+ batches.push(scenarios.slice(i, i + size))
508
+ }
509
+ return batches
510
+ }
511
+
512
+ /** 选择场景:固定绑定优先,否则用一次分批 choice 问句自动路由。 */
513
+ async function resolveScenario({ state, provider, config, scenarios, signal, log, warnedScenarios, usage }) {
389
514
  const fixed = readString(config, 'autoScenario', '').trim()
390
515
  if (fixed) {
391
516
  const scenario = findScenario(scenarios, fixed)
@@ -402,22 +527,59 @@ async function resolveScenario({ state, provider, config, scenarios, signal, log
402
527
  if (scenarios.length === 0) return null
403
528
  if (scenarios.length === 1) return { scenario: scenarios[0], source: '唯一场景', confidence: null }
404
529
 
530
+ // 场景数超过单道 choice 的上限时拆成多道题,一次请求并行问出(题目之间相互独立)
531
+ const batches = chunkScenarios(scenarios)
532
+ const questions = {}
533
+ batches.forEach((batch, index) => {
534
+ questions[`${ROUTE_QUESTION_PREFIX}${index}`] = {
535
+ type: 'choice',
536
+ instructions: batches.length === 1
537
+ ? '这段内容最需要哪一类业务判断?'
538
+ : `这段内容最需要哪一类业务判断?(第 ${index + 1}/${batches.length} 组;若都不匹配,选组内语义最近的一项)`,
539
+ criteria: Object.fromEntries(batch.map((s) => [s.id, s.title])),
540
+ }
541
+ })
542
+
405
543
  const response = await provider.decide({
406
544
  state,
407
- questions: {
408
- scenario: {
409
- type: 'choice',
410
- instructions: '这段内容最需要哪一类业务判断?',
411
- criteria: Object.fromEntries(scenarios.map((s) => [s.id, s.title])),
412
- },
413
- },
545
+ questions,
414
546
  model: readString(config, 'model', ''),
415
547
  signal,
416
548
  })
417
- const answer = response?.answers?.scenario
418
- const scenario = answer?.choice ? findScenario(scenarios, answer.choice) : undefined
419
- if (!scenario) return null
420
- return { scenario, source: '自动路由', confidence: Number(answer?.confidence) }
549
+ // 记账:路由请求不经过 executeDecision,必须在这里单独记,否则自动路由的花销完全不可见。
550
+ // 注意别重复计:目标场景那次决策由 runScenarioDecision → executeDecision 统一记账。
551
+ recordUsage(usage, {
552
+ source: 'auto-route',
553
+ ok: true,
554
+ model: response?.model || readString(config, 'model', ''),
555
+ provider: provider.name,
556
+ usage: response?.usage,
557
+ latencyMs: response?.latency_ms,
558
+ })
559
+ // 每题各选出一个候选,取置信度最高的那题作为路由结果;
560
+ // 置信度不可用时按题目顺序(第 1 组优先),保持确定性。
561
+ const answers = response?.answers || {}
562
+ const candidates = []
563
+ batches.forEach((_batch, index) => {
564
+ const answer = answers[`${ROUTE_QUESTION_PREFIX}${index}`]
565
+ const scenario = answer?.choice ? findScenario(scenarios, answer.choice) : undefined
566
+ if (scenario) candidates.push({ scenario, confidence: Number(answer?.confidence) })
567
+ })
568
+ if (candidates.length === 0) {
569
+ // 路由题全部无法映射回场景 = 上游给了不可用的回答,与请求异常同级。
570
+ // closed 档上抛拦截本轮;open 档维持"静默不注入"。
571
+ if (isFailClosed(config)) {
572
+ throw new Error('自动路由没有产出任何可映射到场景的回答')
573
+ }
574
+ return null
575
+ }
576
+ candidates.sort((a, b) => {
577
+ const ac = Number.isFinite(a.confidence) ? a.confidence : -1
578
+ const bc = Number.isFinite(b.confidence) ? b.confidence : -1
579
+ return bc - ac
580
+ })
581
+ const best = candidates[0]
582
+ return { scenario: best.scenario, source: '自动路由', confidence: best.confidence }
421
583
  }
422
584
 
423
585
  /** 把决策结果渲染成注入文本。 */
@@ -441,7 +603,13 @@ function renderInjection(result, routed, config) {
441
603
  if (derived) lines.push(`派生:${derived}`)
442
604
 
443
605
  if (result.recommendation) lines.push(`建议:${result.recommendation}`)
444
- if (result.needs_human_review) lines.push('注意:本次判断置信度偏低,请人工确认后再执行。')
606
+ if (result.needs_human_review) {
607
+ // 带上判定理由:只写"置信度偏低"会让模型无法判断该不该采信这条结论
608
+ const reasons = Array.isArray(result.review_reasons) && result.review_reasons.length > 0
609
+ ? `(${result.review_reasons.slice(0, 2).join(';')})`
610
+ : ''
611
+ lines.push(`注意:本次判断的可能分布不够集中${reasons},请人工确认后再执行。`)
612
+ }
445
613
 
446
614
  return lines.join('\n')
447
615
  }
@@ -453,18 +621,28 @@ function questionLabel(scenario, qid) {
453
621
  /* ─── 上下文捕获 ───────────────────────────────────────────────────────────── */
454
622
 
455
623
  /**
456
- * 判断是否为"无业务含义"的输入,用于跳过自动决策。
624
+ * 判断是否应当跳过自动决策:**只有"短促"且"整体是寒暄"才跳过**。
625
+ *
626
+ * 语义(0.6.0 起修正):早期实现是"短于 autoMinChars 一律跳过"的**或**关系,
627
+ * 在中文里会静默误杀完整的业务请求——"退款""报错""超时""闪退"都只有 2 个字,
628
+ * 却都是真实请求,结果是开了自动决策却什么都不发生、且没有任何日志。
629
+ *
630
+ * 现在两条规则取**交集**:
631
+ * 1. `TRIVIAL_PATTERN` 是整体匹配(`^…$`),单独一条就足以识别寒暄;
632
+ * 2. `autoMinChars` 从"长度下限"变为**寒暄判定的长度上界**——超过这个长度
633
+ * 即使内容是"你好…"也交给模型判断(如「你好,我的订单一直没到账」)。
457
634
  *
458
- * 两条独立的跳过规则(命中任一即跳过):
459
- * 1. 长度阈值:短于 autoMinChars 的一律跳过(默认 4,"退款失败" 恰好 4 字不被误伤);
460
- * 2. 寒暄黑名单:整体就是一句寒暄/致谢的跳过,与长度无关。
635
+ * 注意:`autoMinChars: 0` 表示**不做长度限制**,即只按寒暄黑名单判定。
636
+ * 副作用:短业务请求现在会真的发起一次上游调用(有 token 与延迟成本)。
461
637
  */
462
638
  const TRIVIAL_PATTERN = /^(你好|您好|哈喽|在吗|hi|hello|hey|ok|okay|好的|好嘞|收到|明白|嗯+|谢谢|多谢|感谢|thanks|thank you|thx|再见|拜拜|bye|测试|test)[\s!!。.~~,,、??]*$/i
463
639
 
464
640
  function isTrivialInput(text, minChars) {
465
641
  const trimmed = text.trim()
466
- if (Number.isFinite(minChars) && minChars > 0 && trimmed.length < minChars) return true
467
- return TRIVIAL_PATTERN.test(trimmed)
642
+ // 只按寒暄黑名单判定(不做长度限制)
643
+ if (!Number.isFinite(minChars) || minChars <= 0) return TRIVIAL_PATTERN.test(trimmed)
644
+ // 短 + 整体是寒暄:两个条件同时成立才跳过
645
+ return trimmed.length < minChars && TRIVIAL_PATTERN.test(trimmed)
468
646
  }
469
647
 
470
648
  /**
@@ -493,9 +671,9 @@ function readHistory(agent, limit) {
493
671
  else if (event?.type === 'assistant/message') message = event.data?.message
494
672
  else continue
495
673
 
496
- if (event.type === 'user/message' && message?.source?.kind === RUNTIME_CONTEXT_SOURCE) continue
674
+ if (event.type === 'user/message' && message?.source?.kind === INJECTED_SOURCE) continue
497
675
  const text = textOfMessage(message)
498
- if (text) picked.unshift(`- ${event.type === 'user/message' ? '用户' : '助手'}:${truncate(text, 2000)}`)
676
+ if (text) picked.unshift(`- ${event.type === 'user/message' ? '用户' : '助手'}:${truncate(text, HISTORY_CHARS_PER_MESSAGE)}`)
499
677
  }
500
678
  return picked
501
679
  }
@@ -504,7 +682,7 @@ function extractUserText(messages) {
504
682
  const texts = []
505
683
  for (const message of messages || []) {
506
684
  if (message?.role !== 'user') continue
507
- if (message?.source?.kind === RUNTIME_CONTEXT_SOURCE) continue
685
+ if (message?.source?.kind === INJECTED_SOURCE) continue
508
686
  const text = textOfMessage(message)
509
687
  if (text) texts.push(text)
510
688
  }
@@ -523,12 +701,12 @@ function textOfMessage(message) {
523
701
  .trim()
524
702
  }
525
703
 
526
- /** 构造与宿主 runtime-context 同形的消息(UserMessage)。 */
704
+ /** 构造本插件自己的注入消息(UserMessage);kind 见 INJECTED_SOURCE 的说明。 */
527
705
  function buildContextMessage(text) {
528
706
  return {
529
707
  role: 'user',
530
708
  content: [{ type: 'text', text }],
531
- source: { kind: RUNTIME_CONTEXT_SOURCE },
709
+ source: { kind: INJECTED_SOURCE },
532
710
  }
533
711
  }
534
712
 
package/lib/client.js CHANGED
@@ -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',
@@ -82,6 +85,10 @@ window.__ModuleLoader__.load({
82
85
  autoInjectHint: 'message=同一步生效;context=走宿主动态上下文,不写入会话历史',
83
86
  autoInjectMessage: '同步注入(message)',
84
87
  autoInjectContext: '动态上下文(context)',
88
+ autoFailMode: '失败语义',
89
+ autoFailModeHint: 'open=决策失败时放行(注入用途的正确默认);closed=决策失败时否决本轮(供将来做闸门用;仅对真失败生效,寒暄/低置信度等主动跳过不受影响;仅 message 通道会拦截)',
90
+ autoFailModeOpen: '放行(open)',
91
+ autoFailModeClosed: '拦截本轮(closed)',
85
92
  autoTimeoutMs: '自动决策超时(毫秒)',
86
93
  autoTimeoutMsHint: '超时即放行,绝不阻塞会话',
87
94
  autoMaxMessages: '捕获历史条数',
@@ -135,6 +142,9 @@ window.__ModuleLoader__.load({
135
142
  providerMock: 'Local mock',
136
143
  apiKey: 'API key',
137
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',
138
148
  apiKeyPlaceholder: 'Leave empty to keep the saved value',
139
149
  baseUrl: 'Base URL',
140
150
  baseUrlHint: 'Defaults to https://maas-api.unisound.com/v1',
@@ -156,6 +166,10 @@ window.__ModuleLoader__.load({
156
166
  autoInjectHint: 'message=applies in the same step; context=host dynamic context, not written to history',
157
167
  autoInjectMessage: 'Same step (message)',
158
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)',
159
173
  autoTimeoutMs: 'Auto decision timeout (ms)',
160
174
  autoTimeoutMsHint: 'On timeout the step proceeds — never blocks the session',
161
175
  autoMaxMessages: 'Captured history messages',
@@ -296,6 +310,7 @@ window.__ModuleLoader__.load({
296
310
 
297
311
  const SERVICE_FIELDS = [
298
312
  { key: 'provider', kind: 'select', options: ['unisound', 'http', 'mock'] },
313
+ { key: 'apiKeyRef', kind: 'text' },
299
314
  { key: 'apiKey', kind: 'password' },
300
315
  { key: 'baseUrl', kind: 'text' },
301
316
  { key: 'endpoint', kind: 'text' },
@@ -403,6 +418,7 @@ window.__ModuleLoader__.load({
403
418
  const AUTO_FIELDS = [
404
419
  { key: 'autoScenario', kind: 'datalist', options: SCENARIO_OPTIONS },
405
420
  { key: 'autoInject', kind: 'select', options: ['message', 'context'] },
421
+ { key: 'autoFailMode', kind: 'select', options: ['open', 'closed'] },
406
422
  { key: 'autoTimeoutMs', kind: 'number' },
407
423
  { key: 'autoMaxMessages', kind: 'number' },
408
424
  { key: 'autoCacheTtlMs', kind: 'number' },
@@ -422,6 +438,10 @@ window.__ModuleLoader__.load({
422
438
  if (value === 'message') return t('autoInjectMessage')
423
439
  if (value === 'context') return t('autoInjectContext')
424
440
  }
441
+ if (key === 'autoFailMode') {
442
+ if (value === 'open') return t('autoFailModeOpen')
443
+ if (value === 'closed') return t('autoFailModeClosed')
444
+ }
425
445
  if (key === 'redact') {
426
446
  return value === 'true' ? t('redactOn') : t('redactOff')
427
447
  }
@@ -534,7 +554,7 @@ window.__ModuleLoader__.load({
534
554
  inputMode: field.kind === 'number' ? 'decimal' : undefined,
535
555
  value,
536
556
  disabled,
537
- placeholder: field.key === 'apiKey' ? t('apiKeyPlaceholder') : undefined,
557
+ placeholder: field.key === 'apiKey' ? t('apiKeyPlaceholder') : field.key === 'apiKeyRef' ? t('apiKeyRefPlaceholder') : undefined,
538
558
  autoComplete: 'off',
539
559
  onChange: (event) => onChange(field.key, event.target.value),
540
560
  })
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
  /* ─── 内部工具 ─────────────────────────────────────────────────────────────── */