@dsh-edge/dsh-edge-agent 0.0.0-stage → 0.1.2
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/CHANGELOG.md +158 -0
- package/LICENSE +21 -0
- package/README.md +181 -2
- package/config/edge.example.json +179 -0
- package/config/privacy-policy.json +202 -0
- package/cordis.desktop-safe.patch.yml +129 -0
- package/cordis.patch.yml +382 -0
- package/cordis.yml +374 -0
- package/lib/attestation.d.ts +2 -0
- package/lib/attestation.d.ts.map +1 -0
- package/lib/attestation.js +2 -0
- package/lib/audit-log.d.ts +2 -0
- package/lib/audit-log.d.ts.map +1 -0
- package/lib/audit-log.js +2 -0
- package/lib/client.js +87 -0
- package/lib/cloud-llm-adapter.d.ts +2 -0
- package/lib/cloud-llm-adapter.d.ts.map +1 -0
- package/lib/cloud-llm-adapter.js +2 -0
- package/lib/dsh-adapter/attestation.d.ts +42 -0
- package/lib/dsh-adapter/attestation.d.ts.map +1 -0
- package/lib/dsh-adapter/attestation.js +40 -0
- package/lib/dsh-adapter/audit-log.d.ts +139 -0
- package/lib/dsh-adapter/audit-log.d.ts.map +1 -0
- package/lib/dsh-adapter/audit-log.js +376 -0
- package/lib/dsh-adapter/cloud-egress.d.ts +181 -0
- package/lib/dsh-adapter/cloud-egress.d.ts.map +1 -0
- package/lib/dsh-adapter/cloud-egress.js +257 -0
- package/lib/dsh-adapter/cloud-llm-adapter.d.ts +416 -0
- package/lib/dsh-adapter/cloud-llm-adapter.d.ts.map +1 -0
- package/lib/dsh-adapter/cloud-llm-adapter.js +1472 -0
- package/lib/dsh-adapter/contract.d.ts +80 -0
- package/lib/dsh-adapter/contract.d.ts.map +1 -0
- package/lib/dsh-adapter/contract.js +194 -0
- package/lib/dsh-adapter/desktop-cookie-janitor.d.ts +214 -0
- package/lib/dsh-adapter/desktop-cookie-janitor.d.ts.map +1 -0
- package/lib/dsh-adapter/desktop-cookie-janitor.js +409 -0
- package/lib/dsh-adapter/dsh-edge-agent.d.ts +48 -0
- package/lib/dsh-adapter/dsh-edge-agent.d.ts.map +1 -0
- package/lib/dsh-adapter/dsh-edge-agent.js +64 -0
- package/lib/dsh-adapter/edge-config.d.ts +484 -0
- package/lib/dsh-adapter/edge-config.d.ts.map +1 -0
- package/lib/dsh-adapter/edge-config.js +898 -0
- package/lib/dsh-adapter/edge-context-view.d.ts +184 -0
- package/lib/dsh-adapter/edge-context-view.d.ts.map +1 -0
- package/lib/dsh-adapter/edge-context-view.js +483 -0
- package/lib/dsh-adapter/effective-model.d.ts +85 -0
- package/lib/dsh-adapter/effective-model.d.ts.map +1 -0
- package/lib/dsh-adapter/effective-model.js +171 -0
- package/lib/dsh-adapter/etscli-shell-guard.d.ts +244 -0
- package/lib/dsh-adapter/etscli-shell-guard.d.ts.map +1 -0
- package/lib/dsh-adapter/etscli-shell-guard.js +420 -0
- package/lib/dsh-adapter/etscli-tools.d.ts +112 -0
- package/lib/dsh-adapter/etscli-tools.d.ts.map +1 -0
- package/lib/dsh-adapter/etscli-tools.js +393 -0
- package/lib/dsh-adapter/file-logger.d.ts +294 -0
- package/lib/dsh-adapter/file-logger.d.ts.map +1 -0
- package/lib/dsh-adapter/file-logger.js +564 -0
- package/lib/dsh-adapter/function-call-external.d.ts +357 -0
- package/lib/dsh-adapter/function-call-external.d.ts.map +1 -0
- package/lib/dsh-adapter/function-call-external.js +1005 -0
- package/lib/dsh-adapter/host-egress-guard.d.ts +161 -0
- package/lib/dsh-adapter/host-egress-guard.d.ts.map +1 -0
- package/lib/dsh-adapter/host-egress-guard.js +213 -0
- package/lib/dsh-adapter/host-llm-trace.d.ts +198 -0
- package/lib/dsh-adapter/host-llm-trace.d.ts.map +1 -0
- package/lib/dsh-adapter/host-llm-trace.js +344 -0
- package/lib/dsh-adapter/http-api-routes.d.ts +61 -0
- package/lib/dsh-adapter/http-api-routes.d.ts.map +1 -0
- package/lib/dsh-adapter/http-api-routes.js +79 -0
- package/lib/dsh-adapter/http-api-sessions.d.ts +105 -0
- package/lib/dsh-adapter/http-api-sessions.d.ts.map +1 -0
- package/lib/dsh-adapter/http-api-sessions.js +173 -0
- package/lib/dsh-adapter/http-api.d.ts +221 -0
- package/lib/dsh-adapter/http-api.d.ts.map +1 -0
- package/lib/dsh-adapter/http-api.js +1811 -0
- package/lib/dsh-adapter/index.d.ts +62 -0
- package/lib/dsh-adapter/index.d.ts.map +1 -0
- package/lib/dsh-adapter/index.js +97 -0
- package/lib/dsh-adapter/input-guard-messages.d.ts +56 -0
- package/lib/dsh-adapter/input-guard-messages.d.ts.map +1 -0
- package/lib/dsh-adapter/input-guard-messages.js +136 -0
- package/lib/dsh-adapter/input-guard.d.ts +508 -0
- package/lib/dsh-adapter/input-guard.d.ts.map +1 -0
- package/lib/dsh-adapter/input-guard.js +2174 -0
- package/lib/dsh-adapter/intent-prompts.d.ts +77 -0
- package/lib/dsh-adapter/intent-prompts.d.ts.map +1 -0
- package/lib/dsh-adapter/intent-prompts.js +97 -0
- package/lib/dsh-adapter/intent-router.d.ts +710 -0
- package/lib/dsh-adapter/intent-router.d.ts.map +1 -0
- package/lib/dsh-adapter/intent-router.js +2802 -0
- package/lib/dsh-adapter/intent-signals.d.ts +208 -0
- package/lib/dsh-adapter/intent-signals.d.ts.map +1 -0
- package/lib/dsh-adapter/intent-signals.js +391 -0
- package/lib/dsh-adapter/invariant-install.d.ts +16 -0
- package/lib/dsh-adapter/invariant-install.d.ts.map +1 -0
- package/lib/dsh-adapter/invariant-install.js +15 -0
- package/lib/dsh-adapter/invariant.d.ts +16 -0
- package/lib/dsh-adapter/invariant.d.ts.map +1 -0
- package/lib/dsh-adapter/invariant.js +22 -0
- package/lib/dsh-adapter/llm-adapters.d.ts +19 -0
- package/lib/dsh-adapter/llm-adapters.d.ts.map +1 -0
- package/lib/dsh-adapter/llm-adapters.js +16 -0
- package/lib/dsh-adapter/llm-internal-caller.d.ts +92 -0
- package/lib/dsh-adapter/llm-internal-caller.d.ts.map +1 -0
- package/lib/dsh-adapter/llm-internal-caller.js +100 -0
- package/lib/dsh-adapter/llm-max-turns.d.ts +55 -0
- package/lib/dsh-adapter/llm-max-turns.d.ts.map +1 -0
- package/lib/dsh-adapter/llm-max-turns.js +127 -0
- package/lib/dsh-adapter/llm-trace-store.d.ts +131 -0
- package/lib/dsh-adapter/llm-trace-store.d.ts.map +1 -0
- package/lib/dsh-adapter/llm-trace-store.js +205 -0
- package/lib/dsh-adapter/llm-trace.d.ts +450 -0
- package/lib/dsh-adapter/llm-trace.d.ts.map +1 -0
- package/lib/dsh-adapter/llm-trace.js +463 -0
- package/lib/dsh-adapter/local-llm-adapter.d.ts +141 -0
- package/lib/dsh-adapter/local-llm-adapter.d.ts.map +1 -0
- package/lib/dsh-adapter/local-llm-adapter.js +787 -0
- package/lib/dsh-adapter/local-storage.d.ts +96 -0
- package/lib/dsh-adapter/local-storage.d.ts.map +1 -0
- package/lib/dsh-adapter/local-storage.js +198 -0
- package/lib/dsh-adapter/model-router.d.ts +79 -0
- package/lib/dsh-adapter/model-router.d.ts.map +1 -0
- package/lib/dsh-adapter/model-router.js +107 -0
- package/lib/dsh-adapter/ohttp-gateway.d.ts +49 -0
- package/lib/dsh-adapter/ohttp-gateway.d.ts.map +1 -0
- package/lib/dsh-adapter/ohttp-gateway.js +32 -0
- package/lib/dsh-adapter/openai-sse.d.ts +85 -0
- package/lib/dsh-adapter/openai-sse.d.ts.map +1 -0
- package/lib/dsh-adapter/openai-sse.js +462 -0
- package/lib/dsh-adapter/pii-utils.d.ts +96 -0
- package/lib/dsh-adapter/pii-utils.d.ts.map +1 -0
- package/lib/dsh-adapter/pii-utils.js +324 -0
- package/lib/dsh-adapter/plugin-host.d.ts +38 -0
- package/lib/dsh-adapter/plugin-host.d.ts.map +1 -0
- package/lib/dsh-adapter/plugin-host.js +38 -0
- package/lib/dsh-adapter/plugin.d.ts +46 -0
- package/lib/dsh-adapter/plugin.d.ts.map +1 -0
- package/lib/dsh-adapter/plugin.js +100 -0
- package/lib/dsh-adapter/privacy-policy.d.ts +237 -0
- package/lib/dsh-adapter/privacy-policy.d.ts.map +1 -0
- package/lib/dsh-adapter/privacy-policy.js +391 -0
- package/lib/dsh-adapter/prompt-guard.d.ts +152 -0
- package/lib/dsh-adapter/prompt-guard.d.ts.map +1 -0
- package/lib/dsh-adapter/prompt-guard.js +872 -0
- package/lib/dsh-adapter/realm-skill-bridge.d.ts +365 -0
- package/lib/dsh-adapter/realm-skill-bridge.d.ts.map +1 -0
- package/lib/dsh-adapter/realm-skill-bridge.js +1180 -0
- package/lib/dsh-adapter/sanitizer-pipeline.d.ts +140 -0
- package/lib/dsh-adapter/sanitizer-pipeline.d.ts.map +1 -0
- package/lib/dsh-adapter/sanitizer-pipeline.js +431 -0
- package/lib/dsh-adapter/secure-bridge.d.ts +47 -0
- package/lib/dsh-adapter/secure-bridge.d.ts.map +1 -0
- package/lib/dsh-adapter/secure-bridge.js +35 -0
- package/lib/dsh-adapter/session-attribution.d.ts +70 -0
- package/lib/dsh-adapter/session-attribution.d.ts.map +1 -0
- package/lib/dsh-adapter/session-attribution.js +76 -0
- package/lib/dsh-adapter/sessions.d.ts +86 -0
- package/lib/dsh-adapter/sessions.d.ts.map +1 -0
- package/lib/dsh-adapter/sessions.js +179 -0
- package/lib/dsh-adapter/tcm.d.ts +55 -0
- package/lib/dsh-adapter/tcm.d.ts.map +1 -0
- package/lib/dsh-adapter/tcm.js +94 -0
- package/lib/dsh-adapter/td-bus.d.ts +87 -0
- package/lib/dsh-adapter/td-bus.d.ts.map +1 -0
- package/lib/dsh-adapter/td-bus.js +196 -0
- package/lib/dsh-adapter/tool-runner.d.ts +185 -0
- package/lib/dsh-adapter/tool-runner.d.ts.map +1 -0
- package/lib/dsh-adapter/tool-runner.js +271 -0
- package/lib/dsh-adapter/tools.d.ts +118 -0
- package/lib/dsh-adapter/tools.d.ts.map +1 -0
- package/lib/dsh-adapter/tools.js +292 -0
- package/lib/dsh-adapter/types.d.ts +23 -0
- package/lib/dsh-adapter/types.d.ts.map +1 -0
- package/lib/dsh-adapter/types.js +16 -0
- package/lib/dsh-adapter/voice-input.d.ts +30 -0
- package/lib/dsh-adapter/voice-input.d.ts.map +1 -0
- package/lib/dsh-adapter/voice-input.js +34 -0
- package/lib/dsh-adapter/web-search-tavily-bocha.d.ts +152 -0
- package/lib/dsh-adapter/web-search-tavily-bocha.d.ts.map +1 -0
- package/lib/dsh-adapter/web-search-tavily-bocha.js +289 -0
- package/lib/http-api.d.ts +2 -0
- package/lib/http-api.d.ts.map +1 -0
- package/lib/http-api.js +2 -0
- package/lib/index.d.ts +48 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +49 -0
- package/lib/intent-router.d.ts +2 -0
- package/lib/intent-router.d.ts.map +1 -0
- package/lib/intent-router.js +2 -0
- package/lib/llm-adapters.d.ts +2 -0
- package/lib/llm-adapters.d.ts.map +1 -0
- package/lib/llm-adapters.js +2 -0
- package/lib/local-llm-adapter.d.ts +2 -0
- package/lib/local-llm-adapter.d.ts.map +1 -0
- package/lib/local-llm-adapter.js +2 -0
- package/lib/local-storage.d.ts +2 -0
- package/lib/local-storage.d.ts.map +1 -0
- package/lib/local-storage.js +2 -0
- package/lib/model-router.d.ts +2 -0
- package/lib/model-router.d.ts.map +1 -0
- package/lib/model-router.js +2 -0
- package/lib/ohttp-gateway.d.ts +2 -0
- package/lib/ohttp-gateway.d.ts.map +1 -0
- package/lib/ohttp-gateway.js +2 -0
- package/lib/plugin-host.d.ts +2 -0
- package/lib/plugin-host.d.ts.map +1 -0
- package/lib/plugin-host.js +2 -0
- package/lib/privacy-policy.d.ts +2 -0
- package/lib/privacy-policy.d.ts.map +1 -0
- package/lib/privacy-policy.js +2 -0
- package/lib/prompt-guard.d.ts +2 -0
- package/lib/prompt-guard.d.ts.map +1 -0
- package/lib/prompt-guard.js +2 -0
- package/lib/sanitizer-pipeline.d.ts +2 -0
- package/lib/sanitizer-pipeline.d.ts.map +1 -0
- package/lib/sanitizer-pipeline.js +2 -0
- package/lib/secure-bridge.d.ts +2 -0
- package/lib/secure-bridge.d.ts.map +1 -0
- package/lib/secure-bridge.js +2 -0
- package/lib/sessions.d.ts +2 -0
- package/lib/sessions.d.ts.map +1 -0
- package/lib/sessions.js +2 -0
- package/lib/tcm.d.ts +2 -0
- package/lib/tcm.d.ts.map +1 -0
- package/lib/tcm.js +2 -0
- package/lib/td-bus.d.ts +2 -0
- package/lib/td-bus.d.ts.map +1 -0
- package/lib/td-bus.js +2 -0
- package/lib/tools.d.ts +2 -0
- package/lib/tools.d.ts.map +1 -0
- package/lib/tools.js +2 -0
- package/lib/voice-input.d.ts +2 -0
- package/lib/voice-input.d.ts.map +1 -0
- package/lib/voice-input.js +2 -0
- package/package.json +253 -3
- package/presets/agent-preset-edge.json +50 -0
- package/presets/edge.desktop-safe.patch.yml +503 -0
- package/presets/edge.patch.yml +557 -0
|
@@ -0,0 +1,2802 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* intent-router — LLM-based intent classification.
|
|
3
|
+
*
|
|
4
|
+
* Uses the local Qwen3-4B (via ctx.llm) to classify user messages as
|
|
5
|
+
* LOCAL (file ops, device control, calculations) or CLOUD (knowledge,
|
|
6
|
+
* chat, creative tasks). Falls back to keyword rules when the LLM
|
|
7
|
+
* call fails (timeout, network error, unclear response).
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-edge-agent/intent-router
|
|
10
|
+
*/
|
|
11
|
+
import { Service } from '@deepseek-ai/cordis';
|
|
12
|
+
import Schema from '@deepseek-ai/schemastery';
|
|
13
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
14
|
+
import { danglingSecretRefs, formatDanglingSecretRefs, jevPool, loadEdgeConfig, localPool, } from './edge-config.js';
|
|
15
|
+
import { resolveEffectiveLocalModel } from './effective-model.js';
|
|
16
|
+
import { withInternalCaller } from './llm-internal-caller.js';
|
|
17
|
+
import { buildRequestTrace, buildResponseTrace, emitTrace, resolveLlmTraceConfig, } from './llm-trace.js';
|
|
18
|
+
import { getTraceStore, nextCallId } from './llm-trace-store.js';
|
|
19
|
+
import { sessionIdField } from './session-attribution.js';
|
|
20
|
+
export const name = 'intent-router';
|
|
21
|
+
export const inject = ['llm', 'auditLog', 'privacyPolicy'];
|
|
22
|
+
/**
|
|
23
|
+
* §7.122: 端点是否落在本机。
|
|
24
|
+
*
|
|
25
|
+
* provider 名(`local-llm`)**不代表**落点 —— config/edge.json 的
|
|
26
|
+
* `llm.local.baseUrl` 可以是任何地址,包括公网。operator 要判断
|
|
27
|
+
* 「这轮原文有没有出网」,看的是落点,不是名字。
|
|
28
|
+
*
|
|
29
|
+
* §7.130: 主人裁决——「是否出网」的判据是 **category(哪个池)**,不是端点
|
|
30
|
+
* 落在哪台机。本函数仍然有用(§7.122 的分类器落点披露是备查信息),但它
|
|
31
|
+
* **不参与出网判断**:local 池是 operator 自有设施,端点不在本机也不算出网。
|
|
32
|
+
*/
|
|
33
|
+
export function isLoopbackEndpoint(endpoint) {
|
|
34
|
+
if (endpoint === undefined || endpoint.trim().length === 0)
|
|
35
|
+
return false;
|
|
36
|
+
let host;
|
|
37
|
+
try {
|
|
38
|
+
host = new URL(endpoint).hostname;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
return host === '127.0.0.1' || host === 'localhost' || host === '::1' || host === '[::1]';
|
|
44
|
+
}
|
|
45
|
+
export const Config = Schema.object({
|
|
46
|
+
defaultCategory: Schema.union(['LOCAL', 'CLOUD', 'SANITIZED'])
|
|
47
|
+
// §7.107 (2026-10-23, operator ruling「默认应该是本地,而不是云端」):
|
|
48
|
+
// the ladder so far was 'CLOUD' (2026-08) → 'SANITIZED' (2026-09-02 —
|
|
49
|
+
// the classifier was silent, so scrub the text and egress anyway).
|
|
50
|
+
// Neither rung is "local": both eventually leave the device. The ruling
|
|
51
|
+
// is one step further — an UNDECIDED turn stays on the device.
|
|
52
|
+
//
|
|
53
|
+
// The cloud is now reached only through an explicit signal: the
|
|
54
|
+
// privacy-policy's public-web rule, a CLOUD keyword, a confident
|
|
55
|
+
// classifier verdict that names a web need, or a caller-supplied route.
|
|
56
|
+
// Consequence: an install with no local endpoint can no longer answer
|
|
57
|
+
// at all — fail-closed with an actionable error (input-guard's
|
|
58
|
+
// `local.route-missing`), by design.
|
|
59
|
+
.default('LOCAL'),
|
|
60
|
+
classificationProvider: Schema.string().default('local-llm'),
|
|
61
|
+
// §7.115: decision-model-only mode switch (see Config docs).
|
|
62
|
+
llmFallback: Schema.boolean(),
|
|
63
|
+
// §7.102: the classification model follows the LOCAL pool in
|
|
64
|
+
// config/edge.json (llm.local.model). The Schema default is only a
|
|
65
|
+
// last-resort fallback when no config file is present at all.
|
|
66
|
+
classificationModel: Schema.string().default('qwen3-4b'),
|
|
67
|
+
// confidenceThreshold is a 0..1 probability, NOT an integer. Pre-fix used
|
|
68
|
+
// Schema.natural() which validates as "integer multiple of 1" — runtime
|
|
69
|
+
// values like 0.6 (the actual default and the DSH_EDGE_INTENT_CONFIDENCE_
|
|
70
|
+
// THRESHOLD env value) were rejected at loader stage:
|
|
71
|
+
// $.confidenceThreshold expected number multiple of 1 but got 0.6
|
|
72
|
+
confidenceThreshold: Schema.number().min(0).max(1).default(0.6),
|
|
73
|
+
llmTimeoutMs: Schema.natural().default(4000),
|
|
74
|
+
recentMessagesLimit: Schema.natural().default(3),
|
|
75
|
+
maxInputLength: Schema.natural().default(2000),
|
|
76
|
+
fallbackKeywords: Schema.array(Schema.string()).default([]),
|
|
77
|
+
cloudKeywords: Schema.array(Schema.string()).default([]),
|
|
78
|
+
localEntities: Schema.array(Schema.string()).default([]),
|
|
79
|
+
// §7.124 (2026-10-27): 家庭设备操作正则层的 operator 扩展点。追加到内置
|
|
80
|
+
// 词表(不替换)——运维加一个新电器名只改 config/edge.json 一行。
|
|
81
|
+
deviceCommands: Schema.object({
|
|
82
|
+
devices: Schema.array(Schema.string()).default([]),
|
|
83
|
+
actions: Schema.array(Schema.string()).default([]),
|
|
84
|
+
}).default({ devices: [], actions: [] }),
|
|
85
|
+
// §7.92.3 (2026-09-23): jev HTTP intent recognizer. Schema default
|
|
86
|
+
// is `false` so a bare unit-test construction (no env) skips jev
|
|
87
|
+
// entirely. §7.102: the toggle's value now comes from config/edge.json
|
|
88
|
+
// (jev.enabled, production default false — §7.182 owner ruling) via
|
|
89
|
+
// apply()'s file-fill; the
|
|
90
|
+
// Schema carries no default so "patch row did not configure it" stays
|
|
91
|
+
// distinguishable from an explicit value. The literal fallbacks live in
|
|
92
|
+
// the constructor (this.jevXxx = config.jevXxx ?? …) and match the old
|
|
93
|
+
// schema defaults verbatim. §7.92.7 boot probe + breaker keep the
|
|
94
|
+
// no-jev-running path fast-fail.
|
|
95
|
+
jevEnabled: Schema.boolean(),
|
|
96
|
+
jevUrl: Schema.string(),
|
|
97
|
+
// §7.112: Bailian decision-model backend surface.
|
|
98
|
+
jevProvider: Schema.union(['local', 'bailian-decision']),
|
|
99
|
+
jevModel: Schema.string(),
|
|
100
|
+
jevApiKey: Schema.string(),
|
|
101
|
+
jevAuthScheme: Schema.union(['raw', 'bearer']),
|
|
102
|
+
jevPath: Schema.string(),
|
|
103
|
+
jevLocalAdoptFloor: Schema.number(),
|
|
104
|
+
// 2.5s = comfortably above jev's documented P95 (< 300ms) with headroom
|
|
105
|
+
// for cold-start model load + inter-container network jitter. Smaller
|
|
106
|
+
// than `llmTimeoutMs` (default 4-30s) so jev-as-prefilter still saves
|
|
107
|
+
// the operator's overall chat budget on jev-failure paths.
|
|
108
|
+
jevTimeoutMs: Schema.natural(),
|
|
109
|
+
// Mirrors jev's built-in `execute` gate (>= 0.85). Operators tune lower
|
|
110
|
+
// (e.g. 0.70) to trade more jev-adopts for higher mis-route risk.
|
|
111
|
+
jevConfidenceThreshold: Schema.number().min(0).max(1),
|
|
112
|
+
// Mirrors jev's built-in risk-score gate (>= 0.7 → clarify → REJECT).
|
|
113
|
+
jevRiskThreshold: Schema.number().min(0).max(1),
|
|
114
|
+
// §7.92.6 (2026-09-24): circuit-breaker TTL. 30s gives jev a chance to
|
|
115
|
+
// recover (e.g. operator restarts the container) without hammering it.
|
|
116
|
+
// Operators tune down (e.g. 5s) for fast-fail rigs, or up (e.g. 5min) for
|
|
117
|
+
// known-flaky services where jev outage is expected to last longer.
|
|
118
|
+
jevBreakerTtlMs: Schema.natural(),
|
|
119
|
+
// §7.92.7 (2026-09-24): boot probe + periodic recovery probe.
|
|
120
|
+
jevBootProbeEnabled: Schema.boolean(),
|
|
121
|
+
jevBootProbeTimeoutMs: Schema.natural(),
|
|
122
|
+
jevBreakerProbeMs: Schema.natural(),
|
|
123
|
+
jevProbeTimeoutMs: Schema.natural(),
|
|
124
|
+
// §7.92.3 (2026-09-23): intent category specs (descriptions + jev
|
|
125
|
+
// intent lists). When privacy-policy is mounted, this is the runtime
|
|
126
|
+
// override surface — tests pass categories directly. Default null:
|
|
127
|
+
// constructor reads from ctx.privacyPolicy.getCategories() and only
|
|
128
|
+
// falls back to a hardcoded built-in when neither is available.
|
|
129
|
+
// Note: `examples` is optional in the privacy-policy.json schema (and
|
|
130
|
+
// optional in IntentCategory); Schemastery's `Schema.array(...).default([])`
|
|
131
|
+
// requires a non-optional inner schema so we coerce undefined → []
|
|
132
|
+
// at the type level here.
|
|
133
|
+
categories: Schema.array(Schema.object({
|
|
134
|
+
id: Schema.union(['LOCAL', 'CLOUD', 'SANITIZED', 'REJECT']).required(),
|
|
135
|
+
description: Schema.string().required(),
|
|
136
|
+
jevIntents: Schema.array(Schema.string()).required(),
|
|
137
|
+
})).default([]),
|
|
138
|
+
});
|
|
139
|
+
/** Built-in last-resort LOCAL keyword list (EN + CN). Used only when
|
|
140
|
+
* privacy-policy is absent / empty AND `config.fallbackKeywords` is unset.
|
|
141
|
+
* Users override via env `DSH_EDGE_INTENT_FALLBACK_KEYWORDS` (comma-sep).
|
|
142
|
+
*
|
|
143
|
+
* §7.123 (2026-10-27): moved to `./intent-signals.ts` — the strategy DATA
|
|
144
|
+
* (keyword tables, entity tables, the fallback taxonomy) now lives apart
|
|
145
|
+
* from the decision LOGIC in this file. */
|
|
146
|
+
import { LOCAL_KEYWORDS_DEFAULT, CLOUD_KEYWORDS_DEFAULT, LOCAL_ENTITY_KEYWORDS_DEFAULT, JEV_MAX_INTENTS, CATEGORIES_FALLBACK, RULE_CATEGORIES, matchDeviceCommand, } from './intent-signals.js';
|
|
147
|
+
import { buildClassificationPrompt, buildJevIntentCriteria, JEV_INTENT_INSTRUCTIONS, JEV_RISK_INSTRUCTIONS, JEV_RISK_CRITERIA, } from './intent-prompts.js';
|
|
148
|
+
export { LOCAL_KEYWORDS_DEFAULT, CLOUD_KEYWORDS_DEFAULT, LOCAL_ENTITY_KEYWORDS_DEFAULT, JEV_MAX_INTENTS, CATEGORIES_FALLBACK, RULE_CATEGORIES, };
|
|
149
|
+
/** §7.92.5: pure helper that splits an array into ≤ `size`-sized
|
|
150
|
+
* chunks. Used by `classifyWithJev()` to break the full
|
|
151
|
+
* `categories[].jevIntents[]` payload into batches that respect the
|
|
152
|
+
* jev v0.3.0 hard cap. Module-scope so it can be unit-tested in
|
|
153
|
+
* isolation if needed. */
|
|
154
|
+
/** §7.112 (2026-10-25): best-effort extraction of the decision-model's
|
|
155
|
+
* answers map from a not-yet-frozen response envelope. The systemone
|
|
156
|
+
* contract is new — tolerate the shapes plausible in compatible-mode
|
|
157
|
+
* deployments:
|
|
158
|
+
* { answers: { intent: ..., risk: ... } } — canonical
|
|
159
|
+
* { output: { answers: ... } } — DashScope native
|
|
160
|
+
* { choices: [{ message: { content: '<json>' } }] } — chat-completions wrap
|
|
161
|
+
* { intent: ..., risk: ... } — flat
|
|
162
|
+
* Returns the first object (depth-first, ≤6 levels) carrying ≥1 question
|
|
163
|
+
* key; null when none does. */
|
|
164
|
+
function extractDecisionAnswers(parsed, keys) {
|
|
165
|
+
if (!parsed || typeof parsed !== 'object')
|
|
166
|
+
return null;
|
|
167
|
+
const content = parsed?.choices?.[0]?.message?.content;
|
|
168
|
+
if (typeof content === 'string') {
|
|
169
|
+
try {
|
|
170
|
+
const fromInner = extractDecisionAnswers(JSON.parse(content), keys);
|
|
171
|
+
if (fromInner)
|
|
172
|
+
return fromInner;
|
|
173
|
+
}
|
|
174
|
+
catch { /* content was prose — keep walking the outer envelope */ }
|
|
175
|
+
}
|
|
176
|
+
const visit = (node, depth) => {
|
|
177
|
+
if (!node || typeof node !== 'object' || depth > 6)
|
|
178
|
+
return null;
|
|
179
|
+
if (Array.isArray(node)) {
|
|
180
|
+
for (const item of node) {
|
|
181
|
+
const hit = visit(item, depth + 1);
|
|
182
|
+
if (hit)
|
|
183
|
+
return hit;
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
if (keys.some((k) => k in node))
|
|
188
|
+
return node;
|
|
189
|
+
for (const value of Object.values(node)) {
|
|
190
|
+
const hit = visit(value, depth + 1);
|
|
191
|
+
if (hit)
|
|
192
|
+
return hit;
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
};
|
|
196
|
+
return visit(parsed, 0);
|
|
197
|
+
}
|
|
198
|
+
function chunkIntents(intents, size = JEV_MAX_INTENTS) {
|
|
199
|
+
const out = [];
|
|
200
|
+
for (let i = 0; i < intents.length; i += size) {
|
|
201
|
+
out.push(intents.slice(i, i + size));
|
|
202
|
+
}
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
export class IntentRouterServiceImpl extends Service {
|
|
206
|
+
defaultCategory;
|
|
207
|
+
classificationProvider;
|
|
208
|
+
classificationModel;
|
|
209
|
+
confidenceThreshold;
|
|
210
|
+
llmFallback;
|
|
211
|
+
llmTimeoutMs;
|
|
212
|
+
recentMessagesLimit;
|
|
213
|
+
maxInputLength;
|
|
214
|
+
fallbackKeywords;
|
|
215
|
+
/** §7.47 (2026-09-12): CLOUD keyword list, merged with built-in defaults.
|
|
216
|
+
* Matched in classifyWithRules BEFORE fallbackKeywords. */
|
|
217
|
+
cloudKeywords;
|
|
218
|
+
/** §7.58 (2026-09-16): LOCAL-entity list, merged with built-in defaults.
|
|
219
|
+
* Checked FIRST in classifyWithRules + as LLM-verdict override guardrail. */
|
|
220
|
+
localEntities;
|
|
221
|
+
/** §7.124: operator-extended device-command terms (ADDITIVE on top of the
|
|
222
|
+
* built-in vocabulary in ./intent-signals.ts). */
|
|
223
|
+
deviceCommandOverrides;
|
|
224
|
+
/** §7.92.3 (2026-09-23): jev HTTP intent recognizer (toggle). When
|
|
225
|
+
* true, classify() invokes classifyWithJev() BEFORE classifyWithLlm(). */
|
|
226
|
+
jevEnabled;
|
|
227
|
+
/** §7.92.3: jev base URL, e.g. `http://127.0.0.1:8001`. */
|
|
228
|
+
jevUrl;
|
|
229
|
+
/** §7.112: Bailian decision-model backend surface (see Config docs). */
|
|
230
|
+
jevProvider;
|
|
231
|
+
jevModel;
|
|
232
|
+
jevApiKey;
|
|
233
|
+
jevAuthScheme;
|
|
234
|
+
jevPath;
|
|
235
|
+
jevLocalAdoptFloor;
|
|
236
|
+
/** §7.92.3: jev fetch timeout (ms). */
|
|
237
|
+
jevTimeoutMs;
|
|
238
|
+
/** §7.92.3: min jev confidence to adopt (else fall through to LLM). */
|
|
239
|
+
jevConfidenceThreshold;
|
|
240
|
+
/** §7.92.3: jev risk_score gate (>= this → REJECT). */
|
|
241
|
+
jevRiskThreshold;
|
|
242
|
+
/** §7.92.6 (2026-09-24): circuit-breaker TTL (ms). When all 3
|
|
243
|
+
* batches fail, set `jevBreakerOpenUntil = Date.now() + ttl` and
|
|
244
|
+
* classify() short-circuits jev (zero fetch) until that timestamp.
|
|
245
|
+
* Any successful batch closes the breaker (sets to 0). */
|
|
246
|
+
jevBreakerTtlMs;
|
|
247
|
+
/** §7.92.6: ms-epoch timestamp until which the breaker is open.
|
|
248
|
+
* 0 = closed. Mutable; reset on every Promise.all settle. */
|
|
249
|
+
jevBreakerOpenUntil = 0;
|
|
250
|
+
/** §7.92.7: boot probe toggle. When true, `start()` fires one
|
|
251
|
+
* `GET ${jevUrl}/health` and pre-opens the breaker on failure. */
|
|
252
|
+
jevBootProbeEnabled;
|
|
253
|
+
/** §7.92.7: boot probe timeout (ms). */
|
|
254
|
+
jevBootProbeTimeoutMs;
|
|
255
|
+
/** §7.92.7: interval between breaker-open `/health` probes (ms).
|
|
256
|
+
* Only active while the breaker is open. */
|
|
257
|
+
jevBreakerProbeMs;
|
|
258
|
+
/** §7.92.7: periodic probe timeout (ms). */
|
|
259
|
+
jevProbeTimeoutMs;
|
|
260
|
+
/** §7.92.7: setInterval handle for the periodic recovery probe.
|
|
261
|
+
* null = no timer running (breaker closed or probe disabled). */
|
|
262
|
+
jevBreakerProbeTimer = null;
|
|
263
|
+
/** §7.92.7: in-flight boot probe promise. Tracked so a test or
|
|
264
|
+
* shutdown hook can `await stop()` without leaving a dangling
|
|
265
|
+
* fetch racing the constructor's first classify() call. */
|
|
266
|
+
jevBootProbePromise = null;
|
|
267
|
+
/** §7.92.3 (2026-09-23) + §7.108 (2026-10-24): categories spec — loaded
|
|
268
|
+
* from privacyPolicy.getCategories() at construction AND refreshed on
|
|
269
|
+
* every `policy/loaded` event when the spec came from the policy
|
|
270
|
+
* service (config.categories keeps its explicit-override semantics and
|
|
271
|
+
* is never refreshed). Drives both the LLM prompt (description field
|
|
272
|
+
* per category) AND the jev-intent → category lookup (jevIntents[] per
|
|
273
|
+
* category, flattened into jevIntentMap). Tests inject via
|
|
274
|
+
* `config.categories` to bypass privacy-policy. */
|
|
275
|
+
categories;
|
|
276
|
+
/** §7.92.3: flat jev-intent-string → category lookup, derived from
|
|
277
|
+
* `this.categories[].jevIntents`. Rebuilt together with categories on
|
|
278
|
+
* policy hot-reload (§7.108). */
|
|
279
|
+
jevIntentMap;
|
|
280
|
+
llm;
|
|
281
|
+
auditLog;
|
|
282
|
+
/** ── classify() 结果缓存(LRU + TTL)───────────────────────────────
|
|
283
|
+
* 只缓存"贵的"路径的最终 verdict(LLM 采纳 / jev 采纳,均过
|
|
284
|
+
* gateCloudVerdict);规则兜底、policy REJECT、jev-reject 都很便宜
|
|
285
|
+
* 且依赖可热重载的状态,不缓存。没有 in-flight 合并 —— 并发同文本
|
|
286
|
+
* 各自走 LLM(保持并发计数语义),只有"完成后"的重复才命中。
|
|
287
|
+
* verdict 对同一文本是确定性的(gate 也是纯文本判定),TTL 只为
|
|
288
|
+
* 让关键词表/策略热重载后的新判定尽快生效。 */
|
|
289
|
+
static CLASSIFY_CACHE_TTL_MS = 60_000;
|
|
290
|
+
static CLASSIFY_CACHE_MAX = 128;
|
|
291
|
+
classifyCache = new Map();
|
|
292
|
+
classifyCacheKey(text, recent) {
|
|
293
|
+
let ctx = '';
|
|
294
|
+
if (recent && recent.length > 0) {
|
|
295
|
+
try {
|
|
296
|
+
ctx = JSON.stringify(recent);
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
ctx = `unserializable:${recent.length}`;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return `${text}\u0000${ctx}`;
|
|
303
|
+
}
|
|
304
|
+
classifyCacheGet(key) {
|
|
305
|
+
const hit = this.classifyCache.get(key);
|
|
306
|
+
if (!hit)
|
|
307
|
+
return undefined;
|
|
308
|
+
if (Date.now() > hit.exp) {
|
|
309
|
+
this.classifyCache.delete(key);
|
|
310
|
+
return undefined;
|
|
311
|
+
}
|
|
312
|
+
// LRU refresh:命中搬到队尾。
|
|
313
|
+
this.classifyCache.delete(key);
|
|
314
|
+
this.classifyCache.set(key, hit);
|
|
315
|
+
return { ...hit.intent };
|
|
316
|
+
}
|
|
317
|
+
classifyCacheSet(key, intent) {
|
|
318
|
+
this.classifyCache.delete(key);
|
|
319
|
+
this.classifyCache.set(key, { intent, exp: Date.now() + IntentRouterServiceImpl.CLASSIFY_CACHE_TTL_MS });
|
|
320
|
+
while (this.classifyCache.size > IntentRouterServiceImpl.CLASSIFY_CACHE_MAX) {
|
|
321
|
+
const oldest = this.classifyCache.keys().next().value;
|
|
322
|
+
if (oldest === undefined)
|
|
323
|
+
break;
|
|
324
|
+
this.classifyCache.delete(oldest);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
stats = {
|
|
328
|
+
total: 0,
|
|
329
|
+
empty: 0,
|
|
330
|
+
truncated: 0,
|
|
331
|
+
llmHit: 0,
|
|
332
|
+
/** 同文本+同上下文的重复 classify() 命中缓存(不付 LLM/jev 往返)。 */
|
|
333
|
+
cacheHit: 0,
|
|
334
|
+
ruleHit: 0,
|
|
335
|
+
/** §7.124: Tier-0 家庭设备操作正则命中数。 */
|
|
336
|
+
deviceCommandHit: 0,
|
|
337
|
+
defaultHit: 0,
|
|
338
|
+
lowConfidence: 0,
|
|
339
|
+
timeout: 0,
|
|
340
|
+
llmError: 0,
|
|
341
|
+
/** P1: LLM stream returned text that parseClassificationVerdict could
|
|
342
|
+
* not turn into a verdict (all three tiers failed). Distinct from
|
|
343
|
+
* `llmError` (network / timeout) so operators can see the parse
|
|
344
|
+
* health of the LLM independently from the transport health. */
|
|
345
|
+
parseFailure: 0,
|
|
346
|
+
rejected: 0,
|
|
347
|
+
sanitized: 0,
|
|
348
|
+
sanitizeError: 0,
|
|
349
|
+
sanitizeSkipped: 0,
|
|
350
|
+
/** §7.92.3 (2026-09-23): jev prefilter counters. jevHit = adopted
|
|
351
|
+
* (path: jev-adopt), jevReject = risk-gated REJECT, jevSkip = fell
|
|
352
|
+
* through to LLM (low-conf / fallback_llm / unrecognized intent),
|
|
353
|
+
* jevError = transport / parse failure (also fell through), jevTimeout
|
|
354
|
+
* = subset of jevError where AbortSignal fired. Operators compare
|
|
355
|
+
* jevHit / total to gauge the latency savings of the toggle. */
|
|
356
|
+
jevHit: 0,
|
|
357
|
+
jevReject: 0,
|
|
358
|
+
jevSkip: 0,
|
|
359
|
+
jevError: 0,
|
|
360
|
+
jevTimeout: 0,
|
|
361
|
+
/** §7.92.3: cumulative jev fetch latency in ms (response.latency_ms).
|
|
362
|
+
* Used to compute average jev latency in ops dashboards without
|
|
363
|
+
* parsing JSONL. Reset on restart. */
|
|
364
|
+
jevLatencyMsTotal: 0,
|
|
365
|
+
/** §7.92.6 (2026-09-24): circuit-breaker counters. jevCircuitOpen
|
|
366
|
+
* climbs once per "all 3 batches failed" event (suggests jev is
|
|
367
|
+
* flapping or down); jevCircuitSkip climbs per short-circuited
|
|
368
|
+
* classify() call (latency saved ≈ this × ~7500ms). */
|
|
369
|
+
jevCircuitOpen: 0,
|
|
370
|
+
jevCircuitSkip: 0,
|
|
371
|
+
/** §7.92.7 (2026-09-24): boot probe counters. jevBootProbeOk fires
|
|
372
|
+
* when the startup `GET /health` returned 2xx (no breaker pre-open);
|
|
373
|
+
* jevBootProbeFail fires when it failed (breaker pre-opened, first
|
|
374
|
+
* classify() short-circuits). Useful for ops to know whether jev
|
|
375
|
+
* was healthy at boot. */
|
|
376
|
+
jevBootProbeOk: 0,
|
|
377
|
+
jevBootProbeFail: 0,
|
|
378
|
+
/** §7.92.7: periodic probe counters. jevProbeAttempts = total
|
|
379
|
+
* `/health` pings while breaker open; jevRecoveredProbe = times
|
|
380
|
+
* a probe succeeded and closed the breaker (recovery was faster
|
|
381
|
+
* than waiting for full TTL). */
|
|
382
|
+
jevProbeAttempts: 0,
|
|
383
|
+
jevRecoveredProbe: 0,
|
|
384
|
+
/** G9: 10-bucket histogram of LLM confidence values × (accepted | rejected).
|
|
385
|
+
* Bucket key is `Math.floor(c * 10)` clamped to [0,9]; `conf=0.0` → '0',
|
|
386
|
+
* `conf=1.0` → '9' (last bucket inclusive). Empty until first
|
|
387
|
+
* `parseClassificationVerdict` succeeds. Timeouts / errors / unclear
|
|
388
|
+
* responses are NOT recorded (no confidence to bucket). */
|
|
389
|
+
confidenceHistogram: {},
|
|
390
|
+
};
|
|
391
|
+
constructor(ctx, config, auditLog) {
|
|
392
|
+
super(ctx, 'intentRouter');
|
|
393
|
+
this.defaultCategory = config.defaultCategory;
|
|
394
|
+
this.classificationProvider = config.classificationProvider;
|
|
395
|
+
this.classificationModel = config.classificationModel;
|
|
396
|
+
this.confidenceThreshold = config.confidenceThreshold;
|
|
397
|
+
this.llmFallback = config.llmFallback ?? true;
|
|
398
|
+
this.llmTimeoutMs = config.llmTimeoutMs ?? 4000;
|
|
399
|
+
this.recentMessagesLimit = config.recentMessagesLimit ?? 3;
|
|
400
|
+
this.maxInputLength = config.maxInputLength ?? 2000;
|
|
401
|
+
// Empty array → use built-in EN+CN default. Non-empty → use as-is so
|
|
402
|
+
// users can fully replace the list (e.g. JA-only deployment).
|
|
403
|
+
this.fallbackKeywords = (config.fallbackKeywords && config.fallbackKeywords.length > 0)
|
|
404
|
+
? config.fallbackKeywords
|
|
405
|
+
: LOCAL_KEYWORDS_DEFAULT;
|
|
406
|
+
// §7.47 (2026-09-12): cloudKeywords APPENDED to built-in (not replaced)
|
|
407
|
+
// so the default safety net (新闻/news/search/...) always applies.
|
|
408
|
+
// Empty config → use built-in only.
|
|
409
|
+
this.cloudKeywords = CLOUD_KEYWORDS_DEFAULT.concat(config.cloudKeywords ?? []);
|
|
410
|
+
// §7.58 (2026-09-16): localEntities APPENDED to built-in (not replaced),
|
|
411
|
+
// same safety-net semantics as cloudKeywords.
|
|
412
|
+
this.localEntities = LOCAL_ENTITY_KEYWORDS_DEFAULT.concat(config.localEntities ?? []);
|
|
413
|
+
// §7.124 (2026-10-27): operator-extended device-command terms, ADDITIVE
|
|
414
|
+
// on top of the built-in vocabulary (same semantics as cloudKeywords).
|
|
415
|
+
this.deviceCommandOverrides = {
|
|
416
|
+
devices: config.deviceCommands?.devices ?? [],
|
|
417
|
+
actions: config.deviceCommands?.actions ?? [],
|
|
418
|
+
};
|
|
419
|
+
// §7.92.3 + §7.92.7: jev config. Schema default is `false` so unit
|
|
420
|
+
// tests that construct the service with minimal config don't make
|
|
421
|
+
// real fetches. PRODUCTION default is ALSO `false` since §7.182
|
|
422
|
+
// (2026-11-11, owner ruling): jev is an optional accelerator that needs
|
|
423
|
+
// a local decision-model service, so it stays off until an operator
|
|
424
|
+
// writes `jev.enabled: true` (or sets DSH_EDGE_INTENT_JEV_ENABLED=true).
|
|
425
|
+
this.jevEnabled = config.jevEnabled ?? false;
|
|
426
|
+
this.jevUrl = config.jevUrl ?? 'http://127.0.0.1:8001';
|
|
427
|
+
// §7.112: Bailian decision-model backend (defaults keep the local
|
|
428
|
+
// ONNX contract byte-identical when unset).
|
|
429
|
+
this.jevProvider = config.jevProvider ?? 'local';
|
|
430
|
+
this.jevModel = config.jevModel ?? 'decision-model-preview';
|
|
431
|
+
this.jevApiKey = config.jevApiKey || undefined;
|
|
432
|
+
this.jevAuthScheme = config.jevAuthScheme ?? 'raw';
|
|
433
|
+
this.jevPath = config.jevPath ?? '/systemone';
|
|
434
|
+
this.jevLocalAdoptFloor = config.jevLocalAdoptFloor ?? 0.5;
|
|
435
|
+
this.jevTimeoutMs = config.jevTimeoutMs ?? 2500;
|
|
436
|
+
this.jevConfidenceThreshold = config.jevConfidenceThreshold ?? 0.85;
|
|
437
|
+
this.jevRiskThreshold = config.jevRiskThreshold ?? 0.7;
|
|
438
|
+
// §7.92.6: circuit breaker. TTL is configurable but the open flag
|
|
439
|
+
// always starts closed — every fresh boot tries jev at least once
|
|
440
|
+
// unless the boot probe (§7.92.7) pre-opens it (fire-and-forget).
|
|
441
|
+
this.jevBreakerTtlMs = config.jevBreakerTtlMs ?? 30000;
|
|
442
|
+
this.jevBreakerOpenUntil = 0;
|
|
443
|
+
// §7.92.7: boot + recovery probe tunables.
|
|
444
|
+
this.jevBootProbeEnabled = config.jevBootProbeEnabled ?? true;
|
|
445
|
+
this.jevBootProbeTimeoutMs = config.jevBootProbeTimeoutMs ?? 2000;
|
|
446
|
+
this.jevBreakerProbeMs = config.jevBreakerProbeMs ?? 5000;
|
|
447
|
+
this.jevProbeTimeoutMs = config.jevProbeTimeoutMs ?? 1000;
|
|
448
|
+
// §7.92.3: categories spec — resolution order:
|
|
449
|
+
// (1) config.categories (test override; explicit in code)
|
|
450
|
+
// (2) ctx.privacyPolicy.getCategories() (production path — loaded
|
|
451
|
+
// from config/privacy-policy.json at boot, hot-reloadable via
|
|
452
|
+
// the `policy/loaded` event)
|
|
453
|
+
// (3) CATEGORIES_FALLBACK (last-resort built-in, mirrored from
|
|
454
|
+
// DEFAULT_POLICY.categories in privacy-policy.ts; identical
|
|
455
|
+
// content so unit tests + early-boot paths stay sane when the
|
|
456
|
+
// privacy-policy service isn't wired)
|
|
457
|
+
const policyCategories = (() => {
|
|
458
|
+
const policy = this.softService('privacyPolicy');
|
|
459
|
+
try {
|
|
460
|
+
return policy?.getCategories?.() ?? [];
|
|
461
|
+
}
|
|
462
|
+
catch {
|
|
463
|
+
return [];
|
|
464
|
+
}
|
|
465
|
+
})();
|
|
466
|
+
const cats = (config.categories && config.categories.length > 0)
|
|
467
|
+
? config.categories
|
|
468
|
+
: (policyCategories.length > 0 ? policyCategories : CATEGORIES_FALLBACK);
|
|
469
|
+
this.categories = cats;
|
|
470
|
+
// Flatten categories[].jevIntents → single lookup map. Iterating once
|
|
471
|
+
// at construction keeps classifyWithJev() at O(1) per call.
|
|
472
|
+
const map = {};
|
|
473
|
+
for (const cat of cats) {
|
|
474
|
+
for (const intent of cat.jevIntents) {
|
|
475
|
+
map[intent] = cat.id;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
this.jevIntentMap = map;
|
|
479
|
+
// §7.108 (2026-10-24): when the categories spec came from the policy
|
|
480
|
+
// service (config.categories not set), follow policy hot-reloads so the
|
|
481
|
+
// LLM prompt and the jev intent map pick up new categories (e.g. a
|
|
482
|
+
// travel domain added to CLOUD) without a restart. A reload that drops
|
|
483
|
+
// categories entirely keeps the last-known spec (defensive).
|
|
484
|
+
if (!(config.categories && config.categories.length > 0)) {
|
|
485
|
+
const policySvc = this.softService('privacyPolicy');
|
|
486
|
+
if (policySvc && typeof policySvc.on === 'function') {
|
|
487
|
+
policySvc.on('policy/loaded', (p) => {
|
|
488
|
+
const next = p?.categories ?? [];
|
|
489
|
+
if (next.length === 0)
|
|
490
|
+
return;
|
|
491
|
+
this.categories = next;
|
|
492
|
+
const m = {};
|
|
493
|
+
for (const cat of next) {
|
|
494
|
+
for (const intent of cat.jevIntents)
|
|
495
|
+
m[intent] = cat.id;
|
|
496
|
+
}
|
|
497
|
+
this.jevIntentMap = m;
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
this.llm = ctx.llm;
|
|
502
|
+
this.auditLog = auditLog;
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Soft service lookup that works in both Cordis (runtime) and unit tests (bare mock).
|
|
506
|
+
*
|
|
507
|
+
* Cordis context: `ctx.get(name)` is available — use it. Throws when the
|
|
508
|
+
* service is not in the `inject` list, so `get` acts as the gate.
|
|
509
|
+
* Bare mock context (tests): `ctx.get` is undefined — fall back to direct
|
|
510
|
+
* property access `ctx[name]`, which is how tests wire fake services.
|
|
511
|
+
*
|
|
512
|
+
* Either way, a missing service returns `undefined` rather than throwing.
|
|
513
|
+
*/
|
|
514
|
+
softService(name) {
|
|
515
|
+
const c = this.ctx;
|
|
516
|
+
if (typeof c?.get === 'function') {
|
|
517
|
+
try {
|
|
518
|
+
return c.get(name);
|
|
519
|
+
}
|
|
520
|
+
catch {
|
|
521
|
+
return undefined;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return c?.[name] ?? undefined;
|
|
525
|
+
}
|
|
526
|
+
audit(level, kind, payload) {
|
|
527
|
+
if (!this.auditLog)
|
|
528
|
+
return;
|
|
529
|
+
void this.auditLog.log({ level, kind, source: 'intent-router', payload }).catch(() => { });
|
|
530
|
+
}
|
|
531
|
+
/* ── §7.133: jev 决策模型调用的 trace ────────────────────────────────── */
|
|
532
|
+
/**
|
|
533
|
+
* §7.133: jev 决策模型调用的 trace。
|
|
534
|
+
*
|
|
535
|
+
* jev 走的是**裸 fetch**,不经过 `ctx.llm` —— 所以 adapter 那条路看不到
|
|
536
|
+
* 它。而它恰恰是把用户原文送出设备的那一call(当前配置指向百炼云端
|
|
537
|
+
* decision-model,§7.112/§7.114)。面板若只显示 local-llm 的分类器调用,
|
|
538
|
+
* 就是在显示一件没发生过的事(§7.107 的诚实性纪律)。
|
|
539
|
+
*
|
|
540
|
+
* 所以这里补一路 trace:caller 仍是 `intent-classifier`(它就是意图
|
|
541
|
+
* 分类器),provider 用 `bailian-decision` / `jev` 区分是哪条后端。
|
|
542
|
+
*/
|
|
543
|
+
traceJevRequest(provider, model, url, sessionId, text, system, callId, rawBody) {
|
|
544
|
+
try {
|
|
545
|
+
const cfg = resolveLlmTraceConfig(loadEdgeConfig());
|
|
546
|
+
if (!cfg.enabled || !cfg.includeRequest)
|
|
547
|
+
return;
|
|
548
|
+
const trace = buildRequestTrace({
|
|
549
|
+
sessionId: sessionId ?? 'default',
|
|
550
|
+
provider,
|
|
551
|
+
model,
|
|
552
|
+
baseUrl: url,
|
|
553
|
+
system,
|
|
554
|
+
messages: [{ role: 'user', content: text }],
|
|
555
|
+
// §7.144: 完整请求体 —— jev 的 body 里最关键的是 `questions`
|
|
556
|
+
// (分类体系 + 风险标准),那是**投影装不下**的部分:主人的原话
|
|
557
|
+
// 「request 里只发了那段 system 吗?」—— 不是,但面板当时只记了两段。
|
|
558
|
+
rawBody,
|
|
559
|
+
config: cfg,
|
|
560
|
+
});
|
|
561
|
+
this.emitJevTrace('REQ', 'intent-router jev LLM_REQUEST', trace, callId);
|
|
562
|
+
}
|
|
563
|
+
catch { /* 观测失败不拖垮主链路 */ }
|
|
564
|
+
}
|
|
565
|
+
traceJevResponse(provider, model, sessionId, content, durationMs, error, callId, rawBody) {
|
|
566
|
+
try {
|
|
567
|
+
const cfg = resolveLlmTraceConfig(loadEdgeConfig());
|
|
568
|
+
if (!cfg.enabled || !cfg.includeResponse)
|
|
569
|
+
return;
|
|
570
|
+
const trace = buildResponseTrace({
|
|
571
|
+
sessionId: sessionId ?? 'default',
|
|
572
|
+
provider,
|
|
573
|
+
model,
|
|
574
|
+
durationMs,
|
|
575
|
+
finishReason: error === undefined ? 'stop' : 'error',
|
|
576
|
+
content,
|
|
577
|
+
toolCalls: [],
|
|
578
|
+
// §7.144: 完整响应体(jev 回的是一整段 JSON:answers + usage + request_id)。
|
|
579
|
+
rawBody,
|
|
580
|
+
error,
|
|
581
|
+
config: cfg,
|
|
582
|
+
});
|
|
583
|
+
this.emitJevTrace('RESP', 'intent-router jev LLM_RESPONSE', trace, callId);
|
|
584
|
+
}
|
|
585
|
+
catch { /* 观测失败不拖垮主链路 */ }
|
|
586
|
+
}
|
|
587
|
+
emitJevTrace(kind, tag, trace, callId) {
|
|
588
|
+
try {
|
|
589
|
+
// ctx.logger 在某些宿主形态下取不到(未 inject 的 plugin proxy 会抛)
|
|
590
|
+
// —— 那是**老通路**(ops log)的事,不能连累索引行与正文 store。
|
|
591
|
+
let logger;
|
|
592
|
+
try {
|
|
593
|
+
logger = this.ctx.logger;
|
|
594
|
+
}
|
|
595
|
+
catch {
|
|
596
|
+
logger = undefined;
|
|
597
|
+
}
|
|
598
|
+
const sink = {
|
|
599
|
+
log: (entry) => this.auditLog?.log(entry) ?? Promise.resolve(),
|
|
600
|
+
logger,
|
|
601
|
+
store: getTraceStore(),
|
|
602
|
+
};
|
|
603
|
+
emitTrace(sink, kind, tag, trace, 'intent-classifier', { toolCount: 0, callId });
|
|
604
|
+
}
|
|
605
|
+
catch { /* 观测失败不拖垮主链路 */ }
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* §7.122: 分类器 provider 的**真实落点**。
|
|
609
|
+
*
|
|
610
|
+
* provider 名(`local-llm`)不等于落点 —— config/edge.json 的
|
|
611
|
+
* `llm.local.baseUrl` 可以指向公网。审计要能回答「这轮原文发到哪去了」,
|
|
612
|
+
* 光有 provider 名不够。
|
|
613
|
+
*
|
|
614
|
+
* 解析规则:provider 以 `cloud-` 开头 → cloud 池;否则 local 池。
|
|
615
|
+
* (与 model-router 的 realm 判定同一口径:`local-*` 前缀 = LOCAL 域,
|
|
616
|
+
* 其余按 CLOUD 处理。)
|
|
617
|
+
*/
|
|
618
|
+
classifierEndpoint() {
|
|
619
|
+
try {
|
|
620
|
+
const provider = this.classificationProvider ?? '';
|
|
621
|
+
const pool = provider.startsWith('cloud-') ? undefined : localPool();
|
|
622
|
+
return pool?.baseUrl;
|
|
623
|
+
}
|
|
624
|
+
catch {
|
|
625
|
+
return undefined;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
/** §7.113 (2026-10-25): the two evidence scalars the Edge tab renders
|
|
629
|
+
* beside the verdict — `keyword` = the regex keyword/entity the rules
|
|
630
|
+
* layer matched (derived from the rationale; empty for semantic
|
|
631
|
+
* verdicts), `semantic` = the chosen category's description sentence,
|
|
632
|
+
* i.e. the "why" the LLM/jev classifier answered with. Both are plain
|
|
633
|
+
* scalars so safePayload passes them (PII-scanned) to the intent panel. */
|
|
634
|
+
classifyEvidence(intent) {
|
|
635
|
+
const out = {};
|
|
636
|
+
const kw = /^(?:rule-cloud-keyword|rule-local-entity|override-local-entity|rule-keyword-match):(.+)$/u.exec(intent.rationale);
|
|
637
|
+
if (kw)
|
|
638
|
+
out.keyword = kw[1];
|
|
639
|
+
const desc = this.categories.find((c) => c.id === intent.category)?.description;
|
|
640
|
+
if (desc)
|
|
641
|
+
out.semantic = desc;
|
|
642
|
+
return out;
|
|
643
|
+
}
|
|
644
|
+
/** Redact PII from a short preview string before logging. Soft-dep on
|
|
645
|
+
* ctx.piiUtils.redactString. Returns `null` (rather than leaking) if
|
|
646
|
+
* redaction is unavailable — callers should log length-only in that case.
|
|
647
|
+
* Defense-in-depth: audit-log already runs redactObject on every payload,
|
|
648
|
+
* but router-level redaction survives audit-log misconfiguration. */
|
|
649
|
+
redactPreview(text) {
|
|
650
|
+
if (!text)
|
|
651
|
+
return null;
|
|
652
|
+
const trimmed = text.trim().slice(0, 40);
|
|
653
|
+
const piiUtils = this.softService('piiUtils');
|
|
654
|
+
if (piiUtils && typeof piiUtils.redactString === 'function') {
|
|
655
|
+
try {
|
|
656
|
+
const result = piiUtils.redactString(trimmed);
|
|
657
|
+
return typeof result?.redacted === 'string' ? result.redacted : null;
|
|
658
|
+
}
|
|
659
|
+
catch {
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return null;
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* §7.107 (2026-10-23, operator ruling「只有需要搜索公网上的信息才需要用到
|
|
667
|
+
* cloudllm」): a CLOUD verdict needs a public-web need.
|
|
668
|
+
*
|
|
669
|
+
* The signal is this router's own cloud keyword list (built-in
|
|
670
|
+
* information-acquisition words + `stages.intent.cloudKeywords` + the
|
|
671
|
+
* policy file's `stages.intent.cloudKeywords`, §7.108) — the same list the
|
|
672
|
+
* rules tier matches, so the gate and the rules tier can never disagree
|
|
673
|
+
* about what counts as a web need.
|
|
674
|
+
*
|
|
675
|
+
* The gate is applied to the LLM / jev verdicts ONLY:
|
|
676
|
+
* - the rules tier reaches CLOUD either through a privacy-policy rule
|
|
677
|
+
* (the operator's own statement — honoured as-is) or through a cloud
|
|
678
|
+
* keyword (which IS the signal);
|
|
679
|
+
* - the LLM/jev verdicts are a MODEL's opinion, and a model's opinion
|
|
680
|
+
* is not what decides whether text may leave the device.
|
|
681
|
+
*
|
|
682
|
+
* It lives HERE — at the source of the verdict — and not in the
|
|
683
|
+
* consumer: the verdict is read by two consumers, input-guard
|
|
684
|
+
* (agent/pre-step) and http-api (which writes `agent._edgeRoute` from it
|
|
685
|
+
* and hands that over as an EXPLICIT route that input-guard honours
|
|
686
|
+
* as-is). A gate in the consumer closed only the input-guard path.
|
|
687
|
+
*/
|
|
688
|
+
gateCloudVerdict(intent, text, sid) {
|
|
689
|
+
if (intent.category !== 'CLOUD')
|
|
690
|
+
return intent;
|
|
691
|
+
const signal = this.cloudSignal(text);
|
|
692
|
+
if (signal !== undefined)
|
|
693
|
+
return intent;
|
|
694
|
+
// §7.109 (2026-10-24): semantic unlock — the policy file may declare
|
|
695
|
+
// operator trust in the classifier's SEMANTIC verdict: an LLM/jev CLOUD
|
|
696
|
+
// at confidence >= stages.intent.semanticCloudMinConfidence passes
|
|
697
|
+
// without a keyword signal (e.g. "规划黄山两日游怎么安排" needs public
|
|
698
|
+
// info but carries no keyword). Undefined (default) keeps §7.107
|
|
699
|
+
// keyword-only. The entity guardrail already ran before this gate and
|
|
700
|
+
// still wins; downstream sanitizer (fail-closed) / prompt-guard PII
|
|
701
|
+
// veto / td-bus are untouched — the unlock relaxes only the KEYWORD
|
|
702
|
+
// requirement, not any data-protection layer.
|
|
703
|
+
//
|
|
704
|
+
// §7.123 (2026-10-27): a SYNTHETIC confidence cannot unlock the gate.
|
|
705
|
+
// The bailian decision-model answers a `choice` question; when it does
|
|
706
|
+
// not report a numeric confidence the decoder substitutes 0.9
|
|
707
|
+
// ("decisive-by-construction"). With the shipped policy threshold of
|
|
708
|
+
// 0.75 that placeholder was ALWAYS >= threshold, so every CLOUD verdict
|
|
709
|
+
// from that provider left the device with no public-web signal — the
|
|
710
|
+
// §7.107 gate was inert on exactly the path that talks to a cloud
|
|
711
|
+
// service. Requiring a *reported* confidence restores the gate's
|
|
712
|
+
// meaning: the operator's trust threshold is a threshold on a
|
|
713
|
+
// measurement, not on a placeholder.
|
|
714
|
+
const semanticMin = this.policyIntentStages()?.semanticCloudMinConfidence;
|
|
715
|
+
const syntheticConfidence = intent.confidenceReported === false;
|
|
716
|
+
if (syntheticConfidence) {
|
|
717
|
+
this.audit('warn', 'intent.cloud.semantic-unlock-skipped', {
|
|
718
|
+
sessionId: sid,
|
|
719
|
+
confidence: intent.confidence,
|
|
720
|
+
minConfidence: semanticMin,
|
|
721
|
+
rationale: intent.rationale,
|
|
722
|
+
note: 'classifier confidence is synthetic (not reported) — semantic unlock does not apply; a public-web signal is required',
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
else if (typeof semanticMin === 'number' && intent.confidence >= semanticMin) {
|
|
726
|
+
this.audit('info', 'intent.cloud.semantic-unlock', {
|
|
727
|
+
sessionId: sid,
|
|
728
|
+
confidence: intent.confidence,
|
|
729
|
+
minConfidence: semanticMin,
|
|
730
|
+
rationale: intent.rationale,
|
|
731
|
+
note: 'policy semantic trust: classifier CLOUD verdict passed without a keyword signal',
|
|
732
|
+
});
|
|
733
|
+
return intent;
|
|
734
|
+
}
|
|
735
|
+
this.audit('warn', 'intent.cloud.blocked-no-web-need', {
|
|
736
|
+
sessionId: sid,
|
|
737
|
+
category: intent.category,
|
|
738
|
+
confidence: intent.confidence,
|
|
739
|
+
rationale: intent.rationale,
|
|
740
|
+
note: 'cloud verdict without a public-web signal — kept on device',
|
|
741
|
+
});
|
|
742
|
+
// The final verdict gets its own classify.end row: the panel reads the
|
|
743
|
+
// LATEST one, and the pre-gate row still says CLOUD — the panel would
|
|
744
|
+
// announce a cloud verdict that never happened.
|
|
745
|
+
this.audit('info', 'intent.classify.end', {
|
|
746
|
+
sessionId: sid,
|
|
747
|
+
textLen: text.length,
|
|
748
|
+
path: 'gate-no-web-need',
|
|
749
|
+
category: 'LOCAL',
|
|
750
|
+
confidence: intent.confidence,
|
|
751
|
+
rationale: 'no-web-need',
|
|
752
|
+
...(this.classifyEvidence({ ...intent, category: 'LOCAL', rationale: 'no-web-need' })),
|
|
753
|
+
});
|
|
754
|
+
return { category: 'LOCAL', confidence: intent.confidence, targetRealm: 'local', rationale: 'no-web-need' };
|
|
755
|
+
}
|
|
756
|
+
async classify(text, context) {
|
|
757
|
+
const start = Date.now();
|
|
758
|
+
this.stats.total++;
|
|
759
|
+
// §7.106 (2026-10-22): thread the session id into every panel-visible classify
|
|
760
|
+
// audit. Without it the fold drops these rows into the _global bucket
|
|
761
|
+
// (turn sentinel -1) and per-turn wiping never hides them — observed:
|
|
762
|
+
// turn-1 classify rows still on screen during turn 2 (17:47 desktop).
|
|
763
|
+
const sid = typeof context?.sessionId === 'string' ? context.sessionId : '';
|
|
764
|
+
// Input edge: empty / whitespace-only text → short-circuit to default.
|
|
765
|
+
// Wasted LLM call is still bounded by llmTimeoutMs, but on the hot path
|
|
766
|
+
// (e.g. UI cleared input) we want zero latency.
|
|
767
|
+
if (!text || !text.trim()) {
|
|
768
|
+
this.stats.empty++;
|
|
769
|
+
this.audit('debug', 'intent.classify.empty', { textLen: text?.length ?? 0 });
|
|
770
|
+
return {
|
|
771
|
+
category: this.defaultCategory,
|
|
772
|
+
confidence: 0.5,
|
|
773
|
+
targetRealm: this.defaultCategory === 'CLOUD' ? 'cloud' : this.defaultCategory === 'SANITIZED' ? 'sanitized' : 'local',
|
|
774
|
+
rationale: 'empty-input',
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
// Input edge: length cap. Past this, truncate and audit. Sanitization
|
|
778
|
+
// (when classification LLM is CLOUD) happens on the truncated text, so
|
|
779
|
+
// a 100kB paste with 5 phone numbers still only ships 5 placeholders.
|
|
780
|
+
let effectiveText = text;
|
|
781
|
+
if (text.length > this.maxInputLength) {
|
|
782
|
+
this.stats.truncated++;
|
|
783
|
+
this.audit('warn', 'intent.classify.truncated', { sessionId: sid,
|
|
784
|
+
originalLen: text.length,
|
|
785
|
+
truncatedLen: this.maxInputLength,
|
|
786
|
+
maxInputLength: this.maxInputLength,
|
|
787
|
+
});
|
|
788
|
+
effectiveText = text.slice(0, this.maxInputLength);
|
|
789
|
+
}
|
|
790
|
+
// Policy pre-check: a privacy-policy REJECT rule short-circuits BEFORE
|
|
791
|
+
// the LLM call. Reasons:
|
|
792
|
+
// 1. REJECT is a strong policy signal — the user asked for something
|
|
793
|
+
// policy already forbids. Spending LLM tokens to confirm is wasteful.
|
|
794
|
+
// 2. Latency on the rejection path matters: the policy-violating query
|
|
795
|
+
// should be denied at router-edge, not after a 4 s LLM timeout.
|
|
796
|
+
// 3. The classification LLM may itself be unavailable (qwen3-4b OOM,
|
|
797
|
+
// llama.cpp hang) — REJECT must survive LLM outage. The fallback
|
|
798
|
+
// path inside classifyWithLlm → catch → classifyWithRules still
|
|
799
|
+
// re-runs matchRouting, so REJECT is also covered post-LLM-failure.
|
|
800
|
+
const policy = this.softService('privacyPolicy');
|
|
801
|
+
if (policy && typeof policy.matchRouting === 'function') {
|
|
802
|
+
try {
|
|
803
|
+
const pre = policy.matchRouting(effectiveText);
|
|
804
|
+
if (pre && pre.realm === 'REJECT') {
|
|
805
|
+
this.stats.rejected++;
|
|
806
|
+
this.audit('security', 'intent.rules.rejected', { sessionId: sid,
|
|
807
|
+
source: 'privacy-policy-precheck',
|
|
808
|
+
pattern: pre.pattern,
|
|
809
|
+
confidence: pre.confidence,
|
|
810
|
+
rationale: pre.rationale,
|
|
811
|
+
textLen: effectiveText.length,
|
|
812
|
+
});
|
|
813
|
+
return {
|
|
814
|
+
category: 'REJECT',
|
|
815
|
+
confidence: pre.confidence,
|
|
816
|
+
targetRealm: 'rejected',
|
|
817
|
+
rationale: pre.rationale || 'policy-rule:reject',
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
catch (err) {
|
|
822
|
+
this.audit('warn', 'intent.policy.precheck-error', { sessionId: sid, error: err?.message ?? String(err) });
|
|
823
|
+
// Pre-check failure is non-fatal; fall through to LLM+rules path.
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
const textPreview = this.redactPreview(effectiveText) ?? `[len=${effectiveText.length}]`;
|
|
827
|
+
// §7.122: `classifier` 是**配置里的备用分类器**,不是本轮的实际裁决者
|
|
828
|
+
// —— jev 预筛命中时(path=jev-adopt)LLM 分类器根本没被调用,而面板把
|
|
829
|
+
// 这个配置值渲染成「分类器 local-llm」,operator 会以为本轮是它判的。
|
|
830
|
+
//
|
|
831
|
+
// 落两样东西:
|
|
832
|
+
// - `classifierEndpoint`:这个 provider 的**真实落点**。provider 名叫
|
|
833
|
+
// `local-llm` 但 baseUrl 可能是公网(config/edge.json 的
|
|
834
|
+
// llm.local.baseUrl),名字骗人,落点不骗人。
|
|
835
|
+
// - `classifierIsLocal`:落点是否真的在本机(127.0.0.1 / localhost)。
|
|
836
|
+
const classifierEndpoint = this.classifierEndpoint();
|
|
837
|
+
// §7.179(2026-11-11):**本轮真正会跑的**分类模型。
|
|
838
|
+
//
|
|
839
|
+
// 之前分类模型名只在 apply() 里跟 `llm.local.model` 走一次(§7.102)。DELEGATE
|
|
840
|
+
// 模式(llm.local.baseUrl 留空)下那个值是空的 ⇒ 模型名停在 Schema 默认
|
|
841
|
+
// `qwen3-4b`,而 wire 上真正跑的是委托解析出来的宿主当前模型 ⇒ 面板写着
|
|
842
|
+
// 「裁决者 LLM 分类器 local-llm/qwen3-4b」而实际跑的是另一个模型(与实际
|
|
843
|
+
// 行为相反)。§7.176 的同一口径:写 wire 上真正跑的那个。
|
|
844
|
+
const effective = await resolveEffectiveLocalModel(this.ctx, this.classificationModel, this.classificationProvider);
|
|
845
|
+
this.audit('info', 'intent.classify.start', {
|
|
846
|
+
sessionId: sid,
|
|
847
|
+
textLen: effectiveText.length,
|
|
848
|
+
textPreview,
|
|
849
|
+
classifier: this.classificationProvider,
|
|
850
|
+
model: effective.model,
|
|
851
|
+
// 真实落点(DELEGATE 时是宿主 provider,不是 local-llm)。
|
|
852
|
+
modelProvider: effective.provider ?? this.classificationProvider,
|
|
853
|
+
modelSource: effective.source,
|
|
854
|
+
classifierEndpoint,
|
|
855
|
+
classifierIsLocal: isLoopbackEndpoint(classifierEndpoint),
|
|
856
|
+
});
|
|
857
|
+
// 缓存命中:同文本+同上下文的已完成 verdict 直接复用,跳过 jev/LLM。
|
|
858
|
+
const cacheKey = this.classifyCacheKey(effectiveText, context?.recentMessages);
|
|
859
|
+
const cachedVerdict = this.classifyCacheGet(cacheKey);
|
|
860
|
+
if (cachedVerdict) {
|
|
861
|
+
this.stats.cacheHit++;
|
|
862
|
+
this.audit('info', 'intent.classify.end', { sessionId: sid,
|
|
863
|
+
textLen: effectiveText.length,
|
|
864
|
+
path: 'cache',
|
|
865
|
+
category: cachedVerdict.category,
|
|
866
|
+
confidence: cachedVerdict.confidence,
|
|
867
|
+
rationale: cachedVerdict.rationale,
|
|
868
|
+
...this.classifyEvidence(cachedVerdict),
|
|
869
|
+
durationMs: Date.now() - start,
|
|
870
|
+
});
|
|
871
|
+
return cachedVerdict;
|
|
872
|
+
}
|
|
873
|
+
// §7.124 (2026-10-27): Tier-0 — 家庭设备操作正则快速层。
|
|
874
|
+
//
|
|
875
|
+
// 用户裁决:「意图识别需要先走正则,快速决策,比如开灯、关门、开空调
|
|
876
|
+
// 这些家庭里家电操作,然后才是决策模型分类,最后才是 llm 分类」。
|
|
877
|
+
//
|
|
878
|
+
// 三级阶梯现在是:
|
|
879
|
+
// Tier-0 正则(本层) ~0ms 命中即定案,绝不外呼
|
|
880
|
+
// Tier-1 jev 决策模型 ~150ms-6s 语义分类
|
|
881
|
+
// Tier-2 LLM 分类器 4s-30s 最后兜底
|
|
882
|
+
//
|
|
883
|
+
// 位置说明:放在缓存之后、jev 之前。缓存命中本来就是 ~0ms 且语义等价,
|
|
884
|
+
// 没有理由绕过它;而放在 jev 之前才能兑现「正则优先」——命中即定案,
|
|
885
|
+
// 原始指令不出设备、不进 jev 的上行请求体(jev 指向云端时这一点尤其
|
|
886
|
+
// 重要)。
|
|
887
|
+
//
|
|
888
|
+
// 判定一律 LOCAL:家电操作由 LOCAL realm 的设备技能执行,不该上云
|
|
889
|
+
// (与 §7.107「默认本地」一致)。
|
|
890
|
+
//
|
|
891
|
+
// 未命中不惩罚 —— 直接落到 jev / LLM,行为与本层存在时完全一致。
|
|
892
|
+
const deviceMatch = matchDeviceCommand(effectiveText, this.deviceCommandTerms());
|
|
893
|
+
if (deviceMatch) {
|
|
894
|
+
this.stats.deviceCommandHit++;
|
|
895
|
+
this.audit('info', 'intent.device-command.hit', {
|
|
896
|
+
sessionId: sid,
|
|
897
|
+
device: deviceMatch.device,
|
|
898
|
+
action: deviceMatch.action,
|
|
899
|
+
matched: deviceMatch.matched,
|
|
900
|
+
textLen: effectiveText.length,
|
|
901
|
+
});
|
|
902
|
+
const deviceIntent = {
|
|
903
|
+
category: 'LOCAL',
|
|
904
|
+
confidence: 0.95,
|
|
905
|
+
targetRealm: 'local',
|
|
906
|
+
rationale: `device-command:${deviceMatch.action}:${deviceMatch.device}`,
|
|
907
|
+
// §7.123: a regex verdict is a MEASURED decision, not a model-reported
|
|
908
|
+
// confidence — mark it as such so the semantic-unlock provenance guard
|
|
909
|
+
// can tell it apart from a synthetic placeholder.
|
|
910
|
+
confidenceReported: true,
|
|
911
|
+
};
|
|
912
|
+
this.audit('info', 'intent.classify.end', { sessionId: sid,
|
|
913
|
+
textLen: effectiveText.length,
|
|
914
|
+
path: 'device-command',
|
|
915
|
+
category: deviceIntent.category,
|
|
916
|
+
confidence: deviceIntent.confidence,
|
|
917
|
+
rationale: deviceIntent.rationale,
|
|
918
|
+
...this.classifyEvidence(deviceIntent),
|
|
919
|
+
durationMs: Date.now() - start,
|
|
920
|
+
device: deviceMatch.device,
|
|
921
|
+
action: deviceMatch.action,
|
|
922
|
+
});
|
|
923
|
+
this.classifyCacheSet(cacheKey, deviceIntent);
|
|
924
|
+
return deviceIntent;
|
|
925
|
+
}
|
|
926
|
+
// §7.92.3 (2026-09-23): jev prefilter. When enabled, runs BEFORE the
|
|
927
|
+
// LLM classifier so confident jev verdicts (action=execute, conf >=
|
|
928
|
+
// threshold, risk < threshold) save the 4-30s LLM round-trip. Failure
|
|
929
|
+
// modes (network, timeout, parse error, low-confidence, unrecognized
|
|
930
|
+
// intent, fallback_llm) all fall through to classifyWithLlm — jev is a
|
|
931
|
+
// strictly-OPTIONAL accelerator, never a behavior regression.
|
|
932
|
+
if (this.jevEnabled) {
|
|
933
|
+
const jevResult = await this.classifyWithJev(effectiveText, context?.sessionId);
|
|
934
|
+
if (jevResult.action === 'reject') {
|
|
935
|
+
// Risk-score gate: security-level audit, count under `rejected`
|
|
936
|
+
// (and `jevReject` for jev-specific observability).
|
|
937
|
+
this.stats.rejected++;
|
|
938
|
+
this.stats.jevReject++;
|
|
939
|
+
this.audit('info', 'intent.classify.end', { sessionId: sid,
|
|
940
|
+
textLen: effectiveText.length,
|
|
941
|
+
path: 'jev-reject',
|
|
942
|
+
category: 'REJECT',
|
|
943
|
+
confidence: jevResult.confidence,
|
|
944
|
+
rationale: `jev-risk:${jevResult.riskScore.toFixed(2)}`,
|
|
945
|
+
...this.classifyEvidence({ category: 'REJECT', confidence: jevResult.confidence, targetRealm: 'rejected', rationale: 'jev-risk' }),
|
|
946
|
+
durationMs: Date.now() - start,
|
|
947
|
+
jevLatencyMs: jevResult.latencyMs,
|
|
948
|
+
jevIntent: jevResult.intent,
|
|
949
|
+
});
|
|
950
|
+
return {
|
|
951
|
+
category: 'REJECT',
|
|
952
|
+
confidence: jevResult.confidence,
|
|
953
|
+
targetRealm: 'rejected',
|
|
954
|
+
rationale: `jev-risk:${jevResult.riskScore.toFixed(2)}`,
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
if (jevResult.action === 'adopt' && jevResult.intent) {
|
|
958
|
+
this.stats.jevHit++;
|
|
959
|
+
// §7.58 entity guardrail: same as LLM path — entities that
|
|
960
|
+
// resolve only behind LOCAL skills force LOCAL, overriding a
|
|
961
|
+
// jev-adopted non-LOCAL verdict (e.g. jev returns 'SANITIZED'
|
|
962
|
+
// for "查我的密盒订单", entity wins → LOCAL). Mirrors the LLM
|
|
963
|
+
// branch logic so behavior is symmetric across both paths.
|
|
964
|
+
const adopted = jevResult.intent;
|
|
965
|
+
let finalIntent = adopted;
|
|
966
|
+
if (adopted.category !== 'LOCAL') {
|
|
967
|
+
const entityHit = this.matchLocalEntity(effectiveText.toLowerCase());
|
|
968
|
+
if (entityHit !== undefined) {
|
|
969
|
+
finalIntent = {
|
|
970
|
+
category: 'LOCAL',
|
|
971
|
+
confidence: 0.9,
|
|
972
|
+
targetRealm: 'local',
|
|
973
|
+
rationale: `override-local-entity:${entityHit}`,
|
|
974
|
+
};
|
|
975
|
+
this.audit('warn', 'intent.classify.entity_override', { sessionId: sid,
|
|
976
|
+
from: adopted.category,
|
|
977
|
+
to: 'LOCAL',
|
|
978
|
+
entity: entityHit,
|
|
979
|
+
jevIntent: jevResult.jevIntentString,
|
|
980
|
+
jevConfidence: jevResult.confidence,
|
|
981
|
+
textLen: effectiveText.length,
|
|
982
|
+
source: 'jev',
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
this.audit('info', 'intent.classify.end', { sessionId: sid,
|
|
987
|
+
textLen: effectiveText.length,
|
|
988
|
+
path: finalIntent === adopted ? 'jev-adopt' : 'jev-adopt-entity-override',
|
|
989
|
+
category: finalIntent.category,
|
|
990
|
+
confidence: finalIntent.confidence,
|
|
991
|
+
rationale: finalIntent.rationale,
|
|
992
|
+
...this.classifyEvidence(finalIntent),
|
|
993
|
+
durationMs: Date.now() - start,
|
|
994
|
+
jevLatencyMs: jevResult.latencyMs,
|
|
995
|
+
jevIntent: jevResult.jevIntentString,
|
|
996
|
+
jevConfidence: jevResult.confidence,
|
|
997
|
+
// §7.121: 决策模型的**原始风险分**。此前只落了 intent/confidence,
|
|
998
|
+
// 「模型到底有多确信这条请求有风险」这个问题没有答案 —— 而它正是
|
|
999
|
+
// §7.107 公网检索闸门和 REJECT 闸门的输入。
|
|
1000
|
+
jevRiskScore: jevResult.riskScore,
|
|
1001
|
+
});
|
|
1002
|
+
// §7.107: a model's CLOUD opinion is not what decides whether text
|
|
1003
|
+
// may leave the device — a public-web need is.
|
|
1004
|
+
const gatedJev = this.gateCloudVerdict(finalIntent, effectiveText, sid);
|
|
1005
|
+
this.classifyCacheSet(cacheKey, gatedJev);
|
|
1006
|
+
return gatedJev;
|
|
1007
|
+
}
|
|
1008
|
+
// action === 'skip' (low-conf / fallback_llm / unrecognized intent /
|
|
1009
|
+
// transport error / timeout). All already audited inside
|
|
1010
|
+
// classifyWithJev with a specific kind. Count under jevSkip (or
|
|
1011
|
+
// jevError/jevTimeout already incremented inside the helper).
|
|
1012
|
+
if (jevResult.action === 'skip' && jevResult.skipReason !== 'jev-error' && jevResult.skipReason !== 'jev-timeout') {
|
|
1013
|
+
this.stats.jevSkip++;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
// §7.115: decision-model-only mode (stages.intent.llmFallback=false) —
|
|
1017
|
+
// the LLM classifier is a fallback operators can switch off entirely.
|
|
1018
|
+
// When off, a jev skip goes straight to the rules tier + default LOCAL
|
|
1019
|
+
// (fail-closed); no LLM round-trip ever happens.
|
|
1020
|
+
if (!this.llmFallback) {
|
|
1021
|
+
const intent = this.classifyWithRules(effectiveText);
|
|
1022
|
+
if (intent.category === 'REJECT')
|
|
1023
|
+
this.stats.rejected++;
|
|
1024
|
+
else if (intent.rationale.startsWith('default:'))
|
|
1025
|
+
this.stats.defaultHit++;
|
|
1026
|
+
else
|
|
1027
|
+
this.stats.ruleHit++;
|
|
1028
|
+
this.audit('info', 'intent.classify.end', { sessionId: sid,
|
|
1029
|
+
textLen: effectiveText.length,
|
|
1030
|
+
path: 'rules-llm-fallback-off',
|
|
1031
|
+
category: intent.category,
|
|
1032
|
+
confidence: intent.confidence,
|
|
1033
|
+
rationale: intent.rationale,
|
|
1034
|
+
...this.classifyEvidence(intent),
|
|
1035
|
+
durationMs: Date.now() - start,
|
|
1036
|
+
});
|
|
1037
|
+
return intent;
|
|
1038
|
+
}
|
|
1039
|
+
// Try LLM classification first
|
|
1040
|
+
try {
|
|
1041
|
+
const intent = await this.classifyWithLlm(effectiveText, context?.recentMessages, context?.sessionId, effective);
|
|
1042
|
+
this.stats.llmHit++;
|
|
1043
|
+
// §7.58 (2026-09-16) entity guardrail: the classification LLM has no
|
|
1044
|
+
// view of the skill-realm table — it answered CLOUD conf=1 for
|
|
1045
|
+
// "查询共登陆了几个密盒" (2026-09-15 turn 1), and the rules layer
|
|
1046
|
+
// never runs on this path. Entities that only resolve behind LOCAL
|
|
1047
|
+
// skills force LOCAL deterministically, overriding any non-LOCAL LLM
|
|
1048
|
+
// verdict. Fail-closed toward LOCAL; audited at warn so ops can grep
|
|
1049
|
+
// 'entity_override' for classifier quality regressions.
|
|
1050
|
+
let finalIntent = intent;
|
|
1051
|
+
if (intent.category !== 'LOCAL') {
|
|
1052
|
+
const entityHit = this.matchLocalEntity(effectiveText.toLowerCase());
|
|
1053
|
+
if (entityHit !== undefined) {
|
|
1054
|
+
finalIntent = {
|
|
1055
|
+
category: 'LOCAL',
|
|
1056
|
+
confidence: 0.9,
|
|
1057
|
+
targetRealm: 'local',
|
|
1058
|
+
rationale: `override-local-entity:${entityHit}`,
|
|
1059
|
+
};
|
|
1060
|
+
this.audit('warn', 'intent.classify.entity_override', { sessionId: sid,
|
|
1061
|
+
from: intent.category,
|
|
1062
|
+
to: 'LOCAL',
|
|
1063
|
+
entity: entityHit,
|
|
1064
|
+
llmConfidence: intent.confidence,
|
|
1065
|
+
llmRationale: intent.rationale,
|
|
1066
|
+
textLen: effectiveText.length,
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
this.audit('info', 'intent.classify.end', { sessionId: sid,
|
|
1071
|
+
textLen: effectiveText.length,
|
|
1072
|
+
path: finalIntent === intent ? 'llm' : 'llm-entity-override',
|
|
1073
|
+
category: finalIntent.category,
|
|
1074
|
+
confidence: finalIntent.confidence,
|
|
1075
|
+
rationale: finalIntent.rationale,
|
|
1076
|
+
...this.classifyEvidence(finalIntent),
|
|
1077
|
+
durationMs: Date.now() - start,
|
|
1078
|
+
});
|
|
1079
|
+
// §7.107: the public-web gate. Without a public-web signal the turn
|
|
1080
|
+
// stays on the device, whatever the classifier said.
|
|
1081
|
+
const gatedLlm = this.gateCloudVerdict(finalIntent, effectiveText, sid);
|
|
1082
|
+
this.classifyCacheSet(cacheKey, gatedLlm);
|
|
1083
|
+
return gatedLlm;
|
|
1084
|
+
}
|
|
1085
|
+
catch (err) {
|
|
1086
|
+
// classifyWithLlm already audited the specific kind (timeout vs error
|
|
1087
|
+
// vs low-confidence vs unclear). Here we just attribute the fallback.
|
|
1088
|
+
this.audit('warn', 'intent.classify.llm_error', { sessionId: sid, textLen: effectiveText.length, error: err?.message ?? String(err) });
|
|
1089
|
+
// Fall back to keyword rules
|
|
1090
|
+
const intent = this.classifyWithRules(effectiveText);
|
|
1091
|
+
if (intent.category === 'REJECT')
|
|
1092
|
+
this.stats.rejected++;
|
|
1093
|
+
else if (intent.rationale.startsWith('default:'))
|
|
1094
|
+
this.stats.defaultHit++;
|
|
1095
|
+
else
|
|
1096
|
+
this.stats.ruleHit++;
|
|
1097
|
+
this.audit('info', 'intent.classify.end', { sessionId: sid,
|
|
1098
|
+
textLen: effectiveText.length,
|
|
1099
|
+
path: 'rules-after-llm-error',
|
|
1100
|
+
category: intent.category,
|
|
1101
|
+
confidence: intent.confidence,
|
|
1102
|
+
rationale: intent.rationale,
|
|
1103
|
+
...this.classifyEvidence(intent),
|
|
1104
|
+
durationMs: Date.now() - start,
|
|
1105
|
+
});
|
|
1106
|
+
return intent;
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
/** §7.92.5 (2026-09-23): build the full jev `intents[]` payload from
|
|
1110
|
+
* `this.categories[]`. Two-phase priority, NO 16-cap (the cap is
|
|
1111
|
+
* enforced one level up by `chunkIntents()`):
|
|
1112
|
+
*
|
|
1113
|
+
* Phase 1: emit every REJECT category's jevIntents first
|
|
1114
|
+
* (security priority — must not be dropped)
|
|
1115
|
+
* Phase 2: emit all other categories in `categories[]` config order
|
|
1116
|
+
* (LOCAL → CLOUD → SANITIZED by default)
|
|
1117
|
+
*
|
|
1118
|
+
* Dedup by name via a Set<string> so operators who accidentally list
|
|
1119
|
+
* the same intent in two categories don't produce a duplicate name
|
|
1120
|
+
* (which would confuse jev's own zero-shot classifier).
|
|
1121
|
+
*
|
|
1122
|
+
* Returns the un-truncated array — the orchestrator batches it. */
|
|
1123
|
+
buildJevIntentsPayload() {
|
|
1124
|
+
const out = [];
|
|
1125
|
+
const seen = new Set();
|
|
1126
|
+
const push = (cat) => {
|
|
1127
|
+
for (const intent of cat.jevIntents) {
|
|
1128
|
+
if (seen.has(intent))
|
|
1129
|
+
continue;
|
|
1130
|
+
seen.add(intent);
|
|
1131
|
+
out.push({ name: intent, description: cat.description });
|
|
1132
|
+
}
|
|
1133
|
+
};
|
|
1134
|
+
for (const cat of this.categories) {
|
|
1135
|
+
if (cat.id === 'REJECT')
|
|
1136
|
+
push(cat);
|
|
1137
|
+
}
|
|
1138
|
+
for (const cat of this.categories) {
|
|
1139
|
+
if (cat.id !== 'REJECT')
|
|
1140
|
+
push(cat);
|
|
1141
|
+
}
|
|
1142
|
+
return out;
|
|
1143
|
+
}
|
|
1144
|
+
/** §7.92.5 (2026-09-23): single-batch jev fetcher. Extracted from
|
|
1145
|
+
* the old monolithic `classifyWithJev()` so the orchestrator can
|
|
1146
|
+
* fire N concurrent calls via `Promise.all` and merge the results.
|
|
1147
|
+
*
|
|
1148
|
+
* Takes the same `text` + `sessionId` as the old function, plus an
|
|
1149
|
+
* already-≤16 `intents[]` slice and `chitchatIntents` (defensive
|
|
1150
|
+
* marker for jev v0.3.0+). Returns the same shape:
|
|
1151
|
+
* - `action: 'reject' | 'adopt' | 'skip'`
|
|
1152
|
+
* - `confidence` / `riskScore` / `latencyMs` (always set)
|
|
1153
|
+
* - `skipReason` when action='skip' — caller can tell whether to
|
|
1154
|
+
* retry the verdict on the next batch (we don't — we just merge)
|
|
1155
|
+
*
|
|
1156
|
+
* Every audit / counter side-effect for THIS batch fires here, not
|
|
1157
|
+
* in the orchestrator, so partial failures are observable per-batch
|
|
1158
|
+
* in the JSONL audit stream. */
|
|
1159
|
+
async fetchJevBatch(text, sessionId, intents, chitchatIntents, jevStart) {
|
|
1160
|
+
// §7.147:这一路的审计行必须带 sessionId —— 否则行归不了属,Edge 面板
|
|
1161
|
+
// 的意图面板永远说不清「这一轮」(实测:jev 的 62 条 adopt/response 无一带
|
|
1162
|
+
// sessionId,全部落全局桶)。闭包一次带上,本函数内的 audit 都走它。
|
|
1163
|
+
//
|
|
1164
|
+
// sessionId 为空时**不写**这个键 ⇒ 归全局桶(而不是造一个假会话桶)。
|
|
1165
|
+
const audit = (level, kind, payload) => this.audit(level, kind, { ...sessionIdField(sessionId), ...payload });
|
|
1166
|
+
const batchStart = Date.now();
|
|
1167
|
+
audit('debug', 'intent.jev.batch.start', {
|
|
1168
|
+
textLen: text.length,
|
|
1169
|
+
batchSize: intents.length,
|
|
1170
|
+
batchLatencyBudgetMs: this.jevTimeoutMs,
|
|
1171
|
+
});
|
|
1172
|
+
// §7.133: 本机 jev 后端同样是「把原文发出去的模型调用」—— 留痕。
|
|
1173
|
+
// §7.134: REQ/RESP 共用一个 callId(面板据此配成一对)。
|
|
1174
|
+
const jevCallId = nextCallId();
|
|
1175
|
+
// §7.144: 请求体先提成变量 —— trace 要记**完整**的那一份(query + 整份
|
|
1176
|
+
// intents 分类表 + chitchat 标记),fetch 与 trace 用同一个对象,
|
|
1177
|
+
// 杜绝「记的和发的不是同一份」。
|
|
1178
|
+
const jevRequestBody = {
|
|
1179
|
+
query: text,
|
|
1180
|
+
lang: 'zh',
|
|
1181
|
+
metadata: sessionId ? { session_id: sessionId } : {},
|
|
1182
|
+
// §7.92.5: jev v0.3.0+ zero-shot taxonomy. Without these
|
|
1183
|
+
// fields jev falls back to its built-in 8-intent default
|
|
1184
|
+
// schema and our `categories[].jevIntents[]` table never
|
|
1185
|
+
// gets used. Each batch carries ≤ 16 intents (chunked by
|
|
1186
|
+
// classifyWithJev).
|
|
1187
|
+
intents,
|
|
1188
|
+
chitchat_intents: chitchatIntents,
|
|
1189
|
+
};
|
|
1190
|
+
this.traceJevRequest('jev', 'jev-local', `${this.jevUrl}/predict`, sessionId, text, '', jevCallId, jevRequestBody);
|
|
1191
|
+
const controller = new AbortController();
|
|
1192
|
+
const timer = setTimeout(() => controller.abort(), this.jevTimeoutMs);
|
|
1193
|
+
let responseText = '';
|
|
1194
|
+
let parsed = null;
|
|
1195
|
+
let jevError;
|
|
1196
|
+
try {
|
|
1197
|
+
const r = await fetch(`${this.jevUrl}/predict`, {
|
|
1198
|
+
method: 'POST',
|
|
1199
|
+
headers: this.jevHeaders(),
|
|
1200
|
+
body: JSON.stringify(jevRequestBody),
|
|
1201
|
+
signal: controller.signal,
|
|
1202
|
+
});
|
|
1203
|
+
if (!r.ok) {
|
|
1204
|
+
this.stats.jevError++;
|
|
1205
|
+
audit('warn', 'intent.jev.error', {
|
|
1206
|
+
httpStatus: r.status,
|
|
1207
|
+
batchLatencyMs: Date.now() - batchStart,
|
|
1208
|
+
});
|
|
1209
|
+
jevError = `HTTP ${r.status}`;
|
|
1210
|
+
return { action: 'skip', confidence: 0, riskScore: 0, latencyMs: Date.now() - jevStart, skipReason: 'jev-error' };
|
|
1211
|
+
}
|
|
1212
|
+
responseText = await r.text();
|
|
1213
|
+
try {
|
|
1214
|
+
parsed = JSON.parse(responseText);
|
|
1215
|
+
}
|
|
1216
|
+
catch (parseErr) {
|
|
1217
|
+
this.stats.jevError++;
|
|
1218
|
+
audit('warn', 'intent.jev.error', {
|
|
1219
|
+
reason: 'parse-failure',
|
|
1220
|
+
error: parseErr?.message ?? String(parseErr),
|
|
1221
|
+
batchLatencyMs: Date.now() - batchStart,
|
|
1222
|
+
});
|
|
1223
|
+
jevError = `parse-failure: ${parseErr?.message ?? String(parseErr)}`.slice(0, 200);
|
|
1224
|
+
return { action: 'skip', confidence: 0, riskScore: 0, latencyMs: Date.now() - jevStart, skipReason: 'jev-error' };
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
catch (err) {
|
|
1228
|
+
const isAbort = err?.name === 'AbortError' || controller.signal.aborted;
|
|
1229
|
+
const latencyMs = Date.now() - jevStart;
|
|
1230
|
+
if (isAbort)
|
|
1231
|
+
this.stats.jevTimeout++;
|
|
1232
|
+
else
|
|
1233
|
+
this.stats.jevError++;
|
|
1234
|
+
audit('warn', isAbort ? 'intent.jev.timeout' : 'intent.jev.error', {
|
|
1235
|
+
latencyMs,
|
|
1236
|
+
timeoutMs: this.jevTimeoutMs,
|
|
1237
|
+
batchLatencyMs: Date.now() - batchStart,
|
|
1238
|
+
error: err?.message ?? String(err),
|
|
1239
|
+
});
|
|
1240
|
+
jevError = isAbort ? 'timeout' : (err?.message ?? String(err)).slice(0, 200);
|
|
1241
|
+
return {
|
|
1242
|
+
action: 'skip',
|
|
1243
|
+
confidence: 0,
|
|
1244
|
+
riskScore: 0,
|
|
1245
|
+
latencyMs,
|
|
1246
|
+
skipReason: isAbort ? 'jev-timeout' : 'jev-error',
|
|
1247
|
+
};
|
|
1248
|
+
}
|
|
1249
|
+
finally {
|
|
1250
|
+
clearTimeout(timer);
|
|
1251
|
+
// §7.133: jev 本机后端的 response trace(失败也有)。
|
|
1252
|
+
this.traceJevResponse('jev', 'jev-local', sessionId, responseText, Date.now() - batchStart, jevError, jevCallId,
|
|
1253
|
+
// §7.144: 完整响应体。
|
|
1254
|
+
responseText);
|
|
1255
|
+
}
|
|
1256
|
+
// Response envelope: {code, message, request_id, data: {...}}
|
|
1257
|
+
// See api_reference §2.1 + §3.2. `code === 0` is the success sentinel.
|
|
1258
|
+
const data = parsed?.data ?? {};
|
|
1259
|
+
const intentStr = typeof data.intent === 'string' ? data.intent : null;
|
|
1260
|
+
const confidence = typeof data.confidence === 'number' ? Math.max(0, Math.min(1, data.confidence)) : 0;
|
|
1261
|
+
const riskScore = typeof data.risk_score === 'number' ? Math.max(0, Math.min(1, data.risk_score)) : 0;
|
|
1262
|
+
const dataAction = typeof data.action === 'string' ? data.action : '';
|
|
1263
|
+
const latencyMs = (typeof data.latency_ms === 'number' ? data.latency_ms : Date.now() - jevStart);
|
|
1264
|
+
this.stats.jevLatencyMsTotal += latencyMs;
|
|
1265
|
+
audit('debug', 'intent.jev.response', {
|
|
1266
|
+
code: parsed?.code,
|
|
1267
|
+
intent: intentStr,
|
|
1268
|
+
confidence,
|
|
1269
|
+
riskScore,
|
|
1270
|
+
action: dataAction,
|
|
1271
|
+
latencyMs,
|
|
1272
|
+
batchLatencyMs: Date.now() - batchStart,
|
|
1273
|
+
});
|
|
1274
|
+
// Risk gate: high-risk queries short-circuit to REJECT before any
|
|
1275
|
+
// LLM call (security-first; user explicitly chose this in plan Q2).
|
|
1276
|
+
if (riskScore >= this.jevRiskThreshold) {
|
|
1277
|
+
audit('security', 'intent.rules.rejected', {
|
|
1278
|
+
source: 'jev',
|
|
1279
|
+
intent: intentStr,
|
|
1280
|
+
riskScore,
|
|
1281
|
+
confidence,
|
|
1282
|
+
textLen: text.length,
|
|
1283
|
+
});
|
|
1284
|
+
return {
|
|
1285
|
+
action: 'reject',
|
|
1286
|
+
confidence,
|
|
1287
|
+
riskScore,
|
|
1288
|
+
latencyMs,
|
|
1289
|
+
jevIntentString: intentStr ?? undefined,
|
|
1290
|
+
};
|
|
1291
|
+
}
|
|
1292
|
+
// Skip conditions: jev's own verdict says fall through, OR our
|
|
1293
|
+
// thresholds aren't met, OR the intent string isn't recognized.
|
|
1294
|
+
const FALLBACK_INTENTS = new Set(['闲聊', '其他', 'chat', 'other', 'fallback', 'null']);
|
|
1295
|
+
if (dataAction === 'fallback_llm') {
|
|
1296
|
+
audit('info', 'intent.jev.fallback_intent', {
|
|
1297
|
+
intent: intentStr,
|
|
1298
|
+
confidence,
|
|
1299
|
+
latencyMs,
|
|
1300
|
+
});
|
|
1301
|
+
return {
|
|
1302
|
+
action: 'skip',
|
|
1303
|
+
confidence,
|
|
1304
|
+
riskScore,
|
|
1305
|
+
latencyMs,
|
|
1306
|
+
jevIntentString: intentStr ?? undefined,
|
|
1307
|
+
skipReason: 'fallback-llm',
|
|
1308
|
+
};
|
|
1309
|
+
}
|
|
1310
|
+
if (confidence < this.jevConfidenceThreshold) {
|
|
1311
|
+
audit('info', 'intent.jev.low_confidence', {
|
|
1312
|
+
intent: intentStr,
|
|
1313
|
+
confidence,
|
|
1314
|
+
threshold: this.jevConfidenceThreshold,
|
|
1315
|
+
latencyMs,
|
|
1316
|
+
});
|
|
1317
|
+
return {
|
|
1318
|
+
action: 'skip',
|
|
1319
|
+
confidence,
|
|
1320
|
+
riskScore,
|
|
1321
|
+
latencyMs,
|
|
1322
|
+
jevIntentString: intentStr ?? undefined,
|
|
1323
|
+
skipReason: 'low-confidence',
|
|
1324
|
+
};
|
|
1325
|
+
}
|
|
1326
|
+
if (!intentStr || FALLBACK_INTENTS.has(intentStr)) {
|
|
1327
|
+
audit('info', 'intent.jev.fallback_intent', {
|
|
1328
|
+
intent: intentStr,
|
|
1329
|
+
confidence,
|
|
1330
|
+
latencyMs,
|
|
1331
|
+
reason: 'no-intent-or-fallback-intent',
|
|
1332
|
+
});
|
|
1333
|
+
return {
|
|
1334
|
+
action: 'skip',
|
|
1335
|
+
confidence,
|
|
1336
|
+
riskScore,
|
|
1337
|
+
latencyMs,
|
|
1338
|
+
jevIntentString: intentStr ?? undefined,
|
|
1339
|
+
skipReason: 'unrecognized-intent',
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
// Map intent string → realm. Missing → skip (defensive).
|
|
1343
|
+
const mapped = this.jevIntentMap[intentStr];
|
|
1344
|
+
if (!mapped) {
|
|
1345
|
+
audit('info', 'intent.jev.unmapped_intent', {
|
|
1346
|
+
intent: intentStr,
|
|
1347
|
+
confidence,
|
|
1348
|
+
latencyMs,
|
|
1349
|
+
});
|
|
1350
|
+
return {
|
|
1351
|
+
action: 'skip',
|
|
1352
|
+
confidence,
|
|
1353
|
+
riskScore,
|
|
1354
|
+
latencyMs,
|
|
1355
|
+
jevIntentString: intentStr,
|
|
1356
|
+
skipReason: 'unmapped-intent',
|
|
1357
|
+
};
|
|
1358
|
+
}
|
|
1359
|
+
// Adopt the verdict. REJECT here is a policy-driven REJECT (not the
|
|
1360
|
+
// risk-score gate handled above) — for intents like '投诉' / 'legal'
|
|
1361
|
+
// mapped to REJECT in JEV_INTENT_MAP_DEFAULT. Security-level audit
|
|
1362
|
+
// mirrors the risk-gate path so SIEM picks both up uniformly.
|
|
1363
|
+
const targetRealm = mapped === 'LOCAL' ? 'local'
|
|
1364
|
+
: mapped === 'CLOUD' ? 'cloud'
|
|
1365
|
+
: mapped === 'REJECT' ? 'rejected'
|
|
1366
|
+
: 'sanitized';
|
|
1367
|
+
if (mapped === 'REJECT') {
|
|
1368
|
+
audit('security', 'intent.rules.rejected', {
|
|
1369
|
+
source: 'jev',
|
|
1370
|
+
intent: intentStr,
|
|
1371
|
+
confidence,
|
|
1372
|
+
riskScore,
|
|
1373
|
+
textLen: text.length,
|
|
1374
|
+
});
|
|
1375
|
+
return {
|
|
1376
|
+
action: 'reject',
|
|
1377
|
+
intent: {
|
|
1378
|
+
category: 'REJECT',
|
|
1379
|
+
confidence,
|
|
1380
|
+
targetRealm,
|
|
1381
|
+
rationale: `jev-intent:${intentStr}`,
|
|
1382
|
+
},
|
|
1383
|
+
jevIntentString: intentStr,
|
|
1384
|
+
confidence,
|
|
1385
|
+
riskScore,
|
|
1386
|
+
latencyMs,
|
|
1387
|
+
};
|
|
1388
|
+
}
|
|
1389
|
+
const adoptedIntent = {
|
|
1390
|
+
category: mapped,
|
|
1391
|
+
confidence,
|
|
1392
|
+
targetRealm,
|
|
1393
|
+
rationale: `jev-intent:${intentStr}`,
|
|
1394
|
+
};
|
|
1395
|
+
audit('info', 'intent.jev.adopt', {
|
|
1396
|
+
intent: intentStr,
|
|
1397
|
+
realm: mapped,
|
|
1398
|
+
confidence,
|
|
1399
|
+
riskScore,
|
|
1400
|
+
latencyMs,
|
|
1401
|
+
});
|
|
1402
|
+
return {
|
|
1403
|
+
action: 'adopt',
|
|
1404
|
+
intent: adoptedIntent,
|
|
1405
|
+
jevIntentString: intentStr,
|
|
1406
|
+
confidence,
|
|
1407
|
+
riskScore,
|
|
1408
|
+
latencyMs,
|
|
1409
|
+
};
|
|
1410
|
+
}
|
|
1411
|
+
/** §7.92.5 (2026-09-23): jev HTTP prefilter — batched orchestrator.
|
|
1412
|
+
* Splits the full `categories[].jevIntents[]` payload into ≤16-size
|
|
1413
|
+
* batches (jev v0.3.0 hard cap from `head_max_len=256`), fires all
|
|
1414
|
+
* batches concurrently via `Promise.all`, and merges the verdicts
|
|
1415
|
+
* with a max-confidence pick:
|
|
1416
|
+
*
|
|
1417
|
+
* 1. Any `reject` action → pick highest `riskScore` (security signal)
|
|
1418
|
+
* 2. Else any `adopt` action → pick highest `confidence`; tie-break
|
|
1419
|
+
* by earlier batch index (REJECT-priority intents live in
|
|
1420
|
+
* batch 0, so the security-first ordering is preserved on ties)
|
|
1421
|
+
* 3. Else all skipped → return the first skip result
|
|
1422
|
+
*
|
|
1423
|
+
* Returns one of:
|
|
1424
|
+
* - `action: 'reject'` → `risk_score >= jevRiskThreshold` (security-level)
|
|
1425
|
+
* - `action: 'adopt'` → `data.action === 'execute'`, confidence ≥
|
|
1426
|
+
* threshold, intent string found in jevIntentMap
|
|
1427
|
+
* (mapped to realm)
|
|
1428
|
+
* - `action: 'skip'` → any other condition (low-conf, fallback_llm,
|
|
1429
|
+
* unrecognized intent, transport / parse error).
|
|
1430
|
+
* Caller falls through to classifyWithLlm.
|
|
1431
|
+
*
|
|
1432
|
+
* Failure handling: every per-batch error path (timeout, network,
|
|
1433
|
+
* parse) is audited at warn level and produces `action: 'skip'` for
|
|
1434
|
+
* THAT batch — jev being unavailable MUST NOT degrade the existing
|
|
1435
|
+
* LLM classification path. This is the same fail-open contract used
|
|
1436
|
+
* by classifyWithLlm's classifyWithRules fallback.
|
|
1437
|
+
*
|
|
1438
|
+
* See ~/work/jev-branch/docs/api_reference.md for the wire spec.
|
|
1439
|
+
* Implementation notes:
|
|
1440
|
+
* - Uses global `fetch` (Node ≥18). AbortSignal for timeout.
|
|
1441
|
+
* - No auth header — jev is local-network by design.
|
|
1442
|
+
* - `metadata.session_id` for cross-service trace correlation.
|
|
1443
|
+
* - `lang` hardcoded 'zh' (we don't auto-detect; the operator's UI
|
|
1444
|
+
* can pass it later if multilingual queries become common). */
|
|
1445
|
+
/** §7.112: headers for every jev HTTP surface (predict / health /
|
|
1446
|
+
* systemone). Content-Type always; Authorization only when a key is
|
|
1447
|
+
* configured — Bailian MaaS requires it, the local ONNX service never
|
|
1448
|
+
* did. */
|
|
1449
|
+
jevHeaders() {
|
|
1450
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
1451
|
+
if (this.jevApiKey) {
|
|
1452
|
+
headers.Authorization = this.jevAuthScheme === 'bearer' ? `Bearer ${this.jevApiKey}` : this.jevApiKey;
|
|
1453
|
+
}
|
|
1454
|
+
return headers;
|
|
1455
|
+
}
|
|
1456
|
+
/** §7.112 (2026-10-25): Bailian decision-model backend for the jev stage.
|
|
1457
|
+
*
|
|
1458
|
+
* One POST `${jevUrl}/systemone` replaces the batched /predict flow:
|
|
1459
|
+
* the structured-decision interface carries the whole taxonomy at once —
|
|
1460
|
+
* questions.intent = choice over categories (criteria = id → description)
|
|
1461
|
+
* questions.risk = score over 4 danger levels (→ risk_score 0..1)
|
|
1462
|
+
* so there is no 16-intent cap and no chitchat markers. Confidence/risk
|
|
1463
|
+
* gates, breaker semantics and audit rows mirror the local flow so
|
|
1464
|
+
* classify() cannot tell the backends apart. */
|
|
1465
|
+
async classifyWithBailianDecision(text, sessionId, jevStart) {
|
|
1466
|
+
// §7.147:这一路的审计行必须带 sessionId —— 否则行归不了属,Edge 面板
|
|
1467
|
+
// 的意图面板永远说不清「这一轮」(实测:jev 的 62 条 adopt/response 无一带
|
|
1468
|
+
// sessionId,全部落全局桶)。闭包一次带上,本函数内的 audit 都走它。
|
|
1469
|
+
//
|
|
1470
|
+
// sessionId 为空时**不写**这个键 ⇒ 归全局桶(而不是造一个假会话桶)。
|
|
1471
|
+
const audit = (level, kind, payload) => this.audit(level, kind, { ...sessionIdField(sessionId), ...payload });
|
|
1472
|
+
const criteria = buildJevIntentCriteria(this.categories);
|
|
1473
|
+
const body = {
|
|
1474
|
+
model: this.jevModel,
|
|
1475
|
+
state: {
|
|
1476
|
+
session_id: sessionId ?? 'default',
|
|
1477
|
+
content: text,
|
|
1478
|
+
},
|
|
1479
|
+
questions: {
|
|
1480
|
+
intent: {
|
|
1481
|
+
type: 'choice',
|
|
1482
|
+
instructions: JEV_INTENT_INSTRUCTIONS,
|
|
1483
|
+
criteria,
|
|
1484
|
+
},
|
|
1485
|
+
risk: {
|
|
1486
|
+
type: 'score',
|
|
1487
|
+
instructions: JEV_RISK_INSTRUCTIONS,
|
|
1488
|
+
criteria: [...JEV_RISK_CRITERIA],
|
|
1489
|
+
},
|
|
1490
|
+
},
|
|
1491
|
+
};
|
|
1492
|
+
const batchStart = Date.now();
|
|
1493
|
+
audit('debug', 'intent.jev.batch.start', {
|
|
1494
|
+
textLen: text.length,
|
|
1495
|
+
provider: 'bailian-decision',
|
|
1496
|
+
model: this.jevModel,
|
|
1497
|
+
batchLatencyBudgetMs: this.jevTimeoutMs,
|
|
1498
|
+
});
|
|
1499
|
+
// §7.133: jev 是裸 fetch,不走 ctx.llm —— 这里补一路 trace,
|
|
1500
|
+
// 否则面板看不到「用户原文被送去了百炼」这一事实。
|
|
1501
|
+
// §7.134: REQ/RESP 共用一个 callId(面板据此配成一对)。
|
|
1502
|
+
const jevCallId = nextCallId();
|
|
1503
|
+
this.traceJevRequest('bailian-decision', this.jevModel, `${this.jevUrl}${this.jevPath}`, sessionId, text, JEV_INTENT_INSTRUCTIONS, jevCallId,
|
|
1504
|
+
// §7.144: 完整请求体(含 questions 的分类体系与风险标准)。
|
|
1505
|
+
body);
|
|
1506
|
+
const controller = new AbortController();
|
|
1507
|
+
const timer = setTimeout(() => controller.abort(), this.jevTimeoutMs);
|
|
1508
|
+
let parsed = null;
|
|
1509
|
+
let jevBody = '';
|
|
1510
|
+
let jevError;
|
|
1511
|
+
try {
|
|
1512
|
+
const r = await fetch(`${this.jevUrl}${this.jevPath}`, {
|
|
1513
|
+
method: 'POST',
|
|
1514
|
+
headers: this.jevHeaders(),
|
|
1515
|
+
body: JSON.stringify(body),
|
|
1516
|
+
signal: controller.signal,
|
|
1517
|
+
});
|
|
1518
|
+
if (!r.ok) {
|
|
1519
|
+
this.stats.jevError++;
|
|
1520
|
+
audit('warn', 'intent.jev.error', {
|
|
1521
|
+
provider: 'bailian-decision',
|
|
1522
|
+
httpStatus: r.status,
|
|
1523
|
+
batchLatencyMs: Date.now() - batchStart,
|
|
1524
|
+
});
|
|
1525
|
+
this.jevBreakerOpenUntil = Date.now() + this.jevBreakerTtlMs;
|
|
1526
|
+
jevError = `HTTP ${r.status}`;
|
|
1527
|
+
return { action: 'skip', confidence: 0, riskScore: 0, latencyMs: Date.now() - jevStart, skipReason: 'jev-error' };
|
|
1528
|
+
}
|
|
1529
|
+
jevBody = await r.text();
|
|
1530
|
+
parsed = JSON.parse(jevBody);
|
|
1531
|
+
}
|
|
1532
|
+
catch (err) {
|
|
1533
|
+
const isAbort = err?.name === 'AbortError' || controller.signal.aborted;
|
|
1534
|
+
const latencyMs = Date.now() - jevStart;
|
|
1535
|
+
if (isAbort)
|
|
1536
|
+
this.stats.jevTimeout++;
|
|
1537
|
+
else
|
|
1538
|
+
this.stats.jevError++;
|
|
1539
|
+
audit('warn', isAbort ? 'intent.jev.timeout' : 'intent.jev.error', {
|
|
1540
|
+
provider: 'bailian-decision',
|
|
1541
|
+
latencyMs,
|
|
1542
|
+
timeoutMs: this.jevTimeoutMs,
|
|
1543
|
+
error: err?.message ?? String(err),
|
|
1544
|
+
});
|
|
1545
|
+
this.jevBreakerOpenUntil = Date.now() + this.jevBreakerTtlMs;
|
|
1546
|
+
jevError = isAbort ? 'timeout' : (err?.message ?? String(err)).slice(0, 200);
|
|
1547
|
+
return {
|
|
1548
|
+
action: 'skip',
|
|
1549
|
+
confidence: 0,
|
|
1550
|
+
riskScore: 0,
|
|
1551
|
+
latencyMs,
|
|
1552
|
+
skipReason: isAbort ? 'jev-timeout' : 'jev-error',
|
|
1553
|
+
};
|
|
1554
|
+
}
|
|
1555
|
+
finally {
|
|
1556
|
+
clearTimeout(timer);
|
|
1557
|
+
// §7.133: 失败也要有 response trace —— 否则面板只剩「发出去了」而
|
|
1558
|
+
// 没有「回来没有 / 为什么没回来」。
|
|
1559
|
+
this.traceJevResponse('bailian-decision', this.jevModel, sessionId, jevBody, Date.now() - batchStart, jevError, jevCallId,
|
|
1560
|
+
// §7.144: 完整响应体(jev 回的整段 JSON:answers / usage / request_id)。
|
|
1561
|
+
jevBody);
|
|
1562
|
+
}
|
|
1563
|
+
// Success → the backend answered; close the breaker (mirrors the
|
|
1564
|
+
// batched flow's "any successful batch closes it").
|
|
1565
|
+
this.jevBreakerOpenUntil = 0;
|
|
1566
|
+
// §7.112.1 (2026-10-25 实测 shape): answers.intent = {type:'choice',
|
|
1567
|
+
// choice:'<criteria key>', confidence, probabilities}; answers.risk =
|
|
1568
|
+
// {type:'score', score:<0..3 连续>, legend, confidence}. score/3 →
|
|
1569
|
+
// 0..1 接回 jevRiskThreshold(0.7 ≈ score 2.1 起 REJECT)。
|
|
1570
|
+
const answers = extractDecisionAnswers(parsed, ['intent', 'risk']);
|
|
1571
|
+
const intentAns = answers?.intent;
|
|
1572
|
+
const riskAns = answers?.risk;
|
|
1573
|
+
let intentStr = null;
|
|
1574
|
+
// §7.123 (2026-10-27): `confidenceReported` tracks whether the backend
|
|
1575
|
+
// actually sent a numeric confidence. The 0.9 fallback below is a
|
|
1576
|
+
// PLACEHOLDER ("the model had to pick exactly one criterion"), not a
|
|
1577
|
+
// measurement — it must not be able to unlock the semantic-trust gate.
|
|
1578
|
+
let confidenceReported = false;
|
|
1579
|
+
let confidence = 0.9; // decisive-by-construction: the model must pick exactly one criterion
|
|
1580
|
+
if (typeof intentAns === 'string') {
|
|
1581
|
+
intentStr = intentAns.toUpperCase();
|
|
1582
|
+
}
|
|
1583
|
+
else if (intentAns && typeof intentAns === 'object') {
|
|
1584
|
+
intentStr = typeof intentAns.choice === 'string' ? intentAns.choice.toUpperCase() : null;
|
|
1585
|
+
if (typeof intentAns.confidence === 'number') {
|
|
1586
|
+
confidence = Math.max(0, Math.min(1, intentAns.confidence));
|
|
1587
|
+
confidenceReported = true;
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
let rawRisk = 0;
|
|
1591
|
+
if (typeof riskAns === 'number')
|
|
1592
|
+
rawRisk = riskAns;
|
|
1593
|
+
else if (riskAns && typeof riskAns === 'object' && typeof riskAns.score === 'number') {
|
|
1594
|
+
rawRisk = riskAns.score;
|
|
1595
|
+
}
|
|
1596
|
+
const riskScore = Math.max(0, Math.min(1, rawRisk / 3));
|
|
1597
|
+
const latencyMs = typeof parsed?.latency_ms === 'number' ? parsed.latency_ms : Date.now() - jevStart;
|
|
1598
|
+
this.stats.jevLatencyMsTotal += latencyMs;
|
|
1599
|
+
audit('info', 'intent.jev.response', {
|
|
1600
|
+
provider: 'bailian-decision',
|
|
1601
|
+
model: this.jevModel,
|
|
1602
|
+
intent: intentStr,
|
|
1603
|
+
confidence,
|
|
1604
|
+
riskScore,
|
|
1605
|
+
latencyMs,
|
|
1606
|
+
});
|
|
1607
|
+
// Risk gate — same semantics as the local flow's risk_score gate.
|
|
1608
|
+
if (riskScore >= this.jevRiskThreshold) {
|
|
1609
|
+
audit('security', 'intent.rules.rejected', {
|
|
1610
|
+
source: 'jev-bailian-decision',
|
|
1611
|
+
intent: intentStr,
|
|
1612
|
+
riskScore,
|
|
1613
|
+
confidence,
|
|
1614
|
+
textLen: text.length,
|
|
1615
|
+
});
|
|
1616
|
+
return {
|
|
1617
|
+
action: 'reject',
|
|
1618
|
+
confidence,
|
|
1619
|
+
riskScore,
|
|
1620
|
+
latencyMs,
|
|
1621
|
+
jevIntentString: intentStr ?? undefined,
|
|
1622
|
+
};
|
|
1623
|
+
}
|
|
1624
|
+
// §7.115 directional confidence gate: resolve the mapped category FIRST,
|
|
1625
|
+
// then split the floor by direction — LOCAL is the fail-safe default
|
|
1626
|
+
// (§7.107), so a LOCAL-leaning verdict adopts at jev.localAdoptFloor
|
|
1627
|
+
// (default 0.5) instead of paying the LLM round-trip for a second
|
|
1628
|
+
// opinion; non-LOCAL (data may leave the device) keeps the strict
|
|
1629
|
+
// jevConfidenceThreshold.
|
|
1630
|
+
// §7.115.1: LOCAL is the default category — it is adoptable even when
|
|
1631
|
+
// the §7.110-simplified taxonomy ships no LOCAL row (the criteria
|
|
1632
|
+
// catch-all above offered it as a choice; the verdict must be adoptable).
|
|
1633
|
+
const mapped = intentStr === 'LOCAL'
|
|
1634
|
+
? 'LOCAL'
|
|
1635
|
+
: this.categories.find((c) => c.id === intentStr)?.id;
|
|
1636
|
+
if (!intentStr || !mapped) {
|
|
1637
|
+
audit('info', 'intent.jev.unmapped_intent', {
|
|
1638
|
+
provider: 'bailian-decision',
|
|
1639
|
+
intent: intentStr,
|
|
1640
|
+
confidence,
|
|
1641
|
+
latencyMs,
|
|
1642
|
+
});
|
|
1643
|
+
return {
|
|
1644
|
+
action: 'skip',
|
|
1645
|
+
confidence,
|
|
1646
|
+
riskScore,
|
|
1647
|
+
latencyMs,
|
|
1648
|
+
jevIntentString: intentStr ?? undefined,
|
|
1649
|
+
skipReason: 'unmapped-intent',
|
|
1650
|
+
};
|
|
1651
|
+
}
|
|
1652
|
+
const adoptFloor = mapped === 'LOCAL' ? this.jevLocalAdoptFloor : this.jevConfidenceThreshold;
|
|
1653
|
+
if (confidence < adoptFloor) {
|
|
1654
|
+
audit('info', 'intent.jev.low_confidence', {
|
|
1655
|
+
provider: 'bailian-decision',
|
|
1656
|
+
intent: intentStr,
|
|
1657
|
+
confidence,
|
|
1658
|
+
threshold: adoptFloor,
|
|
1659
|
+
latencyMs,
|
|
1660
|
+
});
|
|
1661
|
+
return {
|
|
1662
|
+
action: 'skip',
|
|
1663
|
+
confidence,
|
|
1664
|
+
riskScore,
|
|
1665
|
+
latencyMs,
|
|
1666
|
+
jevIntentString: intentStr ?? undefined,
|
|
1667
|
+
skipReason: 'low-confidence',
|
|
1668
|
+
};
|
|
1669
|
+
}
|
|
1670
|
+
const targetRealm = mapped === 'LOCAL' ? 'local'
|
|
1671
|
+
: mapped === 'CLOUD' ? 'cloud'
|
|
1672
|
+
: mapped === 'REJECT' ? 'rejected'
|
|
1673
|
+
: 'sanitized';
|
|
1674
|
+
if (mapped === 'REJECT') {
|
|
1675
|
+
audit('security', 'intent.rules.rejected', {
|
|
1676
|
+
source: 'jev-bailian-decision',
|
|
1677
|
+
intent: intentStr,
|
|
1678
|
+
confidence,
|
|
1679
|
+
riskScore,
|
|
1680
|
+
textLen: text.length,
|
|
1681
|
+
});
|
|
1682
|
+
return {
|
|
1683
|
+
action: 'reject',
|
|
1684
|
+
intent: {
|
|
1685
|
+
category: 'REJECT',
|
|
1686
|
+
confidence,
|
|
1687
|
+
targetRealm,
|
|
1688
|
+
rationale: `jev-intent:${intentStr}`,
|
|
1689
|
+
},
|
|
1690
|
+
jevIntentString: intentStr,
|
|
1691
|
+
confidence,
|
|
1692
|
+
riskScore,
|
|
1693
|
+
latencyMs,
|
|
1694
|
+
};
|
|
1695
|
+
}
|
|
1696
|
+
audit('info', 'intent.jev.adopt', {
|
|
1697
|
+
provider: 'bailian-decision',
|
|
1698
|
+
intent: intentStr,
|
|
1699
|
+
realm: mapped,
|
|
1700
|
+
confidence,
|
|
1701
|
+
riskScore,
|
|
1702
|
+
latencyMs,
|
|
1703
|
+
});
|
|
1704
|
+
return {
|
|
1705
|
+
action: 'adopt',
|
|
1706
|
+
intent: {
|
|
1707
|
+
category: mapped,
|
|
1708
|
+
confidence,
|
|
1709
|
+
targetRealm,
|
|
1710
|
+
rationale: `jev-intent:${intentStr}`,
|
|
1711
|
+
// §7.123: carry the confidence provenance so the §7.109 semantic
|
|
1712
|
+
// gate can tell a measured confidence from the synthetic 0.9.
|
|
1713
|
+
confidenceReported,
|
|
1714
|
+
},
|
|
1715
|
+
jevIntentString: intentStr,
|
|
1716
|
+
confidence,
|
|
1717
|
+
riskScore,
|
|
1718
|
+
latencyMs,
|
|
1719
|
+
};
|
|
1720
|
+
}
|
|
1721
|
+
async classifyWithJev(text, sessionId) {
|
|
1722
|
+
// §7.147:这一路的审计行必须带 sessionId —— 否则行归不了属,Edge 面板
|
|
1723
|
+
// 的意图面板永远说不清「这一轮」(实测:jev 的 62 条 adopt/response 无一带
|
|
1724
|
+
// sessionId,全部落全局桶)。闭包一次带上,本函数内的 audit 都走它。
|
|
1725
|
+
//
|
|
1726
|
+
// sessionId 为空时**不写**这个键 ⇒ 归全局桶(而不是造一个假会话桶)。
|
|
1727
|
+
const audit = (level, kind, payload) => this.audit(level, kind, { ...sessionIdField(sessionId), ...payload });
|
|
1728
|
+
// §7.92.6 (2026-09-24): circuit breaker — short-circuit if a
|
|
1729
|
+
// previous classify() within the TTL window saw all 3 batches
|
|
1730
|
+
// fail. Saves up to ~3 × jevTimeoutMs = 7500ms wall-clock on every
|
|
1731
|
+
// chat when jev is dead. Operators see "circuit open" once in
|
|
1732
|
+
// audit, then this debug-level skip per call until recovery.
|
|
1733
|
+
if (Date.now() < this.jevBreakerOpenUntil) {
|
|
1734
|
+
this.stats.jevCircuitSkip++;
|
|
1735
|
+
audit('debug', 'intent.jev.circuit_open_skip', {
|
|
1736
|
+
textLen: text.length,
|
|
1737
|
+
jevUrl: this.jevUrl,
|
|
1738
|
+
openUntilMs: this.jevBreakerOpenUntil,
|
|
1739
|
+
remainingMs: this.jevBreakerOpenUntil - Date.now(),
|
|
1740
|
+
});
|
|
1741
|
+
return {
|
|
1742
|
+
action: 'skip',
|
|
1743
|
+
confidence: 0,
|
|
1744
|
+
riskScore: 0,
|
|
1745
|
+
latencyMs: 0,
|
|
1746
|
+
skipReason: 'jev-circuit-open',
|
|
1747
|
+
};
|
|
1748
|
+
}
|
|
1749
|
+
const jevStart = Date.now();
|
|
1750
|
+
// §7.112: Bailian decision-model backend — one /systemone call replaces
|
|
1751
|
+
// the batched /predict flow (no 16-intent cap, no chitchat markers).
|
|
1752
|
+
// Verdict / gates / breaker semantics are mirrored so classify() cannot
|
|
1753
|
+
// tell the backends apart.
|
|
1754
|
+
if (this.jevProvider === 'bailian-decision') {
|
|
1755
|
+
return this.classifyWithBailianDecision(text, sessionId, jevStart);
|
|
1756
|
+
}
|
|
1757
|
+
const allIntents = this.buildJevIntentsPayload();
|
|
1758
|
+
const chitchatIntents = ['闲聊', '其他']; // §7.92.5: defensive marker for jev v0.3.0+
|
|
1759
|
+
const batches = chunkIntents(allIntents, JEV_MAX_INTENTS);
|
|
1760
|
+
audit('debug', 'intent.jev.start', {
|
|
1761
|
+
textLen: text.length,
|
|
1762
|
+
jevUrl: this.jevUrl,
|
|
1763
|
+
timeoutMs: this.jevTimeoutMs,
|
|
1764
|
+
batchCount: batches.length,
|
|
1765
|
+
totalIntents: allIntents.length,
|
|
1766
|
+
});
|
|
1767
|
+
// Fire all batches concurrently. Per-batch timeout is still
|
|
1768
|
+
// `this.jevTimeoutMs` — wall-clock is bounded by the slowest batch.
|
|
1769
|
+
const settled = await Promise.all(batches.map((intents) => this.fetchJevBatch(text, sessionId, intents, chitchatIntents, jevStart)));
|
|
1770
|
+
// §7.92.6: breaker state machine. "All 3 batches failed" = every
|
|
1771
|
+
// settled result has skipReason ∈ {'jev-error', 'jev-timeout'}.
|
|
1772
|
+
// Any successful batch (HTTP 2xx + parse OK, even action=
|
|
1773
|
+
// 'fallback_llm') means jev is reachable and we close the breaker.
|
|
1774
|
+
const allBatchesFailed = settled.every((r) => r.skipReason === 'jev-error' || r.skipReason === 'jev-timeout');
|
|
1775
|
+
if (allBatchesFailed) {
|
|
1776
|
+
this.jevBreakerOpenUntil = Date.now() + this.jevBreakerTtlMs;
|
|
1777
|
+
this.stats.jevCircuitOpen++;
|
|
1778
|
+
audit('warn', 'intent.jev.circuit_open', {
|
|
1779
|
+
openUntilMs: this.jevBreakerOpenUntil,
|
|
1780
|
+
ttlMs: this.jevBreakerTtlMs,
|
|
1781
|
+
jevUrl: this.jevUrl,
|
|
1782
|
+
batchCount: settled.length,
|
|
1783
|
+
});
|
|
1784
|
+
// §7.92.7 (2026-09-24): start the recovery probe so we don't have
|
|
1785
|
+
// to wait for full TTL once jev comes back. No-op when a probe
|
|
1786
|
+
// timer is already running (e.g. breaker re-opened by classify()
|
|
1787
|
+
// racing the timer's own check).
|
|
1788
|
+
this.startBreakerProbe();
|
|
1789
|
+
}
|
|
1790
|
+
else {
|
|
1791
|
+
// Healthy response — clear the breaker. Any subsequent classify()
|
|
1792
|
+
// within the original TTL window that previously would have been
|
|
1793
|
+
// skipped will now hit jev normally. Also stop the recovery
|
|
1794
|
+
// probe timer — no longer needed.
|
|
1795
|
+
this.jevBreakerOpenUntil = 0;
|
|
1796
|
+
this.stopBreakerProbe();
|
|
1797
|
+
}
|
|
1798
|
+
// Max-confidence merge. Priority order:
|
|
1799
|
+
// 1. Any reject → pick highest risk_score (security signal)
|
|
1800
|
+
// 2. Else any adopt → pick highest confidence; tie-break by batch
|
|
1801
|
+
// index (earlier batches contain REJECT-priority intents, so
|
|
1802
|
+
// they're preferred on ties — i.e. SECURITY-first tie-break)
|
|
1803
|
+
// 3. Else all skipped → return the first skip
|
|
1804
|
+
let best = null;
|
|
1805
|
+
for (let i = 0; i < settled.length; i++) {
|
|
1806
|
+
const r = settled[i];
|
|
1807
|
+
if (!best) {
|
|
1808
|
+
best = r;
|
|
1809
|
+
continue;
|
|
1810
|
+
}
|
|
1811
|
+
if (r.action === 'reject' && (best.action !== 'reject' || r.riskScore > best.riskScore)) {
|
|
1812
|
+
best = r;
|
|
1813
|
+
}
|
|
1814
|
+
else if (r.action === 'adopt' && best.action !== 'reject') {
|
|
1815
|
+
if (best.action !== 'adopt' || r.confidence > best.confidence)
|
|
1816
|
+
best = r;
|
|
1817
|
+
}
|
|
1818
|
+
else if (r.action === 'skip' && best.action === 'skip' && !best.skipReason) {
|
|
1819
|
+
best = r;
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
return best ?? {
|
|
1823
|
+
action: 'skip',
|
|
1824
|
+
confidence: 0,
|
|
1825
|
+
riskScore: 0,
|
|
1826
|
+
latencyMs: Date.now() - jevStart,
|
|
1827
|
+
skipReason: 'all-batches-empty',
|
|
1828
|
+
};
|
|
1829
|
+
}
|
|
1830
|
+
async classifyWithLlm(text, recentMessages, sessionId,
|
|
1831
|
+
// §7.179:调用方(classify)已经解析过「这一轮真正会跑的模型」⇒ 直接
|
|
1832
|
+
// 传下来,避免同一次分类里重复解析(探针步会触发一次 listModels)。
|
|
1833
|
+
effective) {
|
|
1834
|
+
if (!this.llm) {
|
|
1835
|
+
this.audit('debug', 'intent.llm.skipped', { reason: 'no-llm-handle' });
|
|
1836
|
+
throw new Error('intent-router: no llm handle');
|
|
1837
|
+
}
|
|
1838
|
+
// NEW: route the classification LLM call through model-router if mounted.
|
|
1839
|
+
// Lazy-lookup on every call (not cached) because root cordis.yml declares
|
|
1840
|
+
// intent-router BEFORE model-router — the service may not exist at the
|
|
1841
|
+
// first classify() but will by the next one. Soft dependency: missing or
|
|
1842
|
+
// throwing modelRouter falls back to config values (fail-open).
|
|
1843
|
+
// ⚠️ classificationProvider MUST be a provider route id (with 'local-' /
|
|
1844
|
+
// 'cloud-' prefix); bare model ids resolve to realm: 'CLOUD' (see
|
|
1845
|
+
// model-router.ts:103). Current default 'local-llm' is fine.
|
|
1846
|
+
//
|
|
1847
|
+
// §7.54 (2026-09-14): model-router maps provider ids to themselves
|
|
1848
|
+
// (e.g. 'local-llm' → {provider:'local-llm', model:'local-llm'}). The
|
|
1849
|
+
// wire body wants the actual upstream model name (classificationModel,
|
|
1850
|
+
// typically qwen3.7-plus). When router returns provider==model, fall
|
|
1851
|
+
// back to classificationModel so the adapter receives the right wire
|
|
1852
|
+
// model id (routing-key-only labels like 'local-llm' break upstream
|
|
1853
|
+
// model lookup and produce inconsistent ops log lines).
|
|
1854
|
+
const modelRouter = this.softService('modelRouter');
|
|
1855
|
+
const resolved = (() => {
|
|
1856
|
+
try {
|
|
1857
|
+
return modelRouter?.resolve?.(this.classificationProvider);
|
|
1858
|
+
}
|
|
1859
|
+
catch {
|
|
1860
|
+
return undefined;
|
|
1861
|
+
}
|
|
1862
|
+
})();
|
|
1863
|
+
const provider = resolved?.provider ?? this.classificationProvider;
|
|
1864
|
+
// §7.179:请求里的 model 必须是**真正会跑**的那个(DELEGATE 下 = 委托
|
|
1865
|
+
// 解析出来的宿主模型),不是停在 Schema 默认的 `qwen3-4b`。口径与
|
|
1866
|
+
// effective-model.ts 一致,与 local-llm-adapter 共用同一条解析链。
|
|
1867
|
+
const eff = effective ?? await resolveEffectiveLocalModel(this.ctx, this.classificationModel, this.classificationProvider);
|
|
1868
|
+
const requestedModel = eff.model;
|
|
1869
|
+
const resolvedModel = (resolved && resolved.model === resolved.provider) ? requestedModel : resolved?.model;
|
|
1870
|
+
const model = resolvedModel ?? requestedModel;
|
|
1871
|
+
this.audit('debug', 'intent.llm.route', {
|
|
1872
|
+
configured: this.classificationProvider,
|
|
1873
|
+
resolvedRealm: resolved?.realm,
|
|
1874
|
+
resolvedProvider: provider,
|
|
1875
|
+
resolvedModel: model,
|
|
1876
|
+
modelProvider: eff.provider ?? this.classificationProvider,
|
|
1877
|
+
modelSource: eff.source,
|
|
1878
|
+
modelRouterMounted: Boolean(modelRouter),
|
|
1879
|
+
});
|
|
1880
|
+
// NEW: sanitize user text BEFORE the LLM prompt when the classification
|
|
1881
|
+
// LLM lives in the CLOUD realm. This prevents PII (phone/id/bank/email/
|
|
1882
|
+
// api-key) from leaving the device just to ask "is this LOCAL or CLOUD".
|
|
1883
|
+
// Policy: if sanitizer is unavailable or throws, do NOT send raw text to
|
|
1884
|
+
// the cloud LLM — throw so the outer classifyWithLlm catches and falls
|
|
1885
|
+
// through to classifyWithRules (which can produce LOCAL via policy rules
|
|
1886
|
+
// or keyword fallback). "脱不了敏就不上云".
|
|
1887
|
+
//
|
|
1888
|
+
// Sanitizer is lifted to this scope (not block-scoped inside the if)
|
|
1889
|
+
// so the multi-turn history builder below can reuse it — recentMessages
|
|
1890
|
+
// may contain PII from earlier turns that must also be sanitized before
|
|
1891
|
+
// reaching the cloud LLM. (2026-08-24 P0 fix)
|
|
1892
|
+
let promptText = text;
|
|
1893
|
+
const resolvedRealm = resolved?.realm;
|
|
1894
|
+
let sanitizer;
|
|
1895
|
+
if (resolvedRealm === 'CLOUD') {
|
|
1896
|
+
sanitizer = this.softService('sanitizer');
|
|
1897
|
+
if (sanitizer && typeof sanitizer.sanitize === 'function') {
|
|
1898
|
+
try {
|
|
1899
|
+
const result = await sanitizer.sanitize(text, { sessionId: sessionId ?? 'default' });
|
|
1900
|
+
const replacementCount = Array.isArray(result?.replacements) ? result.replacements.length : 0;
|
|
1901
|
+
promptText = result?.sanitizedText ?? text;
|
|
1902
|
+
this.stats.sanitized++;
|
|
1903
|
+
this.audit('info', 'intent.llm.sanitized', {
|
|
1904
|
+
sessionId: sessionId ?? 'default',
|
|
1905
|
+
originalLen: text.length,
|
|
1906
|
+
sanitizedLen: promptText.length,
|
|
1907
|
+
replacements: replacementCount,
|
|
1908
|
+
});
|
|
1909
|
+
}
|
|
1910
|
+
catch (err) {
|
|
1911
|
+
this.stats.sanitizeError++;
|
|
1912
|
+
// SECURITY-level: PII failed to be stripped from text that the
|
|
1913
|
+
// router would have forwarded to a CLOUD LLM. SIEM must surface.
|
|
1914
|
+
this.audit('security', 'intent.llm.sanitize-error', { sessionId: sessionId ?? '',
|
|
1915
|
+
error: err?.message ?? String(err),
|
|
1916
|
+
originalLen: text.length,
|
|
1917
|
+
});
|
|
1918
|
+
// Fail-closed: throw so classifyWithLlm catches → classifyWithRules
|
|
1919
|
+
const e = new Error('intent-router: cloud sanitization failed');
|
|
1920
|
+
e._intentRouterReason = 'sanitize-failed';
|
|
1921
|
+
throw e;
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
else {
|
|
1925
|
+
this.stats.sanitizeSkipped++;
|
|
1926
|
+
this.audit('security', 'intent.llm.sanitize-unavailable', { sessionId: sessionId ?? '', reason: 'no-sanitizer-handle' });
|
|
1927
|
+
// Fail-closed: throw so classifyWithLlm catches → classifyWithRules
|
|
1928
|
+
const e = new Error('intent-router: cloud sanitization unavailable');
|
|
1929
|
+
e._intentRouterReason = 'sanitize-unavailable';
|
|
1930
|
+
throw e;
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
// Multi-turn context: best-effort extract text from each message and
|
|
1934
|
+
// append a "history" block before the current query. Caps at
|
|
1935
|
+
// recentMessagesLimit (default 3) and per-message length to bound prompt size.
|
|
1936
|
+
//
|
|
1937
|
+
// P0 (2026-08-24): when the classification LLM is in CLOUD realm, each
|
|
1938
|
+
// history message must also be sanitized before inclusion. History may
|
|
1939
|
+
// contain PII from earlier turns (user shared a phone number in turn 1,
|
|
1940
|
+
// now asks a follow-up in turn 4) — sending raw history to the cloud
|
|
1941
|
+
// LLM would leak PII just as much as sending the current message raw.
|
|
1942
|
+
// Fail-closed per message: if sanitize throws for a history entry, that
|
|
1943
|
+
// entry is OMITTED rather than sent raw. The current message was already
|
|
1944
|
+
// fail-closed above if sanitizer is broken, so reaching here with
|
|
1945
|
+
// resolvedRealm === 'CLOUD' implies sanitizer is present.
|
|
1946
|
+
const historyLines = [];
|
|
1947
|
+
if (this.recentMessagesLimit && recentMessages?.length) {
|
|
1948
|
+
const tail = recentMessages.slice(-this.recentMessagesLimit);
|
|
1949
|
+
let historySanitizedCount = 0;
|
|
1950
|
+
for (const m of tail) {
|
|
1951
|
+
let msgText = extractMessageText(m).slice(0, 200);
|
|
1952
|
+
if (!msgText)
|
|
1953
|
+
continue;
|
|
1954
|
+
const role = m?.role ?? m?.source?.kind ?? '?';
|
|
1955
|
+
if (resolvedRealm === 'CLOUD' && sanitizer && typeof sanitizer.sanitize === 'function') {
|
|
1956
|
+
try {
|
|
1957
|
+
const r = await sanitizer.sanitize(msgText, { sessionId: sessionId ?? 'default' });
|
|
1958
|
+
msgText = r?.sanitizedText ?? msgText;
|
|
1959
|
+
if (Array.isArray(r?.replacements) && r.replacements.length > 0)
|
|
1960
|
+
historySanitizedCount++;
|
|
1961
|
+
}
|
|
1962
|
+
catch (err) {
|
|
1963
|
+
// Fail-closed for this history entry: omit rather than leak raw PII.
|
|
1964
|
+
// The current message has already been sanitized (or we would have
|
|
1965
|
+
// thrown above), so only this particular history entry is skipped.
|
|
1966
|
+
this.audit('warn', 'intent.llm.history-sanitize-error', { sessionId: sessionId ?? '',
|
|
1967
|
+
error: err?.message ?? String(err),
|
|
1968
|
+
role,
|
|
1969
|
+
});
|
|
1970
|
+
continue;
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
historyLines.push(`${role}: ${msgText}`);
|
|
1974
|
+
}
|
|
1975
|
+
if (historySanitizedCount > 0) {
|
|
1976
|
+
this.audit('info', 'intent.llm.history-sanitized', {
|
|
1977
|
+
sessionId: sessionId ?? 'default',
|
|
1978
|
+
sanitizedMessageCount: historySanitizedCount,
|
|
1979
|
+
totalMessages: historyLines.length,
|
|
1980
|
+
});
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
const historyBlock = historyLines.length > 0
|
|
1984
|
+
? `\n最近对话上下文(仅参考,不要分类历史消息本身):\n${historyLines.join('\n')}\n`
|
|
1985
|
+
: '';
|
|
1986
|
+
if (recentMessages?.length) {
|
|
1987
|
+
this.audit('debug', 'intent.llm.context', {
|
|
1988
|
+
received: recentMessages.length,
|
|
1989
|
+
embedded: Math.min(recentMessages.length, this.recentMessagesLimit),
|
|
1990
|
+
});
|
|
1991
|
+
}
|
|
1992
|
+
// Phase B: ask for structured JSON output with explicit confidence.
|
|
1993
|
+
// Below the configured threshold we fall back to keyword rules
|
|
1994
|
+
// (fail-open; we do NOT reject — see plan Phase B).
|
|
1995
|
+
//
|
|
1996
|
+
// Confidence calibration (2026-08-25): qwen3-4b tends to always output
|
|
1997
|
+
// 0.98-0.99 regardless of actual certainty, making the 0.6 gate
|
|
1998
|
+
// ineffective. Adding explicit confidence semantics to the prompt so
|
|
1999
|
+
// the model learns to differentiate clear vs ambiguous requests.
|
|
2000
|
+
//
|
|
2001
|
+
// §7.92.3 (2026-09-23): category list is built from
|
|
2002
|
+
// `this.categories` (loaded from config/privacy-policy.json via the
|
|
2003
|
+
// privacy-policy service, hot-reloadable). Replaces the previous
|
|
2004
|
+
// hardcoded 2-category block — now the LLM sees ALL of LOCAL/CLOUD/
|
|
2005
|
+
// SANITIZED/REJECT, matching jev's 4-category capability and giving
|
|
2006
|
+
// the LLM a path to surface SANITIZED/REJECT verdicts without a
|
|
2007
|
+
// separate rules pass. Order matches categories[] order in the
|
|
2008
|
+
// policy file so operators can control priority by reordering rows.
|
|
2009
|
+
// §7.110 (2026-10-24): 默认本地 —— the operator's taxonomy only needs to
|
|
2010
|
+
// POSITIVELY define the exceptions (CLOUD / REJECT); LOCAL is the
|
|
2011
|
+
// catch-all default and may ship no row at all. When the policy has no
|
|
2012
|
+
// LOCAL row, append a fixed catch-all line and include LOCAL in the
|
|
2013
|
+
// output union so the model can express "neither of the above"
|
|
2014
|
+
// (coerceClassification accepts LOCAL regardless of the taxonomy).
|
|
2015
|
+
// §7.123 (2026-10-27): the prompt template moved to
|
|
2016
|
+
// `./intent-prompts.ts` — the two prompt surfaces (local LLM classifier
|
|
2017
|
+
// + Bailian decision-model) now live side by side and cannot drift on
|
|
2018
|
+
// the LOCAL catch-all wording.
|
|
2019
|
+
const { prompt } = buildClassificationPrompt({
|
|
2020
|
+
categories: this.categories,
|
|
2021
|
+
historyBlock,
|
|
2022
|
+
promptText,
|
|
2023
|
+
});
|
|
2024
|
+
const userMessage = createUserMessage({
|
|
2025
|
+
content: [{ type: 'text', text: prompt }],
|
|
2026
|
+
source: { kind: 'user' },
|
|
2027
|
+
});
|
|
2028
|
+
const llmStart = Date.now();
|
|
2029
|
+
let responseText = '';
|
|
2030
|
+
let chunks = 0;
|
|
2031
|
+
// Hard timeout via signal — prevents llama.cpp / qwen3-4b hangs from
|
|
2032
|
+
// blocking the http-api main thread. On AbortError we throw and the
|
|
2033
|
+
// outer classify() falls through to the keyword/default path (fail-open).
|
|
2034
|
+
const controller = new AbortController();
|
|
2035
|
+
const timer = setTimeout(() => controller.abort(), this.llmTimeoutMs);
|
|
2036
|
+
// §7.100: error-finish chunk capture (see loop below); declared outside
|
|
2037
|
+
// the try so the post-loop throw isn't swallowed by our own catch.
|
|
2038
|
+
let providerFailure;
|
|
2039
|
+
try {
|
|
2040
|
+
const streamRaw = this.llm.stream({
|
|
2041
|
+
provider,
|
|
2042
|
+
model,
|
|
2043
|
+
messages: [userMessage],
|
|
2044
|
+
signal: controller.signal,
|
|
2045
|
+
// §7.53 (2026-09-13): propagate sessionId for ops log correlation.
|
|
2046
|
+
// local-llm-adapter reads options.sessionId for its 'LLM call start'
|
|
2047
|
+
// line; realm-skill-bridge probe reads p.sessionId from the
|
|
2048
|
+
// llm/stream waterfall payload. Both go from <pre-session> to the
|
|
2049
|
+
// real sessionId when this is set.
|
|
2050
|
+
...(sessionId ? { sessionId } : {}),
|
|
2051
|
+
// §7.132: 标记这是**意图分类器内部调用**,不是主对话出网。
|
|
2052
|
+
...withInternalCaller({}, 'intent-classifier'),
|
|
2053
|
+
});
|
|
2054
|
+
// §7.37: this.llm.stream() returns Promise<AsyncIterable<StreamChunk>>
|
|
2055
|
+
// since dsh-llm 0.1.2-rc.1 — await if thenable, otherwise use directly.
|
|
2056
|
+
const stream = (streamRaw != null && typeof streamRaw.then === 'function')
|
|
2057
|
+
? await streamRaw
|
|
2058
|
+
: streamRaw;
|
|
2059
|
+
for await (const chunk of stream) {
|
|
2060
|
+
chunks++;
|
|
2061
|
+
if (chunk.type === 'text-delta') {
|
|
2062
|
+
responseText += chunk.text;
|
|
2063
|
+
}
|
|
2064
|
+
else if (chunk.type === 'finish') {
|
|
2065
|
+
// §7.100: adapter 侧错误(NO_ADAPTER / NO_CLOUD_TARGET / key 缺失…)
|
|
2066
|
+
// 被上游转成 terminal error finish chunk,不 throw。以前这里零
|
|
2067
|
+
// text-delta → 落到 'unclear' 分支,dashboard 把基础设施故障记成
|
|
2068
|
+
// "模型输出看不懂"。现在显式识别,归因 llm-error + provider_error。
|
|
2069
|
+
// (检测放 try 外抛出,避免本函数 catch 二次计数/重包裹。)
|
|
2070
|
+
const reason = chunk.reason;
|
|
2071
|
+
if (reason?.kind === 'error' || reason?.kind === 'aborted') {
|
|
2072
|
+
providerFailure = { code: reason.failure?.code, message: reason.failure?.message };
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
catch (err) {
|
|
2078
|
+
const isAbort = err?.name === 'AbortError' || controller.signal.aborted;
|
|
2079
|
+
const durationMs = Date.now() - llmStart;
|
|
2080
|
+
this.stats.timeout += isAbort ? 1 : 0;
|
|
2081
|
+
this.stats.llmError += isAbort ? 0 : 1;
|
|
2082
|
+
this.audit('warn', isAbort ? 'intent.llm.timeout' : 'intent.llm.error', {
|
|
2083
|
+
durationMs,
|
|
2084
|
+
timeoutMs: this.llmTimeoutMs,
|
|
2085
|
+
chunks,
|
|
2086
|
+
responseLen: responseText.length,
|
|
2087
|
+
error: err?.message ?? String(err),
|
|
2088
|
+
});
|
|
2089
|
+
const wrapped = new Error(err?.message ?? String(err));
|
|
2090
|
+
wrapped.name = err?.name ?? 'Error';
|
|
2091
|
+
wrapped._intentRouterReason = isAbort ? 'timeout' : 'llm-error';
|
|
2092
|
+
throw wrapped;
|
|
2093
|
+
}
|
|
2094
|
+
finally {
|
|
2095
|
+
clearTimeout(timer);
|
|
2096
|
+
}
|
|
2097
|
+
// §7.100: 基础设施故障(error/aborted finish chunk,非 throw)显式归因为
|
|
2098
|
+
// llm-error + provider_error 事件,让 dashboard 能把"provider 挂了"和
|
|
2099
|
+
// "模型话看不懂"分开;随后走既有 rules fallback(classify() 外层 catch)。
|
|
2100
|
+
if (providerFailure && responseText.trim().length === 0) {
|
|
2101
|
+
this.stats.llmError++;
|
|
2102
|
+
this.audit('warn', 'intent.llm.provider_error', { sessionId: sessionId ?? '',
|
|
2103
|
+
provider,
|
|
2104
|
+
model,
|
|
2105
|
+
code: providerFailure.code ?? 'UNKNOWN',
|
|
2106
|
+
error: (providerFailure.message ?? '').slice(0, 200),
|
|
2107
|
+
chunks,
|
|
2108
|
+
});
|
|
2109
|
+
const e = new Error(`intent-router: provider error: ${providerFailure.code ?? 'UNKNOWN'}`);
|
|
2110
|
+
e._intentRouterReason = 'llm-error';
|
|
2111
|
+
throw e;
|
|
2112
|
+
}
|
|
2113
|
+
this.audit('debug', 'intent.llm.response', { durationMs: Date.now() - llmStart, chunks, responseLen: responseText.length, responsePreview: this.redactPreview(responseText.trim().slice(0, 40)) });
|
|
2114
|
+
const verdict = parseClassificationVerdict(responseText);
|
|
2115
|
+
if (!verdict) {
|
|
2116
|
+
// P1: count parse failures separately from transport/timeout errors
|
|
2117
|
+
// so the dashboard's parse health can be read independently of
|
|
2118
|
+
// network health.
|
|
2119
|
+
this.stats.parseFailure++;
|
|
2120
|
+
this.stats.llmError++;
|
|
2121
|
+
this.audit('debug', 'intent.llm.unclear', { responsePreview: this.redactPreview(responseText.trim().slice(0, 40)) });
|
|
2122
|
+
const e = new Error('intent-router: llm response unclear');
|
|
2123
|
+
e._intentRouterReason = 'unclear';
|
|
2124
|
+
throw e;
|
|
2125
|
+
}
|
|
2126
|
+
this.audit('debug', 'intent.llm.parsed', { category: verdict.category, confidence: verdict.confidence });
|
|
2127
|
+
// G9: confidence histogram — record every parsed verdict (whether it
|
|
2128
|
+
// crosses the threshold or not) so operators can see the LLM's
|
|
2129
|
+
// distribution and tune `confidenceThreshold` accordingly. Accepted vs
|
|
2130
|
+
// rejected split per bucket tells you exactly where the threshold cuts
|
|
2131
|
+
// the distribution. Confidences are pre-clamped to [0,1] by
|
|
2132
|
+
// coerceClassification, so Math.floor(c*10) yields 0..9 (1.0 clamped).
|
|
2133
|
+
{
|
|
2134
|
+
const bucket = Math.min(9, Math.max(0, Math.floor(verdict.confidence * 10)));
|
|
2135
|
+
const key = String(bucket);
|
|
2136
|
+
const cell = this.stats.confidenceHistogram[key]
|
|
2137
|
+
?? (this.stats.confidenceHistogram[key] = { accepted: 0, rejected: 0 });
|
|
2138
|
+
if (verdict.confidence >= this.confidenceThreshold)
|
|
2139
|
+
cell.accepted++;
|
|
2140
|
+
else
|
|
2141
|
+
cell.rejected++;
|
|
2142
|
+
}
|
|
2143
|
+
// Confidence gate: if below threshold, fall back to keyword rules.
|
|
2144
|
+
if (verdict.confidence < this.confidenceThreshold) {
|
|
2145
|
+
this.stats.lowConfidence++;
|
|
2146
|
+
this.audit('info', 'intent.llm.low-confidence', {
|
|
2147
|
+
confidence: verdict.confidence,
|
|
2148
|
+
threshold: this.confidenceThreshold,
|
|
2149
|
+
category: verdict.category,
|
|
2150
|
+
});
|
|
2151
|
+
const e = new Error(`intent-router: low confidence ${verdict.confidence} < ${this.confidenceThreshold}`);
|
|
2152
|
+
e._intentRouterReason = 'low-confidence';
|
|
2153
|
+
throw e;
|
|
2154
|
+
}
|
|
2155
|
+
return {
|
|
2156
|
+
category: verdict.category,
|
|
2157
|
+
confidence: verdict.confidence,
|
|
2158
|
+
// §7.92.3 (2026-09-23): map all 4 verdict categories to targetRealm.
|
|
2159
|
+
// Previously was binary LOCAL/CLOUD; the LLM can now return
|
|
2160
|
+
// SANITIZED / REJECT directly so targetRealm must mirror them.
|
|
2161
|
+
targetRealm: verdict.category === 'LOCAL' ? 'local'
|
|
2162
|
+
: verdict.category === 'CLOUD' ? 'cloud'
|
|
2163
|
+
: verdict.category === 'SANITIZED' ? 'sanitized'
|
|
2164
|
+
: 'rejected',
|
|
2165
|
+
rationale: `llm-classified:${verdict.category.toLowerCase()}`,
|
|
2166
|
+
};
|
|
2167
|
+
}
|
|
2168
|
+
/** §7.58 (2026-09-16): match a LOCAL-entity keyword. CJK entries are
|
|
2169
|
+
* substring matches; ASCII entries require non-alphanumeric boundaries
|
|
2170
|
+
* (so 'ets' never fires inside "assets"/"settings"). Returns the matched
|
|
2171
|
+
* (lower-cased) keyword, or undefined. */
|
|
2172
|
+
matchLocalEntity(lower) {
|
|
2173
|
+
for (const kw of this.localEntitiesAll()) {
|
|
2174
|
+
const k = kw.toLowerCase().trim();
|
|
2175
|
+
if (k.length === 0)
|
|
2176
|
+
continue;
|
|
2177
|
+
if (/^[\x00-\x7f]+$/.test(k)) {
|
|
2178
|
+
const esc = k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
2179
|
+
if (new RegExp(`(^|[^a-z0-9])${esc}([^a-z0-9]|$)`, 'u').test(lower))
|
|
2180
|
+
return k;
|
|
2181
|
+
}
|
|
2182
|
+
else if (lower.includes(k)) {
|
|
2183
|
+
return k;
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
return undefined;
|
|
2187
|
+
}
|
|
2188
|
+
/** §7.108 (2026-10-24): the policy file's stages.intent signal tables,
|
|
2189
|
+
* read LIVE on every classify so a policy hot-reload takes effect on
|
|
2190
|
+
* the next turn. Missing service / old policy → undefined (callers
|
|
2191
|
+
* fall back to the constructor-built lists). */
|
|
2192
|
+
policyIntentStages() {
|
|
2193
|
+
const policy = this.softService('privacyPolicy');
|
|
2194
|
+
try {
|
|
2195
|
+
return policy?.getIntentStages?.() ?? undefined;
|
|
2196
|
+
}
|
|
2197
|
+
catch {
|
|
2198
|
+
return undefined;
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
/** §7.108: the FULL cloud signal list = built-in + patch config +
|
|
2202
|
+
* policy stages.intent.cloudKeywords. ONE definition shared by the
|
|
2203
|
+
* rules tier and the §7.107 gate — they can never disagree about what
|
|
2204
|
+
* counts as a public-web need. */
|
|
2205
|
+
cloudKeywordsAll() {
|
|
2206
|
+
const fromPolicy = this.policyIntentStages()?.cloudKeywords;
|
|
2207
|
+
return (fromPolicy && fromPolicy.length > 0)
|
|
2208
|
+
? this.cloudKeywords.concat(fromPolicy)
|
|
2209
|
+
: this.cloudKeywords;
|
|
2210
|
+
}
|
|
2211
|
+
/** §7.108: the FULL LOCAL entity list = built-in + patch config + policy. */
|
|
2212
|
+
localEntitiesAll() {
|
|
2213
|
+
const fromPolicy = this.policyIntentStages()?.localEntities;
|
|
2214
|
+
return (fromPolicy && fromPolicy.length > 0)
|
|
2215
|
+
? this.localEntities.concat(fromPolicy)
|
|
2216
|
+
: this.localEntities;
|
|
2217
|
+
}
|
|
2218
|
+
/** §7.108: LOCAL last-resort list — policy stages.intent.fallbackKeywords
|
|
2219
|
+
* (non-empty) REPLACES, mirroring the env var's replace semantics. */
|
|
2220
|
+
/** §7.124 (2026-10-27): the device-command extension terms = patch config
|
|
2221
|
+
* (config/edge.json stages.intent.deviceCommands) + the policy file's
|
|
2222
|
+
* stages.intent.deviceCommands (hot-reloadable, read LIVE per classify).
|
|
2223
|
+
* Both are ADDITIVE on top of the built-in vocabulary. */
|
|
2224
|
+
deviceCommandTerms() {
|
|
2225
|
+
const fromPolicy = this.policyIntentStages()?.deviceCommands;
|
|
2226
|
+
return {
|
|
2227
|
+
devices: this.deviceCommandOverrides.devices.concat(fromPolicy?.devices ?? []),
|
|
2228
|
+
actions: this.deviceCommandOverrides.actions.concat(fromPolicy?.actions ?? []),
|
|
2229
|
+
};
|
|
2230
|
+
}
|
|
2231
|
+
fallbackKeywordsAll() {
|
|
2232
|
+
const fromPolicy = this.policyIntentStages()?.fallbackKeywords;
|
|
2233
|
+
return (fromPolicy && fromPolicy.length > 0) ? fromPolicy : this.fallbackKeywords;
|
|
2234
|
+
}
|
|
2235
|
+
/**
|
|
2236
|
+
* §7.107: the public-web signal — the keyword that unlocks the cloud.
|
|
2237
|
+
*
|
|
2238
|
+
* Same list the rules tier matches (built-in information-acquisition words
|
|
2239
|
+
* + patch `stages.intent.cloudKeywords` + policy-file
|
|
2240
|
+
* `stages.intent.cloudKeywords` (§7.108)); `classifyWithRules` calls this
|
|
2241
|
+
* so the gate and the rules tier cannot drift apart.
|
|
2242
|
+
*/
|
|
2243
|
+
cloudSignal(text) {
|
|
2244
|
+
const lower = text.toLowerCase();
|
|
2245
|
+
return this.cloudKeywordsAll().find((kw) => lower.includes(kw.toLowerCase()));
|
|
2246
|
+
}
|
|
2247
|
+
classifyWithRules(text) {
|
|
2248
|
+
// Phase: try privacy-policy routing rules first (hot-reloadable, richer
|
|
2249
|
+
// semantics: LOCAL/CLOUD/SANITIZED/REJECT with per-rule confidence +
|
|
2250
|
+
// rationale). Soft dep: missing or throwing matchRouting falls through
|
|
2251
|
+
// to the hardcoded keyword list (fail-open). The hardcoded list is the
|
|
2252
|
+
// last resort — even if privacy-policy is mounted but has zero rules,
|
|
2253
|
+
// we still try the local keyword fallback.
|
|
2254
|
+
//
|
|
2255
|
+
// REJECT is now a first-class category (previously silently filtered).
|
|
2256
|
+
// Privacy-policy allows `realm: 'REJECT'` and design-v6 §2.15 treats it as
|
|
2257
|
+
// "blocked at router, no model call, consumer should map to 403 /
|
|
2258
|
+
// { kind: 'reject' } decision". audit kind `intent.rules.rejected` is
|
|
2259
|
+
// security-level so SIEM picks it up.
|
|
2260
|
+
const policy = this.softService('privacyPolicy');
|
|
2261
|
+
let matchedRule = null;
|
|
2262
|
+
try {
|
|
2263
|
+
const r = policy?.matchRouting?.(text);
|
|
2264
|
+
if (r && RULE_CATEGORIES.includes(r.realm)) {
|
|
2265
|
+
matchedRule = r;
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
catch (err) {
|
|
2269
|
+
this.audit('warn', 'intent.policy.error', { error: err?.message ?? String(err) });
|
|
2270
|
+
}
|
|
2271
|
+
if (matchedRule) {
|
|
2272
|
+
const realm = matchedRule.realm;
|
|
2273
|
+
if (realm === 'REJECT') {
|
|
2274
|
+
this.audit('security', 'intent.rules.rejected', {
|
|
2275
|
+
source: 'privacy-policy',
|
|
2276
|
+
pattern: matchedRule.pattern,
|
|
2277
|
+
confidence: matchedRule.confidence,
|
|
2278
|
+
rationale: matchedRule.rationale,
|
|
2279
|
+
textLen: text.length,
|
|
2280
|
+
});
|
|
2281
|
+
return {
|
|
2282
|
+
category: 'REJECT',
|
|
2283
|
+
confidence: matchedRule.confidence,
|
|
2284
|
+
targetRealm: 'rejected',
|
|
2285
|
+
rationale: matchedRule.rationale || 'policy-rule:reject',
|
|
2286
|
+
};
|
|
2287
|
+
}
|
|
2288
|
+
const targetRealm = realm === 'LOCAL' ? 'local' : realm === 'SANITIZED' ? 'sanitized' : 'cloud';
|
|
2289
|
+
this.audit('debug', 'intent.rules.hit', {
|
|
2290
|
+
source: 'privacy-policy',
|
|
2291
|
+
pattern: matchedRule.pattern,
|
|
2292
|
+
realm,
|
|
2293
|
+
confidence: matchedRule.confidence,
|
|
2294
|
+
});
|
|
2295
|
+
return {
|
|
2296
|
+
category: realm,
|
|
2297
|
+
confidence: matchedRule.confidence,
|
|
2298
|
+
targetRealm,
|
|
2299
|
+
rationale: matchedRule.rationale || `policy-rule:${realm.toLowerCase()}`,
|
|
2300
|
+
};
|
|
2301
|
+
}
|
|
2302
|
+
// Fallback: configurable keyword list (defaults to built-in EN + CN).
|
|
2303
|
+
// Used only when privacy-policy is absent / has no matching rule.
|
|
2304
|
+
const lower = text.toLowerCase();
|
|
2305
|
+
// §7.58 (2026-09-16): LOCAL entities checked FIRST — before even CLOUD
|
|
2306
|
+
// strong keywords. "查询共登陆了几个密盒" (2026-09-15 incident) contains
|
|
2307
|
+
// both an entity (密盒) and a verb (查询): the entity names a resource
|
|
2308
|
+
// that only exists behind LOCAL skills, so it decides the realm no
|
|
2309
|
+
// matter what else the sentence contains. Fail-closed toward LOCAL.
|
|
2310
|
+
const entityHit = this.matchLocalEntity(lower);
|
|
2311
|
+
if (entityHit !== undefined) {
|
|
2312
|
+
this.audit('debug', 'intent.rules.hit', { source: 'local-entity', matchedEntity: entityHit });
|
|
2313
|
+
return {
|
|
2314
|
+
category: 'LOCAL',
|
|
2315
|
+
confidence: 0.9,
|
|
2316
|
+
targetRealm: 'local',
|
|
2317
|
+
rationale: `rule-local-entity:${entityHit}`,
|
|
2318
|
+
};
|
|
2319
|
+
}
|
|
2320
|
+
// §7.47 (2026-09-12): CLOUD keywords matched BEFORE LOCAL fallback.
|
|
2321
|
+
// This catches queries that obviously need web/search/latest-news and
|
|
2322
|
+
// would otherwise hit defaultCategory (LOCAL, fail-closed) without
|
|
2323
|
+
// any LLM call. Inspection order is ENTITY → CLOUD → LOCAL → default —
|
|
2324
|
+
// if a query contains both CLOUD and LOCAL signals (e.g. "把本地文件整理
|
|
2325
|
+
// 成新闻稿发到网上"), the CLOUD path wins because user explicitly asked
|
|
2326
|
+
// for online content; a LOCAL *entity* name still beats both (above).
|
|
2327
|
+
// Privacy-policy rules (before all of this) can still REJECT.
|
|
2328
|
+
const cloudMatched = this.cloudSignal(text);
|
|
2329
|
+
if (cloudMatched !== undefined) {
|
|
2330
|
+
this.audit('debug', 'intent.rules.hit', { source: 'cloud-keyword', matchedKeyword: cloudMatched });
|
|
2331
|
+
return {
|
|
2332
|
+
category: 'CLOUD',
|
|
2333
|
+
confidence: 0.8,
|
|
2334
|
+
targetRealm: 'cloud',
|
|
2335
|
+
rationale: `rule-cloud-keyword:${cloudMatched}`,
|
|
2336
|
+
};
|
|
2337
|
+
}
|
|
2338
|
+
const matched = this.fallbackKeywordsAll().find((kw) => lower.includes(kw.toLowerCase()));
|
|
2339
|
+
if (matched !== undefined) {
|
|
2340
|
+
this.audit('debug', 'intent.rules.hit', { source: 'fallback-keyword', matchedKeyword: matched });
|
|
2341
|
+
return {
|
|
2342
|
+
category: 'LOCAL',
|
|
2343
|
+
confidence: 0.7,
|
|
2344
|
+
targetRealm: 'local',
|
|
2345
|
+
// §7.113: carry the keyword in the rationale — the Edge tab's
|
|
2346
|
+
// keyword evidence scalar derives from it.
|
|
2347
|
+
rationale: `rule-keyword-match:${matched}`,
|
|
2348
|
+
};
|
|
2349
|
+
}
|
|
2350
|
+
this.audit('debug', 'intent.rules.miss', { defaultCategory: this.defaultCategory });
|
|
2351
|
+
return {
|
|
2352
|
+
category: this.defaultCategory,
|
|
2353
|
+
confidence: 0.5,
|
|
2354
|
+
targetRealm: this.defaultCategory === 'CLOUD' ? 'cloud' : 'local',
|
|
2355
|
+
rationale: `default:${this.defaultCategory.toLowerCase()}`,
|
|
2356
|
+
};
|
|
2357
|
+
}
|
|
2358
|
+
resolve(intent) {
|
|
2359
|
+
return resolveIntent(intent);
|
|
2360
|
+
}
|
|
2361
|
+
/** Snapshot of in-memory counters. Returned object is a copy so callers
|
|
2362
|
+
* can't mutate the live state. Counters are process-local — restart
|
|
2363
|
+
* zeroes them. */
|
|
2364
|
+
/** §7.92.7 (2026-09-24): boot-time health probe. Fires once at
|
|
2365
|
+
* `start()` (called from `apply()`). Fire-and-forget — does NOT
|
|
2366
|
+
* block plugin init. On 2xx → no-op (the breaker stays closed;
|
|
2367
|
+
* classify() proceeds normally). On non-2xx / network error /
|
|
2368
|
+
* timeout → pre-opens the breaker for `jevBreakerTtlMs` so the
|
|
2369
|
+
* first classify() short-circuits instead of paying
|
|
2370
|
+
* 3 × `jevTimeoutMs` wall-clock.
|
|
2371
|
+
*
|
|
2372
|
+
* Why fire-and-forget: blocking plugin init on a 2s jev probe
|
|
2373
|
+
* adds 2s to cold boot, and a chat-less boot (e.g. CI smoke) has
|
|
2374
|
+
* no classify() to gate. Race against first classify() is fine
|
|
2375
|
+
* because: (1) plugin apply() runs before http-api opens its
|
|
2376
|
+
* port, so the earliest chat is several hundred ms away, (2) if
|
|
2377
|
+
* classify() runs first it just trips the breaker the normal
|
|
2378
|
+
* way (3 batch fails) — same outcome as the probe. */
|
|
2379
|
+
start() {
|
|
2380
|
+
if (!this.jevEnabled)
|
|
2381
|
+
return;
|
|
2382
|
+
if (!this.jevBootProbeEnabled) {
|
|
2383
|
+
this.audit('debug', 'intent.jev.boot_probe.skipped', {
|
|
2384
|
+
reason: 'disabled-by-config',
|
|
2385
|
+
jevUrl: this.jevUrl,
|
|
2386
|
+
});
|
|
2387
|
+
return;
|
|
2388
|
+
}
|
|
2389
|
+
// Capture the promise so stop() can await it on shutdown.
|
|
2390
|
+
this.jevBootProbePromise = this.probeJevHealth('boot', this.jevBootProbeTimeoutMs, true)
|
|
2391
|
+
.then((ok) => {
|
|
2392
|
+
if (!ok) {
|
|
2393
|
+
// Pre-open breaker AND start the periodic recovery probe
|
|
2394
|
+
// — otherwise jev coming back online during the 30s TTL
|
|
2395
|
+
// would have to wait full TTL for the breaker to close.
|
|
2396
|
+
// The probe will close the breaker (and stop itself) the
|
|
2397
|
+
// moment /health returns 2xx.
|
|
2398
|
+
this.jevBreakerOpenUntil = Date.now() + this.jevBreakerTtlMs;
|
|
2399
|
+
this.startBreakerProbe();
|
|
2400
|
+
}
|
|
2401
|
+
})
|
|
2402
|
+
.catch((err) => {
|
|
2403
|
+
// Defensive — `probeJevHealth` swallows transport errors and
|
|
2404
|
+
// resolves false; reaching this branch means a bug in the
|
|
2405
|
+
// probe itself. Log and keep the breaker closed (fail-open
|
|
2406
|
+
// for the probe path).
|
|
2407
|
+
this.audit('error', 'intent.jev.boot_probe.unexpected', {
|
|
2408
|
+
error: err?.message ?? String(err),
|
|
2409
|
+
});
|
|
2410
|
+
})
|
|
2411
|
+
.finally(() => {
|
|
2412
|
+
this.jevBootProbePromise = null;
|
|
2413
|
+
});
|
|
2414
|
+
}
|
|
2415
|
+
/** §7.92.7: dispose hook. Awaits the in-flight boot probe (if
|
|
2416
|
+
* any) and clears the recovery-probe timer. Tests call this to
|
|
2417
|
+
* keep the event loop clean between cases; cordis calls this
|
|
2418
|
+
* on plugin teardown. */
|
|
2419
|
+
async stop() {
|
|
2420
|
+
this.stopBreakerProbe();
|
|
2421
|
+
if (this.jevBootProbePromise) {
|
|
2422
|
+
try {
|
|
2423
|
+
await this.jevBootProbePromise;
|
|
2424
|
+
}
|
|
2425
|
+
catch { /* swallow */ }
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
/** §7.92.7: probe jev `/health`. Returns true on HTTP 2xx, false
|
|
2429
|
+
* on anything else (network error, timeout, non-2xx). Always
|
|
2430
|
+
* resolves — never throws. The `phase` arg distinguishes boot-time
|
|
2431
|
+
* vs periodic-recovery audit kinds so ops can tell them apart in
|
|
2432
|
+
* the log. */
|
|
2433
|
+
async probeJevHealth(phase, timeoutMs, preOpenBreaker) {
|
|
2434
|
+
if (phase === 'periodic')
|
|
2435
|
+
this.stats.jevProbeAttempts++;
|
|
2436
|
+
// §7.112: the systemone contract has no /health surface — GET /health
|
|
2437
|
+
// answers 400 even with a valid key (observed on token-plan), which
|
|
2438
|
+
// pre-opened the breaker at boot and silently skipped every classify.
|
|
2439
|
+
// The backend is billed per call: skip probing entirely and let the
|
|
2440
|
+
// breaker open only on real classify failures (TTL gives recovery).
|
|
2441
|
+
// §7.114: the skip is path-scoped — the MaaS deployments have no
|
|
2442
|
+
// /health surface, but a local MLX serve DOES (GET {url}/health →
|
|
2443
|
+
// 200), and for that deployment probing is free and desirable.
|
|
2444
|
+
if (this.jevProvider === 'bailian-decision' && this.jevPath === '/systemone') {
|
|
2445
|
+
if (phase === 'boot') {
|
|
2446
|
+
this.audit('debug', 'intent.jev.boot_probe', { provider: 'bailian-decision', skipped: true, note: 'no /health surface — probe skipped' });
|
|
2447
|
+
}
|
|
2448
|
+
return true;
|
|
2449
|
+
}
|
|
2450
|
+
const probeStart = Date.now();
|
|
2451
|
+
const controller = new AbortController();
|
|
2452
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
2453
|
+
let ok = false;
|
|
2454
|
+
let httpStatus = 0;
|
|
2455
|
+
let errorMsg = '';
|
|
2456
|
+
try {
|
|
2457
|
+
const r = await fetch(`${this.jevUrl}/health`, {
|
|
2458
|
+
method: 'GET',
|
|
2459
|
+
headers: this.jevHeaders(),
|
|
2460
|
+
signal: controller.signal,
|
|
2461
|
+
});
|
|
2462
|
+
httpStatus = r.status;
|
|
2463
|
+
ok = r.ok;
|
|
2464
|
+
if (!ok) {
|
|
2465
|
+
try {
|
|
2466
|
+
await r.text();
|
|
2467
|
+
}
|
|
2468
|
+
catch { /* ignore */ }
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
2471
|
+
catch (err) {
|
|
2472
|
+
errorMsg = err?.message ?? String(err);
|
|
2473
|
+
}
|
|
2474
|
+
finally {
|
|
2475
|
+
clearTimeout(timer);
|
|
2476
|
+
}
|
|
2477
|
+
const latencyMs = Date.now() - probeStart;
|
|
2478
|
+
if (phase === 'boot') {
|
|
2479
|
+
if (ok) {
|
|
2480
|
+
this.stats.jevBootProbeOk++;
|
|
2481
|
+
this.audit('info', 'intent.jev.boot_probe', {
|
|
2482
|
+
jevUrl: this.jevUrl,
|
|
2483
|
+
httpStatus,
|
|
2484
|
+
latencyMs,
|
|
2485
|
+
ok: true,
|
|
2486
|
+
});
|
|
2487
|
+
}
|
|
2488
|
+
else {
|
|
2489
|
+
this.stats.jevBootProbeFail++;
|
|
2490
|
+
this.audit('warn', 'intent.jev.boot_probe', {
|
|
2491
|
+
jevUrl: this.jevUrl,
|
|
2492
|
+
httpStatus,
|
|
2493
|
+
latencyMs,
|
|
2494
|
+
ok: false,
|
|
2495
|
+
error: errorMsg,
|
|
2496
|
+
preOpenBreaker,
|
|
2497
|
+
// Useful for ops: how long classify() will short-circuit.
|
|
2498
|
+
openUntilMs: preOpenBreaker ? Date.now() + this.jevBreakerTtlMs : 0,
|
|
2499
|
+
});
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
else {
|
|
2503
|
+
// periodic probe (always inside an open-breaker window)
|
|
2504
|
+
this.jevLastProbeLatencyMs = latencyMs;
|
|
2505
|
+
this.audit(ok ? 'debug' : 'debug', 'intent.jev.probe', {
|
|
2506
|
+
jevUrl: this.jevUrl,
|
|
2507
|
+
httpStatus,
|
|
2508
|
+
latencyMs,
|
|
2509
|
+
ok,
|
|
2510
|
+
error: ok ? undefined : errorMsg,
|
|
2511
|
+
});
|
|
2512
|
+
}
|
|
2513
|
+
return ok;
|
|
2514
|
+
}
|
|
2515
|
+
/** §7.92.7: start the recovery probe setInterval. Idempotent.
|
|
2516
|
+
* Called from classifyWithJev() right after `jevBreakerOpenUntil`
|
|
2517
|
+
* is set. When the periodic probe lands on 2xx it closes the
|
|
2518
|
+
* breaker (and stops itself) — recovery is faster than waiting
|
|
2519
|
+
* for full TTL. */
|
|
2520
|
+
startBreakerProbe() {
|
|
2521
|
+
if (this.jevBreakerProbeTimer !== null)
|
|
2522
|
+
return; // already running
|
|
2523
|
+
// unref so the timer never holds the process open by itself
|
|
2524
|
+
// (mirrors file-logger.ts:220 pattern).
|
|
2525
|
+
this.jevBreakerProbeTimer = setInterval(() => {
|
|
2526
|
+
void this.probeJevHealth('periodic', this.jevProbeTimeoutMs, false)
|
|
2527
|
+
.then((ok) => {
|
|
2528
|
+
if (!ok)
|
|
2529
|
+
return;
|
|
2530
|
+
// 2xx from /health → jev is back. Close the breaker
|
|
2531
|
+
// and stop the timer. classify() calls now hit jev
|
|
2532
|
+
// normally; one of them may still trip the breaker
|
|
2533
|
+
// again if /predict is broken (different endpoint),
|
|
2534
|
+
// but the breaker restart path is already covered by
|
|
2535
|
+
// classifyWithJev()'s post-Promise.all branch.
|
|
2536
|
+
if (Date.now() < this.jevBreakerOpenUntil) {
|
|
2537
|
+
this.jevBreakerOpenUntil = 0;
|
|
2538
|
+
this.stats.jevRecoveredProbe++;
|
|
2539
|
+
this.audit('info', 'intent.jev.recovered', {
|
|
2540
|
+
jevUrl: this.jevUrl,
|
|
2541
|
+
probeLatencyMs: this.jevProbeTimerLatency(),
|
|
2542
|
+
});
|
|
2543
|
+
}
|
|
2544
|
+
this.stopBreakerProbe();
|
|
2545
|
+
})
|
|
2546
|
+
.catch(() => { });
|
|
2547
|
+
}, this.jevBreakerProbeMs);
|
|
2548
|
+
if (typeof this.jevBreakerProbeTimer.unref === 'function') {
|
|
2549
|
+
this.jevBreakerProbeTimer.unref();
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2552
|
+
/** §7.92.7: clear the recovery probe setInterval. Idempotent. */
|
|
2553
|
+
stopBreakerProbe() {
|
|
2554
|
+
if (this.jevBreakerProbeTimer === null)
|
|
2555
|
+
return;
|
|
2556
|
+
clearInterval(this.jevBreakerProbeTimer);
|
|
2557
|
+
this.jevBreakerProbeTimer = null;
|
|
2558
|
+
}
|
|
2559
|
+
/** §7.92.7 helper: wall-clock spent in the most recent successful
|
|
2560
|
+
* probe (used in `intent.jev.recovered` audit payload). The
|
|
2561
|
+
* setInterval callback captures `probeStart` in its closure but
|
|
2562
|
+
* doesn't surface it cleanly — keep this lightweight by storing
|
|
2563
|
+
* the latest observed latency in instance state. */
|
|
2564
|
+
jevLastProbeLatencyMs = 0;
|
|
2565
|
+
jevProbeTimerLatency() { return this.jevLastProbeLatencyMs; }
|
|
2566
|
+
getStats() {
|
|
2567
|
+
// G9: deep-copy the histogram too — its nested {accepted, rejected}
|
|
2568
|
+
// cells would otherwise be live references into `this.stats`.
|
|
2569
|
+
const hist = {};
|
|
2570
|
+
for (const k of Object.keys(this.stats.confidenceHistogram)) {
|
|
2571
|
+
const cell = this.stats.confidenceHistogram[k];
|
|
2572
|
+
hist[k] = { accepted: cell.accepted, rejected: cell.rejected };
|
|
2573
|
+
}
|
|
2574
|
+
return { ...this.stats, confidenceHistogram: hist };
|
|
2575
|
+
}
|
|
2576
|
+
}
|
|
2577
|
+
/** Map an Intent to a realm/config tuple the rest of the agent loop can consume. */
|
|
2578
|
+
export function resolveIntent(intent) {
|
|
2579
|
+
return {
|
|
2580
|
+
realm: intent.targetRealm,
|
|
2581
|
+
config: { intentCategory: intent.category },
|
|
2582
|
+
};
|
|
2583
|
+
}
|
|
2584
|
+
/**
|
|
2585
|
+
* Fence-tolerant parser for `{ category, confidence }` JSON the LLM returns.
|
|
2586
|
+
* Falls back to: extract the first {...} block → regex token match for
|
|
2587
|
+
* `category` + a best-effort confidence float from the surrounding text.
|
|
2588
|
+
*/
|
|
2589
|
+
export function parseClassificationVerdict(raw) {
|
|
2590
|
+
if (!raw.length)
|
|
2591
|
+
return null;
|
|
2592
|
+
const cleaned = raw.replace(/^```(?:json)?/iu, '').replace(/```$/u, '').trim();
|
|
2593
|
+
// Tier 1: direct JSON parse
|
|
2594
|
+
try {
|
|
2595
|
+
return coerceClassification(JSON.parse(cleaned));
|
|
2596
|
+
}
|
|
2597
|
+
catch {
|
|
2598
|
+
/* fall through */
|
|
2599
|
+
}
|
|
2600
|
+
// Tier 2: extract first {...} block
|
|
2601
|
+
const fence = cleaned.match(/\{[\s\S]*\}/u);
|
|
2602
|
+
if (fence) {
|
|
2603
|
+
try {
|
|
2604
|
+
return coerceClassification(JSON.parse(fence[0]));
|
|
2605
|
+
}
|
|
2606
|
+
catch {
|
|
2607
|
+
/* fall through */
|
|
2608
|
+
}
|
|
2609
|
+
}
|
|
2610
|
+
// Tier 3: regex token fallback. Require BOTH category AND confidence
|
|
2611
|
+
// extracted from the LLM's own emission. If only one is recoverable
|
|
2612
|
+
// (e.g. the LLM wrote "category=LOCAL" with no confidence), return null
|
|
2613
|
+
// so the outer classifyWithLlm can attribute the failure to the
|
|
2614
|
+
// parseFailure counter and fall through to keyword rules — instead of
|
|
2615
|
+
// returning a 0.5-confidence verdict that ALWAYS fails the 0.6
|
|
2616
|
+
// confidence gate and wastes the LLM call.
|
|
2617
|
+
//
|
|
2618
|
+
// §7.123 (2026-10-27): the category alternation MUST cover the full
|
|
2619
|
+
// 4-category union. It used to accept only `LOCAL|CLOUD`, while
|
|
2620
|
+
// `coerceClassification` accepts LOCAL|CLOUD|SANITIZED|REJECT — so a
|
|
2621
|
+
// malformed-JSON verdict whose category was SANITIZED or REJECT fell
|
|
2622
|
+
// through Tier 3 and landed on the keyword-rules tier. A dropped REJECT
|
|
2623
|
+
// is a dropped BLOCK: the request was classified as dangerous and then
|
|
2624
|
+
// silently re-routed. The alternation now mirrors coerceClassification
|
|
2625
|
+
// exactly; unknown categories still return null → rules tier.
|
|
2626
|
+
const categoryMatch = /["']?category["']?\s*[:=]\s*["']?(LOCAL|CLOUD|SANITIZED|REJECT)["']?/iu.exec(cleaned);
|
|
2627
|
+
// `[:=]?` — separator is optional: qwen3-4b sometimes emits
|
|
2628
|
+
// `confidence 0.8` (whitespace only), sometimes `confidence: 0.8`,
|
|
2629
|
+
// sometimes `confidence=0.8`. All three should be recovered.
|
|
2630
|
+
const confidenceMatch = /["']?confidence["']?\s*[:=]?\s*([0-9.]+)/iu.exec(cleaned);
|
|
2631
|
+
if (categoryMatch && confidenceMatch) {
|
|
2632
|
+
const conf = Number(confidenceMatch[1]);
|
|
2633
|
+
return {
|
|
2634
|
+
category: categoryMatch[1].toUpperCase(),
|
|
2635
|
+
confidence: Number.isFinite(conf) ? Math.max(0, Math.min(1, conf)) : 0.5,
|
|
2636
|
+
};
|
|
2637
|
+
}
|
|
2638
|
+
return null;
|
|
2639
|
+
}
|
|
2640
|
+
export function coerceClassification(obj) {
|
|
2641
|
+
if (!obj || typeof obj !== 'object')
|
|
2642
|
+
return null;
|
|
2643
|
+
const o = obj;
|
|
2644
|
+
const cat = typeof o.category === 'string' ? o.category.toUpperCase() : '';
|
|
2645
|
+
// §7.92.3 (2026-09-23): accept the full 4-category union so the LLM
|
|
2646
|
+
// can return SANITIZED / REJECT verdicts directly (previously dropped
|
|
2647
|
+
// → fallback path; now they reach the §7.58 entity guardrail + the
|
|
2648
|
+
// outer REJECT short-circuit unchanged). Categories outside the
|
|
2649
|
+
// union (typos, lowercase 'sanitized' before .toUpperCase, etc.) still
|
|
2650
|
+
// return null → outer classify() falls through to classifyWithRules.
|
|
2651
|
+
if (cat !== 'LOCAL' && cat !== 'CLOUD' && cat !== 'SANITIZED' && cat !== 'REJECT')
|
|
2652
|
+
return null;
|
|
2653
|
+
const confRaw = typeof o.confidence === 'number'
|
|
2654
|
+
? o.confidence
|
|
2655
|
+
: typeof o.confidence === 'string' ? Number(o.confidence) : NaN;
|
|
2656
|
+
const conf = Number.isFinite(confRaw) ? Math.max(0, Math.min(1, confRaw)) : 0.5;
|
|
2657
|
+
return { category: cat, confidence: conf };
|
|
2658
|
+
}
|
|
2659
|
+
/** Best-effort extract a text payload from a recent-message object.
|
|
2660
|
+
* Handles dsh-llm's `{content: [{type:'text', text}]}`, plain `{text}`,
|
|
2661
|
+
* `{message: string}`, or anything stringifiable. Returns '' on failure. */
|
|
2662
|
+
export function extractMessageText(m) {
|
|
2663
|
+
if (typeof m === 'string')
|
|
2664
|
+
return m;
|
|
2665
|
+
if (!m || typeof m !== 'object')
|
|
2666
|
+
return '';
|
|
2667
|
+
const o = m;
|
|
2668
|
+
if (typeof o.text === 'string')
|
|
2669
|
+
return o.text;
|
|
2670
|
+
if (typeof o.message === 'string')
|
|
2671
|
+
return o.message;
|
|
2672
|
+
if (Array.isArray(o.content)) {
|
|
2673
|
+
const parts = o.content
|
|
2674
|
+
.map((p) => (p && typeof p === 'object' && typeof p.text === 'string' ? p.text : ''))
|
|
2675
|
+
.filter(Boolean);
|
|
2676
|
+
if (parts.length)
|
|
2677
|
+
return parts.join(' ');
|
|
2678
|
+
}
|
|
2679
|
+
return '';
|
|
2680
|
+
}
|
|
2681
|
+
export async function apply(ctx, config) {
|
|
2682
|
+
// §7.102: the intent stage config comes from config/edge.json
|
|
2683
|
+
// (stages.intent.*). The cordis patch row no longer reads process.env —
|
|
2684
|
+
// the desktop GUI never had those variables.
|
|
2685
|
+
const intentStage = loadEdgeConfig().stages?.intent;
|
|
2686
|
+
if (intentStage) {
|
|
2687
|
+
if (intentStage.provider !== undefined) {
|
|
2688
|
+
;
|
|
2689
|
+
config.classificationProvider = intentStage.provider;
|
|
2690
|
+
}
|
|
2691
|
+
if (intentStage.defaultCategory !== undefined) {
|
|
2692
|
+
;
|
|
2693
|
+
config.defaultCategory =
|
|
2694
|
+
intentStage.defaultCategory;
|
|
2695
|
+
}
|
|
2696
|
+
if (intentStage.timeoutMs !== undefined) {
|
|
2697
|
+
;
|
|
2698
|
+
config.llmTimeoutMs = intentStage.timeoutMs;
|
|
2699
|
+
}
|
|
2700
|
+
if (intentStage.confidenceThreshold !== undefined) {
|
|
2701
|
+
;
|
|
2702
|
+
config.confidenceThreshold = intentStage.confidenceThreshold;
|
|
2703
|
+
}
|
|
2704
|
+
if (intentStage.recentLimit !== undefined) {
|
|
2705
|
+
;
|
|
2706
|
+
config.recentMessagesLimit = intentStage.recentLimit;
|
|
2707
|
+
}
|
|
2708
|
+
if (intentStage.maxInput !== undefined) {
|
|
2709
|
+
;
|
|
2710
|
+
config.maxInputLength = intentStage.maxInput;
|
|
2711
|
+
}
|
|
2712
|
+
if (intentStage.cloudKeywords !== undefined) {
|
|
2713
|
+
;
|
|
2714
|
+
config.cloudKeywords = intentStage.cloudKeywords;
|
|
2715
|
+
}
|
|
2716
|
+
if (intentStage.localEntities !== undefined) {
|
|
2717
|
+
;
|
|
2718
|
+
config.localEntities = intentStage.localEntities;
|
|
2719
|
+
}
|
|
2720
|
+
if (intentStage.fallbackKeywords !== undefined) {
|
|
2721
|
+
;
|
|
2722
|
+
config.fallbackKeywords = intentStage.fallbackKeywords;
|
|
2723
|
+
}
|
|
2724
|
+
// §7.124 (2026-10-27): 家庭设备操作正则层的 operator 扩展词表。
|
|
2725
|
+
if (intentStage.deviceCommands !== undefined) {
|
|
2726
|
+
;
|
|
2727
|
+
config.deviceCommands = {
|
|
2728
|
+
devices: intentStage.deviceCommands.devices ?? [],
|
|
2729
|
+
actions: intentStage.deviceCommands.actions ?? [],
|
|
2730
|
+
};
|
|
2731
|
+
}
|
|
2732
|
+
// §7.115: decision-model-only mode switch (stages.intent.llmFallback).
|
|
2733
|
+
if (intentStage.llmFallback !== undefined) {
|
|
2734
|
+
;
|
|
2735
|
+
config.llmFallback = intentStage.llmFallback;
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2738
|
+
// §7.102: the jev pre-filter knobs come from config/edge.json (jev.*).
|
|
2739
|
+
// The Schema carries no defaults for them (constructor `??` literals are
|
|
2740
|
+
// the last resort), so an unset patch row leaves the field undefined
|
|
2741
|
+
// here and the file can fill it.
|
|
2742
|
+
const jev = loadEdgeConfig().jev;
|
|
2743
|
+
if (jev) {
|
|
2744
|
+
const c = config;
|
|
2745
|
+
const fill = (key, value) => {
|
|
2746
|
+
if (value !== undefined && c[key] === undefined)
|
|
2747
|
+
c[key] = value;
|
|
2748
|
+
};
|
|
2749
|
+
fill('jevEnabled', jev.enabled);
|
|
2750
|
+
fill('jevUrl', jev.url);
|
|
2751
|
+
// §7.112: Bailian decision-model backend (provider / model / auth).
|
|
2752
|
+
fill('jevProvider', jev.provider);
|
|
2753
|
+
fill('jevModel', jev.model);
|
|
2754
|
+
fill('jevAuthScheme', jev.authScheme);
|
|
2755
|
+
fill('jevPath', jev.path);
|
|
2756
|
+
const jpoolApiKey = jevPool().apiKey;
|
|
2757
|
+
if (jpoolApiKey !== undefined)
|
|
2758
|
+
fill('jevApiKey', jpoolApiKey);
|
|
2759
|
+
// §7.154: a jev.apiKeyRef naming a slot that is not there leaves the
|
|
2760
|
+
// decision model without a credential — the stage then silently falls
|
|
2761
|
+
// back to the rule tier. Say it at boot, not at the first fallback.
|
|
2762
|
+
{
|
|
2763
|
+
const dangling = danglingSecretRefs().filter((r) => r.field.startsWith('jev.'));
|
|
2764
|
+
if (dangling.length > 0) {
|
|
2765
|
+
ctx.logger?.error?.(`[intent-router] 配置里的密钥引用指向不存在的槽位:${formatDanglingSecretRefs(dangling)}`
|
|
2766
|
+
+ ' —— 判定模型没有凭据,jev 路径会静默退回规则档');
|
|
2767
|
+
}
|
|
2768
|
+
}
|
|
2769
|
+
fill('jevTimeoutMs', jev.timeoutMs);
|
|
2770
|
+
fill('jevConfidenceThreshold', jev.confidenceThreshold);
|
|
2771
|
+
fill('jevRiskThreshold', jev.riskThreshold);
|
|
2772
|
+
// 方向分闸:LOCAL 倾向的判决用低门(默认本地,不落 LLM),
|
|
2773
|
+
// 非 LOCAL 仍走 jevConfidenceThreshold(出网要严门)。
|
|
2774
|
+
// 这一项在配置面(edge-config.ts / edge.example.json)早已声明,
|
|
2775
|
+
// 但此前漏接线 ⇒ 写了不生效,静默走构造默认值。
|
|
2776
|
+
fill('jevLocalAdoptFloor', jev.localAdoptFloor);
|
|
2777
|
+
fill('jevBreakerTtlMs', jev.breakerTtlMs);
|
|
2778
|
+
fill('jevBootProbeEnabled', jev.bootProbeEnabled);
|
|
2779
|
+
fill('jevBootProbeTimeoutMs', jev.bootProbeTimeoutMs);
|
|
2780
|
+
fill('jevBreakerProbeMs', jev.breakerProbeMs);
|
|
2781
|
+
fill('jevProbeTimeoutMs', jev.probeTimeoutMs);
|
|
2782
|
+
}
|
|
2783
|
+
// §7.102: the classification model follows stages.intent.model when set,
|
|
2784
|
+
// else the LOCAL pool (llm.local.model) — the file is the single source
|
|
2785
|
+
// of truth. §7.100 privacy invariant: the classifier may only target the
|
|
2786
|
+
// local pool; a cloud provider here would ship the raw user text out.
|
|
2787
|
+
const stageModel = intentStage?.model?.trim();
|
|
2788
|
+
const poolModel = stageModel && stageModel.length > 0 ? stageModel : localPool().model;
|
|
2789
|
+
if (poolModel && config.classificationModel !== poolModel) {
|
|
2790
|
+
ctx.logger?.info?.(`[intent-router] classificationModel from config/edge.json: ${config.classificationModel} → ${poolModel}`);
|
|
2791
|
+
config.classificationModel = poolModel;
|
|
2792
|
+
}
|
|
2793
|
+
const auditLog = ctx.auditLog;
|
|
2794
|
+
const service = new IntentRouterServiceImpl(ctx, config, auditLog);
|
|
2795
|
+
// §7.92.7 (2026-09-24): fire boot probe (fire-and-forget). The
|
|
2796
|
+
// probe pre-opens the breaker if jev is down so first classify()
|
|
2797
|
+
// short-circuits instead of paying 3 × jevTimeoutMs wall-clock.
|
|
2798
|
+
service.start();
|
|
2799
|
+
return async () => {
|
|
2800
|
+
await service.stop();
|
|
2801
|
+
};
|
|
2802
|
+
}
|