@master0071/dsh-systemone 0.5.0 → 0.6.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/lib/tools.js CHANGED
@@ -8,14 +8,15 @@
8
8
  * 场景全部来自 scenarios.js 的场景库(内置 11 个 + 配置追加),新增场景无需改代码。
9
9
  */
10
10
  import {
11
- computeNeedsHumanReview,
12
11
  deriveFields,
13
12
  formatAnswerLines,
14
13
  formatSummary,
15
14
  normalizeAnswers,
16
15
  renderTemplate,
16
+ reviewAssessment,
17
17
  } from './format.js'
18
18
  import { findScenario } from './scenarios.js'
19
+ import { recordUsage } from './usage.js'
19
20
  import { readNumber, readString } from './volatile.js'
20
21
 
21
22
  const DEFAULT_MIN_CONFIDENCE = 0.6
@@ -25,7 +26,7 @@ const DEFAULT_MIN_CONFIDENCE = 0.6
25
26
  * MAX_CURRENT_CHARS / 每条历史 2000 字),这里只防工具路径被塞进超大输入
26
27
  * 直接打到上游 API;超长内容对决策质量也没有增量。
27
28
  */
28
- const MAX_TOOL_STATE_CHARS = 16000
29
+ export const MAX_TOOL_STATE_CHARS = 16000
29
30
 
30
31
  /**
31
32
  * 给 state 套硬上限:超长字符串截断;对象/数组按序列化长度衡量,
@@ -59,11 +60,12 @@ function capState(state) {
59
60
  * @param {object} [hooks] - 可选运行时钩子
60
61
  * @param {() => string[]} [hooks.warnings] - 读取当前非致命提示(如别名遮蔽),
61
62
  * 用于让 `action=list` 把「哪个别名被抢走了」告诉模型,而不只是写进宿主日志。
63
+ * @param {object} [hooks.usage] - 用量台账(createUsageLedger 的返回值)。有则记录每次上游调用。
62
64
  */
63
65
  export function createTools(provider, config, scenarios, hooks = {}) {
64
66
  return [
65
67
  scenarioTool(provider, config, scenarios, hooks),
66
- decideTool(provider, config),
68
+ decideTool(provider, config, hooks),
67
69
  ]
68
70
  }
69
71
 
@@ -113,7 +115,7 @@ function scenarioTool(provider, config, scenarios, hooks = {}) {
113
115
  const action = args.action || 'run'
114
116
 
115
117
  if (action === 'list') {
116
- return listScenarios(scenarios, args.keyword, readWarnings(hooks))
118
+ return listScenarios(scenarios, args.keyword, readWarnings(hooks), hooks?.usage)
117
119
  }
118
120
  if (action === 'describe') {
119
121
  return describeScenario(scenarios, args.scenario)
@@ -126,6 +128,7 @@ function scenarioTool(provider, config, scenarios, hooks = {}) {
126
128
  state: capState(args.state),
127
129
  params: args.params,
128
130
  exec,
131
+ usage: hooks?.usage,
129
132
  })
130
133
  },
