@master0071/dsh-systemone 0.4.0 → 0.4.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/cordis.patch.yml CHANGED
@@ -1,7 +1,8 @@
1
1
  # @master0071/dsh-systemone bundle patch:把 SystemOne 决策插件插入任何将本包列入
2
2
  # `dsh.profile.bundles` 的 profile。插件依赖 harness 的 `tools`(注册 Agent
3
- # 工具)与 `logger`;无 Web 面。配置项全部有默认值,可在插件设置面板中覆盖。
3
+ # 工具)与 `logger`;浏览器侧配置页由 client.js 提供。配置项全部有默认值,
4
+ # 可在插件设置面板中覆盖。
4
5
  - insert:
5
6
  - id: systemone
6
7
  name: '@master0071/dsh-systemone'
7
- config: {}
8
+ config: {}
package/lib/auto.js CHANGED
@@ -36,6 +36,12 @@ import { readBoolean, readNumber, readString } from './volatile.js'
36
36
  /** 宿主运行时上下文使用的 source.kind(见 dsh-agent-loop 的 SOURCE 常量)。 */
37
37
  const RUNTIME_CONTEXT_SOURCE = 'runtime-context'
38
38
 
39
+ /** 自动决策结果缓存的最大条目数;达到后先清过期再按插入序淘汰最旧。 */
40
+ const MAX_AUTO_CACHE = 200
41
+
42
+ /** 当前请求注入 state 的最大字符数(历史逐条限 2000,这里限制本轮原文)。 */
43
+ const MAX_CURRENT_CHARS = 8000
44
+
39
45
  /**
40
46
  * 安装自动决策。返回卸载函数。
41
47
  *
@@ -56,9 +62,8 @@ export function installAutoDecide(ctx, deps) {
56
62
  const cache = new Map()
57
63
  /** stateHash → Promise:并发相同 state 只发一次上游请求。 */
58
64
  const inFlight = new Map()
59
- // 每次都经 unwrap 读取,配置页里的改动无需重启即生效
60
- const minChars = readNumber(config, 'autoMinChars', 0)
61
- const routeFloor = readNumber(config, 'autoRouteMinConfidence', 0)
65
+ /** 已告警过的无效固定场景 id:同一错误只提示一次,避免每步刷日志。 */
66
+ const warnedScenarios = new Set()
62
67
 
63
68
  const log = (level, message) => {
64
69
  try {
@@ -79,40 +84,44 @@ export function installAutoDecide(ctx, deps) {
79
84
  }))
80
85
 
81
86
  // ── context 通道:入箱预计算 + 动态上下文渲染(不写历史,第二步起稳定可见) ──
