@aiwayds/dsh-topics-memory 0.18.2 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -1
- package/README.zh.md +77 -1
- package/lib/commands.js +3 -0
- package/lib/config.d.ts +62 -1
- package/lib/config.js +51 -2
- package/lib/consolidate.d.ts +39 -1
- package/lib/consolidate.js +195 -1
- package/lib/index.d.ts +14 -0
- package/lib/index.js +86 -13
- package/lib/jev/backends.d.ts +34 -0
- package/lib/jev/backends.js +46 -0
- package/lib/jev/client.d.ts +98 -0
- package/lib/jev/client.js +225 -0
- package/lib/jev/dual.d.ts +48 -0
- package/lib/jev/dual.js +47 -0
- package/lib/jev/gate.d.ts +50 -0
- package/lib/jev/gate.js +125 -0
- package/lib/jev/log.d.ts +73 -0
- package/lib/jev/log.js +121 -0
- package/lib/jev/thresholds.d.ts +21 -0
- package/lib/jev/thresholds.js +27 -0
- package/lib/quality.d.ts +60 -2
- package/lib/quality.js +290 -11
- package/package.json +2 -2
- package/skills/dsh-topics-memory-laya/SKILL.md +84 -0
- package/src/commands.ts +2 -0
- package/src/config.ts +59 -2
- package/src/consolidate.ts +238 -1
- package/src/index.ts +95 -13
- package/src/jev/backends.ts +69 -0
- package/src/jev/client.ts +336 -0
- package/src/jev/dual.ts +73 -0
- package/src/jev/gate.ts +151 -0
- package/src/jev/log.ts +194 -0
- package/src/jev/thresholds.ts +31 -0
- package/src/quality.ts +332 -11
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: dsh-topics-memory-laya
|
|
3
|
+
description: "为 dsh-topics-memory 的 jev 决策层安装并配置本地 laya 陪跑(laya-serve)。当用户要开启 jevLayaFallback、安装 laya、启动 laya-serve、配置 laya 端点、排查 laya 连接失败/陪跑不生效/decisions.jsonl 没有 laya 行时读本指南。覆盖:venv 创建、国内三镜像加速(清华 PyPI / hf-mirror / 禁 Xet)、checkpoint 下载、laya-serve 启动与自启、jevLayaFallback/jevLayaUrl 两键配置、真机验证(decisions.jsonl 出现 backend=laya 行)。触发词:laya、laya-serve、陪跑、本地决策、jevLaya、离线、8000 端口。"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# laya 本地陪跑安装与配置
|
|
7
|
+
|
|
8
|
+
> laya 是开源的 System One 决策引擎(本地推理,jev 的同范式替身)。topics-memory 的
|
|
9
|
+
> `jevLayaFallback: true` 会把每次 jev 调用并发一路本地 laya 请求作对照遥测。本指南把
|
|
10
|
+
> 安装配置一次讲完;全程不涉及付费 API。
|
|
11
|
+
|
|
12
|
+
## 前置检查(先判断要不要装)
|
|
13
|
+
|
|
14
|
+
1. `curl -s --max-time 3 -o /dev/null -w "%{http_code}" -X POST http://127.0.0.1:8000/v1/systemone -H "content-type: application/json" -d '{"state":"w","questions":{"q":{"type":"noul","instructions":"w","criteria":{"true":"t","false":"f"}}}}'`
|
|
15
|
+
返回 200 = laya-serve 已在跑,**跳到「插件配置」**;连接拒绝 = 继续安装。
|
|
16
|
+
2. `python3 --version` 需要 ≥ 3.10(3.13 以下优先,太新的版本 torch 轮子可能缺)。
|
|
17
|
+
3. 磁盘 ≥ 5GB(torch + 两个 checkpoint)。
|
|
18
|
+
|
|
19
|
+
## 安装(国内网络三件套必带,否则要么 100KB/s 要么挂死)
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
mkdir -p ~/laya-tryout && cd ~/laya-tryout
|
|
23
|
+
uv venv --python 3.12 .venv # 没有 uv: brew install uv;或 python3 -m venv .venv
|
|
24
|
+
uv pip install --python .venv/bin/python \
|
|
25
|
+
--index-url https://pypi.tuna.tsinghua.edu.cn/simple "laya[serve]" hf_transfer
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
下载 checkpoint(两个共 ~1.5GB)——三个环境变量一个都不能少:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
export HF_ENDPOINT=https://hf-mirror.com # HF 直连国内常 <50KB/s
|
|
32
|
+
export HF_HUB_DISABLE_XET=1 # 新版 huggingface_hub 默认 Xet 协议,镜像不支持,会 0 字节挂死
|
|
33
|
+
export HF_HUB_ENABLE_HF_TRANSFER=1 # 并行分块,断流自动续传
|
|
34
|
+
.venv/bin/python -c "
|
|
35
|
+
from laya import Router
|
|
36
|
+
r = Router()
|
|
37
|
+
r.preload(['english', 'multilingual'])
|
|
38
|
+
print('checkpoints ok')
|
|
39
|
+
"
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## 启动 laya-serve
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
LAYA_PRELOAD=1 LAYA_MODELS=english,multilingual ~/laya-tryout/.venv/bin/laya-serve
|
|
46
|
+
# binds 0.0.0.0:8000;进程前台跑,挂 nohup/tmux/launchd 由用户自选
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
- `LAYA_MODELS` 不设会尝试预载第三个 checkpoint(typed-decisions,再 +1.7GB)——陪跑用不到,别下。
|
|
50
|
+
- 自启(macOS launchd)可选:写 `~/Library/LaunchAgents/com.laya.serve.plist` 指向上面命令;不开自启就要接受「重启后陪跑静默失效」(无 laya = 毫秒级连接拒绝,无害但没对照数据)。
|
|
51
|
+
|
|
52
|
+
## 插件配置(在 profile patch 的 `dsh-topics-memory` 条目)
|
|
53
|
+
|
|
54
|
+
```yaml
|
|
55
|
+
config:
|
|
56
|
+
jevEnabled: true # 决策层总开关(本身默认关)
|
|
57
|
+
jevLayaFallback: true # laya 陪跑开关
|
|
58
|
+
# jevLayaUrl 默认就是 http://127.0.0.1:8000/v1/systemone,不改端口不用写
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
前置:`jevEnabled: true` 且已按主配置指南配好 jev 后端 key(zen 免费档默认;key 走 patch `env:` 块或 `JEV_KEYCHAIN`——dsh 会清洗 ambient KEY 变量)。
|
|
62
|
+
|
|
63
|
+
## 验证(三步)
|
|
64
|
+
|
|
65
|
+
1. `curl -s -X POST http://127.0.0.1:8000/v1/systemone -H "content-type: application/json" -d '{"state":"w","model":"laya-rl-agent","questions":{"q":{"type":"noul","instructions":"w","criteria":{"true":"t","false":"f"}}}}' | jq .answers` — 返回 noul 概率即 laya 侧通。
|
|
66
|
+
2. 跑一轮真实会话(任意问题),然后:
|
|
67
|
+
`jq -r 'select(.backend=="laya") | [.ref,.probability] | @tsv' ~/.dsh/topics/meta/decisions.jsonl | head` — 出现 `backend:"laya"` 的判定行 = 陪跑生效。
|
|
68
|
+
3. `~/.dsh/topics/meta/decisions.jsonl` 里同一 `digest` 应有成对的 primary/laya 行——这就是永久运行的 laya-vs-jev 对照数据。
|
|
69
|
+
|
|
70
|
+
## 语义边界(必须向用户讲清)
|
|
71
|
+
|
|
72
|
+
- laya 是**纯遥测对照,不参与决策**:真实负载实测其批内排序与 jev 一致率 0/14、负例分 0.63-0.76(绝对分不可用),主路失败照旧回退旧路径。
|
|
73
|
+
- 判定质量上 zen/typesafe 的 jev 系远胜 laya;laya 的价值是免费对照数据流 + 离线可用。
|
|
74
|
+
- laya 换语言(中→英)首次调用会触发 checkpoint 重载(数百 ms),陪跑场景无感。
|
|
75
|
+
|
|
76
|
+
## 常见坑速查
|
|
77
|
+
|
|
78
|
+
| 症状 | 原因 | 解法 |
|
|
79
|
+
|---|---|---|
|
|
80
|
+
| 下载 0 字节挂死 | Xet 协议 + 镜像不兼容 | `HF_HUB_DISABLE_XET=1` |
|
|
81
|
+
| checkpoint 下载 <100KB/s | HF 直连 | `HF_ENDPOINT=https://hf-mirror.com` |
|
|
82
|
+
| `z.string().volatile is not a function` 类插件报错 | (与 laya 无关)dsh 版本漂移 | 钉 0.1.7-rc 线,勿裸解析 |
|
|
83
|
+
| decisions.jsonl 无 backend=laya 行 | laya-serve 没起 / jevLayaFallback 没开 / jevEnabled 没开 | 按「验证」步骤逐层查 |
|
|
84
|
+
| 首次调用慢(秒级) | checkpoint 冷加载 | 预热一发即稳态(32-300ms) |
|
package/src/commands.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type { ConsolidateResult } from './consolidate.ts'
|
|
|
16
16
|
import { serializeTopicDoc, slugify, firstParagraph } from './okf.ts'
|
|
17
17
|
import { buildGraph, renderGraphHtml } from './viz.ts'
|
|
18
18
|
import { CONFIG_KEYS, displayKey, type ConfigKey, type TopicsConfigValue, parseConfigValue } from './config.ts'
|
|
19
|
+
import { reloadSecrets } from './jev/gate.ts'
|
|
19
20
|
import { aggregateStats } from './ilog.ts'
|
|
20
21
|
import {
|
|
21
22
|
askDistillSingle,
|
|
@@ -630,5 +631,6 @@ async function doSet(
|
|
|
630
631
|
if (typeof parsed === 'object' && parsed !== null && 'error' in parsed) return fail(parsed.error)
|
|
631
632
|
await mutate([{ op: 'set', path: [key], value: parsed }])
|
|
632
633
|
if (key === 'repo' || key === 'autoInject') service.invalidate()
|
|
634
|
+
if (key === 'jevSecretFile') reloadSecrets(String(parsed)) // hot-swapped list: force re-read
|
|
633
635
|
return ok(`✅ topics.${displayKey(key)} = ${String(parsed)}`)
|
|
634
636
|
}
|
package/src/config.ts
CHANGED
|
@@ -74,6 +74,31 @@ export const TopicsConfig = z.object({
|
|
|
74
74
|
usageBoost: z.number().default(0.15).volatile(),
|
|
75
75
|
/** Debounced push delay in GitHub mode. */
|
|
76
76
|
pushDebounceSeconds: z.number().default(45).volatile(),
|
|
77
|
+
/** System One decision-model integration (design 2026-09-25), default-off
|
|
78
|
+
* master switch: false means zero behavior change anywhere (the fast lane
|
|
79
|
+
* never calls out regardless — hard guard); the usage stats stop with it. */
|
|
80
|
+
jevEnabled: z.boolean().default(false).volatile(),
|
|
81
|
+
/** Decision backend: zen (free, opencode.ai) | native (typesafe
|
|
82
|
+
* first-party) | openrouter (decisions protocol). */
|
|
83
|
+
jevBackend: z.string().default('zen').volatile(),
|
|
84
|
+
/** Pinned model id; '' = resolve the backend default at call time
|
|
85
|
+
* (distillProvider sentinel pattern): jev-1.13-free / jev-1.13.0 /
|
|
86
|
+
* typesafe/jev-1.13 — an upgrade is a deliberate act. */
|
|
87
|
+
jevModel: z.string().default('').volatile(),
|
|
88
|
+
/** Single-request timeout in ms; one AbortSignal, NO retry (the calling
|
|
89
|
+
* lane's next cadence is the natural retry, fail-open hard-coded). */
|
|
90
|
+
jevTimeoutMs: z.number().default(3000).volatile(),
|
|
91
|
+
/** External secret list for the outbound secret gate (JEV_SECRET_FILE
|
|
92
|
+
* semantics, design §3.3); swapping the path hot reloads the list. */
|
|
93
|
+
jevSecretFile: z.string().default('').volatile(),
|
|
94
|
+
/** Local laya pace-maker (design §3.4, 09-27): fire a parallel laya call on
|
|
95
|
+
* every jev request; its verdict is logged for comparison and takes over
|
|
96
|
+
* ONLY when the primary backend fails (degraded). Default off — zero
|
|
97
|
+
* behavior change, and laya-serve not running costs a refused connection. */
|
|
98
|
+
jevLayaFallback: z.boolean().default(false).volatile(),
|
|
99
|
+
/** laya-serve systemone endpoint for the pace-maker (default: the local
|
|
100
|
+
* laya-serve bind from the laya repo's serve mode). */
|
|
101
|
+
jevLayaUrl: z.string().default('http://127.0.0.1:8000/v1/systemone').volatile(),
|
|
77
102
|
})
|
|
78
103
|
|
|
79
104
|
export type TopicsConfigValue = {
|
|
@@ -103,6 +128,17 @@ export type TopicsConfigValue = {
|
|
|
103
128
|
deprecatedTtlDays: number
|
|
104
129
|
usageBoost: number
|
|
105
130
|
pushDebounceSeconds: number
|
|
131
|
+
/** System One integration keys (design 2026-09-25 §4). Optional in this TS
|
|
132
|
+
* view only: index.ts's bare-harness DEFAULTS literal predates them and
|
|
133
|
+
* schemastery applies the runtime defaults on real hosts — consumers must
|
|
134
|
+
* treat undefined as the documented default. */
|
|
135
|
+
jevEnabled?: boolean
|
|
136
|
+
jevBackend?: 'zen' | 'native' | 'openrouter'
|
|
137
|
+
jevModel?: string
|
|
138
|
+
jevTimeoutMs?: number
|
|
139
|
+
jevSecretFile?: string
|
|
140
|
+
jevLayaFallback?: boolean
|
|
141
|
+
jevLayaUrl?: string
|
|
106
142
|
}
|
|
107
143
|
|
|
108
144
|
export const CONFIG_KEYS = [
|
|
@@ -132,6 +168,13 @@ export const CONFIG_KEYS = [
|
|
|
132
168
|
'deprecatedTtlDays',
|
|
133
169
|
'usageBoost',
|
|
134
170
|
'pushDebounceSeconds',
|
|
171
|
+
'jevEnabled',
|
|
172
|
+
'jevBackend',
|
|
173
|
+
'jevModel',
|
|
174
|
+
'jevTimeoutMs',
|
|
175
|
+
'jevSecretFile',
|
|
176
|
+
'jevLayaFallback',
|
|
177
|
+
'jevLayaUrl',
|
|
135
178
|
] as const
|
|
136
179
|
|
|
137
180
|
export type ConfigKey = (typeof CONFIG_KEYS)[number]
|
|
@@ -155,11 +198,16 @@ export function parseConfigValue(key: ConfigKey, raw: string): boolean | number
|
|
|
155
198
|
case 'suppressEcho':
|
|
156
199
|
case 'autoObserve':
|
|
157
200
|
case 'distillOnSessionEnd':
|
|
158
|
-
case 'includeSubagents':
|
|
201
|
+
case 'includeSubagents':
|
|
202
|
+
case 'jevEnabled': {
|
|
159
203
|
if (raw === 'on' || raw === 'true') return true
|
|
160
204
|
if (raw === 'off' || raw === 'false') return false
|
|
161
205
|
return { error: `${key} 取值 on|off` }
|
|
162
206
|
}
|
|
207
|
+
case 'jevBackend': {
|
|
208
|
+
if (raw === 'zen' || raw === 'native' || raw === 'openrouter') return raw
|
|
209
|
+
return { error: 'jev-backend 取值 zen|native|openrouter' }
|
|
210
|
+
}
|
|
163
211
|
case 'topK':
|
|
164
212
|
case 'perTopicBudget':
|
|
165
213
|
case 'totalBudget':
|
|
@@ -170,7 +218,8 @@ export function parseConfigValue(key: ConfigKey, raw: string): boolean | number
|
|
|
170
218
|
case 'distillBatchSize':
|
|
171
219
|
case 'distillMaxModelCalls':
|
|
172
220
|
case 'deprecatedTtlDays':
|
|
173
|
-
case 'pushDebounceSeconds':
|
|
221
|
+
case 'pushDebounceSeconds':
|
|
222
|
+
case 'jevTimeoutMs': {
|
|
174
223
|
const n = Number(raw)
|
|
175
224
|
if (!Number.isFinite(n) || n < 0 || !Number.isInteger(n)) return { error: `${key} 需要非负整数` }
|
|
176
225
|
return n
|
|
@@ -196,6 +245,14 @@ export function parseConfigValue(key: ConfigKey, raw: string): boolean | number
|
|
|
196
245
|
}
|
|
197
246
|
case 'distillProvider':
|
|
198
247
|
case 'distillModel':
|
|
248
|
+
case 'jevModel':
|
|
249
|
+
case 'jevSecretFile':
|
|
250
|
+
case 'jevLayaUrl':
|
|
199
251
|
return raw
|
|
252
|
+
case 'jevLayaFallback': {
|
|
253
|
+
if (raw === 'on' || raw === 'true') return true
|
|
254
|
+
if (raw === 'off' || raw === 'false') return false
|
|
255
|
+
return { error: 'jev-laya-fallback 取值 on|off' }
|
|
256
|
+
}
|
|
200
257
|
}
|
|
201
258
|
}
|
package/src/consolidate.ts
CHANGED
|
@@ -23,13 +23,25 @@
|
|
|
23
23
|
* - the cadence stamp (meta/consolidate-state.json) advances only when the
|
|
24
24
|
* model actually evaluated at least one cluster — a run that died on the
|
|
25
25
|
* first call leaves the stamp alone, and the next session start retries.
|
|
26
|
+
* - jev prefilter (design 2026-09-25 §2, default-off): with jevEnabled the
|
|
27
|
+
* cluster first answers to ONE cheap System One batch (≤6 questions: the
|
|
28
|
+
* top-5 similar pairs each ask 「同一问题吗」, plus a record-only
|
|
29
|
+
* cluster-level 「值得动吗」). Only when ≥1 pair reaches the adopt band
|
|
30
|
+
* (≥0.50) does the cluster go to the LLM — an all-non-adopt cluster skips
|
|
31
|
+
* the model call entirely. Any jev failure fails open to today's behavior;
|
|
32
|
+
* the merge conclusion generation itself stays LLM.
|
|
26
33
|
*
|
|
27
34
|
* @module consolidate
|
|
28
35
|
*/
|
|
29
36
|
|
|
30
37
|
import * as okf from './okf.ts'
|
|
38
|
+
import type { TopicsConfigValue } from './config.ts'
|
|
31
39
|
import type { TopicsService } from './service.ts'
|
|
32
40
|
import { parseOps, type ModelCaller } from './distill.ts'
|
|
41
|
+
import { jevAsk, type JevAskConfig, type JevQuestion } from './jev/client.ts'
|
|
42
|
+
import { band, type JevBand } from './jev/thresholds.ts'
|
|
43
|
+
import { digestFor, logVerdicts } from './jev/log.ts'
|
|
44
|
+
import type { JevVerdictRecord } from './jev/log.ts'
|
|
33
45
|
|
|
34
46
|
/** Below this pairwise Jaccard two topics never share a cluster. */
|
|
35
47
|
const CLUSTER_THRESHOLD = 0.3
|
|
@@ -42,6 +54,12 @@ const MAX_OPS_PER_RUN = 12
|
|
|
42
54
|
/** Conclusion chars fed per topic in a cluster payload. */
|
|
43
55
|
const CONCLUSION_SNIPPET_CHARS = 600
|
|
44
56
|
const CONSOLIDATE_MAX_TOKENS = 2500
|
|
57
|
+
/**
|
|
58
|
+
* Pair questions per cluster batch (design §2: k≤5, so with the record-only
|
|
59
|
+
* cluster-level question a batch stays ≤6 问/簇 — the measured cheap end of
|
|
60
|
+
* the batch protocol).
|
|
61
|
+
*/
|
|
62
|
+
const PREFILTER_TOP_PAIRS = 5
|
|
45
63
|
|
|
46
64
|
export interface ConsolidateOp {
|
|
47
65
|
op: 'merge' | 'promote' | 'deprecate' | 'refresh'
|
|
@@ -134,10 +152,22 @@ export interface ClusterEntry {
|
|
|
134
152
|
conclusion: string
|
|
135
153
|
}
|
|
136
154
|
|
|
155
|
+
/** One similar pair inside a cluster — the jev prefilter's question units. */
|
|
156
|
+
export interface ClusterPair {
|
|
157
|
+
/** Indices into the cluster's `entries`. */
|
|
158
|
+
i: number
|
|
159
|
+
j: number
|
|
160
|
+
/** Pairwise Jaccard from clustering — REUSED for the prefilter's pair
|
|
161
|
+
* selection (top-k at ask time), never recomputed. */
|
|
162
|
+
similarity: number
|
|
163
|
+
}
|
|
164
|
+
|
|
137
165
|
export interface TopicCluster {
|
|
138
166
|
entries: ClusterEntry[]
|
|
139
167
|
/** Peak pairwise similarity inside the cluster — runs process peaks first. */
|
|
140
168
|
similarity: number
|
|
169
|
+
/** Similar pairs (edges ≥ threshold) inside the cluster, best first. */
|
|
170
|
+
pairs: ClusterPair[]
|
|
141
171
|
}
|
|
142
172
|
|
|
143
173
|
/**
|
|
@@ -199,15 +229,101 @@ export function clusterTopics(
|
|
|
199
229
|
.sort((a, b) => (degree.get(b) ?? 0) - (degree.get(a) ?? 0))
|
|
200
230
|
.slice(0, MAX_CLUSTER_SIZE)
|
|
201
231
|
}
|
|
232
|
+
// Reuse the clustering edges for the prefilter's pair list: positions map
|
|
233
|
+
// into the (possibly capped) kept order, best similarity first. Edges
|
|
234
|
+
// lost to the cap — or transitively-clustered pairs below threshold — are
|
|
235
|
+
// not pair candidates; a capped cluster with no inner edge left simply
|
|
236
|
+
// has no pairs (the prefilter then behaves as today).
|
|
237
|
+
const position = new Map<number, number>()
|
|
238
|
+
kept.forEach((original, pos) => position.set(original, pos))
|
|
239
|
+
const pairs: ClusterPair[] = []
|
|
240
|
+
for (const [key, s] of scores) {
|
|
241
|
+
const i = Math.floor(key / entries.length)
|
|
242
|
+
const j = key % entries.length
|
|
243
|
+
const pi = position.get(i)
|
|
244
|
+
const pj = position.get(j)
|
|
245
|
+
if (pi === undefined || pj === undefined) continue
|
|
246
|
+
pairs.push({ i: pi, j: pj, similarity: s })
|
|
247
|
+
}
|
|
248
|
+
pairs.sort((a, b) => b.similarity - a.similarity)
|
|
202
249
|
clusters.push({
|
|
203
250
|
entries: kept.map((i) => entries[i] as ClusterEntry),
|
|
204
251
|
similarity: peak,
|
|
252
|
+
pairs,
|
|
205
253
|
})
|
|
206
254
|
}
|
|
207
255
|
clusters.sort((a, b) => b.similarity - a.similarity)
|
|
208
256
|
return clusters
|
|
209
257
|
}
|
|
210
258
|
|
|
259
|
+
// --- Jev prefilter protocol (design 2026-09-25 §2, sweep-aligned) ------------
|
|
260
|
+
|
|
261
|
+
// The merge-pair thresholds (band(..., 'mergePair')) are BOUND to the t4
|
|
262
|
+
// threshold-sweep batch protocol: shared state, two-sided criteria, two
|
|
263
|
+
// 600-char side excerpts per question. Absolute scores are protocol-sensitive
|
|
264
|
+
// (t4 §4: the same case scored 0.15 / 0.60 / 0.71 across protocols), so the
|
|
265
|
+
// texts below are copied VERBATIM from
|
|
266
|
+
// .wayfinder/research/t4-labeling-kit/threshold-sweep.mjs (MRG_STATE /
|
|
267
|
+
// MRG_CRITERIA / mrgInstructions) — changing any of them requires a re-sweep
|
|
268
|
+
// of the lines, do not tune in place.
|
|
269
|
+
const PREFILTER_STATE = [
|
|
270
|
+
'背景:你在为一个个人记忆库做簇内 merge 校准。整理(consolidate)时会把讲同一问题的多个 topic 合并成一份;误并会造成信息丢失,漏并会造成库内冗余与后续漂移。',
|
|
271
|
+
'判定标准:同一问题指两条记录的是同一件事——同一 bug/特性/任务的不同阶段、两面或后续进展,合并保留一份不丢实质信息。相邻但独立不算:两条各自成立、合并会丢失各自信息(如命名澄清 vs 机制对照、修不同 bug 的相邻提交、不同仓库的两次发版)。',
|
|
272
|
+
].join('\n')
|
|
273
|
+
|
|
274
|
+
const PAIR_CRITERIA = {
|
|
275
|
+
true: '同一问题——两条记录的是同一件事的不同阶段/两面/后续进展,合并保留一份不丢实质信息',
|
|
276
|
+
false: '相邻但独立——各自成立,合并会丢失各自信息(如命名澄清 vs 机制对照、修不同 bug、不同仓库发版)',
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Cluster-level 「值得动吗」 — v1 records it only, never gates (design §2:
|
|
280
|
+
// 簇级问只记不门控,无扫描数据).
|
|
281
|
+
const CLUSTER_CRITERIA = {
|
|
282
|
+
true: '值得整理——簇内存在值得动作的条目(重复可并、draft 已稳定、已被取代、元数据失准),动作有实质收益',
|
|
283
|
+
false: '不值得动——条目各自成立,或任何整理动作都不会带来实质收益',
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Pair question body — the sweep's mrgInstructions: two sides of
|
|
287
|
+
* title/tags/conclusion excerpt (ClusterEntry.conclusion is already capped
|
|
288
|
+
* at CONCLUSION_SNIPPET_CHARS = the sweep's 600-char protocol). */
|
|
289
|
+
function pairInstructions(a: ClusterEntry, b: ClusterEntry): string {
|
|
290
|
+
const side = (t: ClusterEntry, name: string): string =>
|
|
291
|
+
[`## topic ${name}`, `- title: ${t.title}`, `- tags: ${t.tags.join(', ')}`, '- conclusion:', t.conclusion].join('\n')
|
|
292
|
+
return [side(a, 'A'), '', side(b, 'B'), '', '综合以上材料判断:这两个 topic 讲的是同一个问题、应当合并吗?'].join('\n')
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Cluster-level question body — titles/status/tags only, no conclusions. */
|
|
296
|
+
function clusterInstructions(cluster: TopicCluster): string {
|
|
297
|
+
const lines = cluster.entries.map((e) => `- ${e.title}(${e.status},tags: ${e.tags.join(', ')})`)
|
|
298
|
+
return [
|
|
299
|
+
'## 候选簇内的 topic',
|
|
300
|
+
...lines,
|
|
301
|
+
'',
|
|
302
|
+
'综合以上材料判断:这一簇值得一次整理动作吗(merge/promote/deprecate/refresh 任一)?',
|
|
303
|
+
].join('\n')
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* The prefilter's jev config slice — undefined unless jevEnabled is explicitly
|
|
308
|
+
* true (default-off master switch: false ⇒ the whole branch is dead code and
|
|
309
|
+
* today's behavior runs untouched). Optional keys fall back to their
|
|
310
|
+
* documented defaults (config.ts: the bare-harness DEFAULTS literal predates
|
|
311
|
+
* them; consumers treat undefined as the default).
|
|
312
|
+
*/
|
|
313
|
+
function jevConfigOf(cfg: TopicsConfigValue): JevAskConfig | undefined {
|
|
314
|
+
if (cfg.jevEnabled !== true) return undefined
|
|
315
|
+
return {
|
|
316
|
+
jevBackend: cfg.jevBackend ?? 'zen',
|
|
317
|
+
jevModel: cfg.jevModel ?? '',
|
|
318
|
+
jevTimeoutMs: cfg.jevTimeoutMs ?? 3000,
|
|
319
|
+
jevSecretFile: cfg.jevSecretFile ?? '',
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** The jev seam — injectable so tests run hermetic (same pattern as
|
|
324
|
+
* ModelCaller: production never passes it, the real jevAsk is the default). */
|
|
325
|
+
export type JevAsker = typeof jevAsk
|
|
326
|
+
|
|
211
327
|
// --- Deprecated-TTL housekeeping (local rule, no model) ----------------------
|
|
212
328
|
|
|
213
329
|
/**
|
|
@@ -257,6 +373,14 @@ interface ClusterOutcome {
|
|
|
257
373
|
ops: ConsolidateOp[]
|
|
258
374
|
}
|
|
259
375
|
|
|
376
|
+
/** Per-cluster prefilter verdict. */
|
|
377
|
+
interface PrefilterOutcome {
|
|
378
|
+
/** false = every pair missed the adopt band ⇒ the LLM call is skipped. */
|
|
379
|
+
proceed: boolean
|
|
380
|
+
/** jev failed or a gate question had no usable score ⇒ fail-open path. */
|
|
381
|
+
failOpen: boolean
|
|
382
|
+
}
|
|
383
|
+
|
|
260
384
|
interface AppliedConsolidation {
|
|
261
385
|
merged: string[]
|
|
262
386
|
promoted: string[]
|
|
@@ -270,10 +394,21 @@ export class Consolidator {
|
|
|
270
394
|
private inFlight: Promise<ConsolidateResult> | undefined
|
|
271
395
|
private readonly service: TopicsService
|
|
272
396
|
private readonly caller: ModelCaller | undefined
|
|
397
|
+
private readonly askJev: JevAsker
|
|
398
|
+
/** Best-effort host-logger sink (warn-style); undefined = stay silent,
|
|
399
|
+
* aligned with the lane's quiet discipline for success/no-clusters. */
|
|
400
|
+
private readonly note: ((message: string) => void) | undefined
|
|
273
401
|
|
|
274
|
-
constructor(
|
|
402
|
+
constructor(
|
|
403
|
+
service: TopicsService,
|
|
404
|
+
caller: ModelCaller | undefined,
|
|
405
|
+
askJev: JevAsker = jevAsk,
|
|
406
|
+
note?: (message: string) => void,
|
|
407
|
+
) {
|
|
275
408
|
this.service = service
|
|
276
409
|
this.caller = caller
|
|
410
|
+
this.askJev = askJev
|
|
411
|
+
this.note = note
|
|
277
412
|
}
|
|
278
413
|
|
|
279
414
|
get configured(): boolean {
|
|
@@ -356,8 +491,23 @@ export class Consolidator {
|
|
|
356
491
|
let evaluated = 0
|
|
357
492
|
let fatal: 'model-error' | 'invalid-output' | undefined
|
|
358
493
|
let failureDetail: string | undefined
|
|
494
|
+
// jev prefilter (design §2): one cheap System One batch decides whether a
|
|
495
|
+
// cluster is worth the LLM call at all. undefined = jevEnabled off ⇒ the
|
|
496
|
+
// whole branch never executes and behavior is exactly as before.
|
|
497
|
+
const jev = jevConfigOf(this.service.cfg)
|
|
498
|
+
let prefilterSkipped = 0
|
|
359
499
|
for (const cluster of clusters) {
|
|
360
500
|
if (calls >= MAX_MODEL_CALLS_PER_RUN) break
|
|
501
|
+
if (jev !== undefined) {
|
|
502
|
+
const pf = await this.prefilterCluster(jev, cluster)
|
|
503
|
+
if (!pf.proceed) {
|
|
504
|
+
prefilterSkipped += 1
|
|
505
|
+
this.note?.(
|
|
506
|
+
`topics consolidate: 前置过滤跳过簇(pair 均未达 adopt 线):${cluster.entries.map((e) => e.slug).join('、')}`,
|
|
507
|
+
)
|
|
508
|
+
continue
|
|
509
|
+
}
|
|
510
|
+
}
|
|
361
511
|
const outcome = await this.runCluster(sessionId, cluster)
|
|
362
512
|
calls += 1
|
|
363
513
|
if (outcome.fatal !== undefined) {
|
|
@@ -411,6 +561,7 @@ export class Consolidator {
|
|
|
411
561
|
const tail = [
|
|
412
562
|
applied.dropped > 0 ? `丢弃 ${applied.dropped} 个无效 op` : undefined,
|
|
413
563
|
`${calls} 次模型调用(评估 ${evaluated} 簇 / 候选 ${clusters.length} 簇)`,
|
|
564
|
+
prefilterSkipped > 0 ? `前置过滤跳过 ${prefilterSkipped} 簇` : undefined,
|
|
414
565
|
fatal !== undefined ? `中途失败:${failureDetail ?? 'unknown'}` : undefined,
|
|
415
566
|
stampNote === '' ? undefined : stampNote.replace(/^;/, ''),
|
|
416
567
|
]
|
|
@@ -455,6 +606,92 @@ export class Consolidator {
|
|
|
455
606
|
return clusterTopics(entries).slice(0, MAX_MODEL_CALLS_PER_RUN)
|
|
456
607
|
}
|
|
457
608
|
|
|
609
|
+
/**
|
|
610
|
+
* One jev request per cluster (design 2026-09-25 §2 整理 lane 前置). The
|
|
611
|
+
* top-k similar pairs (k ≤ {@link PREFILTER_TOP_PAIRS}, edges reused from
|
|
612
|
+
* clustering) each get a pair noul question 「同一问题吗」; one record-only
|
|
613
|
+
* cluster-level 「值得动吗」 rides along (固定 band='record', never gates).
|
|
614
|
+
* Gate: ≥1 pair in the adopt band → the cluster goes to the LLM; all pairs
|
|
615
|
+
* below → skipped (省调用). Any jev failure proceeds fail-open — the
|
|
616
|
+
* call-layer telemetry row (outcome !== 'ok' marks the fail-open) was already written by jevAsk
|
|
617
|
+
* itself; only the verdict layer is ours here (agree='n/a', nothing lexical
|
|
618
|
+
* to reconcile against on this lane).
|
|
619
|
+
*/
|
|
620
|
+
private async prefilterCluster(jev: JevAskConfig, cluster: TopicCluster): Promise<PrefilterOutcome> {
|
|
621
|
+
const pairs = cluster.pairs.slice(0, PREFILTER_TOP_PAIRS)
|
|
622
|
+
// No local similarity evidence (e.g. a capped cluster whose edges all
|
|
623
|
+
// fell outside) — behave exactly as today, zero jev calls.
|
|
624
|
+
if (pairs.length === 0) return { proceed: true, failOpen: false }
|
|
625
|
+
const questions: Record<string, JevQuestion> = {}
|
|
626
|
+
interface QuestionMeta {
|
|
627
|
+
questionId: string
|
|
628
|
+
ref: string
|
|
629
|
+
instructions: string
|
|
630
|
+
/** Pair questions gate; the cluster-level question records only. */
|
|
631
|
+
gating: boolean
|
|
632
|
+
}
|
|
633
|
+
const metas: QuestionMeta[] = []
|
|
634
|
+
pairs.forEach((p, idx) => {
|
|
635
|
+
const a = cluster.entries[p.i]
|
|
636
|
+
const b = cluster.entries[p.j]
|
|
637
|
+
const instructions = pairInstructions(a, b)
|
|
638
|
+
const questionId = `p${idx + 1}`
|
|
639
|
+
questions[questionId] = { type: 'noul', instructions, criteria: PAIR_CRITERIA }
|
|
640
|
+
metas.push({ questionId, ref: `pair:${a.slug}|${b.slug}`, instructions, gating: true })
|
|
641
|
+
})
|
|
642
|
+
const clusterQuestion = clusterInstructions(cluster)
|
|
643
|
+
questions['cluster'] = { type: 'noul', instructions: clusterQuestion, criteria: CLUSTER_CRITERIA }
|
|
644
|
+
metas.push({
|
|
645
|
+
questionId: 'cluster',
|
|
646
|
+
ref: `cluster:${cluster.entries[0]?.slug ?? ''}`,
|
|
647
|
+
instructions: clusterQuestion,
|
|
648
|
+
gating: false,
|
|
649
|
+
})
|
|
650
|
+
|
|
651
|
+
const r = await this.askJev({
|
|
652
|
+
state: PREFILTER_STATE,
|
|
653
|
+
questions,
|
|
654
|
+
config: jev,
|
|
655
|
+
lane: 'consolidate-prefilter',
|
|
656
|
+
fallback: false,
|
|
657
|
+
// fallback stays FALSE at call time (jevAsk writes before the outcome
|
|
658
|
+
// is known); fail-open occurrence = outcome !== 'ok' — this lane's
|
|
659
|
+
// "cluster goes to the LLM as today" is deterministic per ADR 0018.
|
|
660
|
+
})
|
|
661
|
+
if (!r.ok) return { proceed: true, failOpen: true }
|
|
662
|
+
const at = new Date().toISOString()
|
|
663
|
+
const recs: JevVerdictRecord[] = []
|
|
664
|
+
let adopt = false
|
|
665
|
+
let failOpen = false
|
|
666
|
+
for (const meta of metas) {
|
|
667
|
+
// Answer shape per the sweep protocol: noul answers carry the
|
|
668
|
+
// probability on the `noul` field; anything else is unusable.
|
|
669
|
+
const raw = (r.answers[meta.questionId] as { noul?: unknown } | undefined)?.noul
|
|
670
|
+
if (typeof raw !== 'number' || !Number.isFinite(raw)) {
|
|
671
|
+
// A gate question without a usable score must never veto the cluster.
|
|
672
|
+
if (meta.gating) failOpen = true
|
|
673
|
+
continue
|
|
674
|
+
}
|
|
675
|
+
const probability = Math.min(1, Math.max(0, raw))
|
|
676
|
+
const verdictBand: JevBand = meta.gating ? band(probability, 'mergePair') : 'record'
|
|
677
|
+
if (verdictBand === 'adopt') adopt = true
|
|
678
|
+
recs.push({
|
|
679
|
+
at,
|
|
680
|
+
lane: 'consolidate-prefilter',
|
|
681
|
+
questionId: meta.questionId,
|
|
682
|
+
qtype: 'noul',
|
|
683
|
+
ref: meta.ref,
|
|
684
|
+
digest: digestFor(PREFILTER_STATE, meta.questionId, meta.instructions),
|
|
685
|
+
probability,
|
|
686
|
+
band: verdictBand,
|
|
687
|
+
agree: 'n/a',
|
|
688
|
+
})
|
|
689
|
+
}
|
|
690
|
+
await logVerdicts(recs)
|
|
691
|
+
if (failOpen) return { proceed: true, failOpen: true }
|
|
692
|
+
return { proceed: adopt, failOpen: false }
|
|
693
|
+
}
|
|
694
|
+
|
|
458
695
|
/** One model call over one cluster, ops shape-validated but not yet applied. */
|
|
459
696
|
private async runCluster(sessionId: string | undefined, cluster: TopicCluster): Promise<ClusterOutcome> {
|
|
460
697
|
const caller = this.caller
|