dsh-decision-engine 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,138 @@
1
+ /**
2
+ * 思维链翻译门槛:包住 `translator.translate()`。
3
+ *
4
+ * 翻译链路没有提供任何 hook —— `ctx.translator` 是一个 Service,`cotTranslation`
5
+ * 直接调用它的 `translate(spec, signal)`。所以这里只能包住那个方法。
6
+ *
7
+ * 包装是「有边界的、可撤销的」,不是野生 monkeypatch:
8
+ *
9
+ * - 用 `ctx.effect(...)` 注册,卸载时一定把原方法装回去;
10
+ * - 原方法的 `this` 保持为 translator 实例,签名与返回值完全不变;
11
+ * - 判定失败一律调用原方法(fail-open),绝不让翻译因为守卫而不可用;
12
+ * - 返回一个空串表示「不需要翻译」会破坏调用方语义,因此**跳过翻译时
13
+ * 返回原文**——调用方拿到的是「已经是目标语言」的合理解释。
14
+ *
15
+ * 生效方式与省 token 的关系:翻译按条计费。判为 skip 时直接返回原文,
16
+ * 那条就完全不发请求;判为 translate 时正常走下原方法。
17
+ *
18
+ * @module dsh-decision-engine/adapters/translate
19
+ */
20
+
21
+ /** 判断一段文本里「值得翻译的正文」占比是否够高(本地预筛,免费的)。 */
22
+ function looksLikeProse(text) {
23
+ if (typeof text !== "string" || text.length === 0) return false;
24
+ // 去掉代码块、行内代码、URL —— 这些不需要翻译。
25
+ const prose = text
26
+ .replace(/```[\s\S]*?```/g, " ")
27
+ .replace(/`[^`]*`/g, " ")
28
+ .replace(/https?:\/\/\S+/g, " ");
29
+ // 统计字母/汉字数量;纯符号/数字/路径不值得发一次翻译请求。
30
+ let letters = 0;
31
+ for (const char of prose) {
32
+ if (/[\p{L}]/u.test(char)) letters += 1;
33
+ }
34
+ return letters >= 4;
35
+ }
36
+
37
+ /**
38
+ * 包住 translator.translate。
39
+ *
40
+ * @param ctx - cordis 插件上下文。
41
+ * @param deps - 运行期依赖。
42
+ * @returns 是否成功挂载。
43
+ */
44
+ export function mountCotTranslatePoint(ctx, deps) {
45
+ const { runner, getConfig, telemetry } = deps;
46
+
47
+ const translator = ctx.get?.("translator");
48
+ if (translator === undefined || typeof translator.translate !== "function") {
49
+ ctx.logger?.info?.("dsh-decision-engine: 未找到 translator 服务,思维链翻译门槛不挂载");
50
+ return false;
51
+ }
52
+
53
+ const original = translator.translate;
54
+ /** 命中缓存的「需要翻译」判断:同一段文本重复翻译时不再问模型。 */
55
+ const decisions = new Map();
56
+ const MAX_DECISIONS = 256;
57
+
58
+ const remember = (key, value) => {
59
+ if (decisions.has(key)) decisions.delete(key);
60
+ decisions.set(key, value);
61
+ while (decisions.size > MAX_DECISIONS) {
62
+ const oldest = decisions.keys().next();
63
+ if (oldest.done) break;
64
+ decisions.delete(oldest.value);
65
+ }
66
+ };
67
+
68
+ const wrapped = async function translate(spec, signal) {
69
+ // 这个点关掉时连一次判断都不做,直接走原路径。
70
+ if (!runner.participates("cotTranslate")) return original.call(this, spec, signal);
71
+
72
+ try {
73
+ const text = typeof spec?.text === "string" ? spec.text : "";
74
+ // 本地预筛:明显不需要翻译的文本零成本跳过,不必问模型。
75
+ if (!looksLikeProse(text)) {
76
+ await telemetry.log({
77
+ ts: new Date().toISOString(),
78
+ point: "cotTranslate",
79
+ tag: "cotTranslate",
80
+ action: "skip",
81
+ detail: "decision-engine-cot: 本地预筛判定为无正文,跳过翻译",
82
+ latencyMs: 0,
83
+ cached: false,
84
+ });
85
+ return text;
86
+ }
87
+
88
+ // 缓存键:目标语言 + 文本摘要。同一段文本重复翻译(重放、重渲染)很常见。
89
+ const key = `${spec.targetLanguage ?? "auto"}|${text.length}|${text.slice(0, 64)}`;
90
+ let verdict = decisions.get(key);
91
+ if (verdict === undefined) {
92
+ const cfg = getConfig();
93
+ const result = await runner.ask(
94
+ "cotTranslate",
95
+ {
96
+ text,
97
+ targetLanguage: spec.targetLanguage,
98
+ sourceLanguage: spec.sourceLanguage,
99
+ head: text.slice(0, 400),
100
+ },
101
+ { signal },
102
+ );
103
+ // 降级 / observe / 关闭:一律按「需要翻译」处理,走原方法。
104
+ if (result.action === undefined || !runner.enforces("cotTranslate")) {
105
+ return original.call(this, spec, signal);
106
+ }
107
+ verdict = result.action.kind === "translate";
108
+ remember(key, verdict);
109
+ }
110
+
111
+ if (verdict === true) return original.call(this, spec, signal);
112
+
113
+ // 判为不需要翻译:返回原文。这与「已经是目标语言」的语义一致,
114
+ // 调用方不会看到异常,用户看到的还是那段文本。
115
+ await telemetry.log({
116
+ ts: new Date().toISOString(),
117
+ point: "cotTranslate",
118
+ tag: "cotTranslate",
119
+ action: "skip",
120
+ detail: "decision-engine-cot: 决策判定无需翻译,返回原文",
121
+ latencyMs: 0,
122
+ cached: true,
123
+ });
124
+ return text;
125
+ } catch (error) {
126
+ telemetry.noteAdapterError("cotTranslate", error, ctx);
127
+ // 任何意外都必须回到原生行为:翻译不能因为守卫而不可用。
128
+ return original.call(this, spec, signal);
129
+ }
130
+ };
131
+
132
+ translator.translate = wrapped;
133
+ // 卸载时恢复原方法:否则插件热卸载后 translator 会一直指着我们的包装。
134
+ ctx.effect(() => () => {
135
+ if (translator.translate === wrapped) translator.translate = original;
136
+ });
137
+ return true;
138
+ }
package/lib/budget.js ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * 决策模型自身的 token 预算。
3
+ *
4
+ * 引入理由:StartLux 每次决策实测约 1,900 token,p50 延迟约 2 秒。
5
+ * 把这个判断层挂到多个决策点上,调用量会成倍上升——如果不设上限,
6
+ * 「省 token 的插件」反而会变成主要的 token 消耗方。
7
+ *
8
+ * 预算按滑动窗口统计(默认 1 小时)。一旦超限,所有决策点立刻退回
9
+ * 本地启发式并 fail-open,等窗口滚过去自动恢复;不需要重启,也不会
10
+ * 让工具调用被卡住。
11
+ */
12
+ export class TokenBudget {
13
+ /**
14
+ * @param getLimitTokens - 返回当前上限的取值函数;0 表示不限制。
15
+ * 刻意接收函数而不是数值:本插件的原则是「配置每次现读」,
16
+ * 若在构造时固化,用户在配置页改了预算就得重启才生效。
17
+ * @param getWindowMs - 返回当前窗口长度的取值函数。
18
+ */
19
+ constructor(getLimitTokens, getWindowMs) {
20
+ this.getLimitTokens = getLimitTokens;
21
+ this.getWindowMs = getWindowMs;
22
+ /** @type {Array<{at: number, tokens: number}>} */
23
+ this.entries = [];
24
+ this.blocked = 0;
25
+ }
26
+
27
+ /** 当前生效的上限(读不到时按不限制处理,绝不因预算自身出错而卡住决策)。 */
28
+ get limitTokens() {
29
+ const raw = typeof this.getLimitTokens === "function" ? this.getLimitTokens() : this.getLimitTokens;
30
+ const value = Number(raw);
31
+ return Number.isFinite(value) && value > 0 ? value : 0;
32
+ }
33
+
34
+ /** 当前生效的窗口长度。 */
35
+ get windowMs() {
36
+ const raw = typeof this.getWindowMs === "function" ? this.getWindowMs() : this.getWindowMs;
37
+ const value = Number(raw);
38
+ return Number.isFinite(value) && value > 0 ? value : 3600000;
39
+ }
40
+
41
+ /** 丢弃窗口外的记录。 */
42
+ prune(now = Date.now()) {
43
+ const cutoff = now - this.windowMs;
44
+ let drop = 0;
45
+ while (drop < this.entries.length && this.entries[drop].at < cutoff) drop += 1;
46
+ if (drop > 0) this.entries.splice(0, drop);
47
+ }
48
+
49
+ /** 窗口内已消耗的 token。 */
50
+ used(now = Date.now()) {
51
+ this.prune(now);
52
+ let total = 0;
53
+ for (const entry of this.entries) total += entry.tokens;
54
+ return total;
55
+ }
56
+
57
+ /** 预算是否已耗尽(未设置上限时永远 false)。 */
58
+ exhausted(now = Date.now()) {
59
+ if (this.limitTokens <= 0) return false;
60
+ return this.used(now) >= this.limitTokens;
61
+ }
62
+
63
+ /**
64
+ * 记一次消耗。
65
+ * @param tokens - 本次估算或实测的 input token 数。
66
+ */
67
+ spend(tokens) {
68
+ const amount = Number.isFinite(tokens) && tokens > 0 ? tokens : 0;
69
+ if (amount === 0) return;
70
+ this.entries.push({ at: Date.now(), tokens: amount });
71
+ }
72
+
73
+ /** 记一次「因为预算耗尽而放弃判断」,用于配置页展示。 */
74
+ noteBlocked() {
75
+ this.blocked += 1;
76
+ }
77
+
78
+ /** 供 UI 展示的快照。 */
79
+ get report() {
80
+ const now = Date.now();
81
+ const used = this.used(now);
82
+ const limit = this.limitTokens;
83
+ return {
84
+ used,
85
+ limit,
86
+ windowMs: this.windowMs,
87
+ remaining: limit <= 0 ? null : Math.max(0, limit - used),
88
+ exhausted: this.exhausted(now),
89
+ blocked: this.blocked,
90
+ };
91
+ }
92
+ }