131
134
  presentCall: (args) => ({
@@ -147,7 +150,7 @@ function readWarnings(hooks) {
147
150
  }
148
151
  }
149
152
 
150
- function listScenarios(scenarios, keyword, warnings = []) {
153
+ function listScenarios(scenarios, keyword, warnings = [], usage) {
151
154
  const needle = typeof keyword === 'string' ? keyword.trim().toLowerCase() : ''
152
155
  const items = scenarios
153
156
  .filter((s) => {
@@ -166,6 +169,19 @@ function listScenarios(scenarios, keyword, warnings = []) {
166
169
  questions: Object.entries(s.questions).map(([qid, q]) => `${qid}:${q.type}`),
167
170
  }))
168
171
 
172
+ // 用量台账:list 是调用方最容易顺手调用的入口,把今日花销挂在这里,
173
+ // 让「花了多少」不需要另开一个工具就能看到。
174
+ let usageLine = null
175
+ let usageSnapshot = null
176
+ try {
177
+ if (typeof usage?.today === 'function') {
178
+ usageSnapshot = usage.today()
179
+ usageLine = typeof usage.formatToday === 'function' ? usage.formatToday() : null
180
+ }
181
+ } catch {
182
+ // 台账读取失败不影响 list
183
+ }
184
+
169
185
  const summary = [
170
186
  `## SystemOne 场景库(${items.length} 个)`,
171
187
  ...items.map((s) => `- **${s.id}**:${s.title} — ${s.question_count} 个问题(${s.questions.join(',')})`),
@@ -174,6 +190,7 @@ function listScenarios(scenarios, keyword, warnings = []) {
174
190
  ...(warnings.length > 0
175
191
  ? ['', '### 配置提示', ...warnings.map((w) => `- ${w}`)]
176
192
  : []),
193
+ ...(usageLine ? ['', '### 用量', `- ${usageLine}`] : []),
177
194
  '',
178
195
  '用 action=describe 查看某场景的问题定义,用 action=run 执行决策。',
179
196
  ].join('\n')
@@ -184,6 +201,7 @@ function listScenarios(scenarios, keyword, warnings = []) {
184
201
  count: items.length,
185
202
  scenarios: items,
186
203
  ...(warnings.length > 0 ? { warnings } : {}),
204
+ ...(usageSnapshot ? { usage_today: usageSnapshot } : {}),
187
205
  summary,
188
206
  }
189
207
  }
@@ -237,6 +255,7 @@ function describeScenario(scenarios, id) {
237
255
  * @param {object} [options.params] - 按问题 id 覆盖问题定义
238
256
  * @param {object} [options.exec] - 工具执行上下文(取 signal)
239
257
  * @param {number} [options.minConfidence] - 覆盖置信度阈值
258
+ * @param {object} [options.usage] - 用量台账;有则记录本次调用
240
259
  * @returns {Promise<object>} 与工具输出同构的决策结果
241
260
  */
242
261
  export async function runScenarioDecision({
@@ -248,6 +267,7 @@ export async function runScenarioDecision({
248
267
  params,
249
268
  exec,
250
269
  minConfidence,
270
+ usage,
251
271
  }) {
252
272
  const scenario = findScenario(scenarios, scenarioRef)
253
273
  if (!scenario) {
@@ -286,6 +306,7 @@ export async function runScenarioDecision({
286
306
  config,
287
307
  minConfidence: threshold,
288
308
  exec,
309
+ usage,
289
310
  warnings: ignored.length > 0
290
311
  ? [`以下 params 覆盖无效,已忽略:${ignored.join(';')}`]
291
312
  : [],
@@ -355,7 +376,7 @@ function applyParams(questions, params) {
355
376
 
356
377
  /* ─── 2. 通用决策工具 ─────────────────────────────────────────────────────── */
357
378
 
358
- function decideTool(provider, config) {
379
+ function decideTool(provider, config, hooks = {}) {
359
380
  return {
360
381
  name: 'systemone_decide',
361
382
  description: [
@@ -404,6 +425,7 @@ function decideTool(provider, config) {
404
425
  minConfidence,
405
426
  exec,
406
427
  model: args.model,
428
+ usage: hooks?.usage,
407
429
  finalize(answers) {
408
430
  const { decision, labels, confidences } = normalizeAnswers(answers, args.questions || {})
409
431
  return { decision, labels, derived: {}, confidences, recommendation: '' }
@@ -433,6 +455,7 @@ async function executeDecision({
433
455
  model,
434
456
  finalize,
435
457
  warnings = [],
458
+ usage,
436
459
  }) {
437
460
  try {
438
461
  const started = Date.now()
@@ -443,9 +466,69 @@ async function executeDecision({
443
466
  model: model || configuredModel,
444
467
  signal: exec?.signal,
445
468
  })
469
+ // 记账:成功与失败都记(上游可能已计费)。记账失败绝不影响决策。
470
+ recordUsage(usage, {
471
+ source: 'tool',
472
+ ok: true,
473
+ model: response.model || model || configuredModel,
474
+ provider: provider.name,
475
+ scenario: scenarioId || '',
476
+ usage: response.usage,
477
+ latencyMs: response.latency_ms,
478
+ })
446
479
  const answers = response.answers || {}
447
480
  const { decision, labels, derived, confidences, recommendation } = finalize(answers)
448
- const needsHumanReview = computeNeedsHumanReview(answers, minConfidence)
481
+
482
+ // ── 空决策守卫 ────────────────────────────────────────────────────────────
483
+ // 必须同时覆盖两种"沉默"形态:
484
+ // a) answers 为空 / 全是无法识别的 type → decision 里一个键都没有;
485
+ // b) 有 answers 但关键字段缺失(如 noul 答案没有 noul 数值)→
486
+ // normalizeAnswers 会把该问题写成 null("无法判断")。
487
+ // 注意 type 的取值是 `question.type || raw.type`:上游给个无法识别的 type、
488
+ // 而问题定义是合法 type 时,会走 (b) 这条路——所以只看 decision 是否为空
489
+ // 是不够的,必须看**有没有任何一个问题真正产出了判断**。
490
+ // 若此时仍报 ok:true,调用方(模型)会以为"判断已完成",实际拿到的是空判断;
491
+ // 对决策工具来说,这种静默空成功比显式失败更危险。
492
+ const asked = Object.keys(questions || {})
493
+ const usable = asked.filter((qid) => {
494
+ const value = decision[qid]
495
+ return value !== null && value !== undefined
496
+ })
497
+ if (usable.length === 0) {
498
+ const returned = Object.keys(answers)
499
+ const unusable = asked.filter((qid) => qid in decision)
500
+ let detail
501
+ if (asked.length === 0) {
502
+ detail = '本次没有提交任何问题'
503
+ } else if (returned.length === 0) {
504
+ detail = `提交了 ${asked.length} 个问题(${asked.join('、')});上游未返回任何 answers`
505
+ } else if (unusable.length > 0) {
506
+ detail = `提交了 ${asked.length} 个问题(${asked.join('、')});上游返回的 answers 里没有可用取值`
507
+ + `(无法判断:${unusable.join('、')})`
508
+ } else {
509
+ detail = `提交了 ${asked.length} 个问题(${asked.join('、')});`
510
+ + `上游仅返回了 ${returned.join('、')},与所问的问题对不上`
511
+ }
512
+ const error = `SystemOne 未产出任何可用的决策:${detail}`
513
+ return {
514
+ ok: false,
515
+ scenario: scenarioId,
516
+ provider: provider.name,
517
+ error,
518
+ answers,
519
+ decision,
520
+ labels,
521
+ derived: {},
522
+ confidences: {},
523
+ recommendation: '',
524
+ needs_human_review: true,
525
+ warnings,
526
+ summary: `## ${title}\n\n**未产出任何可用的决策**:${detail}\n\n无法给出置信度与概率分布。请核对问题定义(type 必须是 choice / noul / score;choice 的 criteria 需 2~26 个选项;score 的 criteria 需 ≥2 级标签)后重试。`,
527
+ }
528
+ }
529
+
530
+ const assessment = reviewAssessment(answers, minConfidence)
531
+ const needsHumanReview = assessment.needed
449
532
  const lines = formatAnswerLines(answers, questions)
450
533
  const latencyMs = Number.isFinite(Number(response.latency_ms))
451
534
  ? Number(response.latency_ms)
@@ -461,6 +544,7 @@ async function executeDecision({
461
544
  recommendation,
462
545
  notes: warnings,
463
546
  needsHumanReview,
547
+ reviewReasons: assessment.reasons,
464
548
  })
465
549
  return {
466
550
  ok: true,
@@ -475,12 +559,14 @@ async function executeDecision({
475
559
  derived,
476
560
  confidences,
477
561
  needs_human_review: needsHumanReview,
562
+ review_reasons: assessment.reasons,
478
563
  recommendation,
479
564
  warnings,
480
565
  summary,
481
566
  }
482
567
  } catch (err) {
483
568
  const msg = messageOf(err)
569
+ recordUsage(usage, { source: 'tool', ok: false, model, provider: provider?.name, scenario: scenarioId || '' })
484
570
  return {
485
571
  ok: false,
486
572
  scenario: scenarioId,
package/lib/usage.js ADDED
@@ -0,0 +1,167 @@
1
+ /**
2
+ * usage.js — 调用与 token 用量台账。
3
+ *
4
+ * 为什么需要它:SystemOne 调用是**按量计费**的,而本插件默认关闭不了的两条路径
5
+ * (自动路由 + 场景决策)会随会话推进持续发请求。上游返回的 `usage.input_tokens`
6
+ * 原本只是塞进工具返回值里,既不累计也不设上限——用户看不到花销,也就没有刹车。
7
+ *
8
+ * 设计取舍(对齐生态里同类做法,如 dsh-jev-plugin 的 ledger + 双日限):
9
+ * - 每次调用**追加**一行 JSONL,包括失败/超时的调用(失败也可能已计费);
10
+ * - 日限在**发请求之前**判定并拒绝,附上该改哪个配置项;
11
+ * - 台账不可写时降级为「仅内存」,绝不因为记账失败而让决策失败(fail-open 记账);
12
+ * - 计数按**本地日**切分,跨日自动归零,不需要定时任务。
13
+ *
14
+ * 模块无第三方依赖,使用 node:fs / node:os / node:path。
15
+ */
16
+ import { appendFileSync, mkdirSync } from 'node:fs'
17
+ import { homedir } from 'node:os'
18
+ import { join } from 'node:path'
19
+
20
+ /** token 数缺失时的兜底(上游不保证返回 usage)。 */
21
+ const UNKNOWN_TOKENS = 0
22
+
23
+ /** 本地日键,形如 2026-10-05。 */
24
+ function localDayKey(now = Date.now()) {
25
+ const d = new Date(now)
26
+ const pad = (n) => String(n).padStart(2, '0')
27
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
28
+ }
29
+
30
+ /** 解析 DSH 主目录(尊重 DSH_HOME);provider.js 的凭证读取与此同源。 */
31
+ export function dshHome() {
32
+ const fromEnv = typeof process.env.DSH_HOME === 'string' ? process.env.DSH_HOME.trim() : ''
33
+ return fromEnv || join(homedir(), '.dsh')
34
+ }
35
+
36
+ /**
37
+ * 安全记账:台账是**旁路**设施,任何异常都不得影响决策或注入。
38
+ *
39
+ * 台账自身已经吞掉了写盘失败,但调用方可能传入别的实现(测试桩、将来的远端
40
+ * 上报),所以这里再兜一层——「记账失败绝不影响决策」是写在文档里的承诺,
41
+ * 不能只靠台账实现自觉。auto.js 与 tools.js 共用本函数。
42
+ */
43
+ export function recordUsage(usage, entry) {
44
+ try {
45
+ usage?.record?.(entry)
46
+ } catch {
47
+ // 静默忽略
48
+ }
49
+ }
50
+
51
+ /** 台账文件绝对路径。 */
52
+ export function usageLogPath(dir) {
53
+ const base = typeof dir === 'string' && dir.trim() ? dir.trim() : join(dshHome(), 'dsh-systemone')
54
+ return join(base, 'usage.jsonl')
55
+ }
56
+
57
+ /** 从任意形态的 usage 字段里取 input_tokens(容忍缺字段与字符串)。 */
58
+ function inputTokensOf(usage) {
59
+ const value = Number(usage?.input_tokens)
60
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : UNKNOWN_TOKENS
61
+ }
62
+
63
+ /**
64
+ * 创建用量台账。
65
+ *
66
+ * @param {object} [options]
67
+ * @param {string} [options.path] - 台账文件路径;留空则用 `$DSH_HOME/dsh-systemone/usage.jsonl`
68
+ * @param {object} [options.logger] - 可选 logger,用于告警(写盘失败等)
69
+ * @returns {{ record: Function, today: Function, check: Function, formatToday: Function }}
70
+ */
71
+ export function createUsageLedger(options = {}) {
72
+ const file = typeof options.path === 'string' && options.path.trim()
73
+ ? options.path.trim()
74
+ : usageLogPath('')
75
+ const logger = options.logger
76
+
77
+ /** 内存累计:{ day, calls, tokens, failures, refused } —— 读取时先按天校验再复用 */
78
+ let totals = { day: localDayKey(), calls: 0, tokens: 0, failures: 0 }
79
+ /** 写盘是否失败过(只告警一次,避免每一步刷日志) */
80
+ let writeFailed = false
81
+
82
+ function rollIfNeeded(now = Date.now()) {
83
+ const day = localDayKey(now)
84
+ if (totals.day !== day) {
85
+ totals = { day, calls: 0, tokens: 0, failures: 0 }
86
+ }
87
+ return totals
88
+ }
89
+
90
+ /** 记录一次调用。永不抛错——记账失败不影响决策。 */
91
+ function record(entry = {}) {
92
+ const now = Number.isFinite(entry.at) ? entry.at : Date.now()
93
+ const tokens = inputTokensOf(entry.usage)
94
+ const ok = entry.ok !== false
95
+ const current = rollIfNeeded(now)
96
+ current.calls += 1
97
+ current.tokens += tokens
98
+ if (!ok) current.failures += 1
99
+
100
+ try {
101
+ mkdirSync(join(file, '..'), { recursive: true })
102
+ appendFileSync(file, `${JSON.stringify({
103
+ ts: now,
104
+ day: current.day,
105
+ source: String(entry.source || 'unknown'),
106
+ model: entry.model ? String(entry.model) : '',
107
+ provider: entry.provider ? String(entry.provider) : '',
108
+ scenario: entry.scenario ? String(entry.scenario) : '',
109
+ ok,
110
+ input_tokens: tokens,
111
+ latency_ms: Number.isFinite(Number(entry.latencyMs)) ? Number(entry.latencyMs) : null,
112
+ })}\n`, 'utf8')
113
+ } catch (error) {
114
+ if (!writeFailed) {
115
+ writeFailed = true
116
+ try {
117
+ logger?.warn?.(`@master0071/dsh-systemone[usage]: 台账写入失败,本次起降级为仅内存统计:${error?.message || error}`)
118
+ } catch {
119
+ // 日志失败不影响决策
120
+ }
121
+ }
122
+ }
123
+ return { calls: current.calls, tokens: current.tokens, failures: current.failures, day: current.day }
124
+ }
125
+
126
+ /** 今日累计(只读快照)。 */
127
+ function today(now = Date.now()) {
128
+ const current = rollIfNeeded(now)
129
+ return { day: current.day, calls: current.calls, tokens: current.tokens, failures: current.failures }
130
+ }
131
+
132
+ /**
133
+ * 发请求前的限额判定。
134
+ * @param {{dailyCallLimit:number, dailyTokenLimit:number}} limits - 0 表示不限
135
+ * @returns {{allowed:boolean, reason?:string, snapshot:object}}
136
+ */
137
+ function check(limits = {}) {
138
+ const snapshot = today()
139
+ const callLimit = Number(limits.dailyCallLimit)
140
+ const tokenLimit = Number(limits.dailyTokenLimit)
141
+ if (Number.isFinite(callLimit) && callLimit > 0 && snapshot.calls >= callLimit) {
142
+ return {
143
+ allowed: false,
144
+ snapshot,
145
+ reason: `今日调用数已达上限 ${callLimit}(当前 ${snapshot.calls})。调大 dailyCallLimit,或设为 0 关闭上限。`,
146
+ }
147
+ }
148
+ if (Number.isFinite(tokenLimit) && tokenLimit > 0 && snapshot.tokens >= tokenLimit) {
149
+ return {
150
+ allowed: false,
151
+ snapshot,
152
+ reason: `今日 input token 已达上限 ${tokenLimit}(当前 ${snapshot.tokens})。调大 dailyTokenLimit,或设为 0 关闭上限。`,
153
+ }
154
+ }
155
+ return { allowed: true, snapshot }
156
+ }
157
+
158
+ /** 一行人类可读的今日用量(供工具输出与日志)。 */
159
+ function formatToday(now = Date.now()) {
160
+ const s = today(now)
161
+ const tokens = s.tokens >= 1000 ? `${(s.tokens / 1000).toFixed(1)}k` : String(s.tokens)
162
+ const fail = s.failures > 0 ? `,其中失败 ${s.failures} 次` : ''
163
+ return `今日:${s.calls} 次判断 · ${tokens} input tokens(${s.day})${fail}`
164
+ }
165
+
166
+ return { record, today, check, formatToday, get path() { return file } }
167
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@master0071/dsh-systemone",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "DeepSeek Harness 插件:将 SystemOne 决策模型(默认云知声 u2-decision,可切换提供商)接入软件开发、需求预检、客服、内容审核、路由、销售线索、风控、招聘、数据治理、教育、需求变更等 11 大业务场景,为 Agent 返回带概率分布的决策与处置建议。",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -18,7 +18,8 @@
18
18
  "files": [
19
19
  "lib/",
20
20
  "cordis.patch.yml",
21
- "README.md"
21
+ "README.md",
22
+ "LICENSE"
22
23
  ],
23
24
  "main": "./lib/index.js",
24
25
  "types": "./lib/index.d.ts",
@@ -31,6 +32,7 @@
31
32
  "./package.json": "./package.json"
32
33
  },
33
34
  "dsh": {
35
+ "manifestVersion": 1,
34
36
  "bundle": {
35
37
  "patch": "cordis.patch.yml"
36
38
  },
@@ -46,14 +48,18 @@
46
48
  "node": ">=22.19"
47
49
  },
48
50
  "scripts": {
49
- "check": "node --check lib/index.js && node --check lib/provider.js && node --check lib/tools.js && node --check lib/format.js && node --check lib/scenarios.js && node --check lib/auto.js && node --check lib/volatile.js && node --check lib/client.js",
50
- "test": "node --test \"test/**/*.test.mjs\""
51
+ "check": "node -e \"const{readdirSync}=require('node:fs'),{spawnSync}=require('node:child_process');const files=readdirSync('lib').filter((f)=>f.endsWith('.js')).sort();if(files.length===0){console.error('check: lib/ 下没有 .js 文件,检查失败');process.exit(1)}const bad=files.filter((f)=>spawnSync(process.execPath,['--check','lib/'+f],{stdio:'inherit'}).status!==0);if(bad.length>0){console.error('check: 语法检查失败 -> '+bad.join(', '));process.exit(1)}console.log('check: '+files.length+' 个 lib/*.js 语法检查通过 -> '+files.join(', '))\"",
52
+ "test": "node --test \"test/**/*.test.mjs\"",
53
+ "probe": "node scripts/probe-host.mjs",
54
+ "verify:docs": "node scripts/verify-doc-claims.mjs",
55
+ "fix:docs": "node scripts/verify-doc-claims.mjs --update"
51
56
  },
52
57
  "dependencies": {
53
58
  "@deepseek-ai/schemastery": "^3.18.1"
54
59
  },
55
60
  "peerDependencies": {
56
61
  "@deepseek-ai/cordis": "^4.0.1",
62
+ "@deepseek-ai/dsh": ">=0.1.7-rc.1 <0.3.0",
57
63
  "@deepseek-ai/schemastery": "^3.18.1"
58
64
  }
59
65
  }