@master0071/dsh-systemone 0.0.0-stage → 0.4.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.
@@ -0,0 +1,526 @@
1
+ /**
2
+ * scenarios.js — SystemOne 场景库。
3
+ *
4
+ * 一个"场景"= 一组结构化问题(choice / noul / score)+ 可选的派生字段与建议模板。
5
+ * 场景是与厂商无关的纯数据,既可以是内置的,也可以由用户在插件配置里用 JSON 追加。
6
+ *
7
+ * 场景定义格式:
8
+ * {
9
+ * id: string, // 唯一标识(必填)
10
+ * title: string, // 展示名(必填)
11
+ * description?: string, // 场景说明
12
+ * aliases?: string[], // 别名,便于模型用中文名/英文名查找
13
+ * questions: { // 问题映射(必填,至少 1 个)
14
+ * [qid]: {
15
+ * type: 'choice' | 'noul' | 'score',
16
+ * instructions: string, // 给模型的问题描述
17
+ * label?: string, // 摘要里的显示名(默认用 qid)
18
+ * criteria?: object | string[], // choice=选项映射;score=分级标签数组
19
+ * }
20
+ * },
21
+ * derive?: { // 可选派生字段
22
+ * [name]: { question: string, values?: string[], map?: object }
23
+ * },
24
+ * recommendation?: string, // 可选建议模板
25
+ * }
26
+ *
27
+ * 建议模板语法:
28
+ * {qid} → 该问题的可读标签(choice=选项说明,score=分级说明,noul=是/否)
29
+ * {qid.key} → 原始值(choice=选项 key,score=取整后的分值,noul=true/false)
30
+ * {qid.confidence} → 置信度百分比
31
+ * {derivedName} → 派生字段
32
+ * {qid?文案A|文案B} → 条件文案(noul 为真 / 分值≥1 / choice 非空非 none 时取 A)
33
+ */
34
+
35
+ /** 内置场景:10 个业务域,每个 3 个结构化问题。 */
36
+ export const BUILTIN_SCENARIOS = [
37
+ {
38
+ id: 'customer_service',
39
+ title: '客服运营 · 工单派单与分流',
40
+ description: '判断工单归属团队、严重程度,以及是否需要立即升级值班或 PO。',
41
+ aliases: ['ticket_triage', '工单分流', '客服', '派单'],
42
+ questions: {
43
+ department: {
44
+ type: 'choice',
45
+ label: '受理部门',
46
+ instructions: '这个工单应该由哪个团队处理?',
47
+ criteria: {
48
+ billing: '支付、退款和账单问题',
49
+ technical: '产品故障和集成问题',
50
+ account: '账号、权限与登录问题',
51
+ logistics: '物流与交付问题',
52
+ other: '其他问题',
53
+ },
54
+ },
55
+ severity: {
56
+ type: 'score',
57
+ label: '严重程度',
58
+ instructions: '这个问题有多严重?',
59
+ criteria: [
60
+ '轻微问题,不影响功能',
61
+ '部分功能受影响,但存在替代方案',
62
+ '核心功能不可用,没有替代方案',
63
+ '造成严重业务或安全影响',
64
+ ],
65
+ },
66
+ escalate: {
67
+ type: 'noul',
68
+ label: '立即升级',
69
+ instructions: '是否需要立即通知值班人员或升级 PO?',
70
+ },
71
+ },
72
+ derive: { priority: { question: 'severity', values: ['P4', 'P3', 'P2', 'P1'] } },
73
+ recommendation: '转 {department} 处理(优先级 {priority})。{escalate?需立即通知值班人员|无需紧急升级}',
74
+ },
75
+ {
76
+ id: 'content_moderation',
77
+ title: '内容审核 · 违规判定与处置',
78
+ description: '判定内容是否违规、违规类型归类、风险等级,并给出放行/复审/拦截动作。',
79
+ aliases: ['moderation', '内容安全', '审核'],
80
+ questions: {
81
+ violation: {
82
+ type: 'choice',
83
+ label: '违规类型',
84
+ instructions: '这段内容属于哪种情况?',
85
+ criteria: {
86
+ none: '正常内容,无违规',
87
+ violence: '暴力或血腥内容',
88
+ hate: '仇恨或歧视言论',
89
+ sexual: '色情或低俗内容',
90
+ fraud: '诈骗、虚假或误导信息',
91
+ politics: '政治敏感内容',
92
+ other: '其他违规类型',
93
+ },
94
+ },
95
+ severity: {
96
+ type: 'score',
97
+ label: '风险等级',
98
+ instructions: '这段内容的风险程度有多高?',
99
+ criteria: ['无风险', '低风险', '中风险', '高风险'],
100
+ },
101
+ action: {
102
+ type: 'choice',
103
+ label: '处置动作',
104
+ instructions: '应该采取什么处置动作?',
105
+ criteria: {
106
+ allow: '放行',
107
+ review: '进入人工复审',
108
+ block: '拦截并下架',
109
+ },
110
+ },
111
+ },
112
+ recommendation: '处置动作「{action}」,违规类型「{violation}」,风险等级 {severity}。',
113
+ },
114
+ {
115
+ id: 'agent_routing',
116
+ title: '智能体路由 · 意图分流与工具决策',
117
+ description: '识别用户意图,判断是否需要调用工具或检索知识库,以及是否需要转人工。',
118
+ aliases: ['routing', '意图分流', '智能体'],
119
+ questions: {
120
+ intent: {
121
+ type: 'choice',
122
+ label: '用户意图',
123
+ instructions: '这个请求属于哪类意图?',
124
+ criteria: {
125
+ knowledge_qa: '知识问答',
126
+ order_service: '订单与售后',
127
+ billing: '账单与支付',
128
+ technical_support: '技术支持',
129
+ chitchat: '闲聊或无法归类',
130
+ },
131
+ },
132
+ use_tool: {
133
+ type: 'noul',
134
+ label: '调用工具',
135
+ instructions: '是否需要调用外部工具或检索知识库?',
136
+ },
137
+ handoff: {
138
+ type: 'noul',
139
+ label: '转人工',
140
+ instructions: '是否需要转交人工客服处理?',
141
+ },
142
+ },
143
+ recommendation: '路由到「{intent}」。{use_tool?需要调用工具或检索|可直接回答},{handoff?需转人工|可自动处理}',
144
+ },
145
+ {
146
+ id: 'sales_lead',
147
+ title: '销售线索 · 质量评分与分配',
148
+ description: '评估线索质量、分配归属,判断是否值得主动跟进。',
149
+ aliases: ['lead_scoring', '线索评分', '销售'],
150
+ questions: {
151
+ quality: {
152
+ type: 'score',
153
+ label: '线索质量',
154
+ instructions: '这条线索的质量有多高?',
155
+ criteria: ['无效线索', '低质量', '中等质量', '高质量', '极高意向'],
156
+ },
157
+ owner: {
158
+ type: 'choice',
159
+ label: '线索归属',
160
+ instructions: '这条线索应该分配给谁?',
161
+ criteria: {
162
+ enterprise_sales: '大客户销售',
163
+ smb_sales: '中小客户销售',
164
+ channel: '渠道团队',
165
+ self_service: '自助注册,无需跟进',
166
+ },
167
+ },
168
+ follow_up: {
169
+ type: 'noul',
170
+ label: '是否跟进',
171
+ instructions: '这条线索是否值得主动跟进?',
172
+ },
173
+ },
174
+ recommendation: '线索质量 {quality} 分,分配给「{owner}」。{follow_up?值得主动跟进|暂不跟进}',
175
+ },
176
+ {
177
+ id: 'risk_control',
178
+ title: '金融风控 · 交易异常与风险等级',
179
+ description: '评估交易异常程度、划分风险等级,判断是否需要人工复核。',
180
+ aliases: ['fraud', '风控', '反欺诈'],
181
+ questions: {
182
+ anomaly: {
183
+ type: 'score',
184
+ label: '异常评分',
185
+ instructions: '这笔交易的异常程度有多高?',
186
+ criteria: ['完全正常', '轻微异常', '可疑', '高度可疑', '明确欺诈'],
187
+ },
188
+ risk_level: {
189
+ type: 'choice',
190
+ label: '风险等级',
191
+ instructions: '这笔交易应归入哪个风险等级?',
192
+ criteria: {
193
+ low: '低风险',
194
+ medium: '中风险',
195
+ high: '高风险',
196
+ critical: '极高风险',
197
+ },
198
+ },
199
+ manual_review: {
200
+ type: 'noul',
201
+ label: '人工复核',
202
+ instructions: '是否需要转人工复核?',
203
+ },
204
+ },
205
+ recommendation: '交易异常评分 {anomaly},风险等级「{risk_level}」。{manual_review?需转人工复核|可自动放行}',
206
+ },
207
+ {
208
+ id: 'recruiting',
209
+ title: '招聘 HR · 简历匹配与流程推进',
210
+ description: '评估简历匹配度、判断是否进入下一轮,并给出岗位归属建议。',
211
+ aliases: ['hr', 'resume', '招聘'],
212
+ questions: {
213
+ match: {
214
+ type: 'score',
215
+ label: '匹配度',
216
+ instructions: '这份简历与岗位的匹配度有多高?',
217
+ criteria: ['不匹配', '匹配度较低', '基本匹配', '匹配度较高', '高度匹配'],
218
+ },
219
+ next_round: {
220
+ type: 'noul',
221
+ label: '进入下一轮',
222
+ instructions: '是否建议进入下一轮面试?',
223
+ },
224
+ position: {
225
+ type: 'choice',
226
+ label: '岗位归属',
227
+ instructions: '这份简历更适合哪个岗位?',
228
+ criteria: {
229
+ backend: '后端工程师',
230
+ frontend: '前端工程师',
231
+ algorithm: '算法工程师',
232
+ product: '产品经理',
233
+ operation: '运营',
234
+ talent_pool: '人才库储备',
235
+ },
236
+ },
237
+ },
238
+ recommendation: '简历匹配度 {match} 分,建议岗位「{position}」。{next_round?进入下一轮|本轮不通过}',
239
+ },
240
+ {
241
+ id: 'data_governance',
242
+ title: '数据治理 · 打标、归因与敏感识别',
243
+ description: '自动打标文档、归因数据问题,并识别是否包含敏感数据。',
244
+ aliases: ['governance', '数据标注', '数据治理'],
245
+ questions: {
246
+ tags: {
247
+ type: 'choice',
248
+ label: '文档标签',
249
+ instructions: '这份文档应该打上哪个标签?',
250
+ criteria: {
251
+ contract: '合同与法务',
252
+ finance: '财务与账单',
253
+ product_doc: '产品文档',
254
+ technical_doc: '技术文档',
255
+ hr_doc: '人事文档',
256
+ other: '其他',
257
+ },
258
+ },
259
+ root_cause: {
260
+ type: 'choice',
261
+ label: '问题归因',
262
+ instructions: '数据问题最可能的根因是什么?',
263
+ criteria: {
264
+ upstream_missing: '上游数据缺失',
265
+ pipeline_bug: '采集/加工链路缺陷',
266
+ schema_change: '模型或口径变更',
267
+ business_change: '业务规则变更',
268
+ unknown: '无法判断',
269
+ },
270
+ },
271
+ sensitive: {
272
+ type: 'noul',
273
+ label: '敏感数据',
274
+ instructions: '这份数据是否包含敏感信息(个人信息或商业机密)?',
275
+ },
276
+ },
277
+ recommendation: '文档标签「{tags}」,问题归因「{root_cause}」。{sensitive?包含敏感数据,需脱敏处理|非敏感数据}',
278
+ },
279
+ {
280
+ id: 'education',
281
+ title: '教育内容 · 知识点、难度与合规',
282
+ description: '归类题目知识点、评定难度等级,并预检内容合规性。',
283
+ aliases: ['edu', '教学内容', '教育'],
284
+ questions: {
285
+ knowledge_point: {
286
+ type: 'choice',
287
+ label: '知识点',
288
+ instructions: '这道题考查哪个知识点?',
289
+ criteria: {
290
+ algebra: '代数',
291
+ geometry: '几何',
292
+ probability: '概率统计',
293
+ physics_mechanics: '力学',
294
+ physics_electricity: '电学',
295
+ chemistry: '化学',
296
+ language: '语文/英语',
297
+ other: '其他',
298
+ },
299
+ },
300
+ difficulty: {
301
+ type: 'score',
302
+ label: '难度等级',
303
+ instructions: '这道题的难度有多高?',
304
+ criteria: ['入门', '基础', '中等', '较难', '竞赛级'],
305
+ },
306
+ compliance: {
307
+ type: 'noul',
308
+ label: '合规预检',
309
+ instructions: '内容是否通过合规预检(无违规内容、无错误导向)?',
310
+ },
311
+ },
312
+ recommendation: '知识点「{knowledge_point}」,难度 {difficulty}。{compliance?合规预检通过|存在合规风险,需人工复核}',
313
+ },
314
+ {
315
+ id: 'requirements',
316
+ title: '需求与变更 · 优先级、风险与派发',
317
+ description: '评估需求优先级、变更风险等级,判断是否需要拆分并派发子任务。',
318
+ aliases: ['requirement', '变更管理', '需求'],
319
+ questions: {
320
+ priority: {
321
+ type: 'score',
322
+ label: '优先级',
323
+ instructions: '这个需求或变更的优先级有多高?',
324
+ criteria: ['可延后', '低优先级', '中优先级', '高优先级', '最高优先级,需立即排期'],
325
+ },
326
+ change_risk: {
327
+ type: 'choice',
328
+ label: '变更风险',
329
+ instructions: '这个变更的风险等级是什么?',
330
+ criteria: {
331
+ low: '低风险,可直接上线',
332
+ medium: '中风险,需灰度验证',
333
+ high: '高风险,需完整回归',
334
+ critical: '极高风险,需专项评审',
335
+ },
336
+ },
337
+ subtask_dispatch: {
338
+ type: 'noul',
339
+ label: '拆分派发',
340
+ instructions: '是否需要拆分并派发子任务?',
341
+ },
342
+ },
343
+ recommendation: '优先级 {priority},变更风险「{change_risk}」。{subtask_dispatch?需拆分并派发子任务|无需拆分}',
344
+ },
345
+ {
346
+ id: 'software_dev',
347
+ title: '软件开发 · 任务类型、复杂度与处置',
348
+ description: '判断编码请求属于缺陷修复、功能开发、重构、评审、测试、文档还是构建运维,评估复杂度并给出处置建议。',
349
+ aliases: ['software', 'dev', 'coding', '开发', '编程', '编码', '研发'],
350
+ questions: {
351
+ task_type: {
352
+ type: 'choice',
353
+ label: '任务类型',
354
+ instructions: '这个编码请求属于哪一类开发任务?',
355
+ criteria: {
356
+ bugfix: '修复缺陷或报错',
357
+ feature: '新增功能或能力',
358
+ refactor: '重构、整理或清理代码',
359
+ review: '代码评审与质量检查',
360
+ test: '编写或修复测试',
361
+ docs: '文档、注释与说明',
362
+ build: '构建、依赖、CI 与环境',
363
+ perf: '性能优化',
364
+ other: '其他开发任务',
365
+ },
366
+ },
367
+ complexity: {
368
+ type: 'score',
369
+ label: '复杂度',
370
+ instructions: '完成这个任务需要改动的范围有多大?',
371
+ criteria: [
372
+ '单点改动,一处即可完成',
373
+ '局部改动,涉及少数几个文件',
374
+ '跨模块改动,需要理解多处上下文',
375
+ '系统性改动,影响架构或大量代码',
376
+ ],
377
+ },
378
+ needs_context: {
379
+ type: 'noul',
380
+ label: '需先探查代码库',
381
+ instructions: '动手前是否需要先检索代码库、阅读相关实现或运行验证?',
382
+ },
383
+ },
384
+ derive: { effort: { question: 'complexity', values: ['S', 'M', 'L', 'XL'] } },
385
+ recommendation: '任务类型「{task_type}」,改动规模 {effort}。{needs_context?建议先检索代码库并确认相关实现|可直接动手}',
386
+ },
387
+ ]
388
+
389
+ const VALID_TYPES = new Set(['choice', 'noul', 'score'])
390
+
391
+ /**
392
+ * 校验一个场景定义。
393
+ * @returns {string[]} 问题列表,空数组表示合法。
394
+ */
395
+ export function validateScenario(spec) {
396
+ const problems = []
397
+ if (!spec || typeof spec !== 'object') return ['场景必须是对象']
398
+ if (typeof spec.id !== 'string' || !spec.id.trim()) problems.push('缺少 id')
399
+ if (typeof spec.title !== 'string' || !spec.title.trim()) problems.push('缺少 title')
400
+ const questions = spec.questions
401
+ if (!questions || typeof questions !== 'object' || Array.isArray(questions)) {
402
+ problems.push('缺少 questions 对象')
403
+ return problems
404
+ }
405
+ const ids = Object.keys(questions)
406
+ if (ids.length === 0) problems.push('questions 至少需要 1 个问题')
407
+ if (ids.length > 16) problems.push(`questions 最多 16 个(当前 ${ids.length} 个,延迟随问题数线性增长)`)
408
+ for (const [qid, q] of Object.entries(questions)) {
409
+ if (!q || typeof q !== 'object') {
410
+ problems.push(`问题 ${qid} 必须是对象`)
411
+ continue
412
+ }
413
+ if (!VALID_TYPES.has(q.type)) {
414
+ problems.push(`问题 ${qid} 的 type 必须是 choice/noul/score`)
415
+ continue
416
+ }
417
+ if (typeof q.instructions !== 'string' || !q.instructions.trim()) {
418
+ problems.push(`问题 ${qid} 缺少 instructions`)
419
+ }
420
+ if (q.type === 'choice') {
421
+ if (!q.criteria || typeof q.criteria !== 'object' || Array.isArray(q.criteria)) {
422
+ problems.push(`问题 ${qid}(choice)的 criteria 必须是"选项 key → 说明"的对象`)
423
+ } else if (Object.keys(q.criteria).length < 2) {
424
+ problems.push(`问题 ${qid}(choice)至少需要 2 个选项`)
425
+ } else if (Object.keys(q.criteria).length > 26) {
426
+ problems.push(`问题 ${qid}(choice)选项建议不超过 26 个`)
427
+ }
428
+ }
429
+ if (q.type === 'score' && (!Array.isArray(q.criteria) || q.criteria.length < 2)) {
430
+ problems.push(`问题 ${qid}(score)的 criteria 必须是长度 ≥2 的分级标签数组`)
431
+ }
432
+ }
433
+ return problems
434
+ }
435
+
436
+ /**
437
+ * 从配置的 JSON 字符串加载自定义场景,并与内置场景合并。
438
+ * 同 id 的自定义场景会覆盖内置场景;非法条目被跳过并记入 problems。
439
+ *
440
+ * @param {string} customJson - 自定义场景 JSON(数组)
441
+ * @returns {{ scenarios: object[], problems: string[] }}
442
+ */
443
+ export function resolveScenarios(customJson) {
444
+ const scenarios = BUILTIN_SCENARIOS.map((s) => ({ ...s, source: 'builtin' }))
445
+ const problems = []
446
+ const raw = typeof customJson === 'string' ? customJson.trim() : ''
447
+ if (!raw) return { scenarios, problems }
448
+
449
+ let parsed
450
+ try {
451
+ parsed = JSON.parse(raw)
452
+ } catch (error) {
453
+ problems.push(`自定义场景 JSON 解析失败:${messageOf(error)}`)
454
+ return { scenarios, problems }
455
+ }
456
+ if (!Array.isArray(parsed)) {
457
+ problems.push('自定义场景必须是 JSON 数组([...])')
458
+ return { scenarios, problems }
459
+ }
460
+
461
+ const indexById = new Map(scenarios.map((s, i) => [s.id.toLowerCase(), i]))
462
+ parsed.forEach((spec, i) => {
463
+ const issues = validateScenario(spec)
464
+ if (issues.length > 0) {
465
+ problems.push(`自定义场景 #${i + 1} 已跳过:${issues.join(';')}`)
466
+ return
467
+ }
468
+ const entry = normalizeScenario(spec)
469
+ const key = entry.id.toLowerCase()
470
+ if (indexById.has(key)) {
471
+ scenarios[indexById.get(key)] = entry
472
+ } else {
473
+ indexById.set(key, scenarios.length)
474
+ scenarios.push(entry)
475
+ }
476
+ })
477
+ return { scenarios, problems }
478
+ }
479
+
480
+ /** 规范化场景定义,补齐可选字段。 */
481
+ function normalizeScenario(spec) {
482
+ const questions = {}
483
+ for (const [qid, q] of Object.entries(spec.questions)) {
484
+ const question = {
485
+ type: q.type,
486
+ instructions: String(q.instructions ?? ''),
487
+ label: typeof q.label === 'string' && q.label ? q.label : qid,
488
+ }
489
+ if (q.type === 'choice') {
490
+ question.criteria = Object.fromEntries(
491
+ Object.entries(q.criteria).map(([k, v]) => [String(k), String(v)]),
492
+ )
493
+ } else if (q.type === 'score') {
494
+ question.criteria = q.criteria.map((v) => String(v))
495
+ }
496
+ questions[qid] = question
497
+ }
498
+ return {
499
+ id: spec.id,
500
+ title: spec.title,
501
+ description: typeof spec.description === 'string' ? spec.description : '',
502
+ aliases: Array.isArray(spec.aliases) ? spec.aliases.filter((a) => typeof a === 'string') : [],
503
+ questions,
504
+ derive: spec.derive && typeof spec.derive === 'object' ? spec.derive : {},
505
+ recommendation: typeof spec.recommendation === 'string' ? spec.recommendation : '',
506
+ minConfidence: Number.isFinite(Number(spec.minConfidence)) ? Number(spec.minConfidence) : undefined,
507
+ source: 'custom',
508
+ }
509
+ }
510
+
511
+ /**
512
+ * 按 id 或别名查找场景(大小写不敏感)。
513
+ * @returns {object | undefined}
514
+ */
515
+ export function findScenario(scenarios, id) {
516
+ if (typeof id !== 'string' || !id.trim()) return undefined
517
+ const needle = id.trim().toLowerCase()
518
+ return scenarios.find(
519
+ (s) => s.id.toLowerCase() === needle || (s.aliases || []).some((a) => a.toLowerCase() === needle),
520
+ )
521
+ }
522
+
523
+ function messageOf(err) {
524
+ if (err !== null && typeof err === 'object' && typeof err.message === 'string') return err.message
525
+ return String(err)
526
+ }