@master0071/dsh-systemone 0.4.2 → 0.4.3
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 +11 -4
- package/lib/index.js +61 -22
- package/lib/scenarios.js +117 -15
- package/lib/tools.js +38 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -305,7 +305,7 @@ const decision = await waterfall('agent/pre-step', { // ③ 再跑瀑布
|
|
|
305
305
|
provider: unisound # unisound / http / mock
|
|
306
306
|
apiKey: '' # 留空则读环境变量 UNISOUND_API_KEY / SYSTEMONE_API_KEY
|
|
307
307
|
model: u2-decision
|
|
308
|
-
customScenarios: '' #
|
|
308
|
+
customScenarios: '' # 热更新:保存后场景库立即重建
|
|
309
309
|
autoDecide: false # 结构性:需要重启
|
|
310
310
|
```
|
|
311
311
|
|
|
@@ -332,6 +332,13 @@ const decision = await waterfall('agent/pre-step', { // ③ 再跑瀑布
|
|
|
332
332
|
|
|
333
333
|
也可以在 profile 的 `cordis.patch.yml` 里配置 `customScenarios`(同样的 JSON 字符串)。同 `id` 会覆盖内置场景,否则新增。
|
|
334
334
|
|
|
335
|
+
覆盖与别名的解析规则:
|
|
336
|
+
|
|
337
|
+
- **同 `id` 覆盖**:自定义场景替换同名内置场景,只写 `title`/`questions` 也够——被替换场景原有的 `aliases` 会被**继承**(省略 `aliases` 不等于清空别名),新声明的别名与继承的别名共存且自动去重。
|
|
338
|
+
- **别名冲突**:别名与 `id` 平权参与查找,冲突时**后登记者获胜**(自定义场景优先于内置场景,自定义之间后声明者优先)。因此自定义场景声明的别名一定指向它自己,不会被内置场景抢走。
|
|
339
|
+
- **冲突提示**:别名撞名是**非致命**的——场景照常可用,但插件会给出提示(宿主日志里带「(提示)」前缀,`systemone_scenario(action: "list")` 的 `warnings` 字段与「配置提示」小节里也会列出),说明谁遮蔽了谁,避免调用方把「别名被占」误判成「场景不存在」。
|
|
340
|
+
- **查找大小写不敏感**,`findScenario` 同时匹配 `id` 与 `aliases`。
|
|
341
|
+
|
|
335
342
|
### 用自定义场景做通用意图识别
|
|
336
343
|
|
|
337
344
|
SystemOne 是「结构化问题 → 概率分布」的决策模型,**不是开放式意图分类器**:它的标签空间就是你声明的 `criteria`。所以要「通用意图识别」,就自己定义一个意图场景,把标签集写进去,再把「固定场景」指向它:
|
|
@@ -425,11 +432,11 @@ SystemOne 是「结构化问题 → 概率分布」的决策模型,**不是开
|
|
|
425
432
|
|
|
426
433
|
| 字段 | 必填 | 说明 |
|
|
427
434
|
| --- | --- | --- |
|
|
428
|
-
| `id` | ✅ |
|
|
435
|
+
| `id` | ✅ | 唯一标识;首尾空白会被自动 trim |
|
|
429
436
|
| `title` | ✅ | 展示名 |
|
|
430
437
|
| `description` | | 场景说明(`list` 时展示) |
|
|
431
|
-
| `aliases` | |
|
|
432
|
-
| `questions` | ✅ | 问题映射,最多 16
|
|
438
|
+
| `aliases` | | 别名数组,便于按中文名/英文名查找;与 `id` 平权,冲突时自定义场景优先于内置场景。首尾空白自动 trim,空串/纯空白项会被丢弃 |
|
|
439
|
+
| `questions` | ✅ | 问题映射,最多 16 个(延迟随问题数线性增长)。问题 id 不能是 `__proto__` / `constructor` / `prototype` |
|
|
433
440
|
| `derive` | | 派生字段:`{ name: { question, values } }` 或 `{ name: { question, map } }` |
|
|
434
441
|
| `recommendation` | | 建议模板 |
|
|
435
442
|
|
package/lib/index.js
CHANGED
|
@@ -44,7 +44,8 @@ function createProviderProxy(current) {
|
|
|
44
44
|
*
|
|
45
45
|
* 标了 `.volatile()` 的字段可以在「插件 → @master0071/dsh-systemone → systemone」配置页
|
|
46
46
|
* 里直接修改并立即生效,无需重启;读取时必须经 `unwrap()` 脱壳(见 volatile.js)。
|
|
47
|
-
*
|
|
47
|
+
* 唯一的非 volatile 字段是 `autoDecide`(结构性:决定装配期要不要挂 pre-step 钩子),
|
|
48
|
+
* 它不在配置页里,改动需重启。
|
|
48
49
|
*/
|
|
49
50
|
const Config = z.object({
|
|
50
51
|
/* ── 热更新字段(volatile):配置页可直接编辑 ────────────────────────────── */
|
|
@@ -52,8 +53,9 @@ const Config = z.object({
|
|
|
52
53
|
/**
|
|
53
54
|
* 自定义场景:JSON 数组字符串,同 id 覆盖内置场景。
|
|
54
55
|
*
|
|
55
|
-
* 这是「通用意图识别」的入口——把意图标签集写成 choice 的 criteria
|
|
56
|
-
*
|
|
56
|
+
* 这是「通用意图识别」的入口——把意图标签集写成 choice 的 criteria 即可。
|
|
57
|
+
* volatile:配置页(client.js 的 CUSTOM_FIELDS)可直接编辑,场景库会随保存
|
|
58
|
+
* 立即重建(见 liveScenarios),无需重启。
|
|
57
59
|
*/
|
|
58
60
|
customScenarios: z.string().default('').volatile(),
|
|
59
61
|
/** 提供商类型:unisound(官方)/ http(SystemOne 兼容端点)/ mock(本地模拟) */
|
|
@@ -71,7 +73,7 @@ const Config = z.object({
|
|
|
71
73
|
/** 置信度阈值:任一答案低于该值则建议人工复核 */
|
|
72
74
|
minConfidence: z.number().min(0).max(1).default(0.6).volatile(),
|
|
73
75
|
|
|
74
|
-
/* ──
|
|
76
|
+
/* ── 结构性字段:不在配置页里,改动需要重启 ───────────────────────────────── */
|
|
75
77
|
|
|
76
78
|
/** 是否在每一步推理前自动捕获上下文并执行决策(挂载 pre-step,改动需重启) */
|
|
77
79
|
autoDecide: z.boolean().default(false),
|
|
@@ -128,6 +130,7 @@ export default class SystemOneService extends Service {
|
|
|
128
130
|
this._scenarioSource = null
|
|
129
131
|
this._scenarioRawValue = undefined
|
|
130
132
|
this.scenarioProblems = []
|
|
133
|
+
this.scenarioWarnings = []
|
|
131
134
|
this._scenarioArray = []
|
|
132
135
|
this._refreshScenarios()
|
|
133
136
|
const scenarios = this.liveScenarios()
|
|
@@ -137,7 +140,11 @@ export default class SystemOneService extends Service {
|
|
|
137
140
|
if (!tools || typeof tools.register !== 'function') {
|
|
138
141
|
throw new Error('@master0071/dsh-systemone: 工具注册表不可用(inject: [\'tools\'] 未生效)')
|
|
139
142
|
}
|
|
140
|
-
this._disposers = createTools(this.provider, config, scenarios
|
|
143
|
+
this._disposers = createTools(this.provider, config, scenarios, {
|
|
144
|
+
// 惰性读取:场景库可能已随 customScenarios 热更新重建,
|
|
145
|
+
// 这里必须每次取最新的 warnings,而不是装配期快照。
|
|
146
|
+
warnings: () => this.scenarioWarnings || [],
|
|
147
|
+
}).map((tool) => tools.register(tool))
|
|
141
148
|
|
|
142
149
|
// 自动决策:默认关闭;开启后在每一步推理前自动捕获上下文并注入判断
|
|
143
150
|
this._autoDispose = () => {}
|
|
@@ -192,31 +199,57 @@ export default class SystemOneService extends Service {
|
|
|
192
199
|
* 返回一个 Proxy:任何读取(`.map`、`.length`、`findScenario` 的遍历……)都会
|
|
193
200
|
* 先按需重建,然后转发到真实数组。这样工具与自动决策在装配期捕获一次引用就够,
|
|
194
201
|
* 之后 `customScenarios` 怎么改都能立刻看到新场景。
|
|
202
|
+
*
|
|
203
|
+
* 实现要点:**不维护 trap 白名单**,而是统一走 `_forwardTrap()` 转发全部
|
|
204
|
+
* 内部方法([[Get]]/[[HasProperty]]/[[OwnPropertyKeys]]/[[GetPrototypeOf]]/
|
|
205
|
+
* [[IsExtensible]]……)。白名单的毛病是「漏一个就静默读到旧值」——早期版本
|
|
206
|
+
* 只挂了 get/has/ownKeys/getOwnPropertyDescriptor,`getPrototypeOf` 与
|
|
207
|
+
* `isExtensible` 就绕过了刷新(`instanceof`、按原型嗅探 `Array.prototype`
|
|
208
|
+
* 方法的库都会走这两条路),于是同一份引用在不同消费方眼里可能新旧不一。
|
|
209
|
+
* 补齐 trap 后,将来新增的内部方法也自动覆盖,不需要再回来改这里。
|
|
210
|
+
*
|
|
211
|
+
* 注意 `Array.isArray` 是规范内部方法,不经过任何 trap(也无从拦截),
|
|
212
|
+
* 它靠目标数组本身判定,因此始终正确、无需刷新。
|
|
195
213
|
*/
|
|
196
214
|
liveScenarios() {
|
|
197
215
|
if (this._scenarioProxy === undefined) {
|
|
216
|
+
// 转发全部内部方法:每个 trap 都先刷新,再调用目标数组上的同名方法。
|
|
198
217
|
this._scenarioProxy = new Proxy(this._scenarioArray, {
|
|
199
|
-
get: (
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
this._refreshScenarios()
|
|
213
|
-
return Reflect.getOwnPropertyDescriptor(target, prop)
|
|
214
|
-
},
|
|
218
|
+
get: this._forwardTrap('get'),
|
|
219
|
+
set: this._forwardTrap('set'),
|
|
220
|
+
has: this._forwardTrap('has'),
|
|
221
|
+
deleteProperty: this._forwardTrap('deleteProperty'),
|
|
222
|
+
ownKeys: this._forwardTrap('ownKeys'),
|
|
223
|
+
getOwnPropertyDescriptor: this._forwardTrap('getOwnPropertyDescriptor'),
|
|
224
|
+
defineProperty: this._forwardTrap('defineProperty'),
|
|
225
|
+
getPrototypeOf: this._forwardTrap('getPrototypeOf'),
|
|
226
|
+
setPrototypeOf: this._forwardTrap('setPrototypeOf'),
|
|
227
|
+
isExtensible: this._forwardTrap('isExtensible'),
|
|
228
|
+
preventExtensions: this._forwardTrap('preventExtensions'),
|
|
229
|
+
apply: this._forwardTrap('apply'),
|
|
230
|
+
construct: this._forwardTrap('construct'),
|
|
215
231
|
})
|
|
216
232
|
}
|
|
217
233
|
return this._scenarioProxy
|
|
218
234
|
}
|
|
219
235
|
|
|
236
|
+
/**
|
|
237
|
+
* 生成一个「先刷新、再转发」的 Proxy trap。
|
|
238
|
+
*
|
|
239
|
+
* 统一入口而不是逐个手写,保证刷新逻辑只有一份,新增 trap 也不会漏掉刷新。
|
|
240
|
+
*
|
|
241
|
+
* @param {PropertyKey} name - Reflect 上的方法名,与 trap 名一一对应
|
|
242
|
+
* @returns {(...args: unknown[]) => unknown}
|
|
243
|
+
*/
|
|
244
|
+
_forwardTrap(name) {
|
|
245
|
+
return (...args) => {
|
|
246
|
+
// 目标数组恒为 this._scenarioArray(原地改写,引用不变),
|
|
247
|
+
// 但刷新必须发生在读取之前,否则拿到的是上一版场景。
|
|
248
|
+
this._refreshScenarios()
|
|
249
|
+
return Reflect[name](...args)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
220
253
|
/**
|
|
221
254
|
* 按需重建场景库:`customScenarios` 的原始字符串变了才重建。
|
|
222
255
|
*
|
|
@@ -232,14 +265,20 @@ export default class SystemOneService extends Service {
|
|
|
232
265
|
this._scenarioRawValue = value
|
|
233
266
|
const source = String(value).trim()
|
|
234
267
|
if (source === this._scenarioSource) return
|
|
235
|
-
const { scenarios, problems } = resolveScenarios(source)
|
|
268
|
+
const { scenarios, problems, warnings } = resolveScenarios(source)
|
|
236
269
|
this._scenarioArray.length = 0
|
|
237
270
|
this._scenarioArray.push(...scenarios)
|
|
238
271
|
this._scenarioSource = source
|
|
239
272
|
this.scenarioProblems = problems
|
|
273
|
+
this.scenarioWarnings = warnings
|
|
274
|
+
// problems=致命(条目已被跳过),warnings=非致命提示(别名遮蔽等,场景仍可用)。
|
|
275
|
+
// 两者都用 warn 输出,但文案前缀区分,便于一眼看出是否需要处理。
|
|
240
276
|
for (const problem of problems) {
|
|
241
277
|
this.ctx?.logger?.warn?.(`@master0071/dsh-systemone: ${problem}`)
|
|
242
278
|
}
|
|
279
|
+
for (const warning of warnings) {
|
|
280
|
+
this.ctx?.logger?.warn?.(`@master0071/dsh-systemone(提示): ${warning}`)
|
|
281
|
+
}
|
|
243
282
|
}
|
|
244
283
|
|
|
245
284
|
/** 列出当前全部场景(内置 + 自定义)。 */
|
package/lib/scenarios.js
CHANGED
|
@@ -406,6 +406,12 @@ export function validateScenario(spec) {
|
|
|
406
406
|
if (ids.length === 0) problems.push('questions 至少需要 1 个问题')
|
|
407
407
|
if (ids.length > 16) problems.push(`questions 最多 16 个(当前 ${ids.length} 个,延迟随问题数线性增长)`)
|
|
408
408
|
for (const [qid, q] of Object.entries(questions)) {
|
|
409
|
+
if (isUnsafeKey(qid)) {
|
|
410
|
+
// 提前拦下:这类键在普通对象上会与原型机制冲突(见 normalizeScenario),
|
|
411
|
+
// 与其在规范化时静默丢弃、产出一个零问题的空场景,不如直接报错。
|
|
412
|
+
problems.push(`问题 id 不能是 ${qid}(与对象原型机制冲突)`)
|
|
413
|
+
continue
|
|
414
|
+
}
|
|
409
415
|
if (!q || typeof q !== 'object') {
|
|
410
416
|
problems.push(`问题 ${qid} 必须是对象`)
|
|
411
417
|
continue
|
|
@@ -437,28 +443,50 @@ export function validateScenario(spec) {
|
|
|
437
443
|
* 从配置的 JSON 字符串加载自定义场景,并与内置场景合并。
|
|
438
444
|
* 同 id 的自定义场景会覆盖内置场景;非法条目被跳过并记入 problems。
|
|
439
445
|
*
|
|
446
|
+
* 别名解析的正确性由两件事共同保证:
|
|
447
|
+
* 1. 覆盖内置场景时**继承**被替换者的别名(见下面的 merged),
|
|
448
|
+
* 避免原场景的中文别名变成查不到的死键;
|
|
449
|
+
* 2. `findScenario` 采用「后写优先」的倒序匹配(见该函数注释)。
|
|
450
|
+
* 早期实现用 `Array.find` 正序匹配,等价于「内置永远优先」,
|
|
451
|
+
* 自定义场景声明的别名会被内置场景静默抢走。
|
|
452
|
+
*
|
|
453
|
+
* 返回值分两条通道:
|
|
454
|
+
* - `problems`:**致命**问题,对应场景已被跳过(解析失败、字段非法);
|
|
455
|
+
* - `warnings`:**非致命**提示,场景仍然可用,只是别名解析结果需要知会用户
|
|
456
|
+
* (例如自定义场景的别名与内置场景撞名——按「后写优先」自定义场景获胜,
|
|
457
|
+
* 但被遮蔽者从此查不到,应当告知)。分开是因为 warnings 不该让条目被跳过,
|
|
458
|
+
* 也不该在每次重建时按 problems 的级别刷日志。
|
|
459
|
+
*
|
|
440
460
|
* @param {string} customJson - 自定义场景 JSON(数组)
|
|
441
|
-
* @returns {{ scenarios: object[], problems: string[] }}
|
|
461
|
+
* @returns {{ scenarios: object[], problems: string[], warnings: string[] }}
|
|
442
462
|
*/
|
|
443
463
|
export function resolveScenarios(customJson) {
|
|
444
464
|
const scenarios = BUILTIN_SCENARIOS.map((s) => ({ ...s, source: 'builtin' }))
|
|
445
465
|
const problems = []
|
|
466
|
+
const warnings = []
|
|
446
467
|
const raw = typeof customJson === 'string' ? customJson.trim() : ''
|
|
447
|
-
if (!raw) return { scenarios, problems }
|
|
468
|
+
if (!raw) return { scenarios, problems, warnings }
|
|
448
469
|
|
|
449
470
|
let parsed
|
|
450
471
|
try {
|
|
451
472
|
parsed = JSON.parse(raw)
|
|
452
473
|
} catch (error) {
|
|
453
474
|
problems.push(`自定义场景 JSON 解析失败:${messageOf(error)}`)
|
|
454
|
-
return { scenarios, problems }
|
|
475
|
+
return { scenarios, problems, warnings }
|
|
455
476
|
}
|
|
456
477
|
if (!Array.isArray(parsed)) {
|
|
457
478
|
problems.push('自定义场景必须是 JSON 数组([...])')
|
|
458
|
-
return { scenarios, problems }
|
|
479
|
+
return { scenarios, problems, warnings }
|
|
459
480
|
}
|
|
460
481
|
|
|
482
|
+
// 仅用于「按 id 判断是覆盖还是新增」;别名解析不经过这里。
|
|
461
483
|
const indexById = new Map(scenarios.map((s, i) => [s.id.toLowerCase(), i]))
|
|
484
|
+
// 别名 → 当前持有者,仅用于生成遮蔽提示(不参与查找)。
|
|
485
|
+
const aliasOwner = new Map()
|
|
486
|
+
scenarios.forEach((s) => {
|
|
487
|
+
for (const alias of s.aliases || []) aliasOwner.set(alias.toLowerCase(), s.id)
|
|
488
|
+
})
|
|
489
|
+
|
|
462
490
|
parsed.forEach((spec, i) => {
|
|
463
491
|
const issues = validateScenario(spec)
|
|
464
492
|
if (issues.length > 0) {
|
|
@@ -467,20 +495,69 @@ export function resolveScenarios(customJson) {
|
|
|
467
495
|
}
|
|
468
496
|
const entry = normalizeScenario(spec)
|
|
469
497
|
const key = entry.id.toLowerCase()
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
498
|
+
|
|
499
|
+
// 别名撞名是一条提示:声明者获胜(后写优先),被遮蔽者从此无法通过该别名查到。
|
|
500
|
+
// 覆盖内置场景时继承的别名不算撞名——那是同一个场景换了实现。
|
|
501
|
+
const shadowed = []
|
|
502
|
+
for (const alias of entry.aliases) {
|
|
503
|
+
const akey = alias.toLowerCase()
|
|
504
|
+
// 与自己的 id 同名是无害的(findScenario 两者都会命中自己)
|
|
505
|
+
if (akey === key) continue
|
|
506
|
+
const owner = aliasOwner.get(akey)
|
|
507
|
+
if (owner !== undefined && owner.toLowerCase() !== key) {
|
|
508
|
+
shadowed.push(`别名「${alias}」原本指向 "${owner}"`)
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (!indexById.has(key)) {
|
|
473
513
|
indexById.set(key, scenarios.length)
|
|
474
514
|
scenarios.push(entry)
|
|
515
|
+
} else {
|
|
516
|
+
// 覆盖时继承被替换场景的别名:覆盖者通常只想改 title/questions,
|
|
517
|
+
// 不该顺手把原场景的中文别名变成查不到的死键(省略 aliases ≠ 清空别名)。
|
|
518
|
+
const replaced = scenarios[indexById.get(key)]
|
|
519
|
+
const merged = [...(replaced.aliases || [])]
|
|
520
|
+
for (const alias of entry.aliases || []) {
|
|
521
|
+
if (!merged.some((a) => a.toLowerCase() === alias.toLowerCase())) merged.push(alias)
|
|
522
|
+
}
|
|
523
|
+
entry.aliases = merged
|
|
524
|
+
scenarios[indexById.get(key)] = entry
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// 覆盖已完成,刷新别名归属(继承来的别名也一并登记)
|
|
528
|
+
for (const alias of entry.aliases || []) aliasOwner.set(alias.toLowerCase(), entry.id)
|
|
529
|
+
|
|
530
|
+
if (shadowed.length > 0) {
|
|
531
|
+
warnings.push(
|
|
532
|
+
`自定义场景 "${entry.id}" 的别名与已有场景冲突,按「自定义优先」解析到 "${entry.id}"`
|
|
533
|
+
+ `(被遮蔽者不再能通过该别名查到):${shadowed.join(';')}`,
|
|
534
|
+
)
|
|
475
535
|
}
|
|
476
536
|
})
|
|
477
|
-
return { scenarios, problems }
|
|
537
|
+
return { scenarios, problems, warnings }
|
|
478
538
|
}
|
|
479
539
|
|
|
480
|
-
/**
|
|
540
|
+
/**
|
|
541
|
+
* 规范化场景定义,补齐可选字段。
|
|
542
|
+
*
|
|
543
|
+
* 两处规范化是**必要**的,不是美化:
|
|
544
|
+
*
|
|
545
|
+
* 1. `id` / `aliases` 一律 trim。`validateScenario` 只用 trim 判空,
|
|
546
|
+
* 若这里原样存,而 `findScenario` 又会 trim 查询值,两边就不对称——
|
|
547
|
+
* `id: ' legal\n'` 能通过校验、能出现在 list 里,却连用自己的原值都查不到,
|
|
548
|
+
* 且想覆盖内置场景时只会新增一个永久查不到的副本。
|
|
549
|
+
* trim 后 `indexById` / `aliasOwner` / 去重比较的 key 也与存储值一致。
|
|
550
|
+
*
|
|
551
|
+
* 2. `questions` 用 `Object.create(null)` 建,并跳过 `__proto__` 这类危险键。
|
|
552
|
+
* 普通对象上 `questions['__proto__'] = q` 走的是 [[Set]],改的是**原型**而不是
|
|
553
|
+
* 添加属性;而 `Object.keys()` 又能看到 `__proto__` 这个自有键,
|
|
554
|
+
* 于是「至少 1 个问题」的校验会通过、却产出 0 个问题的空场景,
|
|
555
|
+
* 下游 run 静默返回 ok + 空 answers。无原型的对象天然免疫这类键。
|
|
556
|
+
*/
|
|
481
557
|
function normalizeScenario(spec) {
|
|
482
|
-
const questions =
|
|
558
|
+
const questions = Object.create(null)
|
|
483
559
|
for (const [qid, q] of Object.entries(spec.questions)) {
|
|
560
|
+
if (isUnsafeKey(qid)) continue
|
|
484
561
|
const question = {
|
|
485
562
|
type: q.type,
|
|
486
563
|
instructions: String(q.instructions ?? ''),
|
|
@@ -496,10 +573,12 @@ function normalizeScenario(spec) {
|
|
|
496
573
|
questions[qid] = question
|
|
497
574
|
}
|
|
498
575
|
return {
|
|
499
|
-
id: spec.id,
|
|
576
|
+
id: spec.id.trim(),
|
|
500
577
|
title: spec.title,
|
|
501
578
|
description: typeof spec.description === 'string' ? spec.description : '',
|
|
502
|
-
aliases: Array.isArray(spec.aliases)
|
|
579
|
+
aliases: Array.isArray(spec.aliases)
|
|
580
|
+
? spec.aliases.filter((a) => typeof a === 'string').map((a) => a.trim()).filter(Boolean)
|
|
581
|
+
: [],
|
|
503
582
|
questions,
|
|
504
583
|
derive: spec.derive && typeof spec.derive === 'object' ? spec.derive : {},
|
|
505
584
|
recommendation: typeof spec.recommendation === 'string' ? spec.recommendation : '',
|
|
@@ -508,16 +587,39 @@ function normalizeScenario(spec) {
|
|
|
508
587
|
}
|
|
509
588
|
}
|
|
510
589
|
|
|
590
|
+
/**
|
|
591
|
+
* 判断一个键名是否会与对象内部机制冲突(原型污染类键)。
|
|
592
|
+
*
|
|
593
|
+
* `__proto__` 在普通对象上是访问器而非普通属性,赋值会改原型;
|
|
594
|
+
* `constructor` / `prototype` 虽然赋值本身无害,但没有理由作为问题 id,
|
|
595
|
+
* 一并拒绝以免下游做 `Object.prototype` 相关操作时踩坑。
|
|
596
|
+
*/
|
|
597
|
+
function isUnsafeKey(key) {
|
|
598
|
+
return key === '__proto__' || key === 'constructor' || key === 'prototype'
|
|
599
|
+
}
|
|
600
|
+
|
|
511
601
|
/**
|
|
512
602
|
* 按 id 或别名查找场景(大小写不敏感)。
|
|
603
|
+
*
|
|
604
|
+
* 匹配顺序与 `resolveScenarios` 的登记顺序保持一致——**必须倒序**遍历:
|
|
605
|
+
* 后登记的场景在后,覆盖关系由下标大小体现,倒序即「后写优先」。
|
|
606
|
+
*
|
|
607
|
+
* 早期实现用 `Array.find` 正序匹配,等价于「内置永远优先」,于是自定义场景
|
|
608
|
+
* 声明的别名一旦与内置场景撞名,就会被内置场景静默抢走——场景就在库里,
|
|
609
|
+
* 却永远查不到。
|
|
610
|
+
*
|
|
513
611
|
* @returns {object | undefined}
|
|
514
612
|
*/
|
|
515
613
|
export function findScenario(scenarios, id) {
|
|
516
614
|
if (typeof id !== 'string' || !id.trim()) return undefined
|
|
517
615
|
const needle = id.trim().toLowerCase()
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
616
|
+
for (let i = scenarios.length - 1; i >= 0; i--) {
|
|
617
|
+
const s = scenarios[i]
|
|
618
|
+
if (!s) continue
|
|
619
|
+
if (String(s.id).toLowerCase() === needle) return s
|
|
620
|
+
if ((s.aliases || []).some((a) => String(a).toLowerCase() === needle)) return s
|
|
621
|
+
}
|
|
622
|
+
return undefined
|
|
521
623
|
}
|
|
522
624
|
|
|
523
625
|
function messageOf(err) {
|
package/lib/tools.js
CHANGED
|
@@ -20,17 +20,26 @@ import { readNumber, readString } from './volatile.js'
|
|
|
20
20
|
|
|
21
21
|
const DEFAULT_MIN_CONFIDENCE = 0.6
|
|
22
22
|
|
|
23
|
-
/**
|
|
24
|
-
|
|
23
|
+
/**
|
|
24
|
+
* 创建全部工具定义。
|
|
25
|
+
*
|
|
26
|
+
* @param {object} provider - 提供商
|
|
27
|
+
* @param {object} config - 插件配置
|
|
28
|
+
* @param {object[]} scenarios - 场景库(活引用)
|
|
29
|
+
* @param {object} [hooks] - 可选运行时钩子
|
|
30
|
+
* @param {() => string[]} [hooks.warnings] - 读取当前非致命提示(如别名遮蔽),
|
|
31
|
+
* 用于让 `action=list` 把「哪个别名被抢走了」告诉模型,而不只是写进宿主日志。
|
|
32
|
+
*/
|
|
33
|
+
export function createTools(provider, config, scenarios, hooks = {}) {
|
|
25
34
|
return [
|
|
26
|
-
scenarioTool(provider, config, scenarios),
|
|
35
|
+
scenarioTool(provider, config, scenarios, hooks),
|
|
27
36
|
decideTool(provider, config),
|
|
28
37
|
]
|
|
29
38
|
}
|
|
30
39
|
|
|
31
40
|
/* ─── 1. 场景调度工具 ─────────────────────────────────────────────────────── */
|
|
32
41
|
|
|
33
|
-
function scenarioTool(provider, config, scenarios) {
|
|
42
|
+
function scenarioTool(provider, config, scenarios, hooks = {}) {
|
|
34
43
|
return {
|
|
35
44
|
name: 'systemone_scenario',
|
|
36
45
|
description: [
|
|
@@ -74,7 +83,7 @@ function scenarioTool(provider, config, scenarios) {
|
|
|
74
83
|
const action = args.action || 'run'
|
|
75
84
|
|
|
76
85
|
if (action === 'list') {
|
|
77
|
-
return listScenarios(scenarios, args.keyword)
|
|
86
|
+
return listScenarios(scenarios, args.keyword, readWarnings(hooks))
|
|
78
87
|
}
|
|
79
88
|
if (action === 'describe') {
|
|
80
89
|
return describeScenario(scenarios, args.scenario)
|
|
@@ -98,7 +107,17 @@ function scenarioTool(provider, config, scenarios) {
|
|
|
98
107
|
}
|
|
99
108
|
}
|
|
100
109
|
|
|
101
|
-
|
|
110
|
+
/** 安全读取钩子里的提示列表:钩子缺失或抛错都不应影响 list。 */
|
|
111
|
+
function readWarnings(hooks) {
|
|
112
|
+
try {
|
|
113
|
+
const value = hooks?.warnings?.()
|
|
114
|
+
return Array.isArray(value) ? value : []
|
|
115
|
+
} catch {
|
|
116
|
+
return []
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function listScenarios(scenarios, keyword, warnings = []) {
|
|
102
121
|
const needle = typeof keyword === 'string' ? keyword.trim().toLowerCase() : ''
|
|
103
122
|
const items = scenarios
|
|
104
123
|
.filter((s) => {
|
|
@@ -120,11 +139,23 @@ function listScenarios(scenarios, keyword) {
|
|
|
120
139
|
const summary = [
|
|
121
140
|
`## SystemOne 场景库(${items.length} 个)`,
|
|
122
141
|
...items.map((s) => `- **${s.id}**:${s.title} — ${s.question_count} 个问题(${s.questions.join(',')})`),
|
|
142
|
+
// 别名遮蔽提示只在 list 时给出:它影响「用别名能否调到这个场景」,
|
|
143
|
+
// 不说的话调用方会以为是场景不存在,而不是别名被别的场景占了。
|
|
144
|
+
...(warnings.length > 0
|
|
145
|
+
? ['', '### 配置提示', ...warnings.map((w) => `- ${w}`)]
|
|
146
|
+
: []),
|
|
123
147
|
'',
|
|
124
148
|
'用 action=describe 查看某场景的问题定义,用 action=run 执行决策。',
|
|
125
149
|
].join('\n')
|
|
126
150
|
|
|
127
|
-
return {
|
|
151
|
+
return {
|
|
152
|
+
ok: true,
|
|
153
|
+
action: 'list',
|
|
154
|
+
count: items.length,
|
|
155
|
+
scenarios: items,
|
|
156
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
157
|
+
summary,
|
|
158
|
+
}
|
|
128
159
|
}
|
|
129
160
|
|
|
130
161
|
function describeScenario(scenarios, id) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@master0071/dsh-systemone",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"description": "DeepSeek Harness 插件:将 SystemOne 决策模型(默认云知声 u2-decision,可切换提供商)接入软件开发、客服、内容审核、路由、销售线索、风控、招聘、数据治理、教育、需求变更等 10 大业务场景,为 Agent 返回带概率分布的决策与处置建议。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|