@a9i5k4/dsh-auto-memory 2.1.5 → 2.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -147,7 +147,7 @@ window.__ModuleLoader__.load({
147
147
  storageScanHint: '语料健康 = 逐源比对索引(sidecar)与正文的 digest。手动改动记忆文件后索引会失配,该记忆会退出检索直到重建索引。',
148
148
  storageDeleteHint: '删除记忆 = 正文原子删除 + 在途唤起包清理 + 派生事实撤销(三联动)。已产生的 seen 证据不改写。',
149
149
  secSemantic: '自动记忆引擎', semMode: '检索模式', semAuto: '自动(推荐)', semLexOnly: '仅词法', semJs: '内置语义', semPy: '高级 Python',
150
- pyWizTitle: 'Python 引擎一键安装', pyWizStep1: '① 检测 Python 环境', pyWizStep2: '② 创建独立虚拟环境(~/.dsh/python-engine/.venv,不污染系统)', pyWizStep3: '③ 安装引擎依赖(fastembed + onnxruntime)', pyWizStep4: '④ 下载 BGE-M3 int8 模型(~539MB,断点续传,失败自动换源)',
150
+ pyWizTitle: 'Python 引擎一键安装', pyWizStep1: '① 检测 Python 环境', pyWizStep2: '② 创建独立虚拟环境(~/.dsh/python-engine/.venv,不污染系统)', pyWizStep3: '③ 安装引擎依赖(transformers + onnxruntime + torch)', pyWizStep4: '④ 下载 BGE-M3 int8 模型(~539MB,断点续传,失败自动换源)',
151
151
  pyWizDetect: '开始检测', pyWizRedetect: '重新检测', pyWizCreate: '创建虚拟环境', pyWizInstall: '安装依赖', pyWizDownload: '开始下载', pyWizCancel: '取消下载', pyWizDone: '全部就绪 ✓ — 回上方启用即可', pyWizOk: 'ok', pyWizMissing: '未找到', pyWizTooOld: '版本不兼容(需 3.9-3.12)', pyWizVenvRec: '推荐', pyWizEta: '剩余', pyWizSec: '秒', pyWizModelHint: '模型与虚拟环境安装在 ~/.dsh/python-engine/(用户目录),升级/重装插件不受影响。',
152
152
  semModeHint: '自动=内置语义就绪即用,否则词法保底;高级 Python 需另行安装。',
153
153
  fAssocEngine: '启用自动记忆引擎', fAssocEngineHint: '总开关。开启后自动观测上下文、语义检索并适时唤起记忆注入(消费少量 token)。关闭则整个引擎不运行——不检索、不判定、不注入、不生成唤起记录。介意 token 消耗或担心动作跑偏的用户可关闭。默认关。',
@@ -286,7 +286,7 @@ window.__ModuleLoader__.load({
286
286
  storageScanHint: 'Corpus health = compare each source\'s index (sidecar) against its body digest. After you hand-edit a memory file the index no longer matches, and that memory drops out of retrieval until the index is rebuilt.',
287
287
  storageDeleteHint: 'Delete = atomic body removal + in-flight activation purge + derived-fact revocation (cascading). Evidence already recorded (seen) is never rewritten.',
288
288
  secSemantic: 'Semantic engine', semMode: 'Retrieval mode', semAuto: 'Auto (recommended)', semLexOnly: 'Lexical only', semJs: 'Built-in semantic', semPy: 'Advanced Python',
289
- pyWizTitle: 'One-click Python engine setup', pyWizStep1: '1. Detect Python environment', pyWizStep2: '2. Create isolated venv (~/.dsh/python-engine/.venv, system untouched)', pyWizStep3: '3. Install engine deps (fastembed + onnxruntime)', pyWizStep4: '4. Download BGE-M3 int8 model (~539MB, resumable, auto mirror failover)',
289
+ pyWizTitle: 'One-click Python engine setup', pyWizStep1: '1. Detect Python environment', pyWizStep2: '2. Create isolated venv (~/.dsh/python-engine/.venv, system untouched)', pyWizStep3: '3. Install engine deps (transformers + onnxruntime + torch)', pyWizStep4: '4. Download BGE-M3 int8 model (~539MB, resumable, auto mirror failover)',
290
290
  pyWizDetect: 'Detect', pyWizRedetect: 'Re-detect', pyWizCreate: 'Create venv', pyWizInstall: 'Install deps', pyWizDownload: 'Start download', pyWizCancel: 'Cancel download', pyWizDone: 'All ready - enable it above', pyWizOk: 'ok', pyWizMissing: 'not found', pyWizTooOld: 'incompatible (need 3.9-3.12)', pyWizVenvRec: 'recommended', pyWizEta: 'eta', pyWizSec: 's', pyWizModelHint: 'Model and venv live in ~/.dsh/python-engine/ (user dir) - plugin upgrades never touch them.',
291
291
  fAssocEngine: 'Enable automatic memory engine', fAssocEngineHint: 'Master switch. On = auto-observe context, semantic retrieval, and timely memory-activation injection (costs a little token). Off = the whole engine stops — no retrieval, no decide, no injection, no activation records. For users concerned about token cost or off-course actions. Default off.',
292
292
  secMemoryHubHint: 'Memory Hub = the orchestrator for three memory layers (episodic / semantic / procedural). When on, it distills episodes from dialogue, solidifies facts, and turns repeatedly-successful workflows into skills that are auto-recalled in similar contexts.',
@@ -4,7 +4,7 @@
4
4
  * 四步链路(全部走本模块,UI 只管展示状态与点击):
5
5
  * ①detect — 探测系统 Python(≥3.9)/既有 venv/模型本体;全部只读。
6
6
  * ②venv — python -m venv <userDir>/python-engine/.venv(幂等:已存在直接跳过)。
7
- * ③deps — venv 内 pip 安装运行依赖(fastembed/onnxruntime,清华镜像兜底)。
7
+ * ③deps — venv 内 pip 安装运行依赖(transformers/onnxruntime/torch,清华镜像兜底)。
8
8
  * ④model — BGE-M3 int8(~539MB)下载到 <userDir>/python-engine/models/,
9
9
  * cn(hf-mirror)/intl(hf 官方)双通道+SHA256 校验,复用 JS 档下载器的状态机形态。
10
10
  *
@@ -35,8 +35,8 @@ const MODEL_SPEC = {
35
35
  ],
36
36
  }
37
37
 
38
- /** venv 内 pip 依赖(语义引擎最小集;transformers 栈换成 fastembed=纯 onnxruntime,免 torch)。 */
39
- const PIP_DEPS = ['fastembed', 'onnxruntime']
38
+ /** venv 内 pip 依赖:与 bench 校准环境同源(transformers AutoTokenizer + onnxruntime;torch 为张量构造所需;池化/精度契约冻结自 R@5 0.925 基线,fastembed 注册表无 BGE-M3 且池化契约不同,不可用)。 */
39
+ const PIP_DEPS = ['transformers', 'onnxruntime', 'torch']
40
40
 
41
41
  export function createPythonSetupPre(opts = {}) {
42
42
  const dshHomeOf = typeof opts.dshHome === 'function' ? opts.dshHome : () => opts.dshHome || path.join(homedir(), '.dsh')
@@ -99,7 +99,7 @@ export function createPythonSetupPre(opts = {}) {
99
99
  }
100
100
  // 既有成果快照
101
101
  st.venvOk = existsSync(venvPython())
102
- st.depsOk = st.venvOk ? (await probe(venvPython(), ['-c', 'import fastembed, onnxruntime; print("deps-ok")'])).ok : false
102
+ st.depsOk = st.venvOk ? (await probe(venvPython(), ['-c', 'import transformers, onnxruntime, torch; print("deps-ok")'])).ok : false
103
103
  st.modelReady = existsSync(modelPath())
104
104
  st.pythons = out
105
105
  st.phase = 'idle'
@@ -136,7 +136,7 @@ export function createPythonSetupPre(opts = {}) {
136
136
  return snapshot()
137
137
  }
138
138
 
139
- /** ③deps:venv 内 pip 装 fastembed+onnxruntime;官方源失败切清华镜像。 */
139
+ /** ③deps:venv 内 pip 装 transformers+onnxruntime+torch;官方源失败切清华镜像。 */
140
140
  async function ensureDeps() {
141
141
  if (!st.venvOk) { st.error = 'venv 未就绪,先执行 venv 步骤'; st.phase = 'error'; return snapshot() }
142
142
  st.phase = 'deps'
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@a9i5k4/dsh-auto-memory",
3
- "description": "主动联想记忆插件:记忆不靠模型调用,自己被唤回——Host 观察情境,NLP+向量化双轨检索,固定边界注入不破坏前缀缓存。三层记忆自动沉淀、欢迎向导、唤起回顾、无人值守、AI 问候与反思、日历、跨工具记忆继承。Proactive associative memory for DSH: zero-call recall, three-layer auto-consolidation, welcome tour, unattended mode.",
4
- "version": "2.1.5",
3
+ "description": "Proactive associative memory for DSH: zero-prompt recall injected before the model speaks, three-layer auto-consolidation, skill crystallization, and Astra-style context management - handoff ledgers, PLAN whiteboard, water-level sensing. Local-first, model-agnostic, zero deps. 主动联想记忆+Astra 式上下文管理:自动唤回/自动沉淀/技能固化/交接账本与白板跨窗口续命/水位感知。",
4
+ "version": "2.1.7",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -11,7 +11,10 @@
11
11
  },
12
12
  "files": [
13
13
  "lib",
14
- "cordis.patch.yml"
14
+ "python",
15
+ "cordis.patch.yml",
16
+ "!python/bench",
17
+ "!python/__pycache__"
15
18
  ],
16
19
  "dsh": {
17
20
  "bundle": {
@@ -0,0 +1,392 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """M7 activation features pre v2 (policy: activation_policy_v2).
4
+
5
+ Round-1 implementation of the two-lane activation eligibility decision.
6
+ Pure stdlib; deterministic; NO pickle/joblib/sklearn at runtime — the
7
+ calibrated intent head is reproduced from the auditable JSON artifact
8
+ python/policies/recall_intent_lr_v1.json (vocabulary + IDF + logistic
9
+ regression coefficients/intercept + Platt calibration), exported by the
10
+ calibration pipeline (runId label-review-cal20260824-1954).
11
+
12
+ Decision order (main-Agent amendment 2026-08-25, supersedes any earlier
13
+ draft that placed echo veto globally):
14
+
15
+ 1. JS/M7 hard gates : harmful / correction / ignored / stale /
16
+ wrong_scope / pii_high -> suppress
17
+ 2. lane decision : intentProb >= tauLane -> explicit, else proactive
18
+ 3. explicit lane : echoRisk is a FEATURE here, never a veto
19
+ emit : intent>=tauHi AND candidateHit AND
20
+ margin>=deltaExp AND completeness==complete
21
+ prefetch : intent>=tauHi AND hit AND
22
+ completeness in (partial, unknown)
23
+ OR intent>=tauLo AND hit
24
+ 4. proactive lane : high echoRisk (containmentArm|denseTopArm) with
25
+ statement form and intent<cap -> hard suppress;
26
+ margin>=deltaPro AND nCand>=2 AND low intent AND
27
+ denseTop<arm -> prefetch
28
+ 5. default suppress
29
+
30
+ repetition this round is logging-only: counts may upgrade suppress->prefetch,
31
+ never activate on counts alone; life/chitchat topics never escalate.
32
+
33
+ Fail-closed policy: load_and_verify_policy refuses to serve when the JSON is
34
+ missing fields, length-inconsistent, or configHash does not recompute. The
35
+ hash algorithm mirrors export_v2_artifacts.py exactly:
36
+ payload = json.dumps(policy_minus_configHash, sort_keys=True,
37
+ ensure_ascii=False)
38
+ configHash = 'cfgh_' + sha256(payload.encode('utf-8')).hexdigest()[:32]
39
+
40
+ Public API (pure functions / pure objects):
41
+ normalize_text, infer_recall_intent, infer_dialogue_act, infer_task_need,
42
+ compute_echo_risk, compute_completeness, compute_lane,
43
+ decide_activation_v2, load_and_verify_policy, replay_features_v2
44
+ """
45
+ import hashlib
46
+ import json
47
+ import math
48
+ import os
49
+ import re
50
+
51
+ FEATURES_POLICY_VERSION = 'activation_features_v2'
52
+ INTENT_POLICY_VERSION = 'recall_intent_lr_v1'
53
+ ACTIVATION_POLICY_VERSION = 'activation_policy_v2'
54
+
55
+ INTERROG = ['什么', '如何', '怎么', '哪些', '哪个', '为什么', '多少', '吗',
56
+ '呢', '啥', 'recall', 'what', 'how', 'which', 'when', 'where',
57
+ 'why', 'who']
58
+ RECALL_CTX = ['之前', '上次', '当时', '早前', '以往', '历史', '记录里', '记忆里',
59
+ '找出来', '调出来', '翻下', '说下', 'previous', 'earlier',
60
+ 'last time']
61
+ COMPLETENESS_LEXICON_DEFAULT = ['对比', '分别', '两个', '一起', '都调', '各自']
62
+ ACK_TOKENS = ['好的', '嗯嗯', '谢谢', '晚安', '收到']
63
+ ERR_TOKENS = ['又失败', '又超限', '又不对', '第三次', '报错', '又出现', '又丢']
64
+ REQ_TOKENS = ['帮我', '找出来', '调出来', '说一下', '再讲讲', '发我']
65
+ PLAN_TOKENS = ['准备', '打算', '计划', '之后', '接下来', '继续']
66
+
67
+ _WORD_RE = re.compile(r'(?u)\b\w\w+\b')
68
+ _WS_RUN = re.compile(r'\s\s+')
69
+
70
+
71
+ # ---------------------------------------------------------------- text utils
72
+
73
+ def normalize_text(text):
74
+ """Lowercase; keep [a-z0-9] and CJK; drop everything else.
75
+
76
+ Must byte-match the calibration exporter's normalize_text so that
77
+ vocabulary lookups are stable across offline/online."""
78
+ return ''.join(ch for ch in str(text).lower()
79
+ if ch.isalnum() or '\u4e00' <= ch <= '\u9fff')
80
+
81
+
82
+ def _char_wb_ngram_counts(norm_text, min_n, max_n):
83
+ """sklearn char_wb parity: collapse whitespace runs, tokenize with
84
+ (?u)\\b\\w\\w+\\b, pad each word with spaces, take char n-grams."""
85
+ t = _WS_RUN.sub(' ', norm_text)
86
+ counts = {}
87
+ for word in _WORD_RE.findall(t):
88
+ padded = ' ' + word + ' '
89
+ L = len(padded)
90
+ for n in range(min_n, min(max_n, L) + 1):
91
+ for i in range(L - n + 1):
92
+ gram = padded[i:i + n]
93
+ counts[gram] = counts.get(gram, 0) + 1
94
+ return counts
95
+
96
+
97
+ def bigram_set(text):
98
+ t = normalize_text(text)
99
+ return set(t[i:i + 2] for i in range(len(t) - 1)) or {t}
100
+
101
+
102
+ def lexical_containment(query_text, candidate_text):
103
+ q = bigram_set(query_text)
104
+ c = bigram_set(candidate_text)
105
+ if not q:
106
+ return 0.0
107
+ return len(q & c) / len(q)
108
+
109
+
110
+ # ------------------------------------------------------------ intent head
111
+
112
+ class RecallIntentHead:
113
+ """Deterministic pure-Python reproduction of the calibrated LR head."""
114
+
115
+ def __init__(self, artifact):
116
+ fs = artifact['featureSchema']
117
+ vf = fs['vectorizer']
118
+ assert vf['analyzer'] == 'char_wb' and vf['minDf'] == 1 \
119
+ and vf['sublinearTf'] is True and vf['ngramRange'] == [2, 4], \
120
+ 'unsupported featureSchema'
121
+ self.min_n, self.max_n = vf['ngramRange']
122
+ self.vocab = artifact['vocabulary']
123
+ self.idf = artifact['idf']
124
+ self.coef = artifact['coefficients']
125
+ self.intercept = float(artifact['intercept'])
126
+ cal = artifact['calibration']
127
+ assert cal['method'] == 'platt'
128
+ self.platt_a = float(cal['a'])
129
+ self.platt_b = float(cal['b'])
130
+ self._norm_cache = {}
131
+
132
+ def infer(self, text):
133
+ key = id(text)
134
+ grams = self._char_counts(text)
135
+ # sublinear tf * idf over the sparse support, then L2 normalise
136
+ acc = {}
137
+ for gram, cnt in grams.items():
138
+ idx = self.vocab.get(gram)
139
+ if idx is None:
140
+ continue
141
+ acc[idx] = (1.0 + math.log(cnt)) * self.idf[idx]
142
+ norm = math.sqrt(sum(v * v for v in acc.values())) or 1.0
143
+ z = self.intercept
144
+ for idx, w in acc.items():
145
+ z += self.coef[idx] * (w / norm)
146
+ p_raw = 1.0 / (1.0 + math.exp(-max(-30.0, min(30.0, z))))
147
+ zz = math.log(max(p_raw, 1e-6) / max(1e-6, 1.0 - p_raw))
148
+ p = 1.0 / (1.0 + math.exp(-max(-30.0, min(30.0,
149
+ self.platt_a * zz + self.platt_b))))
150
+ return round(p, 6)
151
+
152
+ def _char_counts(self, text):
153
+ ck = ('t', text)
154
+ cached = self._norm_cache.get(ck)
155
+ if cached is None:
156
+ cached = _char_wb_ngram_counts(normalize_text(text),
157
+ self.min_n, self.max_n)
158
+ if len(self._norm_cache) < 512:
159
+ self._norm_cache[ck] = cached
160
+ return cached
161
+
162
+
163
+ def infer_recall_intent(text, head):
164
+ return head.infer(text)
165
+
166
+
167
+ # ------------------------------------------------------- dialogue/task/echo
168
+
169
+ def infer_dialogue_act(text, intent_prob):
170
+ tl = str(text).lower()
171
+ if any(k in tl for k in ERR_TOKENS):
172
+ return 'error_report'
173
+ if any(k in tl for k in ACK_TOKENS) and len(tl) <= 12:
174
+ return 'acknowledgement'
175
+ has_interrogative = ('?' in tl or '?' in tl
176
+ or any(k in tl for k in INTERROG))
177
+ recall_ctx = any(k in tl for k in RECALL_CTX)
178
+ if recall_ctx and has_interrogative:
179
+ return 'question'
180
+ if has_interrogative:
181
+ return 'question'
182
+ if any(k in tl for k in REQ_TOKENS):
183
+ return 'request'
184
+ if any(k in tl for k in PLAN_TOKENS):
185
+ return 'planning'
186
+ if intent_prob < 0.40:
187
+ return 'statement'
188
+ return 'other'
189
+
190
+
191
+ def infer_task_need(dialogue_act):
192
+ return {'error_report': 'required', 'question': 'optional',
193
+ 'request': 'optional', 'planning': 'none',
194
+ 'acknowledgement': 'none', 'statement': 'none',
195
+ 'correction': 'none', 'other': 'none'}[dialogue_act]
196
+
197
+
198
+ def compute_echo_risk(containment, dense_top, mark_zero, intent_prob,
199
+ policy):
200
+ ev = policy['echoVeto']
201
+ arms = {'containmentArm': containment >= ev['containmentArm'],
202
+ 'denseTopArm': dense_top >= ev['denseTopArm'],
203
+ 'markZero': bool(mark_zero),
204
+ 'intentBelowCap': intent_prob < ev['requiresIntentBelow']}
205
+ hit = ((arms['containmentArm'] or arms['denseTopArm'])
206
+ and arms['markZero'] and arms['intentBelowCap'])
207
+ return {'arms': arms, 'hit': hit}
208
+
209
+
210
+ def compute_completeness(text, lexicon, required_hint=None,
211
+ resolved_count=None):
212
+ """Phase-1 conservative proxy. requiredTargetCount is heuristic (no
213
+ gold knowledge at runtime); resolvedTargetCount is an environment
214
+ input (count of expected targets resolved by retrieval) and stays
215
+ None in production shadow until a coverage signal exists."""
216
+ tl = str(text).lower()
217
+ kw = any(k in tl for k in lexicon)
218
+ required = int(required_hint) if required_hint is not None else (2 if kw else 1)
219
+ status = 'unknown' if kw else 'complete'
220
+ return {'requiredTargetCount': required,
221
+ 'resolvedTargetCount': resolved_count,
222
+ 'status': status}
223
+
224
+
225
+ def compute_lane(intent_prob, policy):
226
+ return 'explicit' if intent_prob >= policy['thresholds']['tauLane'] \
227
+ else 'proactive'
228
+
229
+
230
+ # ------------------------------------------------------------- policy load
231
+
232
+ def load_and_verify_policy(intent_path, policy_path):
233
+ """Load both artifacts and fail closed on any inconsistency."""
234
+ with open(intent_path, encoding='utf-8') as f:
235
+ ip = json.load(f)
236
+ with open(policy_path, encoding='utf-8') as f:
237
+ ap = json.load(f)
238
+ need_ip = ('policyVersion', 'goldDigest', 'runId', 'configHash',
239
+ 'featureSchema', 'vocabulary', 'idf', 'coefficients',
240
+ 'intercept', 'calibration')
241
+ need_ap = ('policyVersion', 'goldDigest', 'runId', 'configHash', 'mode',
242
+ 'thresholds', 'decisionOrder', 'echoVeto', 'completenessGate',
243
+ 'hardGates', 'reasonCodes')
244
+ for k in need_ip:
245
+ if k not in ip:
246
+ raise ValueError('intent policy missing field: %s' % k)
247
+ for k in need_ap:
248
+ if k not in ap:
249
+ raise ValueError('activation policy missing field: %s' % k)
250
+ if ip['goldDigest'] != ap['goldDigest'] or ip['runId'] != ap['runId']:
251
+ raise ValueError('intent/activation policy provenance mismatch')
252
+ L = len(ip['vocabulary'])
253
+ if not (L == len(ip['idf']) == len(ip['coefficients'])):
254
+ raise ValueError('vocab/idf/coefficient length mismatch')
255
+ for name, doc in (('intent', ip), ('activation', ap)):
256
+ probe = {k: v for k, v in doc.items() if k != 'configHash'}
257
+ payload = json.dumps(probe, sort_keys=True, ensure_ascii=False)
258
+ expect = 'cfgh_' + hashlib.sha256(payload.encode('utf-8')).hexdigest()[:32]
259
+ if expect != doc['configHash']:
260
+ raise ValueError('%s configHash mismatch: %s vs %s'
261
+ % (name, expect, doc.get('configHash')))
262
+ if ap['mode'] != 'shadow-candidate':
263
+ raise ValueError('refusing non-shadow mode in round-1 runtime')
264
+ head = RecallIntentHead(ip)
265
+ return {'head': head, 'policy': ap}
266
+
267
+
268
+ # ------------------------------------------------------------ decision core
269
+
270
+ def decide_activation_v2(features, head, policy):
271
+ """features keys:
272
+ text raw query text
273
+ denseTop top-1 fused/dense similarity of ranked candidates
274
+ margin denseTop - second dense (or 1.0 when single)
275
+ containment lexical containment query->top1 candidate text
276
+ mark 0/1 interrogative-or-recall-marker present
277
+ nCand number of ranked candidates in view
278
+ candidateHit env input: expected/relevant target present in top-K
279
+ (shadow eval: gold match; production: memoryRefs
280
+ overlap or future coverage signal; default False)
281
+ resolvedTargets env input: count of expected targets resolved
282
+ requiredHint optional completeness hint
283
+ repetition optional logging-only counters {mentions, failures}
284
+ hardGates dict of booleans: harmful/correction/ignored/stale/
285
+ wrongScope/piiHigh (absent => False)
286
+ Returns dict: lane, decision, reasonCodes, features snapshot."""
287
+ th = policy['thresholds']
288
+ reason = []
289
+ hg = features.get('hardGates') or {}
290
+ if any(bool(hg.get(k)) for k in
291
+ ('harmful', 'correction', 'ignored', 'stale', 'wrongScope')):
292
+ hit_gate = next((k for k in ('piiHigh', 'wrongScope', 'stale',
293
+ 'ignored', 'correction', 'harmful')
294
+ if hg.get(k)), 'harmful')
295
+ return _pack(features, policy, None, 'suppress',
296
+ ['hard_gate_%s' % hit_gate])
297
+ if hg.get('piiHigh'):
298
+ return _pack(features, policy, None, 'suppress', ['hard_gate_pii'])
299
+ intent = infer_recall_intent(features['text'], head)
300
+ dact = infer_dialogue_act(features['text'], intent)
301
+ tneed = infer_task_need(dact)
302
+ echo = compute_echo_risk(features['containment'], features['denseTop'],
303
+ features['mark'] == 0, intent, policy)
304
+ comp = compute_completeness(features['text'],
305
+ policy['completenessGate']['lexicon'],
306
+ required_hint=features.get('requiredHint'),
307
+ resolved_count=features.get('resolvedTargets'))
308
+ lane = compute_lane(intent, policy)
309
+ hit = bool(features.get('candidateHit'))
310
+ margin = float(features.get('margin') or 0.0)
311
+
312
+ def finish(decision, extra=()):
313
+ snap = {'intentProb': intent, 'dialogueAct': dact, 'taskNeed': tneed,
314
+ 'echoRisk': echo, 'completeness': comp, 'lane': lane,
315
+ 'margin': margin}
316
+ rep = features.get('repetition') or {}
317
+ snap['repetitionLogged'] = rep
318
+ return _pack(features, policy, snap, decision, list(reason) + list(extra))
319
+
320
+ if lane == 'explicit':
321
+ if intent >= th['tauHi'] and hit:
322
+ if margin >= th['deltaExp'] and comp['status'] == 'complete':
323
+ return finish('emit', ['explicit_lane',
324
+ 'completeness_complete'])
325
+ if margin >= th['deltaExp']:
326
+ return finish('prefetch',
327
+ ['explicit_lane',
328
+ 'completeness_%s' % comp['status']])
329
+ return finish('prefetch', ['explicit_lane', 'margin_below_delta'])
330
+ if intent >= th['tauLo'] and hit:
331
+ return finish('prefetch', ['explicit_lane_weak'])
332
+ # fall through: explicit lane without hit behaves like proactive
333
+ # relevance check minus echo suppression rights
334
+ if (margin >= th['deltaPro'] and features.get('nCand', 0) >= 2
335
+ and intent < 0.35
336
+ and features['denseTop'] < policy['echoVeto']['denseTopArm']):
337
+ return finish('prefetch', ['proactive_margin_fallback'])
338
+ return finish('suppress', ['suppress_low_signal'])
339
+ # proactive lane
340
+ if echo['hit']:
341
+ return finish('suppress', ['echo_veto_proactive'])
342
+ if margin >= th['deltaPro'] and features.get('nCand', 0) >= 2 \
343
+ and features['denseTop'] < policy['echoVeto']['denseTopArm']:
344
+ return finish('prefetch', ['proactive_margin'])
345
+ return finish('suppress', ['suppress_low_signal'])
346
+
347
+
348
+ def _pack(features, policy, snapshot, decision, reason_codes):
349
+ out = {
350
+ 'featurePolicyVersion': FEATURES_POLICY_VERSION,
351
+ 'activationPolicyVersion': ACTIVATION_POLICY_VERSION,
352
+ 'decision': decision,
353
+ 'reasonCodes': list(reason_codes),
354
+ 'advisoryOnly': None,
355
+ 'requiresCrossWorkspaceRelay': bool(features.get('requiresRelayFlag')),
356
+ 'piiClass': features.get('piiClass'),
357
+ 'note': 'round-1 shadow only; cross-workspace/PII tiers are JS '
358
+ 'authority layers (fail closed without explicit policy)',
359
+ }
360
+ if snapshot is not None:
361
+ out['features'] = snapshot
362
+ return out
363
+
364
+
365
+ def replay_features_v2(rows, head, policy):
366
+ """Batch helper for offline replay/parity: rows carry precomputed
367
+ retrieval features; returns per-row decision dicts."""
368
+ return [dict(decide_activation_v2(r, head, policy),
369
+ sampleId=r.get('id')) for r in rows]
370
+
371
+
372
+ # --------------------------------------------------------------- self-test
373
+
374
+ if __name__ == '__main__':
375
+ here = os.path.dirname(os.path.abspath(__file__))
376
+ pol_dir = os.path.join(here, 'policies')
377
+ loaded = load_and_verify_policy(
378
+ os.path.join(pol_dir, 'recall_intent_lr_v1.json'),
379
+ os.path.join(pol_dir, 'activation_policy_v2.json'))
380
+ demo_rows = [
381
+ {'id': 'demo-recall', 'text': '之前为什么选 BGE-M3?',
382
+ 'denseTop': 0.72, 'margin': 0.10, 'containment': 0.55, 'mark': 1,
383
+ 'nCand': 5, 'candidateHit': True},
384
+ {'id': 'demo-echo', 'text': '中午那碗面条挺不错的。',
385
+ 'denseTop': 0.83, 'margin': 0.30, 'containment': 0.22, 'mark': 0,
386
+ 'nCand': 4, 'candidateHit': False},
387
+ ]
388
+ for r in demo_rows:
389
+ out = decide_activation_v2(r, loaded['head'], loaded['policy'])
390
+ print(r['id'], out['decision'], out['reasonCodes'],
391
+ 'intent=%.4f' % out['features']['intentProb'])
392
+ print('SELFTEST OK')