@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/LICENSE +21 -0
- package/README.md +124 -22
- package/lib/auto.js +218 -40
- package/lib/client.js +21 -1
- package/lib/format.js +106 -9
- package/lib/index.d.ts +54 -2
- package/lib/index.js +121 -7
- package/lib/provider.js +107 -6
- package/lib/scenarios.js +15 -4
- package/lib/tools.js +93 -7
- package/lib/usage.js +167 -0
- package/package.json +10 -4
package/lib/index.d.ts
CHANGED
|
@@ -7,7 +7,12 @@ import { Context, Service } from '@deepseek-ai/cordis'
|
|
|
7
7
|
export interface Config {
|
|
8
8
|
/** 提供商类型:unisound(官方)/ http(SystemOne 兼容端点)/ mock(本地模拟) */
|
|
9
9
|
provider?: string
|
|
10
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* 凭证引用名(推荐):从 `$DSH_HOME/.credentials.yaml` 的 `refs.<名字>` 读密钥,
|
|
12
|
+
* 配置文件里只留这个名字。留空时依次尝试 SYSTEMONE_API_KEY / UNISOUND_API_KEY / TYPESAFE_API_KEY。
|
|
13
|
+
*/
|
|
14
|
+
apiKeyRef?: string
|
|
15
|
+
/** 明文 API Key(备用;也可用环境变量 UNISOUND_API_KEY / SYSTEMONE_API_KEY) */
|
|
11
16
|
apiKey?: string
|
|
12
17
|
/** Unisound API 基础地址 */
|
|
13
18
|
baseUrl?: string
|
|
@@ -17,12 +22,17 @@ export interface Config {
|
|
|
17
22
|
model?: string
|
|
18
23
|
/** 请求超时(毫秒) */
|
|
19
24
|
timeoutMs?: number
|
|
20
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* **次要**复核信号:置信度低于该值时也标记需复核。
|
|
27
|
+
* 主要判据是概率分布平坦度(见 `reviewAssessment` 语义),因为这类概率未标定、跨后端不可比。
|
|
28
|
+
*/
|
|
21
29
|
minConfidence?: number
|
|
22
30
|
/** 发送前对 state 做内置脱敏(手机号/身份证/邮箱/银行卡号替换为类型标签) */
|
|
23
31
|
redact?: boolean
|
|
24
32
|
/** 自定义场景:JSON 数组字符串 */
|
|
25
33
|
customScenarios?: string
|
|
34
|
+
/** 用量台账文件路径;留空则用 `$DSH_HOME/dsh-systemone/usage.jsonl` */
|
|
35
|
+
usageLogPath?: string
|
|
26
36
|
|
|
27
37
|
/* ── 自动决策(默认关闭) ─────────────────────────────── */
|
|
28
38
|
/** 是否在每一步推理前自动捕获上下文并执行决策 */
|
|
@@ -31,6 +41,8 @@ export interface Config {
|
|
|
31
41
|
autoScenario?: string
|
|
32
42
|
/** 注入通道:message=同一步生效并去重;context=宿主动态上下文(不写历史) */
|
|
33
43
|
autoInject?: string
|
|
44
|
+
/** 自动决策失败语义:open=放行(默认);closed=决策失败时否决本轮(供将来做闸门用,仅 message 通道) */
|
|
45
|
+
autoFailMode?: string
|
|
34
46
|
/** 自动决策超时(毫秒) */
|
|
35
47
|
autoTimeoutMs?: number
|
|
36
48
|
/** 捕获的历史消息条数 */
|
|
@@ -64,6 +76,46 @@ export interface ScenarioInfo {
|
|
|
64
76
|
questions: string[]
|
|
65
77
|
}
|
|
66
78
|
|
|
79
|
+
/**
|
|
80
|
+
* 工具决策结果里与复核相关的字段。
|
|
81
|
+
*
|
|
82
|
+
* `computeNeedsHumanReview` 的语义自本版本起改为「**概率分布是否平坦**」
|
|
83
|
+
* (最大概率相对均匀分布不足 1.5 倍即视为在猜,不依赖标定),
|
|
84
|
+
* 绝对置信度 `minConfidence` 降为次要信号。
|
|
85
|
+
*/
|
|
86
|
+
export interface ReviewOutcome {
|
|
87
|
+
/**
|
|
88
|
+
* 是否需要人工复核。判据为分布平坦(主要)或低于 `minConfidence`(次要),
|
|
89
|
+
* 以及 `choice` 返回 uncertain/空值、`noul` 落在 0.45~0.55 摇摆区间。
|
|
90
|
+
*/
|
|
91
|
+
needs_human_review: boolean
|
|
92
|
+
/** 判定理由(每条形如 `qid:原因`);为空表示无需复核。仅工具输出提供。 */
|
|
93
|
+
review_reasons?: string[]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 工具返回的决策结果(`systemone_scenario(action:"run")` 与 `systemone_decide`)。 */
|
|
97
|
+
export interface DecisionResult extends ReviewOutcome {
|
|
98
|
+
ok: boolean
|
|
99
|
+
scenario: string | null
|
|
100
|
+
provider: string
|
|
101
|
+
model?: string
|
|
102
|
+
request_id?: string
|
|
103
|
+
latency_ms?: number
|
|
104
|
+
answers?: Record<string, unknown>
|
|
105
|
+
decision?: Record<string, unknown>
|
|
106
|
+
labels?: Record<string, unknown>
|
|
107
|
+
derived?: Record<string, unknown>
|
|
108
|
+
confidences?: Record<string, number>
|
|
109
|
+
recommendation?: string
|
|
110
|
+
warnings?: string[]
|
|
111
|
+
/** 人可读的 Markdown 摘要(含复核理由子项)。 */
|
|
112
|
+
summary: string
|
|
113
|
+
/** 失败时的人类可读原因。 */
|
|
114
|
+
error?: string
|
|
115
|
+
/** 未知场景时列出的可用场景 id。 */
|
|
116
|
+
available?: string[]
|
|
117
|
+
}
|
|
118
|
+
|
|
67
119
|
declare module '@deepseek-ai/cordis' {
|
|
68
120
|
interface Context {
|
|
69
121
|
/** SystemOne 决策服务。 */
|
package/lib/index.js
CHANGED
|
@@ -13,11 +13,12 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { Service } from '@deepseek-ai/cordis'
|
|
15
15
|
import z from '@deepseek-ai/schemastery'
|
|
16
|
-
import { createProvider } from './provider.js'
|
|
17
|
-
import { createTools } from './tools.js'
|
|
16
|
+
import { createProvider, redactRuleSummary } from './provider.js'
|
|
17
|
+
import { createTools, MAX_TOOL_STATE_CHARS } from './tools.js'
|
|
18
18
|
import { resolveScenarios } from './scenarios.js'
|
|
19
|
-
import { installAutoDecide, AUTO_DEFAULTS } from './auto.js'
|
|
20
|
-
import {
|
|
19
|
+
import { installAutoDecide, AUTO_DEFAULTS, MAX_CURRENT_CHARS, HISTORY_CHARS_PER_MESSAGE } from './auto.js'
|
|
20
|
+
import { createUsageLedger } from './usage.js'
|
|
21
|
+
import { readBoolean, readNumber, readString, unwrap } from './volatile.js'
|
|
21
22
|
|
|
22
23
|
/** 合法的提供商类型;其他值会按 unisound 处理并告警一次。 */
|
|
23
24
|
const KNOWN_PROVIDERS = ['unisound', 'http', 'mock']
|
|
@@ -73,7 +74,21 @@ const Config = z.object({
|
|
|
73
74
|
customScenarios: z.string().default('').volatile(),
|
|
74
75
|
/** 提供商类型:unisound(官方)/ http(SystemOne 兼容端点)/ mock(本地模拟) */
|
|
75
76
|
provider: z.string().default('unisound').volatile(),
|
|
76
|
-
/**
|
|
77
|
+
/**
|
|
78
|
+
* 凭证引用名:从 DSH 凭证缝 `$DSH_HOME/.credentials.yaml` 的 `refs.<名字>` 读取密钥。
|
|
79
|
+
*
|
|
80
|
+
* 这是**推荐的凭证方式**:`apiKey` 虽然标了 `.role('secret')`(表单不回显),
|
|
81
|
+
* 但值仍以明文落在 profile 的 `cordis.patch.yml` 里。用引用名则配置文件里只有
|
|
82
|
+
* 一个名字,真正的密钥留在凭证文件(或环境变量)中。
|
|
83
|
+
*
|
|
84
|
+
* 留空时按顺序尝试内置默认引用名:SYSTEMONE_API_KEY → UNISOUND_API_KEY → TYPESAFE_API_KEY。
|
|
85
|
+
*/
|
|
86
|
+
apiKeyRef: z.string().default('').volatile(),
|
|
87
|
+
/**
|
|
88
|
+
* Unisound API Key(明文;不想落明文请改用 apiKeyRef 或环境变量)。
|
|
89
|
+
*
|
|
90
|
+
* 解析顺序:apiKey → 环境变量 → 凭证引用(apiKeyRef 或内置默认名)。
|
|
91
|
+
*/
|
|
77
92
|
apiKey: z.string().default('').role('secret').volatile(),
|
|
78
93
|
/** Unisound API 基础地址(默认 https://maas-api.unisound.com/v1) */
|
|
79
94
|
baseUrl: z.string().default('https://maas-api.unisound.com/v1').volatile(),
|
|
@@ -100,8 +115,27 @@ const Config = z.object({
|
|
|
100
115
|
/* ── 自动决策运行参数(volatile):钩子已挂载时实时生效 ────────────────────── */
|
|
101
116
|
/** 自动决策固定使用的场景 id;留空则用一次 choice 问句自动路由 */
|
|
102
117
|
autoScenario: z.string().default('').volatile(),
|
|
103
|
-
/**
|
|
104
|
-
|
|
118
|
+
/**
|
|
119
|
+
* 注入通道:message=本步生效(pre-step 注入);context=官方动态上下文(晚一步生效)。
|
|
120
|
+
*
|
|
121
|
+
* 必须是枚举而不是裸 string:auto.js 的三处门控都是精确比较
|
|
122
|
+
* (`!== 'context'` / `=== 'context'` / `!== 'message'`),写错大小写
|
|
123
|
+
* (如 `Context`)会静默掉进 message 分支、产生意料之外的历史写入。
|
|
124
|
+
* 枚举让非法值在加载期就报错,而不是运行时无提示地走错通道。
|
|
125
|
+
*/
|
|
126
|
+
autoInject: z.union(['message', 'context']).default('message').volatile(),
|
|
127
|
+
/**
|
|
128
|
+
* 自动决策失败语义:open=放行(注入用途的正确默认);closed=否决本轮
|
|
129
|
+
* (pre-step 返回 `{ kind: 'reject' }`,宿主把本轮 turn 记为 blocked)。
|
|
130
|
+
*
|
|
131
|
+
* 必须是枚举而不是裸 string,理由同 autoInject:auto.js 的分支是精确比较,
|
|
132
|
+
* 写错值会静默落回 open。closed 只对"本应得到决策却没有"的真失败生效
|
|
133
|
+
* (请求异常、超时、场景执行失败、路由无可用品);"主动不注入"
|
|
134
|
+
* (寒暄、低置信度、斜杠命令)永远放行。仅 message 通道会拦截——
|
|
135
|
+
* context 通道只注入参考信息,不 gate 任何东西。本档是为将来做闸门
|
|
136
|
+
* (形态 C)预留的语义缝,当前注入用途请保持 open。
|
|
137
|
+
*/
|
|
138
|
+
autoFailMode: z.union(['open', 'closed']).default('open').volatile(),
|
|
105
139
|
/** 自动决策超时(毫秒),超时即放行,绝不阻塞会话 */
|
|
106
140
|
autoTimeoutMs: z.number().min(500).default(AUTO_DEFAULTS.autoTimeoutMs).volatile(),
|
|
107
141
|
/** 捕获的历史消息条数(不含本轮新消息) */
|
|
@@ -114,6 +148,11 @@ const Config = z.object({
|
|
|
114
148
|
autoRouteMinConfidence: z.number().min(0).max(1).default(AUTO_DEFAULTS.autoRouteMinConfidence).volatile(),
|
|
115
149
|
/** 触发自动决策的最小输入长度(字符);低于该长度且整体是寒暄才跳过 */
|
|
116
150
|
autoMinChars: z.number().min(0).default(AUTO_DEFAULTS.autoMinChars).volatile(),
|
|
151
|
+
|
|
152
|
+
/* ── 用量台账(volatile)────────────────────────────────────────────────── */
|
|
153
|
+
|
|
154
|
+
/** 用量台账文件路径;留空则用 `$DSH_HOME/dsh-systemone/usage.jsonl` */
|
|
155
|
+
usageLogPath: z.string().default('').volatile(),
|
|
117
156
|
})
|
|
118
157
|
|
|
119
158
|
export default class SystemOneService extends Service {
|
|
@@ -154,6 +193,15 @@ export default class SystemOneService extends Service {
|
|
|
154
193
|
this._refreshScenarios()
|
|
155
194
|
const scenarios = this.liveScenarios()
|
|
156
195
|
|
|
196
|
+
// 外发声明:装载时打印一行,之后 provider / redact 变化时再打印一次。
|
|
197
|
+
// 见 _announceEgress 的说明——state 会原样发往上游,这件事必须是可审计的。
|
|
198
|
+
this._egressStamp = null
|
|
199
|
+
this._announceEgress()
|
|
200
|
+
|
|
201
|
+
// 用量台账:工具路径与自动路径都记账(含路由请求),让花销可见。
|
|
202
|
+
// 台账写盘失败会降级为仅内存统计,绝不影响决策。
|
|
203
|
+
this.usageLedger = createUsageLedger({ path: readString(config, 'usageLogPath', ''), logger: ctx.logger })
|
|
204
|
+
|
|
157
205
|
// inject 保证 ctx.tools 存在;注册失败必须显式抛出,绝不静默降级
|
|
158
206
|
const tools = ctx.get('tools') ?? ctx.tools
|
|
159
207
|
if (!tools || typeof tools.register !== 'function') {
|
|
@@ -163,6 +211,7 @@ export default class SystemOneService extends Service {
|
|
|
163
211
|
// 惰性读取:场景库可能已随 customScenarios 热更新重建,
|
|
164
212
|
// 这里必须每次取最新的 warnings,而不是装配期快照。
|
|
165
213
|
warnings: () => this.scenarioWarnings || [],
|
|
214
|
+
usage: this.usageLedger,
|
|
166
215
|
}).map((tool) => tools.register(tool))
|
|
167
216
|
|
|
168
217
|
// 自动决策:默认关闭;开启后在每一步推理前自动捕获上下文并注入判断
|
|
@@ -174,6 +223,7 @@ export default class SystemOneService extends Service {
|
|
|
174
223
|
config,
|
|
175
224
|
scenarios,
|
|
176
225
|
logger: ctx.logger,
|
|
226
|
+
usage: this.usageLedger,
|
|
177
227
|
})
|
|
178
228
|
} catch (error) {
|
|
179
229
|
ctx.logger?.warn?.(`@master0071/dsh-systemone: 自动决策安装失败,工具仍可用:${error?.message || error}`)
|
|
@@ -187,6 +237,66 @@ export default class SystemOneService extends Service {
|
|
|
187
237
|
)
|
|
188
238
|
}
|
|
189
239
|
|
|
240
|
+
/**
|
|
241
|
+
* 外发声明:把「这次会不会联网、往哪发、发什么」打成一行可审计的日志。
|
|
242
|
+
*
|
|
243
|
+
* 为什么需要它:`state`(含对话历史与用户原文)会原样发往上游决策服务,
|
|
244
|
+
* 而 `redact` 默认是 **false**。生态里同类插件被审计出的最常见问题正是
|
|
245
|
+
* 「名为 guard/gate 的模块同时在往外发数据,而 README 不说」。一行启动声明
|
|
246
|
+
* 让这件事在日志里可见,而不是只藏在配置文档里。
|
|
247
|
+
*
|
|
248
|
+
* `provider` 与 `redact` 都是热更新字段,所以本方法在**值变化时**会再打一次;
|
|
249
|
+
* 相同状态不重复输出,避免每步刷日志。
|
|
250
|
+
*/
|
|
251
|
+
_announceEgress() {
|
|
252
|
+
try {
|
|
253
|
+
const kind = readString(this.config, 'provider', 'unisound')
|
|
254
|
+
const redact = readBoolean(this.config, 'redact', false)
|
|
255
|
+
const stamp = `${kind}|${redact}`
|
|
256
|
+
if (stamp === this._egressStamp) return
|
|
257
|
+
this._egressStamp = stamp
|
|
258
|
+
|
|
259
|
+
const logger = this.ctx?.logger
|
|
260
|
+
if (kind === 'mock') {
|
|
261
|
+
logger?.info?.('@master0071/dsh-systemone[egress]: OFF — provider=mock,不会发起任何网络请求')
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const budget = this._stateBudget()
|
|
266
|
+
// 尽量报告真实端点;解析失败(配错 URL 等)时退回配置原文,不让声明本身抛错
|
|
267
|
+
let target = ''
|
|
268
|
+
try {
|
|
269
|
+
target = this.currentProvider().resolveEndpoint()
|
|
270
|
+
} catch {
|
|
271
|
+
target = readString(this.config, 'endpoint', '') || readString(this.config, 'baseUrl', '')
|
|
272
|
+
}
|
|
273
|
+
const where = target || '(端点未配置)'
|
|
274
|
+
|
|
275
|
+
const redactLine = redact
|
|
276
|
+
? `ON — 发送前把 ${redactRuleSummary()} 替换为 [类型] 标签(尽力而为:不在规则内的敏感串仍会外发)`
|
|
277
|
+
: `OFF — ${redactRuleSummary()} 等敏感内容**原样外发**,建议在处理含隐私数据的内容时开启 redact`
|
|
278
|
+
|
|
279
|
+
logger?.info?.(`@master0071/dsh-systemone[egress]: ON → ${where}`)
|
|
280
|
+
logger?.info?.(
|
|
281
|
+
`@master0071/dsh-systemone[egress]: SENDS state(<=${budget} 字符,含对话历史与用户原文) + questions`
|
|
282
|
+
+ ` + model;redact=${redactLine}`,
|
|
283
|
+
)
|
|
284
|
+
} catch {
|
|
285
|
+
// 声明失败绝不能影响装配
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* state 的字符预算:工具路径截断到 MAX_TOOL_STATE_CHARS,自动决策路径拼装后可能更大。
|
|
291
|
+
* 声明里报一个保守上界,让「发出去多大」有个量级概念。
|
|
292
|
+
* 数字全部同源引用(tools.js / auto.js 的截断常量),改一处声明自动跟随。
|
|
293
|
+
*/
|
|
294
|
+
_stateBudget() {
|
|
295
|
+
if (!readBoolean(this.config, 'autoDecide', false)) return MAX_TOOL_STATE_CHARS
|
|
296
|
+
const messages = readNumber(this.config, 'autoMaxMessages', 6)
|
|
297
|
+
return Math.max(MAX_TOOL_STATE_CHARS, MAX_CURRENT_CHARS + messages * HISTORY_CHARS_PER_MESSAGE)
|
|
298
|
+
}
|
|
299
|
+
|
|
190
300
|
/**
|
|
191
301
|
* 返回当前应当使用的提供商;`provider` 配置变化时按需重建。
|
|
192
302
|
*
|
|
@@ -200,6 +310,10 @@ export default class SystemOneService extends Service {
|
|
|
200
310
|
this._provider = createProvider(this.config)
|
|
201
311
|
this._providerKind = kind
|
|
202
312
|
}
|
|
313
|
+
// 所有请求路径(工具/自动决策/服务 decide)取提供商都经过这里:
|
|
314
|
+
// provider **或 redact** 任一变化(stamp 去重)即重发外发声明。
|
|
315
|
+
// 放在方法末尾,保证声明解析端点用的是(可能刚重建的)新提供商。
|
|
316
|
+
this._announceEgress()
|
|
203
317
|
return this._provider
|
|
204
318
|
}
|
|
205
319
|
|
package/lib/provider.js
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
* - "mock" :本地确定性模拟(无需 API Key,用于测试与演示)
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import { readFileSync } from 'node:fs'
|
|
19
|
+
import { join } from 'node:path'
|
|
18
20
|
import { readBoolean, readNumber, readString } from './volatile.js'
|
|
19
21
|
|
|
20
22
|
const DEFAULT_BASE_URL = 'https://maas-api.unisound.com/v1'
|
|
@@ -58,15 +60,25 @@ export class HttpProvider {
|
|
|
58
60
|
return assertValidUrl(`${base}/systemone`)
|
|
59
61
|
}
|
|
60
62
|
|
|
61
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* 解析 API Key,顺序为:插件配置明文 → 环境变量 → DSH 凭证缝引用。
|
|
65
|
+
*
|
|
66
|
+
* 推荐用最后一种(`apiKeyRef`):配置里只留一个引用名,密钥本体留在
|
|
67
|
+
* `$DSH_HOME/.credentials.yaml`,避免明文落进 profile 的 `cordis.patch.yml`。
|
|
68
|
+
* 三种来源都没有时给出可操作的报错。
|
|
69
|
+
*/
|
|
62
70
|
resolveApiKey() {
|
|
63
71
|
const key = readString(this.config, 'apiKey', '')
|
|
64
72
|
|| process.env.UNISOUND_API_KEY
|
|
65
73
|
|| process.env.SYSTEMONE_API_KEY
|
|
74
|
+
|| readCredentialRef(readString(this.config, 'apiKeyRef', ''))
|
|
66
75
|
|| ''
|
|
67
76
|
if (!key) {
|
|
68
77
|
throw new Error(
|
|
69
|
-
'SystemOne:
|
|
78
|
+
'SystemOne: 未配置凭证。按序尝试了:插件配置的 apiKey → 环境变量 UNISOUND_API_KEY / SYSTEMONE_API_KEY '
|
|
79
|
+
+ `→ 凭证缝 ${credentialsPath()} 的 refs.<apiKeyRef>`
|
|
80
|
+
+ `${readString(this.config, 'apiKeyRef', '') ? '' : '(apiKeyRef 留空,回退默认名 ' + DEFAULT_KEY_REFS.join(' / ') + ')'}。`
|
|
81
|
+
+ '推荐在凭证文件里写 `refs: { SYSTEMONE_API_KEY: <你的 key> }` 并保持 apiKey 为空。',
|
|
70
82
|
)
|
|
71
83
|
}
|
|
72
84
|
return key
|
|
@@ -218,8 +230,8 @@ function mockAnswer(id, q, seedSource) {
|
|
|
218
230
|
}
|
|
219
231
|
}
|
|
220
232
|
case 'noul': {
|
|
221
|
-
// 与真实响应对齐:noul 也带 confidence
|
|
222
|
-
//
|
|
233
|
+
// 与真实响应对齐:noul 也带 confidence(取倾向一侧的概率)。
|
|
234
|
+
// mock 的分布是合成的,**不代表真实模型行为**,只能用于契约与确定性回归。
|
|
223
235
|
const v = Math.round((rand() * 0.6 + 0.2) * 1000) / 1000
|
|
224
236
|
return { type: 'noul', noul: v, confidence: Math.max(v, 1 - v) }
|
|
225
237
|
}
|
|
@@ -310,6 +322,93 @@ function redactValue(value, enabled) {
|
|
|
310
322
|
return value
|
|
311
323
|
}
|
|
312
324
|
|
|
325
|
+
/* ─── DSH 凭证缝读取 ───────────────────────────────────────────────────────── */
|
|
326
|
+
|
|
327
|
+
/** DSH 主目录:与用量台账同源(usage.js 导出)。 */
|
|
328
|
+
import { dshHome } from './usage.js'
|
|
329
|
+
|
|
330
|
+
/** 未配置 apiKeyRef 时依次尝试的内置引用名(覆盖 SystemOne 各家后端)。 */
|
|
331
|
+
const DEFAULT_KEY_REFS = ['SYSTEMONE_API_KEY', 'UNISOUND_API_KEY', 'TYPESAFE_API_KEY']
|
|
332
|
+
|
|
333
|
+
/** 凭证文件路径(用于报错文案与读取)。 */
|
|
334
|
+
function credentialsPath() {
|
|
335
|
+
return join(dshHome(), '.credentials.yaml')
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* 从凭证文件里取 `refs.<name>` 的值。
|
|
340
|
+
*
|
|
341
|
+
* 只解析 `refs:` 下的**单层标量**,不引入 YAML 依赖(插件对 `@deepseek-ai/*` 之外的
|
|
342
|
+
* 依赖保持为零)。解析要点:
|
|
343
|
+
* - 只认 `refs:` 之后、缩进更深的行,避免误取段外同名键;
|
|
344
|
+
* - 值两端的成对引号会被剥掉(`TYPESAFE_API_KEY: "sk-x"` 应得到 `sk-x`)——
|
|
345
|
+
* 这是同类实现常见的一个缺陷:直接把捕获组当值,会连引号一起塞进 Authorization 头;
|
|
346
|
+
* - 行内 ` #` 注释会被裁掉(仅对未加引号的值);
|
|
347
|
+
* - 多个候选名都在文件里时,**按 `wanted` 的顺序**返回,而不是按文件出现顺序。
|
|
348
|
+
*
|
|
349
|
+
* @param {string} name - 引用名;留空时依次尝试 DEFAULT_KEY_REFS
|
|
350
|
+
* @returns {string|undefined} 找到的凭证;找不到返回 undefined
|
|
351
|
+
*/
|
|
352
|
+
function readCredentialRef(name) {
|
|
353
|
+
const wanted = name && String(name).trim() ? [String(name).trim()] : DEFAULT_KEY_REFS
|
|
354
|
+
let raw
|
|
355
|
+
try {
|
|
356
|
+
raw = readFileSync(credentialsPath(), 'utf8')
|
|
357
|
+
} catch {
|
|
358
|
+
return undefined // 文件不存在/不可读 → 视为未配置
|
|
359
|
+
}
|
|
360
|
+
const refs = new Map()
|
|
361
|
+
let inRefs = false
|
|
362
|
+
let refsIndent = -1
|
|
363
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
364
|
+
if (line.trim() === '' || line.trimStart().startsWith('#')) continue
|
|
365
|
+
const indent = line.length - line.trimStart().length
|
|
366
|
+
if (!inRefs) {
|
|
367
|
+
if (/^refs\s*:\s*$/.test(line.trim())) {
|
|
368
|
+
inRefs = true
|
|
369
|
+
refsIndent = indent
|
|
370
|
+
}
|
|
371
|
+
continue
|
|
372
|
+
}
|
|
373
|
+
// 缩进不深于 refs: 即说明已离开该段
|
|
374
|
+
if (indent <= refsIndent) break
|
|
375
|
+
const match = /^\s*([^\s:#][^:]*?)\s*:\s*(.*)$/.exec(line)
|
|
376
|
+
if (!match) continue
|
|
377
|
+
const value = unquoteScalar(match[2]) ?? ''
|
|
378
|
+
if (value) refs.set(match[1].trim(), value)
|
|
379
|
+
}
|
|
380
|
+
// 按候选名给定的优先级取值(不是文件顺序)
|
|
381
|
+
for (const key of wanted) {
|
|
382
|
+
const hit = refs.get(key)
|
|
383
|
+
if (hit) return hit
|
|
384
|
+
}
|
|
385
|
+
return undefined
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** 剥掉标量两端成对的引号并裁掉未加引号值后的行内注释。 */
|
|
389
|
+
function unquoteScalar(raw) {
|
|
390
|
+
const trimmed = String(raw ?? '').trim()
|
|
391
|
+
if (trimmed === '') return ''
|
|
392
|
+
const first = trimmed[0]
|
|
393
|
+
if (first === '"' || first === "'") {
|
|
394
|
+
// 引号值:取到配对的收尾引号为止,引号内的 # 不算注释
|
|
395
|
+
const end = trimmed.indexOf(first, 1)
|
|
396
|
+
return end === -1 ? trimmed.slice(1) : trimmed.slice(1, end)
|
|
397
|
+
}
|
|
398
|
+
const hash = trimmed.indexOf(' #')
|
|
399
|
+
return hash === -1 ? trimmed : trimmed.slice(0, hash).trim()
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/* ─── 脱敏规则清单(供外发声明复用,避免文案与实现漂移)────────────────────── */
|
|
403
|
+
|
|
404
|
+
/** 规则的中文类型标签,按 REDACT_RULES 的顺序。 */
|
|
405
|
+
const REDACT_TAGS = REDACT_RULES.map((rule) => rule.tag.replace(/^\[|\]$/g, ''))
|
|
406
|
+
|
|
407
|
+
/** 脱敏规则的中文清单,形如 `手机号/身份证/邮箱/银行卡号`。 */
|
|
408
|
+
export function redactRuleSummary() {
|
|
409
|
+
return REDACT_TAGS.join('/')
|
|
410
|
+
}
|
|
411
|
+
|
|
313
412
|
/* ─── 通用内部工具 ─────────────────────────────────────────────────────────── */
|
|
314
413
|
|
|
315
414
|
/** URL 合法性在发请求前拦下:漏写协议时 fetch 的报错非常隐晦,不如直接说清楚。 */
|
|
@@ -329,8 +428,10 @@ function isRetryable(error) {
|
|
|
329
428
|
}
|
|
330
429
|
|
|
331
430
|
function sleep(ms) {
|
|
431
|
+
// ref 定时器(不能 unref):重试退避期间调用方正在 await decide(),
|
|
432
|
+
// 这个承诺必须落定。unref 会让无其他句柄的进程(如测试 runner)事件循环
|
|
433
|
+
// 提前抽干、承诺悬空,也可能在宿主退出时静默吞掉一次重试。
|
|
332
434
|
return new Promise((resolve) => {
|
|
333
|
-
|
|
334
|
-
timer.unref?.()
|
|
435
|
+
setTimeout(resolve, ms)
|
|
335
436
|
})
|
|
336
437
|
}
|
package/lib/scenarios.js
CHANGED
|
@@ -33,6 +33,17 @@
|
|
|
33
33
|
* {qid?文案A|文案B} → 条件文案(noul 为真 / 分值≥1 / choice 非空非 none 时取 A)
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* choice 题型的选项数上限。
|
|
38
|
+
*
|
|
39
|
+
* 单一真源:场景校验(validateScenario)与自动路由的分批逻辑都读这里。
|
|
40
|
+
* 上游决策模型对 choice 的选项数有硬限制,超过会被拒——因此不仅是"建议"。
|
|
41
|
+
*/
|
|
42
|
+
export const MAX_CHOICE_OPTIONS = 26
|
|
43
|
+
|
|
44
|
+
/** choice 题型的选项数下限。 */
|
|
45
|
+
export const MIN_CHOICE_OPTIONS = 2
|
|
46
|
+
|
|
36
47
|
/** 内置场景:11 个业务域,每个 3 个结构化问题。 */
|
|
37
48
|
export const BUILTIN_SCENARIOS = [
|
|
38
49
|
{
|
|
@@ -465,10 +476,10 @@ export function validateScenario(spec) {
|
|
|
465
476
|
if (q.type === 'choice') {
|
|
466
477
|
if (!q.criteria || typeof q.criteria !== 'object' || Array.isArray(q.criteria)) {
|
|
467
478
|
problems.push(`问题 ${qid}(choice)的 criteria 必须是"选项 key → 说明"的对象`)
|
|
468
|
-
} else if (Object.keys(q.criteria).length <
|
|
469
|
-
problems.push(`问题 ${qid}(choice)至少需要
|
|
470
|
-
} else if (Object.keys(q.criteria).length >
|
|
471
|
-
problems.push(`问题 ${qid}(choice
|
|
479
|
+
} else if (Object.keys(q.criteria).length < MIN_CHOICE_OPTIONS) {
|
|
480
|
+
problems.push(`问题 ${qid}(choice)至少需要 ${MIN_CHOICE_OPTIONS} 个选项`)
|
|
481
|
+
} else if (Object.keys(q.criteria).length > MAX_CHOICE_OPTIONS) {
|
|
482
|
+
problems.push(`问题 ${qid}(choice)选项最多 ${MAX_CHOICE_OPTIONS} 个(当前 ${Object.keys(q.criteria).length} 个)`)
|
|
472
483
|
}
|
|
473
484
|
}
|
|
474
485
|
if (q.type === 'score' && (!Array.isArray(q.criteria) || q.criteria.length < 2)) {
|