82
- if (readString(config, 'autoInject', 'message') === 'context') {
83
- disposers.push(ctx.on('agent/created', (payload) => {
84
- const agent = payload?.agent
85
- const agentCtx = agent?.ctx
86
- if (!agentCtx) return
87
- // ① 消息一入箱就异步预计算,让决策尽可能在 assembly 之前就绪
88
- if (typeof agentCtx.on === 'function') {
89
- try {
90
- disposers.push(agentCtx.on('agent/inbox/inserted', (event) => {
91
- const newText = extractUserText([event?.message])
92
- if (!newText) return
93
- if (newText.trimStart().startsWith('/')) return
94
- if (isTrivialInput(newText, minChars)) return
95
- // 返回 promise 便于测试等待;宿主事件总线忽略返回值
96
- return computeAndStore(agent, newText)
97
- }))
98
- } catch (error) {
99
- log('warn', `注册入箱预计算失败:${messageOf(error)}`)
100
- }
87
+ // 监听器无条件注册,autoInject 的门控放在事件回调里按当前配置判断:
88
+ // autoInject 是 volatile 字段,只有事件期判断才能让 message ↔ context
89
+ // 在运行期双向切换都立即生效(在装配期固化只支持一个方向)。
90
+ disposers.push(ctx.on('agent/created', (payload) => {
91
+ const agent = payload?.agent
92
+ const agentCtx = agent?.ctx
93
+ if (!agentCtx) return
94
+ // ① 消息一入箱就异步预计算,让决策尽可能在 assembly 之前就绪
95
+ if (typeof agentCtx.on === 'function') {
96
+ try {
97
+ disposers.push(agentCtx.on('agent/inbox/inserted', (event) => {
98
+ if (readString(config, 'autoInject', 'message') !== 'context') return
99
+ const newText = extractUserText([event?.message])
100
+ if (!newText) return
101
+ if (newText.trimStart().startsWith('/')) return
102
+ if (isTrivialInput(newText, readNumber(config, 'autoMinChars', 0))) return
103
+ // 返回 promise 便于测试等待;宿主事件总线忽略返回值
104
+ return computeAndStore(agent, newText)
105
+ }))
106
+ } catch (error) {
107
+ log('warn', `注册入箱预计算失败:${messageOf(error)}`)
101
108
  }
102
- // ② 把最新决策渲染进作用域动态上下文
103
- if (agentCtx?.systemPrompt?.context) {
104
- try {
105
- disposers.push(agentCtx.systemPrompt.context({
106
- name: 'systemone-auto-decision',
107
- order: 900,
108
- text: () => perAgent.get(agent?.id)?.text || '',
109
- }))
110
- } catch (error) {
111
- log('warn', `注册动态上下文失败:${messageOf(error)}`)
112
- }
109
+ }
110
+ // ② 把最新决策渲染进作用域动态上下文
111
+ if (agentCtx?.systemPrompt?.context) {
112
+ try {
113
+ disposers.push(agentCtx.systemPrompt.context({
114
+ name: 'systemone-auto-decision',
115
+ order: 900,
116
+ text: () => (readString(config, 'autoInject', 'message') === 'context')
117
+ ? (perAgent.get(agent?.id)?.text || '')
118
+ : '',
119
+ }))
120
+ } catch (error) {
121
+ log('warn', `注册动态上下文失败:${messageOf(error)}`)
113
122
  }
114
- }))
115
- }
123
+ }
124
+ }))
116
125
 
117
126
  // ── pre-step:同一步生效(仅 message 通道) ──
118
127
  disposers.push(ctx.on('agent/pre-step', async (payload, next) => {
@@ -132,7 +141,7 @@ export function installAutoDecide(ctx, deps) {
132
141
  const newText = extractUserText(claimed)
133
142
  if (!newText) return decision // 工具结果步骤 / 无新用户消息
134
143
  if (newText.trimStart().startsWith('/')) return decision // 斜杠命令
135
- if (isTrivialInput(newText, minChars)) return decision // 寒暄/致谢等无业务含义的输入
144
+ if (isTrivialInput(newText, readNumber(config, 'autoMinChars', 0))) return decision // 寒暄/致谢等无业务含义的输入
136
145
 
137
146
  let injected = null
138
147
  try {
@@ -151,6 +160,7 @@ export function installAutoDecide(ctx, deps) {
151
160
  inFlight,
152
161
  signal: timeout.signal,
153
162
  log,
163
+ warnedScenarios,
154
164
  }),
155
165
  readNumber(config, 'autoTimeoutMs', 8000),
156
166
  )
@@ -200,6 +210,7 @@ export function installAutoDecide(ctx, deps) {
200
210
  inFlight,
201
211
  signal: timeout.signal,
202
212
  log,
213
+ warnedScenarios,
203
214
  }),
204
215
  readNumber(config, 'autoTimeoutMs', 8000),
205
216
  )
@@ -232,7 +243,7 @@ export function installAutoDecide(ctx, deps) {
232
243
  /**
233
244
  * 计算决策(带并发去重)。相同 state 只发一次上游请求。
234
245
  */
235
- async function computeDecision({ agent, newText, provider, config, scenarios, cache, inFlight, signal, log }) {
246
+ async function computeDecision({ agent, newText, provider, config, scenarios, cache, inFlight, signal, log, warnedScenarios }) {
236
247
  const state = captureState(agent, newText, readNumber(config, 'autoMaxMessages', 6))
237
248
  const key = hashString(state)
238
249
  const existing = inFlight.get(key)
@@ -248,6 +259,7 @@ async function computeDecision({ agent, newText, provider, config, scenarios, ca
248
259
  cache,
249
260
  signal,
250
261
  log,
262
+ warnedScenarios,
251
263
  }).finally(() => {
252
264
  if (inFlight.get(key) === promise) inFlight.delete(key)
253
265
  })
@@ -255,7 +267,7 @@ async function computeDecision({ agent, newText, provider, config, scenarios, ca
255
267
  return promise
256
268
  }
257
269
 
258
- async function decide({ agent, newText, state, key, provider, config, scenarios, cache, signal, log }) {
270
+ async function decide({ agent, newText, state, key, provider, config, scenarios, cache, signal, log, warnedScenarios }) {
259
271
  const ttl = readNumber(config, 'autoCacheTtlMs', 0) || 0
260
272
  if (ttl > 0) {
261
273
  const hit = cache.get(key)
@@ -263,7 +275,7 @@ async function decide({ agent, newText, state, key, provider, config, scenarios,
263
275
  }
264
276
 
265
277
  // 1) 选场景:固定绑定,或让模型做一次自动路由
266
- const routed = await resolveScenario({ state, provider, config, scenarios, signal })
278
+ const routed = await resolveScenario({ state, provider, config, scenarios, signal, log, warnedScenarios })
267
279
  if (!routed) return null
268
280
 
269
281
  // 路由置信度不足:宁可不注入,也不要塞一个瞎猜的结论
@@ -297,16 +309,35 @@ async function decide({ agent, newText, state, key, provider, config, scenarios,
297
309
  headline: result.recommendation || routed.scenario.title,
298
310
  at: Date.now(),
299
311
  }
300
- if (ttl > 0) cache.set(key, { at: Date.now(), result: injected })
312
+ if (ttl > 0) {
313
+ // 缓存上限:先清过期,再按插入序淘汰最旧,防止长会话无限增长
314
+ if (cache.size >= MAX_AUTO_CACHE) {
315
+ const now = Date.now()
316
+ for (const [k, v] of cache) {
317
+ if (now - v.at >= ttl) cache.delete(k)
318
+ }
319
+ }
320
+ while (cache.size >= MAX_AUTO_CACHE) {
321
+ cache.delete(cache.keys().next().value)
322
+ }
323
+ cache.set(key, { at: Date.now(), result: injected })
324
+ }
301
325
  return injected
302
326
  }
303
327
 
304
328
  /** 选择场景:固定绑定优先,否则用一次 choice 问句自动路由。 */
305
- async function resolveScenario({ state, provider, config, scenarios, signal }) {
329
+ async function resolveScenario({ state, provider, config, scenarios, signal, log, warnedScenarios }) {
306
330
  const fixed = readString(config, 'autoScenario', '').trim()
307
331
  if (fixed) {
308
332
  const scenario = findScenario(scenarios, fixed)
309
- if (!scenario) return null
333
+ if (!scenario) {
334
+ // 配置错误不能静默:同一 id 只告警一次,避免每步刷日志
335
+ if (!warnedScenarios.has(fixed)) {
336
+ warnedScenarios.add(fixed)
337
+ log?.('warn', `固定场景 "${fixed}" 不在场景库中,自动决策已停用(请检查 autoScenario 配置;可用:${scenarios.map((s) => s.id).join('、')})`)
338
+ }
339
+ return null
340
+ }
310
341
  return { scenario, source: '固定绑定', confidence: null }
311
342
  }
312
343
  if (scenarios.length === 0) return null
@@ -365,8 +396,9 @@ function questionLabel(scenario, qid) {
365
396
  /**
366
397
  * 判断是否为"无业务含义"的输入,用于跳过自动决策。
367
398
  *
368
- * 只用长度阈值会误伤真实请求("退款失败" 只有 4 个字),所以改成
369
- * 「长度阈值 + 寒暄黑名单」:短且整体就是寒暄才跳过。
399
+ * 两条独立的跳过规则(命中任一即跳过):
400
+ * 1. 长度阈值:短于 autoMinChars 的一律跳过(默认 4,"退款失败" 恰好 4 字不被误伤);
401
+ * 2. 寒暄黑名单:整体就是一句寒暄/致谢的跳过,与长度无关。
370
402
  */
371
403
  const TRIVIAL_PATTERN = /^(你好|您好|哈喽|在吗|hi|hello|hey|ok|okay|好的|好嘞|收到|明白|嗯+|谢谢|多谢|感谢|thanks|thank you|thx|再见|拜拜|bye|测试|test)[\s!!。.~~,,、??]*$/i
372
404
 
@@ -387,7 +419,7 @@ function captureState(agent, newText, maxMessages) {
387
419
  const history = readHistory(agent, limit)
388
420
  const parts = []
389
421
  if (history.length > 0) parts.push(`【近期对话】\n${history.join('\n')}`)
390
- parts.push(`【当前请求】\n${newText}`)
422
+ parts.push(`【当前请求】\n${truncate(newText, MAX_CURRENT_CHARS)}`)
391
423
  return parts.join('\n\n')
392
424
  }
393
425
 
package/lib/client.js CHANGED
@@ -89,12 +89,14 @@ window.__ModuleLoader__.load({
89
89
  autoRouteMinConfidence: '自动路由置信度门槛',
90
90
  autoRouteMinConfidenceHint: '低于该值时宁可不注入,避免塞入瞎猜的结论',
91
91
  autoMinChars: '最小触发长度(字符)',
92
- autoMinCharsHint: '低于该长度且整体是寒暄时跳过',
92
+ autoMinCharsHint: '低于该长度、或整体就是寒暄/致谢时跳过',
93
93
  save: '保存',
94
94
  saving: '保存中…',
95
95
  revert: '撤销修改',
96
96
  saved: '已保存,立即生效',
97
97
  saveFailed: '保存失败,请检查填写内容',
98
+ numberInvalid: '必须是有效数字',
99
+ numberRequired: '数字字段不能为空',
98
100
  conflict: '配置已被其他地方改动,已刷新为最新值,请重新修改',
99
101
  readOnly: '当前不可编辑',
100
102
  noForm: '宿主要求该条目声明 volatile 配置字段后才会显示表单。',
@@ -157,12 +159,14 @@ window.__ModuleLoader__.load({
157
159
  autoRouteMinConfidence: 'Auto routing confidence floor',
158
160
  autoRouteMinConfidenceHint: 'Below this value nothing is injected rather than guessing',
159
161
  autoMinChars: 'Minimum trigger length (chars)',
160
- autoMinCharsHint: 'Shorter greetings are skipped',
162
+ autoMinCharsHint: 'Skipped when shorter than this, or when it is a bare greeting',
161
163
  save: 'Save',
162
164
  saving: 'Saving…',
163
165
  revert: 'Revert changes',
164
166
  saved: 'Saved — applied immediately',
165
167
  saveFailed: 'Save failed — check the values',
168
+ numberInvalid: 'Must be a valid number',
169
+ numberRequired: 'Number fields cannot be empty',
166
170
  conflict: 'The configuration changed elsewhere; the newest values were reloaded',
167
171
  readOnly: 'Read-only right now',
168
172
  noForm: 'The host exposes a form only when the entry declares volatile config fields.',
@@ -261,7 +265,6 @@ window.__ModuleLoader__.load({
261
265
 
262
266
  /* ────────────────────────────── 字段定义 ────────────────────────────── */
263
267
 
264
- /** 数字字段在草稿里保持字符串,保存时再转数字,避免用户输入中途被强制转换。 */
265
268
  /**
266
269
  * 内置场景候选(供「固定场景」输入框的下拉提示)。
267
270
  *
@@ -401,7 +404,7 @@ window.__ModuleLoader__.load({
401
404
  /**
402
405
  * 一个可折叠的配置卡片:标题 + 说明,展开后是字段表单。
403
406
  */
404
- function ConfigCard({ t, title, description, open, onToggle, children }) {
407
+ function ConfigCard({ title, description, open, onToggle, children }) {
405
408
  return hs('li', {
406
409
  style: cardStyle,
407
410
  children: [
@@ -579,8 +582,22 @@ window.__ModuleLoader__.load({
579
582
  const customCheck = validateCustomScenarios(values.customScenarios)
580
583
  const customErrorText = customCheck.ok ? undefined : describeCustomError(customCheck.error, t)
581
584
 
585
+ // 数字字段同样先校验:非法值直接拦住保存,
586
+ // 而不是保存时静默丢弃却仍提示「已保存」
587
+ const numberErrors = {}
588
+ for (const field of ALL_FIELDS) {
589
+ if (field.kind !== 'number') continue
590
+ const raw = String(values[field.key] ?? '').trim()
591
+ if (raw === '') numberErrors[field.key] = t('numberRequired')
592
+ else if (!Number.isFinite(Number(raw))) numberErrors[field.key] = t('numberInvalid')
593
+ }
594
+ const numberExtras = {}
595
+ for (const [key, error] of Object.entries(numberErrors)) {
596
+ numberExtras[key] = { error }
597
+ }
598
+
582
599
  const dirty = draft !== null && Object.keys(draft).some((key) => draft[key] !== current[key])
583
- const canSave = dirty && !busy && writable && customCheck.ok
600
+ const canSave = dirty && !busy && writable && customCheck.ok && Object.keys(numberErrors).length === 0
584
601
 
585
602
  // 「固定场景」候选 = 10 个内置 + 当前生效的自定义场景 id
586
603
  const scenarioOptions = [
@@ -602,6 +619,7 @@ window.__ModuleLoader__.load({
602
619
  setBusy(true)
603
620
  setNotice(null)
604
621
  try {
622
+ // 数字字段在草稿里保持字符串,这里再转数字(合法性已由上方 numberErrors 拦截)
605
623
  const ops = []
606
624
  for (const [key, value] of Object.entries(draft ?? {})) {
607
625
  if (value === current[key]) continue
@@ -666,7 +684,6 @@ window.__ModuleLoader__.load({
666
684
  children: [
667
685
  h(ConfigCard, {
668
686
  key: 'service',
669
- t,
670
687
  title: t('tabService'),
671
688
  description: t('tabServiceHint'),
672
689
  open: openService,
@@ -674,7 +691,7 @@ window.__ModuleLoader__.load({
674
691
  // 注意:jsx(type, props, key) 的第三个参数是 key,正文必须作为 children 放进 props
675
692
  children: hs(Fragment, {
676
693
  children: [
677
- ...renderFields(SERVICE_FIELDS),
694
+ ...renderFields(SERVICE_FIELDS, numberExtras),
678
695
  hs('div', {
679
696
  key: 'footer',
680
697
  style: footerStyle,
@@ -706,7 +723,6 @@ window.__ModuleLoader__.load({
706
723
  }),
707
724
  h(ConfigCard, {
708
725
  key: 'custom',
709
- t,
710
726
  title: t('tabCustom'),
711
727
  description: t('tabCustomHint'),
712
728
  open: openCustom,
@@ -803,14 +819,13 @@ window.__ModuleLoader__.load({
803
819
  }),
804
820
  h(ConfigCard, {
805
821
  key: 'auto',
806
- t,
807
822
  title: t('tabAuto'),
808
823
  description: t('tabAutoHint'),
809
824
  open: openAuto,
810
825
  onToggle: () => setOpenAuto((value) => !value),
811
826
  children: hs(Fragment, {
812
827
  children: [
813
- ...renderFields(AUTO_FIELDS),
828
+ ...renderFields(AUTO_FIELDS, numberExtras),
814
829
  hs('div', {
815
830
  key: 'footer',
816
831
  style: footerStyle,
@@ -872,8 +887,3 @@ window.__ModuleLoader__.load({
872
887
  return module.exports
873
888
  },
874
889
  })
875
-
876
-
877
-
878
-
879
-
package/lib/format.js CHANGED
@@ -11,6 +11,7 @@ export function formatSummary({
11
11
  latencyMs,
12
12
  lines = [],
13
13
  recommendation,
14
+ notes = [],
14
15
  needsHumanReview = false,
15
16
  }) {
16
17
  const out = [
@@ -18,10 +19,11 @@ export function formatSummary({
18
19
  `- **决策服务**:${provider} / ${model}`,
19
20
  ...lines,
20
21
  ]
22
+ for (const note of notes) out.push(`- **注意**:${note}`)
21
23
  if (recommendation) out.push(`- **建议**:${recommendation}`)
22
24
  out.push(`- **建议复核**:${needsHumanReview ? '需要人工复核' : '无需人工复核'}`)
23
25
  if (requestId) out.push(`- **请求 ID**:${requestId}(延迟 ${latencyMs}ms)`)
24
- return out.filter(Boolean).join('\n')
26
+ return out.join('\n')
25
27
  }
26
28
 
27
29
  /**
@@ -53,7 +55,7 @@ export function normalizeAnswers(answers, questions = {}) {
53
55
  decision[qid] = Number.isFinite(score) ? idx : null
54
56
  const legend = raw.legend || {}
55
57
  const scale = Array.isArray(question?.criteria) ? question.criteria : []
56
- labels[qid] = idx != null ? (legend[String(idx)] ?? scale[Number(raw.score ?? 0)] ?? String(idx)) : '无法判断'
58
+ labels[qid] = idx != null ? (legend[String(idx)] ?? scale[idx] ?? String(idx)) : '无法判断'
57
59
  if (Number.isFinite(Number(raw.confidence))) confidences[qid] = Number(raw.confidence)
58
60
  continue
59
61
  }
@@ -150,13 +152,21 @@ export function noulLine(label, answer) {
150
152
  return `- **${label}**:${v >= 0.5 ? '是' : '否'}(${(v * 100).toFixed(1)}%)`
151
153
  }
152
154
 
155
+ /** noul 落在该区间视为「摇摆不定」(接近 0.5),建议人工复核。 */
156
+ const NOUL_UNCERTAIN_LOW = 0.45
157
+ const NOUL_UNCERTAIN_HIGH = 0.55
158
+
159
+ /** choice 返回这些取值时视为「无法判断」。 */
160
+ const UNCERTAIN_CHOICES = new Set(['uncertain', 'unknown'])
161
+
153
162
  /** 是否需要人工复核:置信度低于阈值,或出现不确定项。 */
154
163
  export function computeNeedsHumanReview(answers, minConfidence) {
155
164
  for (const a of Object.values(answers || {})) {
156
165
  if (!a || typeof a !== 'object') continue
157
166
  if (typeof a.confidence === 'number' && a.confidence < minConfidence) return true
158
- if (a.type === 'choice' && (a.choice == null || a.choice === 'uncertain' || a.choice === 'unknown')) return true
159
- if (a.type === 'noul' && typeof a.noul === 'number' && a.noul > 0.45 && a.noul < 0.55) 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
160
170
  }
161
171
  return false
162
172
  }
package/lib/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * @master0071/dsh-systemone 插件入口。
3
3
  *
4
4
  * 架构:Cordis 插件(Service 形态)。依赖 harness 的 `tools`(工具注册表)
5
- * 与 `logger`;无 Web 面。
5
+ * 与 `logger`;浏览器侧配置页由 client.js 提供(经 dsh.client 注入)。
6
6
  *
7
7
  * - 注册 2 个 Agent 工具:
8
8
  * systemone_scenario — 统一场景调度(list / describe / run)
@@ -17,7 +17,10 @@ import { createProvider } from './provider.js'
17
17
  import { createTools } from './tools.js'
18
18
  import { resolveScenarios } from './scenarios.js'
19
19
  import { installAutoDecide } from './auto.js'
20
- import { readBoolean, readString } from './volatile.js'
20
+ import { readBoolean, readString, unwrap } from './volatile.js'
21
+
22
+ /** 合法的提供商类型;其他值会按 unisound 处理并告警一次。 */
23
+ const KNOWN_PROVIDERS = ['unisound', 'http', 'mock']
21
24
 
22
25
  /**
23
26
  * 把「当前提供商」包成一个稳定引用的对象,内部每次调用都转发到最新实例。
@@ -105,7 +108,7 @@ export default class SystemOneService extends Service {
105
108
 
106
109
  /**
107
110
  * @param {import('@deepseek-ai/cordis').Context} ctx
108
- * @param {import('zod').infer<typeof Config>} config
111
+ * @param {object} config - 经 Config schema 校验的插件配置(volatile 字段是引用对象,读取须经 volatile.js 脱壳)
109
112
  */
110
113
  constructor(ctx, config) {
111
114
  super(ctx, 'systemone')
@@ -114,13 +117,16 @@ export default class SystemOneService extends Service {
114
117
 
115
118
  // provider 字段是 volatile:配置页里切换 unisound / http / mock 时,
116
119
  // 下面这个代理会按需重建真正的提供商,工具与自动决策无需改动即可跟随。
117
- this._provider = createProvider(ctx, config)
118
- this._providerKind = readString(config, 'provider', 'unisound')
120
+ this._warnedProviders = new Set()
121
+ this._providerKind = null
122
+ this._provider = null
119
123
  this.provider = createProviderProxy(() => this.currentProvider())
124
+ this.currentProvider()
120
125
 
121
126
  // 场景库同样是热更新的:customScenarios 一变就原地重建,
122
127
  // 工具与自动决策持有的是同一个 liveScenarios 引用,因此无需重新装配。
123
128
  this._scenarioSource = null
129
+ this._scenarioRawValue = undefined
124
130
  this.scenarioProblems = []
125
131
  this._scenarioArray = []
126
132
  this._refreshScenarios()
@@ -164,12 +170,22 @@ export default class SystemOneService extends Service {
164
170
  currentProvider() {
165
171
  const kind = readString(this.config, 'provider', 'unisound')
166
172
  if (kind !== this._providerKind) {
167
- this._provider = createProvider(this.ctx, this.config)
173
+ this._warnUnknownProvider(kind)
174
+ this._provider = createProvider(this.config)
168
175
  this._providerKind = kind
169
176
  }
170
177
  return this._provider
171
178
  }
172
179
 
180
+ /** 未知 provider 值会在 provider.js 里静默回退到 unisound,这里负责告警(每个未知值只提示一次)。 */
181
+ _warnUnknownProvider(kind) {
182
+ if (KNOWN_PROVIDERS.includes(kind) || this._warnedProviders.has(kind)) return
183
+ this._warnedProviders.add(kind)
184
+ this.ctx?.logger?.warn?.(
185
+ `@master0071/dsh-systemone: 未知 provider "${kind}",将按 unisound 处理(可选:${KNOWN_PROVIDERS.join(' / ')})`,
186
+ )
187
+ }
188
+
173
189
  /**
174
190
  * 场景库的「活引用」。
175
191
  *
@@ -208,7 +224,13 @@ export default class SystemOneService extends Service {
208
224
  * 因为工具与自动决策捕获的是那个数组本身。
209
225
  */
210
226
  _refreshScenarios() {
211
- const source = readString(this.config, 'customScenarios', '').trim()
227
+ // 先做引用比较快速返回:Proxy 的每次属性访问都会走到这里,
228
+ // 字符串不可变,volatile 热更新换值时会换引用,引用相同即配置未变,
229
+ // 避免对大段自定义场景 JSON 反复做全量 String() + trim + 比较。
230
+ const value = unwrap(this.config?.customScenarios) ?? ''
231
+ if (value === this._scenarioRawValue) return
232
+ this._scenarioRawValue = value
233
+ const source = String(value).trim()
212
234
  if (source === this._scenarioSource) return
213
235
  const { scenarios, problems } = resolveScenarios(source)
214
236
  this._scenarioArray.length = 0
package/lib/provider.js CHANGED
@@ -20,23 +20,21 @@ import { readNumber, readString } from './volatile.js'
20
20
  const DEFAULT_BASE_URL = 'https://maas-api.unisound.com/v1'
21
21
  const DEFAULT_MODEL = 'u2-decision'
22
22
 
23
- /** 创建提供商实例。config 来自插件配置(已由 Config schema 校验)。 */
24
- export function createProvider(ctx, config) {
23
+ /** 创建提供商实例。config 来自插件配置(已由 Config schema 校验)。未知 provider 值回退到 unisound(入口层负责告警)。 */
24
+ export function createProvider(config) {
25
25
  const kind = readString(config, 'provider', 'unisound')
26
26
  if (kind === 'mock') return new MockProvider(config)
27
- if (kind === 'http') return new HttpProvider(ctx, config, 'http')
28
- return new UnisoundProvider(ctx, config)
27
+ if (kind === 'http') return new HttpProvider(config, 'http')
28
+ return new UnisoundProvider(config)
29
29
  }
30
30
 
31
31
  /** 通用 HTTP 提供商:请求 SystemOne 兼容端点,Bearer 认证。 */
32
32
  export class HttpProvider {
33
33
  /**
34
- * @param {object} ctx - Cordis 上下文
35
34
  * @param {object} config - 插件配置
36
35
  * @param {string} [name] - 提供商显示名
37
36
  */
38
- constructor(ctx, config, name = 'http') {
39
- this.ctx = ctx
37
+ constructor(config, name = 'http') {
40
38
  this.config = config || {}
41
39
  this.name = name
42
40
  }
@@ -83,6 +81,7 @@ export class HttpProvider {
83
81
 
84
82
  const controller = new AbortController()
85
83
  const timer = setTimeout(() => controller.abort(new Error(`SystemOne: 请求超时(${timeoutMs}ms)`)), timeoutMs)
84
+ timer.unref?.()
86
85
  const onOuterAbort = () => controller.abort()
87
86
  if (signal && typeof signal.addEventListener === 'function') {
88
87
  signal.addEventListener('abort', onOuterAbort, { once: true })
@@ -128,8 +127,8 @@ export class HttpProvider {
128
127
 
129
128
  /** Unisound 官方提供商(默认)。 */
130
129
  export class UnisoundProvider extends HttpProvider {
131
- constructor(ctx, config) {
132
- super(ctx, { ...config, provider: 'unisound' }, 'unisound')
130
+ constructor(config) {
131
+ super({ ...config, provider: 'unisound' }, 'unisound')
133
132
  }
134
133
  }
135
134
 
package/lib/tools.js CHANGED
@@ -79,7 +79,15 @@ function scenarioTool(provider, config, scenarios) {
79
79
  if (action === 'describe') {
80
80
  return describeScenario(scenarios, args.scenario)
81
81
  }
82
- return runScenario({ provider, config, scenarios, args, exec })
82
+ return runScenarioDecision({
83
+ provider,
84
+ config,
85
+ scenarios,
86
+ scenario: args.scenario,
87
+ state: args.state,
88
+ params: args.params,
89
+ exec,
90
+ })
83
91
  },
84
92
  presentCall: (args) => ({
85
93
  card: 'generic',
@@ -156,18 +164,6 @@ function describeScenario(scenarios, id) {
156
164
  return { ok: true, action: 'describe', scenario: scenario.id, title: scenario.title, questions, summary }
157
165
  }
158
166
 
159
- async function runScenario({ provider, config, scenarios, args, exec }) {
160
- return runScenarioDecision({
161
- provider,
162
- config,
163
- scenarios,
164
- scenario: args.scenario,
165
- state: args.state,
166
- params: args.params,
167
- exec,
168
- })
169
- }
170
-
171
167
  /**
172
168
  * 执行一次场景决策(工具与自动决策共用的核心入口)。
173
169
  *
@@ -179,7 +175,6 @@ async function runScenario({ provider, config, scenarios, args, exec }) {
179
175
  * @param {unknown} options.state - 业务上下文
180
176
  * @param {object} [options.params] - 按问题 id 覆盖问题定义
181
177
  * @param {object} [options.exec] - 工具执行上下文(取 signal)
182
- * @param {string} [options.model] - 覆盖模型
183
178
  * @param {number} [options.minConfidence] - 覆盖置信度阈值
184
179
  * @returns {Promise<object>} 与工具输出同构的决策结果
185
180
  */
@@ -191,7 +186,6 @@ export async function runScenarioDecision({
191
186
  state,
192
187
  params,
193
188
  exec,
194
- model,
195
189
  minConfidence,
196
190
  }) {
197
191
  const scenario = findScenario(scenarios, scenarioRef)
@@ -212,7 +206,7 @@ export async function runScenarioDecision({
212
206
  }
213
207
  }
214
208
 
215
- const questions = applyParams(scenario.questions, params)
209
+ const { questions, ignored } = applyParams(scenario.questions, params)
216
210
  const configThreshold = readNumber(config, 'minConfidence', NaN)
217
211
  const threshold = Number.isFinite(Number(minConfidence))
218
212
  ? Number(minConfidence)
@@ -231,7 +225,9 @@ export async function runScenarioDecision({
231
225
  config,
232
226
  minConfidence: threshold,
233
227
  exec,
234
- model,
228
+ warnings: ignored.length > 0
229
+ ? [`以下 params 覆盖无效,已忽略:${ignored.join(';')}`]
230
+ : [],
235
231
  finalize(answers) {
236
232
  const { decision, labels, confidences } = normalizeAnswers(answers, questions)
237
233
  const derived = deriveFields(scenario.derive, decision)
@@ -243,10 +239,16 @@ export async function runScenarioDecision({
243
239
  })
244
240
  }
245
241
 
246
- /** 按 params 覆盖问题定义。 */
242
+ /**
243
+ * 按 params 覆盖问题定义,并收集无法应用的覆盖项(类型不匹配、未知问题 id),
244
+ * 让调用方能把「传了但没生效」反馈给模型,而不是静默丢弃。
245
+ *
246
+ * @returns {{ questions: object, ignored: string[] }}
247
+ */
247
248
  function applyParams(questions, params) {
248
- if (!params || typeof params !== 'object') return questions
249
+ if (!params || typeof params !== 'object') return { questions, ignored: [] }
249
250
  const merged = {}
251
+ const ignored = []
250
252
  for (const [qid, question] of Object.entries(questions)) {
251
253
  const override = params[qid]
252
254
  if (!override || typeof override !== 'object') {
@@ -268,14 +270,23 @@ function applyParams(questions, params) {
268
270
  )
269
271
  } else if (question.type === 'score' && Array.isArray(override.criteria) && override.criteria.length >= 2) {
270
272
  next.criteria = override.criteria.map((v) => String(v))
273
+ } else {
274
+ ignored.push(`${qid}.criteria(与问题类型 ${question.type} 不匹配)`)
271
275
  }
272
276
  }
273
- if (override.addCriteria && question.type === 'choice' && !Array.isArray(override.addCriteria)) {
274
- next.criteria = { ...(next.criteria || {}), ...override.addCriteria }
277
+ if (override.addCriteria) {
278
+ if (question.type === 'choice' && !Array.isArray(override.addCriteria)) {
279
+ next.criteria = { ...(next.criteria || {}), ...override.addCriteria }
280
+ } else {
281
+ ignored.push(`${qid}.addCriteria(仅支持 choice 问题)`)
282
+ }
275
283
  }
276
284
  merged[qid] = next
277
285
  }
278
- return merged
286
+ for (const qid of Object.keys(params)) {
287
+ if (!(qid in questions)) ignored.push(`${qid}(未知问题 id)`)
288
+ }
289
+ return { questions: merged, ignored }
279
290
  }
280
291
 
281
292
  /* ─── 2. 通用决策工具 ─────────────────────────────────────────────────────── */
@@ -357,6 +368,7 @@ async function executeDecision({
357
368
  exec,
358
369
  model,
359
370
  finalize,
371
+ warnings = [],
360
372
  }) {
361
373
  try {
362
374
  const started = Date.now()
@@ -383,6 +395,7 @@ async function executeDecision({
383
395
  latencyMs,
384
396
  lines,
385
397
  recommendation,
398
+ notes: warnings,
386
399
  needsHumanReview,
387
400
  })
388
401
  return {
@@ -399,6 +412,7 @@ async function executeDecision({
399
412
  confidences,
400
413
  needs_human_review: needsHumanReview,
401
414
  recommendation,
415
+ warnings,
402
416
  summary,
403
417
  }
404
418
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@master0071/dsh-systemone",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "DeepSeek Harness 插件:将 SystemOne 决策模型(默认云知声 u2-decision,可切换提供商)接入软件开发、客服、内容审核、路由、销售线索、风控、招聘、数据治理、教育、需求变更等 10 大业务场景,为 Agent 返回带概率分布的决策与处置建议。",
5
5
  "license": "MIT",
6
6
  "type": "module",