@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.
Files changed (238) hide show
  1. package/CHANGELOG.md +158 -0
  2. package/LICENSE +21 -0
  3. package/README.md +181 -2
  4. package/config/edge.example.json +179 -0
  5. package/config/privacy-policy.json +202 -0
  6. package/cordis.desktop-safe.patch.yml +129 -0
  7. package/cordis.patch.yml +382 -0
  8. package/cordis.yml +374 -0
  9. package/lib/attestation.d.ts +2 -0
  10. package/lib/attestation.d.ts.map +1 -0
  11. package/lib/attestation.js +2 -0
  12. package/lib/audit-log.d.ts +2 -0
  13. package/lib/audit-log.d.ts.map +1 -0
  14. package/lib/audit-log.js +2 -0
  15. package/lib/client.js +87 -0
  16. package/lib/cloud-llm-adapter.d.ts +2 -0
  17. package/lib/cloud-llm-adapter.d.ts.map +1 -0
  18. package/lib/cloud-llm-adapter.js +2 -0
  19. package/lib/dsh-adapter/attestation.d.ts +42 -0
  20. package/lib/dsh-adapter/attestation.d.ts.map +1 -0
  21. package/lib/dsh-adapter/attestation.js +40 -0
  22. package/lib/dsh-adapter/audit-log.d.ts +139 -0
  23. package/lib/dsh-adapter/audit-log.d.ts.map +1 -0
  24. package/lib/dsh-adapter/audit-log.js +376 -0
  25. package/lib/dsh-adapter/cloud-egress.d.ts +181 -0
  26. package/lib/dsh-adapter/cloud-egress.d.ts.map +1 -0
  27. package/lib/dsh-adapter/cloud-egress.js +257 -0
  28. package/lib/dsh-adapter/cloud-llm-adapter.d.ts +416 -0
  29. package/lib/dsh-adapter/cloud-llm-adapter.d.ts.map +1 -0
  30. package/lib/dsh-adapter/cloud-llm-adapter.js +1472 -0
  31. package/lib/dsh-adapter/contract.d.ts +80 -0
  32. package/lib/dsh-adapter/contract.d.ts.map +1 -0
  33. package/lib/dsh-adapter/contract.js +194 -0
  34. package/lib/dsh-adapter/desktop-cookie-janitor.d.ts +214 -0
  35. package/lib/dsh-adapter/desktop-cookie-janitor.d.ts.map +1 -0
  36. package/lib/dsh-adapter/desktop-cookie-janitor.js +409 -0
  37. package/lib/dsh-adapter/dsh-edge-agent.d.ts +48 -0
  38. package/lib/dsh-adapter/dsh-edge-agent.d.ts.map +1 -0
  39. package/lib/dsh-adapter/dsh-edge-agent.js +64 -0
  40. package/lib/dsh-adapter/edge-config.d.ts +484 -0
  41. package/lib/dsh-adapter/edge-config.d.ts.map +1 -0
  42. package/lib/dsh-adapter/edge-config.js +898 -0
  43. package/lib/dsh-adapter/edge-context-view.d.ts +184 -0
  44. package/lib/dsh-adapter/edge-context-view.d.ts.map +1 -0
  45. package/lib/dsh-adapter/edge-context-view.js +483 -0
  46. package/lib/dsh-adapter/effective-model.d.ts +85 -0
  47. package/lib/dsh-adapter/effective-model.d.ts.map +1 -0
  48. package/lib/dsh-adapter/effective-model.js +171 -0
  49. package/lib/dsh-adapter/etscli-shell-guard.d.ts +244 -0
  50. package/lib/dsh-adapter/etscli-shell-guard.d.ts.map +1 -0
  51. package/lib/dsh-adapter/etscli-shell-guard.js +420 -0
  52. package/lib/dsh-adapter/etscli-tools.d.ts +112 -0
  53. package/lib/dsh-adapter/etscli-tools.d.ts.map +1 -0
  54. package/lib/dsh-adapter/etscli-tools.js +393 -0
  55. package/lib/dsh-adapter/file-logger.d.ts +294 -0
  56. package/lib/dsh-adapter/file-logger.d.ts.map +1 -0
  57. package/lib/dsh-adapter/file-logger.js +564 -0
  58. package/lib/dsh-adapter/function-call-external.d.ts +357 -0
  59. package/lib/dsh-adapter/function-call-external.d.ts.map +1 -0
  60. package/lib/dsh-adapter/function-call-external.js +1005 -0
  61. package/lib/dsh-adapter/host-egress-guard.d.ts +161 -0
  62. package/lib/dsh-adapter/host-egress-guard.d.ts.map +1 -0
  63. package/lib/dsh-adapter/host-egress-guard.js +213 -0
  64. package/lib/dsh-adapter/host-llm-trace.d.ts +198 -0
  65. package/lib/dsh-adapter/host-llm-trace.d.ts.map +1 -0
  66. package/lib/dsh-adapter/host-llm-trace.js +344 -0
  67. package/lib/dsh-adapter/http-api-routes.d.ts +61 -0
  68. package/lib/dsh-adapter/http-api-routes.d.ts.map +1 -0
  69. package/lib/dsh-adapter/http-api-routes.js +79 -0
  70. package/lib/dsh-adapter/http-api-sessions.d.ts +105 -0
  71. package/lib/dsh-adapter/http-api-sessions.d.ts.map +1 -0
  72. package/lib/dsh-adapter/http-api-sessions.js +173 -0
  73. package/lib/dsh-adapter/http-api.d.ts +221 -0
  74. package/lib/dsh-adapter/http-api.d.ts.map +1 -0
  75. package/lib/dsh-adapter/http-api.js +1811 -0
  76. package/lib/dsh-adapter/index.d.ts +62 -0
  77. package/lib/dsh-adapter/index.d.ts.map +1 -0
  78. package/lib/dsh-adapter/index.js +97 -0
  79. package/lib/dsh-adapter/input-guard-messages.d.ts +56 -0
  80. package/lib/dsh-adapter/input-guard-messages.d.ts.map +1 -0
  81. package/lib/dsh-adapter/input-guard-messages.js +136 -0
  82. package/lib/dsh-adapter/input-guard.d.ts +508 -0
  83. package/lib/dsh-adapter/input-guard.d.ts.map +1 -0
  84. package/lib/dsh-adapter/input-guard.js +2174 -0
  85. package/lib/dsh-adapter/intent-prompts.d.ts +77 -0
  86. package/lib/dsh-adapter/intent-prompts.d.ts.map +1 -0
  87. package/lib/dsh-adapter/intent-prompts.js +97 -0
  88. package/lib/dsh-adapter/intent-router.d.ts +710 -0
  89. package/lib/dsh-adapter/intent-router.d.ts.map +1 -0
  90. package/lib/dsh-adapter/intent-router.js +2802 -0
  91. package/lib/dsh-adapter/intent-signals.d.ts +208 -0
  92. package/lib/dsh-adapter/intent-signals.d.ts.map +1 -0
  93. package/lib/dsh-adapter/intent-signals.js +391 -0
  94. package/lib/dsh-adapter/invariant-install.d.ts +16 -0
  95. package/lib/dsh-adapter/invariant-install.d.ts.map +1 -0
  96. package/lib/dsh-adapter/invariant-install.js +15 -0
  97. package/lib/dsh-adapter/invariant.d.ts +16 -0
  98. package/lib/dsh-adapter/invariant.d.ts.map +1 -0
  99. package/lib/dsh-adapter/invariant.js +22 -0
  100. package/lib/dsh-adapter/llm-adapters.d.ts +19 -0
  101. package/lib/dsh-adapter/llm-adapters.d.ts.map +1 -0
  102. package/lib/dsh-adapter/llm-adapters.js +16 -0
  103. package/lib/dsh-adapter/llm-internal-caller.d.ts +92 -0
  104. package/lib/dsh-adapter/llm-internal-caller.d.ts.map +1 -0
  105. package/lib/dsh-adapter/llm-internal-caller.js +100 -0
  106. package/lib/dsh-adapter/llm-max-turns.d.ts +55 -0
  107. package/lib/dsh-adapter/llm-max-turns.d.ts.map +1 -0
  108. package/lib/dsh-adapter/llm-max-turns.js +127 -0
  109. package/lib/dsh-adapter/llm-trace-store.d.ts +131 -0
  110. package/lib/dsh-adapter/llm-trace-store.d.ts.map +1 -0
  111. package/lib/dsh-adapter/llm-trace-store.js +205 -0
  112. package/lib/dsh-adapter/llm-trace.d.ts +450 -0
  113. package/lib/dsh-adapter/llm-trace.d.ts.map +1 -0
  114. package/lib/dsh-adapter/llm-trace.js +463 -0
  115. package/lib/dsh-adapter/local-llm-adapter.d.ts +141 -0
  116. package/lib/dsh-adapter/local-llm-adapter.d.ts.map +1 -0
  117. package/lib/dsh-adapter/local-llm-adapter.js +787 -0
  118. package/lib/dsh-adapter/local-storage.d.ts +96 -0
  119. package/lib/dsh-adapter/local-storage.d.ts.map +1 -0
  120. package/lib/dsh-adapter/local-storage.js +198 -0
  121. package/lib/dsh-adapter/model-router.d.ts +79 -0
  122. package/lib/dsh-adapter/model-router.d.ts.map +1 -0
  123. package/lib/dsh-adapter/model-router.js +107 -0
  124. package/lib/dsh-adapter/ohttp-gateway.d.ts +49 -0
  125. package/lib/dsh-adapter/ohttp-gateway.d.ts.map +1 -0
  126. package/lib/dsh-adapter/ohttp-gateway.js +32 -0
  127. package/lib/dsh-adapter/openai-sse.d.ts +85 -0
  128. package/lib/dsh-adapter/openai-sse.d.ts.map +1 -0
  129. package/lib/dsh-adapter/openai-sse.js +462 -0
  130. package/lib/dsh-adapter/pii-utils.d.ts +96 -0
  131. package/lib/dsh-adapter/pii-utils.d.ts.map +1 -0
  132. package/lib/dsh-adapter/pii-utils.js +324 -0
  133. package/lib/dsh-adapter/plugin-host.d.ts +38 -0
  134. package/lib/dsh-adapter/plugin-host.d.ts.map +1 -0
  135. package/lib/dsh-adapter/plugin-host.js +38 -0
  136. package/lib/dsh-adapter/plugin.d.ts +46 -0
  137. package/lib/dsh-adapter/plugin.d.ts.map +1 -0
  138. package/lib/dsh-adapter/plugin.js +100 -0
  139. package/lib/dsh-adapter/privacy-policy.d.ts +237 -0
  140. package/lib/dsh-adapter/privacy-policy.d.ts.map +1 -0
  141. package/lib/dsh-adapter/privacy-policy.js +391 -0
  142. package/lib/dsh-adapter/prompt-guard.d.ts +152 -0
  143. package/lib/dsh-adapter/prompt-guard.d.ts.map +1 -0
  144. package/lib/dsh-adapter/prompt-guard.js +872 -0
  145. package/lib/dsh-adapter/realm-skill-bridge.d.ts +365 -0
  146. package/lib/dsh-adapter/realm-skill-bridge.d.ts.map +1 -0
  147. package/lib/dsh-adapter/realm-skill-bridge.js +1180 -0
  148. package/lib/dsh-adapter/sanitizer-pipeline.d.ts +140 -0
  149. package/lib/dsh-adapter/sanitizer-pipeline.d.ts.map +1 -0
  150. package/lib/dsh-adapter/sanitizer-pipeline.js +431 -0
  151. package/lib/dsh-adapter/secure-bridge.d.ts +47 -0
  152. package/lib/dsh-adapter/secure-bridge.d.ts.map +1 -0
  153. package/lib/dsh-adapter/secure-bridge.js +35 -0
  154. package/lib/dsh-adapter/session-attribution.d.ts +70 -0
  155. package/lib/dsh-adapter/session-attribution.d.ts.map +1 -0
  156. package/lib/dsh-adapter/session-attribution.js +76 -0
  157. package/lib/dsh-adapter/sessions.d.ts +86 -0
  158. package/lib/dsh-adapter/sessions.d.ts.map +1 -0
  159. package/lib/dsh-adapter/sessions.js +179 -0
  160. package/lib/dsh-adapter/tcm.d.ts +55 -0
  161. package/lib/dsh-adapter/tcm.d.ts.map +1 -0
  162. package/lib/dsh-adapter/tcm.js +94 -0
  163. package/lib/dsh-adapter/td-bus.d.ts +87 -0
  164. package/lib/dsh-adapter/td-bus.d.ts.map +1 -0
  165. package/lib/dsh-adapter/td-bus.js +196 -0
  166. package/lib/dsh-adapter/tool-runner.d.ts +185 -0
  167. package/lib/dsh-adapter/tool-runner.d.ts.map +1 -0
  168. package/lib/dsh-adapter/tool-runner.js +271 -0
  169. package/lib/dsh-adapter/tools.d.ts +118 -0
  170. package/lib/dsh-adapter/tools.d.ts.map +1 -0
  171. package/lib/dsh-adapter/tools.js +292 -0
  172. package/lib/dsh-adapter/types.d.ts +23 -0
  173. package/lib/dsh-adapter/types.d.ts.map +1 -0
  174. package/lib/dsh-adapter/types.js +16 -0
  175. package/lib/dsh-adapter/voice-input.d.ts +30 -0
  176. package/lib/dsh-adapter/voice-input.d.ts.map +1 -0
  177. package/lib/dsh-adapter/voice-input.js +34 -0
  178. package/lib/dsh-adapter/web-search-tavily-bocha.d.ts +152 -0
  179. package/lib/dsh-adapter/web-search-tavily-bocha.d.ts.map +1 -0
  180. package/lib/dsh-adapter/web-search-tavily-bocha.js +289 -0
  181. package/lib/http-api.d.ts +2 -0
  182. package/lib/http-api.d.ts.map +1 -0
  183. package/lib/http-api.js +2 -0
  184. package/lib/index.d.ts +48 -0
  185. package/lib/index.d.ts.map +1 -0
  186. package/lib/index.js +49 -0
  187. package/lib/intent-router.d.ts +2 -0
  188. package/lib/intent-router.d.ts.map +1 -0
  189. package/lib/intent-router.js +2 -0
  190. package/lib/llm-adapters.d.ts +2 -0
  191. package/lib/llm-adapters.d.ts.map +1 -0
  192. package/lib/llm-adapters.js +2 -0
  193. package/lib/local-llm-adapter.d.ts +2 -0
  194. package/lib/local-llm-adapter.d.ts.map +1 -0
  195. package/lib/local-llm-adapter.js +2 -0
  196. package/lib/local-storage.d.ts +2 -0
  197. package/lib/local-storage.d.ts.map +1 -0
  198. package/lib/local-storage.js +2 -0
  199. package/lib/model-router.d.ts +2 -0
  200. package/lib/model-router.d.ts.map +1 -0
  201. package/lib/model-router.js +2 -0
  202. package/lib/ohttp-gateway.d.ts +2 -0
  203. package/lib/ohttp-gateway.d.ts.map +1 -0
  204. package/lib/ohttp-gateway.js +2 -0
  205. package/lib/plugin-host.d.ts +2 -0
  206. package/lib/plugin-host.d.ts.map +1 -0
  207. package/lib/plugin-host.js +2 -0
  208. package/lib/privacy-policy.d.ts +2 -0
  209. package/lib/privacy-policy.d.ts.map +1 -0
  210. package/lib/privacy-policy.js +2 -0
  211. package/lib/prompt-guard.d.ts +2 -0
  212. package/lib/prompt-guard.d.ts.map +1 -0
  213. package/lib/prompt-guard.js +2 -0
  214. package/lib/sanitizer-pipeline.d.ts +2 -0
  215. package/lib/sanitizer-pipeline.d.ts.map +1 -0
  216. package/lib/sanitizer-pipeline.js +2 -0
  217. package/lib/secure-bridge.d.ts +2 -0
  218. package/lib/secure-bridge.d.ts.map +1 -0
  219. package/lib/secure-bridge.js +2 -0
  220. package/lib/sessions.d.ts +2 -0
  221. package/lib/sessions.d.ts.map +1 -0
  222. package/lib/sessions.js +2 -0
  223. package/lib/tcm.d.ts +2 -0
  224. package/lib/tcm.d.ts.map +1 -0
  225. package/lib/tcm.js +2 -0
  226. package/lib/td-bus.d.ts +2 -0
  227. package/lib/td-bus.d.ts.map +1 -0
  228. package/lib/td-bus.js +2 -0
  229. package/lib/tools.d.ts +2 -0
  230. package/lib/tools.d.ts.map +1 -0
  231. package/lib/tools.js +2 -0
  232. package/lib/voice-input.d.ts +2 -0
  233. package/lib/voice-input.d.ts.map +1 -0
  234. package/lib/voice-input.js +2 -0
  235. package/package.json +253 -3
  236. package/presets/agent-preset-edge.json +50 -0
  237. package/presets/edge.desktop-safe.patch.yml +503 -0
  238. 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
+ }