@a9i5k4/dsh-auto-memory 0.1.29 → 0.1.30
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 +352 -266
- package/README.zh-CN.md +370 -264
- package/cordis.patch.yml +9 -9
- package/lib/activation-host.js +455 -0
- package/lib/activation-inbox-state.js +261 -0
- package/lib/activation-inbox.js +426 -0
- package/lib/client.js +1379 -50
- package/lib/context-bridge.js +619 -0
- package/lib/context-host.js +712 -0
- package/lib/context-sink-python.js +90 -0
- package/lib/episodic-store.js +316 -0
- package/lib/evidence-store.js +272 -0
- package/lib/fact-store.js +418 -0
- package/lib/index-sync.js +160 -0
- package/lib/index.js +2357 -102
- package/lib/intent-clean.js +74 -0
- package/lib/m4-corpus.js +169 -0
- package/lib/m7-index-sync-host.js +194 -0
- package/lib/m7-wire.js +268 -0
- package/lib/memory-anchor.js +451 -0
- package/lib/memory-hub.js +259 -0
- package/lib/memory-index.js +145 -0
- package/lib/memory-writer.js +391 -0
- package/lib/policies/activation_policy_v2.json +88 -0
- package/lib/policies/recall_intent_lr_v1.json +1 -0
- package/lib/procedure-store.js +406 -0
- package/lib/python-sidecar-client.js +326 -0
- package/lib/semantic-decide.js +265 -0
- package/lib/semantic-js.js +381 -0
- package/lib/shadow-host.js +361 -0
- package/lib/shadow-retrieval.js +673 -0
- package/lib/storage-manage.js +203 -0
- package/package.json +2 -2
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M7-0 JS SidecarClient(docs/PYTHON-SIDECAR-CONTRACT.md §7,§13;handoff M7-1 第一项)。
|
|
3
|
+
* no-shell spawn 标准库 Python fake worker;JSONL 单行帧;lazy start(仅在显式启用路径上被调用)。
|
|
4
|
+
*
|
|
5
|
+
* 纪律:
|
|
6
|
+
* - request() 永不 reject:结构化失败 {ok:false, code, reason}(Python 不可用不影响基础对话)。
|
|
7
|
+
* - workerEpoch:每次进程启动新 opaque epoch;入站帧 epoch 不匹配即丢弃(fail closed)。
|
|
8
|
+
* - 帧纪律:partial/multiple JSONL 行重组、单行 256KiB 上限(超限 fatal)、坏 JSON/坏 envelope/
|
|
9
|
+
* 错误 epoch/未知 requestId/重复或过期 response 全部计账丢弃,绝不注入上层。
|
|
10
|
+
* - 四种身份不混用:requestId(transport)/observationId(M5)/activationId(M6)/syncId(index)。
|
|
11
|
+
* - timeout/AbortSignal(cancel 通知)/latest-wins(由上层 M5 bridge 驱动)/crash recovery/circuit breaker。
|
|
12
|
+
* 无 shell;无 HTTP;stdout 只进协议解析器;stderr 仅有界诊断。UTF-8 无 BOM。
|
|
13
|
+
*/
|
|
14
|
+
import { spawn } from 'node:child_process'
|
|
15
|
+
import { randomBytes } from 'node:crypto'
|
|
16
|
+
import path from 'node:path'
|
|
17
|
+
import { fileURLToPath } from 'node:url'
|
|
18
|
+
import {
|
|
19
|
+
validateTransportFramePre, makeRequestFramePre, RESPONSE_TYPE_FOR_V1,
|
|
20
|
+
M7_TRANSPORT_BUDGET_V1, PY_FRAME_TYPES_V1,
|
|
21
|
+
} from './m7-wire.js'
|
|
22
|
+
|
|
23
|
+
const B = M7_TRANSPORT_BUDGET_V1
|
|
24
|
+
|
|
25
|
+
/** 捆绑 fake worker 的默认绝对路径(python/worker_v1.py)。 */
|
|
26
|
+
export function defaultWorkerScriptPathPre() {
|
|
27
|
+
try {
|
|
28
|
+
return path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'python', 'worker_v1.py')
|
|
29
|
+
} catch (_) { return '' }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const FAILURE_CODES = new Set(['timeout', 'crashed', 'unavailable', 'protocol', 'line-oversize'])
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 创建 SidecarClient。opts 可为值或 () => 值(启动时惰性求值):
|
|
36
|
+
* command('python') / scriptPath(捆绑 worker) / dshHome(''=worker 仅内存派生态) /
|
|
37
|
+
* requestTimeoutMs / maxLineBytes / breakerFailureThreshold / breakerCooldownMs / maxPendingRequests。
|
|
38
|
+
*/
|
|
39
|
+
export function createPythonSidecarClientPre(opts = {}) {
|
|
40
|
+
const opt = (k, dflt) => {
|
|
41
|
+
const v = opts[k]
|
|
42
|
+
return typeof v === 'function' ? v() : (v === undefined ? dflt : v)
|
|
43
|
+
}
|
|
44
|
+
let disposed = false
|
|
45
|
+
let child = null
|
|
46
|
+
let epoch = null
|
|
47
|
+
let buffer = Buffer.alloc(0)
|
|
48
|
+
let stderrTail = ''
|
|
49
|
+
let reqCounter = 0
|
|
50
|
+
const pending = new Map()
|
|
51
|
+
const activationHandlers = new Set()
|
|
52
|
+
const seenActivationIds = []
|
|
53
|
+
const seenActivationIdSet = new Set()
|
|
54
|
+
let writeChain = Promise.resolve()
|
|
55
|
+
let lastSentFrame = null
|
|
56
|
+
const breaker = { consecutiveFailures: 0, openUntil: 0 }
|
|
57
|
+
const stats = {
|
|
58
|
+
starts: 0, exits: 0, framesIn: 0, requests: 0, succeeded: 0, activationsReceived: 0,
|
|
59
|
+
cancelNotifications: 0, restarts: 0,
|
|
60
|
+
dropped: { badJson: 0, badEnvelope: 0, staleEpoch: 0, unknownRequest: 0, typeMismatch: 0, duplicateActivation: 0 },
|
|
61
|
+
failed: { timeout: 0, crashed: 0, aborted: 0, circuitOpen: 0, unavailable: 0, backpressure: 0, protocol: 0, workerError: 0, disposed: 0 },
|
|
62
|
+
lastExit: null, lastFatal: null,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function resolveOpt(k) { return opt(k, null) }
|
|
66
|
+
|
|
67
|
+
function noteFailure(code) {
|
|
68
|
+
if (FAILURE_CODES.has(code)) {
|
|
69
|
+
breaker.consecutiveFailures++
|
|
70
|
+
if (breaker.consecutiveFailures >= Number(opt('breakerFailureThreshold', B.breakerFailureThreshold))) {
|
|
71
|
+
breaker.openUntil = Date.now() + Number(opt('breakerCooldownMs', B.breakerCooldownMs))
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function breakerOpen() { return Date.now() < breaker.openUntil }
|
|
77
|
+
|
|
78
|
+
/** lazy start:no-shell spawn;仅由显式启用的调用路径触达。 */
|
|
79
|
+
function ensureStarted() {
|
|
80
|
+
if (disposed) return { ok: false, code: 'disposed' }
|
|
81
|
+
if (child && (child.killed || (child.stdin && child.stdin.destroyed))) {
|
|
82
|
+
// 上一个进程正在收尾(exit 事件未到):按已死处理,允许立即重生
|
|
83
|
+
try { child.kill() } catch (_) {}
|
|
84
|
+
child = null
|
|
85
|
+
epoch = null
|
|
86
|
+
}
|
|
87
|
+
if (child) return { ok: true }
|
|
88
|
+
const scriptPath = String(resolveOpt('scriptPath') || defaultWorkerScriptPathPre())
|
|
89
|
+
const command = String(resolveOpt('command') || 'python')
|
|
90
|
+
const dshHome = String(resolveOpt('dshHome') || '')
|
|
91
|
+
epoch = 'wk_' + randomBytes(16).toString('hex')
|
|
92
|
+
const args = [scriptPath, '--expect-epoch', epoch]
|
|
93
|
+
if (dshHome) args.push('--dsh-home', dshHome)
|
|
94
|
+
let proc
|
|
95
|
+
try {
|
|
96
|
+
proc = spawn(command, args, { shell: false, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] })
|
|
97
|
+
} catch (_) {
|
|
98
|
+
epoch = null
|
|
99
|
+
noteFailure('unavailable')
|
|
100
|
+
return { ok: false, code: 'unavailable' }
|
|
101
|
+
}
|
|
102
|
+
child = proc
|
|
103
|
+
stats.starts++
|
|
104
|
+
proc.stdout.on('data', (chunk) => { try { feed(chunk) } catch (_) { fatal('protocol') } })
|
|
105
|
+
proc.stderr.on('data', (chunk) => {
|
|
106
|
+
stderrTail = (stderrTail + chunk.toString('utf8')).slice(-4096)
|
|
107
|
+
})
|
|
108
|
+
proc.on('error', () => {
|
|
109
|
+
// spawn 失败(ENOENT 等):结构化失败,不计崩溃重启
|
|
110
|
+
const wasChild = child
|
|
111
|
+
child = null
|
|
112
|
+
epoch = null
|
|
113
|
+
void wasChild
|
|
114
|
+
rejectAll('unavailable')
|
|
115
|
+
noteFailure('unavailable')
|
|
116
|
+
})
|
|
117
|
+
proc.on('exit', (code, signalName) => {
|
|
118
|
+
if (child !== proc) return
|
|
119
|
+
child = null
|
|
120
|
+
epoch = null
|
|
121
|
+
buffer = Buffer.alloc(0)
|
|
122
|
+
stats.exits++
|
|
123
|
+
stats.lastExit = { code, signal: signalName }
|
|
124
|
+
rejectAll('crashed')
|
|
125
|
+
})
|
|
126
|
+
return { ok: true }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function rejectAll(code) {
|
|
130
|
+
for (const [, entry] of pending) settle(entry, { ok: false, code })
|
|
131
|
+
noteFailure(code)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function fatal(kind) {
|
|
135
|
+
stats.lastFatal = kind
|
|
136
|
+
buffer = Buffer.alloc(0) // 丢弃残留半帧,防止污染重生进程的解析流
|
|
137
|
+
if (child) { try { child.stdin.destroy() } catch (_) {} try { child.kill() } catch (_) {} }
|
|
138
|
+
rejectAll('protocol')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function feed(chunk) {
|
|
142
|
+
buffer = buffer.length ? Buffer.concat([buffer, chunk]) : chunk
|
|
143
|
+
const cap = Number(opt('maxLineBytes', B.maxLineBytes))
|
|
144
|
+
for (;;) {
|
|
145
|
+
const idx = buffer.indexOf(10)
|
|
146
|
+
if (idx === -1) {
|
|
147
|
+
if (buffer.length > cap) fatal('line-oversize')
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
const line = buffer.subarray(0, idx)
|
|
151
|
+
buffer = buffer.subarray(idx + 1)
|
|
152
|
+
if (line.length > cap) { fatal('line-oversize'); return }
|
|
153
|
+
handleLine(line)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function handleLine(line) {
|
|
158
|
+
stats.framesIn++
|
|
159
|
+
let obj
|
|
160
|
+
try { obj = JSON.parse(line.toString('utf8')) } catch (_) { stats.dropped.badJson++; return }
|
|
161
|
+
const v = validateTransportFramePre(obj, { direction: 'in' })
|
|
162
|
+
if (!v.ok) { stats.dropped.badEnvelope++; return }
|
|
163
|
+
const frame = v.frame
|
|
164
|
+
if (epoch !== null && frame.workerEpoch !== epoch) { stats.dropped.staleEpoch++; return }
|
|
165
|
+
if (frame.type === 'activation_request') {
|
|
166
|
+
const activation = frame.payload && frame.payload.activation
|
|
167
|
+
const aid = activation && activation.activationId
|
|
168
|
+
if (!aid) { stats.dropped.badEnvelope++; return }
|
|
169
|
+
if (seenActivationIdSet.has(aid)) { stats.dropped.duplicateActivation++; return }
|
|
170
|
+
seenActivationIdSet.add(aid)
|
|
171
|
+
seenActivationIds.push(aid)
|
|
172
|
+
while (seenActivationIds.length > B.activationIdsCapacity) seenActivationIdSet.delete(seenActivationIds.shift())
|
|
173
|
+
stats.activationsReceived++
|
|
174
|
+
for (const h of activationHandlers) {
|
|
175
|
+
try { h({ frame, activation, requestId: frame.requestId, workerEpoch: frame.workerEpoch }) } catch (_) {}
|
|
176
|
+
}
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
const entry = pending.get(frame.requestId)
|
|
180
|
+
if (frame.type === 'error') {
|
|
181
|
+
// error 帧是对该 requestId 的终局答复,先于类型匹配检查(error ≠ expectedType 恒成立)
|
|
182
|
+
if (!entry) { stats.dropped.unknownRequest++; return }
|
|
183
|
+
settle(entry, { ok: false, code: 'worker-error', reason: String((frame.payload && frame.payload.reason) || 'error'), detail: frame.payload || {} })
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
if (!entry) { stats.dropped.unknownRequest++; return }
|
|
187
|
+
if (frame.type !== entry.expectedType) { stats.dropped.typeMismatch++; return }
|
|
188
|
+
settle(entry, { ok: true, frame })
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function settle(entry, result) {
|
|
192
|
+
if (entry.settled) return
|
|
193
|
+
entry.settled = true
|
|
194
|
+
if (entry.timer) clearTimeout(entry.timer)
|
|
195
|
+
if (entry.onAbort) { try { entry.signal.removeEventListener('abort', entry.onAbort) } catch (_) {} }
|
|
196
|
+
pending.delete(entry.requestId)
|
|
197
|
+
if (result.ok) { stats.succeeded++; breaker.consecutiveFailures = 0 }
|
|
198
|
+
else {
|
|
199
|
+
const bucket = stats.failed[result.code]
|
|
200
|
+
if (bucket === undefined) stats.failed.protocol++
|
|
201
|
+
else stats.failed[result.code]++
|
|
202
|
+
noteFailure(result.code)
|
|
203
|
+
}
|
|
204
|
+
entry.resolve(result)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function writeFrame(frame) {
|
|
208
|
+
lastSentFrame = frame
|
|
209
|
+
if (!child || !child.stdin || child.stdin.destroyed) return false
|
|
210
|
+
const line = Buffer.from(JSON.stringify(frame) + '\n', 'utf8')
|
|
211
|
+
writeChain = writeChain.then(() => new Promise((done) => {
|
|
212
|
+
if (!child || !child.stdin || child.stdin.destroyed) { done(); return }
|
|
213
|
+
child.stdin.write(line, () => done())
|
|
214
|
+
}))
|
|
215
|
+
writeChain = writeChain.catch(() => {})
|
|
216
|
+
return true
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* 结构化请求:resolve({ok:true, frame}) 或 resolve({ok:false, code, reason?});永不 reject。
|
|
221
|
+
* opts: {timeoutMs, signal}。signal 中止 → 结构化 aborted + 向 worker 发 cancel 通知。
|
|
222
|
+
*/
|
|
223
|
+
function request(type, payload, rOpts = {}) {
|
|
224
|
+
if (disposed) return Promise.resolve({ ok: false, code: 'disposed' })
|
|
225
|
+
if (!RESPONSE_TYPE_FOR_V1[type]) return Promise.resolve({ ok: false, code: 'unsupported-frame' })
|
|
226
|
+
if (breakerOpen()) {
|
|
227
|
+
stats.failed.circuitOpen++
|
|
228
|
+
return Promise.resolve({ ok: false, code: 'circuit-open', retryInMs: breaker.openUntil - Date.now() })
|
|
229
|
+
}
|
|
230
|
+
const started = ensureStarted()
|
|
231
|
+
if (!started.ok) {
|
|
232
|
+
stats.failed[started.code] = (stats.failed[started.code] || 0) + 1
|
|
233
|
+
return Promise.resolve({ ok: false, code: started.code })
|
|
234
|
+
}
|
|
235
|
+
const maxPending = Number(opt('maxPendingRequests', B.maxPendingRequests))
|
|
236
|
+
if (pending.size >= maxPending) { stats.failed.backpressure++; return Promise.resolve({ ok: false, code: 'backpressure' }) }
|
|
237
|
+
const requestId = 'req_' + randomBytes(9).toString('hex') + (++reqCounter).toString(36)
|
|
238
|
+
const sentAt = Date.now()
|
|
239
|
+
const mf = makeRequestFramePre({ type, payload, requestId, workerEpoch: epoch, sentAt })
|
|
240
|
+
if (!mf.ok) { stats.failed.protocol++; return Promise.resolve({ ok: false, code: 'protocol', reason: mf.reason }) }
|
|
241
|
+
return new Promise((resolve) => {
|
|
242
|
+
const entry = { requestId, expectedType: RESPONSE_TYPE_FOR_V1[type], resolve, settled: false, timer: null, signal: rOpts.signal || null, onAbort: null }
|
|
243
|
+
pending.set(requestId, entry)
|
|
244
|
+
stats.requests++
|
|
245
|
+
const written = writeFrame(mf.frame)
|
|
246
|
+
if (!written) { settle(entry, { ok: false, code: 'unavailable' }); return }
|
|
247
|
+
const timeoutMs = Math.max(1, Number(rOpts.timeoutMs) || Number(opt('requestTimeoutMs', B.requestTimeoutMs)))
|
|
248
|
+
entry.timer = setTimeout(() => {
|
|
249
|
+
settle(entry, { ok: false, code: 'timeout', timeoutMs })
|
|
250
|
+
notify('cancel', { requestId })
|
|
251
|
+
}, timeoutMs)
|
|
252
|
+
if (entry.signal) {
|
|
253
|
+
if (entry.signal.aborted) {
|
|
254
|
+
settle(entry, { ok: false, code: 'aborted' })
|
|
255
|
+
notify('cancel', { requestId })
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
entry.onAbort = () => {
|
|
259
|
+
settle(entry, { ok: false, code: 'aborted' })
|
|
260
|
+
notify('cancel', { requestId })
|
|
261
|
+
}
|
|
262
|
+
entry.signal.addEventListener('abort', entry.onAbort, { once: true })
|
|
263
|
+
}
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** fire-and-forget 帧(cancel/close_session;契约上无响应帧)。 */
|
|
268
|
+
function notify(type, payload) {
|
|
269
|
+
if (disposed || !PY_FRAME_TYPES_V1) return
|
|
270
|
+
if (!child) return
|
|
271
|
+
const requestId = 'ntf_' + randomBytes(6).toString('hex')
|
|
272
|
+
const sentAt = Date.now()
|
|
273
|
+
const mf = makeRequestFramePre({ type, payload, requestId, workerEpoch: epoch, sentAt })
|
|
274
|
+
if (mf.ok) { if (writeFrame(mf.frame)) stats.cancelNotifications++ }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** health 探针(breaker half-open 用;有界响应)。 */
|
|
278
|
+
function health(rOpts = {}) { return request('health', {}, rOpts) }
|
|
279
|
+
|
|
280
|
+
/** 刻意重启:旧 epoch 作废,旧 in-flight 全部 rejected;下次请求以新 epoch 重生。 */
|
|
281
|
+
function restart(reason) {
|
|
282
|
+
if (child) { try { child.kill() } catch (_) {} }
|
|
283
|
+
child = null
|
|
284
|
+
epoch = null
|
|
285
|
+
stats.restarts++
|
|
286
|
+
void reason
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function currentEpoch() { return epoch }
|
|
290
|
+
function isStarted() { return !!child }
|
|
291
|
+
function processForTest() { return child }
|
|
292
|
+
|
|
293
|
+
function debugView() {
|
|
294
|
+
return {
|
|
295
|
+
started: !!child,
|
|
296
|
+
epoch: epoch ? epoch.slice(0, 12) + '…' : null,
|
|
297
|
+
pending: pending.size,
|
|
298
|
+
breaker: { open: breakerOpen(), consecutiveFailures: breaker.consecutiveFailures, cooldownMs: Number(opt('breakerCooldownMs', B.breakerCooldownMs)) },
|
|
299
|
+
stderrTailBytes: stderrTail.length,
|
|
300
|
+
stats: JSON.parse(JSON.stringify(stats)),
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function dispose(reason) {
|
|
305
|
+
if (disposed) return
|
|
306
|
+
disposed = true
|
|
307
|
+
if (child) { try { child.kill() } catch (_) {} }
|
|
308
|
+
child = null
|
|
309
|
+
epoch = null
|
|
310
|
+
for (const [, entry] of [...pending]) settle(entry, { ok: false, code: 'disposed' })
|
|
311
|
+
activationHandlers.clear()
|
|
312
|
+
void reason
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return {
|
|
316
|
+
kind: 'python-sidecar-pre',
|
|
317
|
+
request, notify, health, restart, dispose, debugView,
|
|
318
|
+
ensureStarted, isStarted, currentEpoch, processForTest, breakerOpenForTest: breakerOpen,
|
|
319
|
+
// 测试钩子:确定性 framing 注入(partial/multiple/bad JSON/oversize/伪造帧)与最后出站帧检查
|
|
320
|
+
_feedForTest(chunk) { feed(typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk) },
|
|
321
|
+
_lastFrameForTest() { return lastSentFrame },
|
|
322
|
+
onActivation(handler) { activationHandlers.add(handler); return () => activationHandlers.delete(handler) },
|
|
323
|
+
_statsForTest: stats,
|
|
324
|
+
_pendingForTest: pending,
|
|
325
|
+
}
|
|
326
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JS 端激活判定核(js_activation_decide_v1) —— 2026-08-27 补全 JS 默认闭环。
|
|
3
|
+
*
|
|
4
|
+
* 与 Python m7_activation_features_v2.decide_activation_v2 逐字段对齐:
|
|
5
|
+
* - 意图头:char_wb 2-4gram + sublinear TF-IDF + L2 归一 + LR + Platt(读 recall_intent_lr_v1.json)
|
|
6
|
+
* - 两车道:explicit(interrogative/recall-ctx) / proactive
|
|
7
|
+
* - echo veto / completeness / margin / delta 门 → decision(lane/reasonCodes 与 Python 一致)
|
|
8
|
+
*
|
|
9
|
+
* 设计:
|
|
10
|
+
* - 纯函数、零依赖(仅 node:crypto? 不需要——无哈希,纯算术)。
|
|
11
|
+
* - 工件加载 fail closed(缺字段/configHash 校验失败 → 抛错,调用方回退)。
|
|
12
|
+
* - 输入 features 与 Python 相同:{text,denseTop,margin,containment,mark,nCand,candidateHit,
|
|
13
|
+
* hardGates,repetition,requiresRelayFlag,piiClass}。
|
|
14
|
+
* - 输出与 Python _pack 相同:{lane,decision,reasonCodes,features(snapshot),advisoryOnly,...}。
|
|
15
|
+
*/
|
|
16
|
+
import { readFileSync } from 'node:fs'
|
|
17
|
+
|
|
18
|
+
export const JS_ACTIVATION_DECIDE_VERSION = 'js_activation_decide_v1'
|
|
19
|
+
|
|
20
|
+
// ---- token 常量(与 Python 逐字一致) ----
|
|
21
|
+
const INTERROG = ['什么', '如何', '怎么', '哪些', '哪个', '为什么', '多少', '吗', '呢', '是不是', '对不对', '有没有', '怎么用', '怎么回事', '是什么', 'how', 'what', 'why', 'which', 'where', 'when']
|
|
22
|
+
const RECALL_CTX = ['之前', '上次', '当时', '早前', '以往', '历史', '记录里', '记忆里', '之前有', '上次说', '当时定', '以前']
|
|
23
|
+
const ACK_TOKENS = ['好的', '嗯嗯', '谢谢', '晚安', '收到']
|
|
24
|
+
const ERR_TOKENS = ['又失败', '又超限', '又不对', '第三次', '报错', '又出现', '又丢', 'error', 'failed', 'broken']
|
|
25
|
+
const REQ_TOKENS = ['帮我', '找出来', '调出来', '说一下', '再讲讲', '发我']
|
|
26
|
+
const PLAN_TOKENS = ['准备', '打算', '计划', '之后', '接下来', '继续']
|
|
27
|
+
const WS_RUN = /\s\s+/g
|
|
28
|
+
// Python `(?u)\b\w\w+\b` 中 \w 含 CJK;JS 的 \w 默认只含 ASCII,须显式加 CJK
|
|
29
|
+
// 否则中文词不被切分 → 无 gram → 中文意图全靠 intercept(严重偏差)。
|
|
30
|
+
const WORD_RE = /[A-Za-z0-9_\u4e00-\u9fff]+/g
|
|
31
|
+
|
|
32
|
+
/** 与 Python normalize_text 对齐(大小写折叠 + 保留 [a-z0-9]+CJK + 去其他)。 */
|
|
33
|
+
function normalizeText(text) {
|
|
34
|
+
return String(text || '')
|
|
35
|
+
.toLowerCase()
|
|
36
|
+
.replace(WS_RUN, ' ')
|
|
37
|
+
// 保留字母数字与 CJK,其余变空格(近似 Python 的 keep [a-z0-9] and CJK)
|
|
38
|
+
.replace(/[^a-z0-9\u4e00-\u9fff\s]/g, ' ')
|
|
39
|
+
.trim()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** char_wb n-gram 计数(与 Python _char_wb_ngram_counts 对齐)。 */
|
|
43
|
+
function charWbNgramCounts(normText, minN, maxN) {
|
|
44
|
+
const counts = {}
|
|
45
|
+
const words = String(normText || '').match(WORD_RE) || []
|
|
46
|
+
for (const w of words) {
|
|
47
|
+
const padded = ' ' + w + ' '
|
|
48
|
+
const L = padded.length
|
|
49
|
+
for (let n = minN; n <= Math.min(maxN, L); n++) {
|
|
50
|
+
for (let i = 0; i <= L - n; i++) {
|
|
51
|
+
const gram = padded.slice(i, i + n)
|
|
52
|
+
counts[gram] = (counts[gram] || 0) + 1
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return counts
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** bigram 集合(echo 用,与 Python bigram_set 对齐)。 */
|
|
60
|
+
function bigramSet(text) {
|
|
61
|
+
const t = normalizeText(text)
|
|
62
|
+
const s = new Set()
|
|
63
|
+
for (let i = 0; i < t.length - 1; i++) s.add(t.slice(i, i + 2))
|
|
64
|
+
if (!s.size) s.add(t)
|
|
65
|
+
return s
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 词法包含(echo veto,与 Python lexical_containment 对齐)。 */
|
|
69
|
+
function lexicalContainment(queryText, candidateText) {
|
|
70
|
+
const q = bigramSet(queryText)
|
|
71
|
+
const c = bigramSet(candidateText)
|
|
72
|
+
if (!q.size) return 0
|
|
73
|
+
let hit = 0
|
|
74
|
+
for (const g of q) if (c.has(g)) hit++
|
|
75
|
+
return hit / q.size
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 意图头(读 recall_intent_lr_v1.json,与 Python RecallIntentHead 对齐)。 */
|
|
79
|
+
function createRecallIntentHead(artifact) {
|
|
80
|
+
const fs = artifact.featureSchema
|
|
81
|
+
const vf = fs && fs.vectorizer
|
|
82
|
+
if (!vf || vf.analyzer !== 'char_wb' || vf.sublinearTf !== true ||
|
|
83
|
+
!Array.isArray(vf.ngramRange) || vf.ngramRange[0] !== 2 || vf.ngramRange[1] !== 4) {
|
|
84
|
+
throw new Error('intent: unsupported featureSchema')
|
|
85
|
+
}
|
|
86
|
+
const vocab = artifact.vocabulary
|
|
87
|
+
const idf = artifact.idf
|
|
88
|
+
const coef = artifact.coefficients
|
|
89
|
+
const intercept = Number(artifact.intercept)
|
|
90
|
+
const cal = artifact.calibration || {}
|
|
91
|
+
if (cal.method !== 'platt') throw new Error('intent: calibration not platt')
|
|
92
|
+
const plattA = Number(cal.a)
|
|
93
|
+
const plattB = Number(cal.b)
|
|
94
|
+
const L = Object.keys(vocab).length
|
|
95
|
+
if (!(L === idf.length && L === coef.length)) throw new Error('intent: length mismatch')
|
|
96
|
+
|
|
97
|
+
function infer(text) {
|
|
98
|
+
const grams = charWbNgramCounts(normalizeText(text), 2, 4)
|
|
99
|
+
const acc = new Map()
|
|
100
|
+
for (const gram of Object.keys(grams)) {
|
|
101
|
+
const idx = vocab[gram]
|
|
102
|
+
if (idx === undefined || idx === null) continue
|
|
103
|
+
const cnt = grams[gram]
|
|
104
|
+
const v = (1 + Math.log(cnt)) * idf[idx]
|
|
105
|
+
acc.set(idx, v)
|
|
106
|
+
}
|
|
107
|
+
let norm = 0
|
|
108
|
+
for (const v of acc.values()) norm += v * v
|
|
109
|
+
norm = Math.sqrt(norm) || 1
|
|
110
|
+
let z = intercept
|
|
111
|
+
for (const [idx, w] of acc) z += coef[idx] * (w / norm)
|
|
112
|
+
const pRaw = 1 / (1 + Math.exp(-Math.max(-30, Math.min(30, z))))
|
|
113
|
+
const zz = Math.log(Math.max(pRaw, 1e-6) / Math.max(1e-6, 1 - pRaw))
|
|
114
|
+
const p = 1 / (1 + Math.exp(-Math.max(-30, Math.min(30, plattA * zz + plattB))))
|
|
115
|
+
return Math.round(p * 1e6) / 1e6
|
|
116
|
+
}
|
|
117
|
+
return { infer }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function inferDialogueAct(text, intentProb) {
|
|
121
|
+
const tl = String(text || '').toLowerCase()
|
|
122
|
+
if (ERR_TOKENS.some((k) => tl.includes(k))) return 'error_report'
|
|
123
|
+
if (ACK_TOKENS.some((k) => tl.includes(k)) && tl.length <= 12) return 'acknowledgement'
|
|
124
|
+
const hasInterrogative = tl.includes('?') || tl.includes('?') || INTERROG.some((k) => tl.includes(k))
|
|
125
|
+
const recallCtx = RECALL_CTX.some((k) => tl.includes(k))
|
|
126
|
+
if (recallCtx && hasInterrogative) return 'question'
|
|
127
|
+
if (hasInterrogative) return 'question'
|
|
128
|
+
if (REQ_TOKENS.some((k) => tl.includes(k))) return 'request'
|
|
129
|
+
if (PLAN_TOKENS.some((k) => tl.includes(k))) return 'planning'
|
|
130
|
+
if (intentProb < 0.4) return 'statement'
|
|
131
|
+
return 'other'
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const TASK_NEED_MAP = { error_report: 'required', question: 'optional', request: 'optional', planning: 'none', acknowledgement: 'none', statement: 'none', correction: 'none', other: 'none' }
|
|
135
|
+
function inferTaskNeed(act) { return TASK_NEED_MAP[act] || 'none' }
|
|
136
|
+
|
|
137
|
+
function computeEchoRisk(containment, denseTop, markZero, intentProb, policy) {
|
|
138
|
+
const ev = policy.echoVeto || {}
|
|
139
|
+
const arms = {
|
|
140
|
+
containmentArm: containment >= (ev.containmentArm || 0),
|
|
141
|
+
denseTopArm: denseTop >= (ev.denseTopArm || 0),
|
|
142
|
+
markZero: Boolean(markZero),
|
|
143
|
+
intentBelowCap: intentProb < (ev.requiresIntentBelow || 0),
|
|
144
|
+
}
|
|
145
|
+
const hit = (arms.containmentArm || arms.denseTopArm) && arms.markZero && arms.intentBelowCap
|
|
146
|
+
return { arms, hit }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function computeCompleteness(text, policy, requiredHint, resolvedCount) {
|
|
150
|
+
const cg = policy.completenessGate || {}
|
|
151
|
+
const lexicon = cg.lexicon || []
|
|
152
|
+
const tl = String(text || '').toLowerCase()
|
|
153
|
+
const kw = lexicon.some((k) => tl.includes(String(k || '').toLowerCase()))
|
|
154
|
+
const required = requiredHint != null ? Number(requiredHint) : (kw ? 2 : 1)
|
|
155
|
+
const status = kw ? 'unknown' : 'complete'
|
|
156
|
+
return { requiredTargetCount: required, resolvedTargetCount: resolvedCount != null ? resolvedCount : null, status }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function computeLane(intentProb, policy) {
|
|
160
|
+
const th = policy.thresholds || {}
|
|
161
|
+
return intentProb >= (th.tauLane || 0) ? 'explicit' : 'proactive'
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* 主决策(与 Python decide_activation_v2 逐字段对齐)。
|
|
166
|
+
* features: {text, denseTop, margin, containment, mark, nCand, candidateHit,
|
|
167
|
+
* hardGates?, repetition?, requiresRelayFlag?, piiClass?, requiredHint?, resolvedTargets?}
|
|
168
|
+
*/
|
|
169
|
+
export function decideActivationV2(features, head, policy) {
|
|
170
|
+
const th = policy.thresholds || {}
|
|
171
|
+
const reason = []
|
|
172
|
+
const hg = features.hardGates || {}
|
|
173
|
+
for (const k of ['harmful', 'correction', 'ignored', 'stale', 'wrongScope']) {
|
|
174
|
+
if (hg[k]) {
|
|
175
|
+
reason.push('hard_gate_' + (hg.piiHigh ? 'pii' : k))
|
|
176
|
+
return pack(features, policy, null, 'suppress', reason, null)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (hg.piiHigh) return pack(features, policy, null, 'suppress', ['hard_gate_pii'], null)
|
|
180
|
+
const intent = head.infer(features.text)
|
|
181
|
+
const dact = inferDialogueAct(features.text, intent)
|
|
182
|
+
const tneed = inferTaskNeed(dact)
|
|
183
|
+
const echo = computeEchoRisk(features.containment || 0, features.denseTop || 0, (features.mark || 0) === 0, intent, policy)
|
|
184
|
+
const comp = computeCompleteness(features.text, policy, features.requiredHint, features.resolvedTargets)
|
|
185
|
+
const lane = computeLane(intent, policy)
|
|
186
|
+
const hit = Boolean(features.candidateHit)
|
|
187
|
+
const margin = Number(features.margin) || 0
|
|
188
|
+
|
|
189
|
+
const finish = (decision, extra) => {
|
|
190
|
+
const snap = { intentProb: intent, dialogueAct: dact, taskNeed: tneed, echoRisk: echo, completeness: comp, lane, margin }
|
|
191
|
+
if (features.repetition) snap.repetitionLogged = features.repetition
|
|
192
|
+
return pack(features, policy, snap, decision, reason.concat(extra || []), lane)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (lane === 'explicit') {
|
|
196
|
+
if (intent >= (th.tauHi || 0) && hit) {
|
|
197
|
+
if (margin >= (th.deltaExp || 0) && comp.status === 'complete') return finish('emit', ['explicit_lane', 'completeness_complete'])
|
|
198
|
+
if (margin >= (th.deltaExp || 0)) return finish('prefetch', ['explicit_lane', 'completeness_' + comp.status])
|
|
199
|
+
return finish('prefetch', ['explicit_lane', 'margin_below_delta'])
|
|
200
|
+
}
|
|
201
|
+
if (intent >= (th.tauLo || 0) && hit) return finish('prefetch', ['explicit_lane_weak'])
|
|
202
|
+
if (margin >= (th.deltaPro || 0) && (features.nCand || 0) >= 2 && intent < 0.35 &&
|
|
203
|
+
(features.denseTop || 0) < (policy.echoVeto || {}).denseTopArm) {
|
|
204
|
+
return finish('prefetch', ['proactive_margin_fallback'])
|
|
205
|
+
}
|
|
206
|
+
return finish('suppress', ['suppress_low_signal'])
|
|
207
|
+
}
|
|
208
|
+
// proactive
|
|
209
|
+
if (echo.hit) return finish('suppress', ['echo_veto_proactive'])
|
|
210
|
+
if (margin >= (th.deltaPro || 0) && (features.nCand || 0) >= 2 &&
|
|
211
|
+
(features.denseTop || 0) < (policy.echoVeto || {}).denseTopArm) {
|
|
212
|
+
return finish('prefetch', ['proactive_margin'])
|
|
213
|
+
}
|
|
214
|
+
return finish('suppress', ['suppress_low_signal'])
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function pack(features, policy, snapshot, decision, reasonCodes, lane) {
|
|
218
|
+
return {
|
|
219
|
+
featurePolicyVersion: (policy && policy.policyVersion) || 'activation_policy_v2',
|
|
220
|
+
activationPolicyVersion: (policy && policy.policyVersion) || 'activation_policy_v2',
|
|
221
|
+
decision,
|
|
222
|
+
reasonCodes,
|
|
223
|
+
advisoryOnly: null,
|
|
224
|
+
requiresCrossWorkspaceRelay: Boolean(features.requiresRelayFlag),
|
|
225
|
+
piiClass: features.piiClass || 'unknown',
|
|
226
|
+
features: snapshot,
|
|
227
|
+
lane,
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* 加载并校验两个策略工件(fail closed)。JS 端独立实现,不依赖 Python 运行时。
|
|
233
|
+
*
|
|
234
|
+
* configHash 是 Python 导出工件时用其 json.dumps 细节算的内部标记;JS 端作为独立实现
|
|
235
|
+
* 不绑定该哈希算法(避免 int/float 序列化等格式耦合)。JS 用自有的结构校验保证 fail-closed:
|
|
236
|
+
* - 必需字段齐全
|
|
237
|
+
* - intent/activation provenance(goldDigest/runId)一致
|
|
238
|
+
* - vocab/idf/coef 长度一致
|
|
239
|
+
* - mode 必须 shadow-candidate(拒绝非 shadow)
|
|
240
|
+
* - 阈值/权重数值合法(有限、范围内)
|
|
241
|
+
* 若未来需要与 Python 严格对齐哈希,可另加 stableJson 实现(纯 JS,含 int/float 语义)。
|
|
242
|
+
*/
|
|
243
|
+
export function loadAndVerifyPolicy(intentPath, policyPath) {
|
|
244
|
+
const ip = JSON.parse(readFileSync(intentPath, 'utf8'))
|
|
245
|
+
const ap = JSON.parse(readFileSync(policyPath, 'utf8'))
|
|
246
|
+
const needIp = ['policyVersion', 'goldDigest', 'runId', 'featureSchema', 'vocabulary', 'idf', 'coefficients', 'intercept', 'calibration']
|
|
247
|
+
const needAp = ['policyVersion', 'goldDigest', 'runId', 'mode', 'thresholds', 'decisionOrder', 'echoVeto', 'completenessGate', 'hardGates', 'reasonCodes']
|
|
248
|
+
for (const k of needIp) if (!(k in ip)) throw new Error('intent policy missing: ' + k)
|
|
249
|
+
for (const k of needAp) if (!(k in ap)) throw new Error('activation policy missing: ' + k)
|
|
250
|
+
if (ip.goldDigest !== ap.goldDigest || ip.runId !== ap.runId) throw new Error('provenance mismatch')
|
|
251
|
+
const L = Object.keys(ip.vocabulary).length
|
|
252
|
+
if (!(L === ip.idf.length && L === ip.coefficients.length)) throw new Error('length mismatch')
|
|
253
|
+
// 数值合法性(fail closed):阈值/权重必须有限且合理
|
|
254
|
+
const fin = (x) => typeof x === 'number' && Number.isFinite(x)
|
|
255
|
+
const th = ap.thresholds || {}
|
|
256
|
+
for (const k of ['tauLane', 'tauHi', 'tauLo', 'deltaExp', 'deltaPro']) {
|
|
257
|
+
if (!fin(th[k]) || th[k] < 0 || th[k] > 1) throw new Error('activation threshold invalid: ' + k + '=' + th[k])
|
|
258
|
+
}
|
|
259
|
+
if (!fin(ip.intercept)) throw new Error('intent intercept invalid')
|
|
260
|
+
if (!ip.idf.every(fin) || !ip.coefficients.every(fin)) throw new Error('intent weights invalid')
|
|
261
|
+
if (ap.mode !== 'shadow-candidate') throw new Error('refusing non-shadow mode')
|
|
262
|
+
return { head: createRecallIntentHead(ip), policy: ap }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export { normalizeText, charWbNgramCounts, lexicalContainment }
|