dsh-prime-memory 0.25.0 → 0.26.1

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.
@@ -1,21 +1,76 @@
1
1
  import { callLLM, parseJsonLogged } from './llm.js';
2
2
  import { normKeywords, normUsageScenario, KEYWORDS_METADATA_KEY } from './metadata-validators.js';
3
3
  import { yieldLoop } from './util/yield.js';
4
- /** 每块条数(一次 LLM 调用判定的记录数;与 wing-backfill 同口径)。 */
5
- const CHUNK = 20;
4
+ // 一个请求只装**一条**记忆(旧形状是 20 条/次,已改)。与 redistill 同口径:
5
+ // 合并请求把爆炸半径从 1 条放大到 20 条(一次截断/空输出就整批丢弃),而实测单条
6
+ // 在本地端点上只是秒级,合并并不省时间。
6
7
  /** 扫描页大小(轻量投影分页;与 wing-backfill 一致)。 */
7
8
  const PAGE = 200;
8
9
  /** 单次任务默认处理上限(cfg.backfill.maxRecords 优先)。 */
9
- const DEFAULT_MAX_RECORDS = 300;
10
- /** 墙钟默认预算(cfg.backfill.timeBudgetMs 优先)。 */
11
- const DEFAULT_TIME_BUDGET_MS = 120_000;
12
- const state = { running: false, updated: 0, failed: 0 };
10
+ const DEFAULT_MAX_RECORDS = 40;
11
+ /** 总墙钟默认预算(cfg.backfill.timeBudgetMs 优先)。 */
12
+ const DEFAULT_TIME_BUDGET_MS = 1_800_000;
13
+ /** 默认并行度(cfg.backfill.concurrency 优先):1 = 串行逐条(用户定的形状)。 */
14
+ const DEFAULT_CONCURRENCY = 1;
15
+ /**
16
+ * 一次调用(含整条回退链)的默认超时(cfg.backfill.callTimeoutMs 优先)。
17
+ * 不能沿用 `llm.timeoutMs`(120s):它是整次调用的预算而不是每条路由各享一份,
18
+ * 主路由空转一轮再降级就会撞线。
19
+ */
20
+ const DEFAULT_CALL_TIMEOUT_MS = 240_000;
21
+ /** 单条关键词标注的输出预算(5 关键词 + 3 情景,实测远低于此)。 */
22
+ const LABEL_MAX_TOKENS = 512;
23
+ /** 并发度钳位(与 schema 的 min1/max8 同口径)。 */
24
+ function clampConcurrency(raw) {
25
+ const n = typeof raw === 'number' && Number.isFinite(raw) ? Math.round(raw) : DEFAULT_CONCURRENCY;
26
+ return Math.min(8, Math.max(1, n));
27
+ }
28
+ const state = {
29
+ running: false, updated: 0, failed: 0,
30
+ chunk: 1, candidates: 0, processed: 0,
31
+ scanning: false, batchesDone: 0, batchTotal: 0, concurrency: DEFAULT_CONCURRENCY, deferred: 0,
32
+ cancelled: false, scanned: 0, scanTotal: 0,
33
+ };
34
+ /**
35
+ * 人工中止信号。置位后:①池子不再派新活;②已发出的请求先拿**宽限**(cancelGraceMs)
36
+ * 自然收工(token 不白费、已写回的全入账);③**宽限到期强杀**在飞请求——
37
+ * 没有③的话「中止」最坏要等满 callTimeoutMs(240s) 才生效,等于没中止。
38
+ */
39
+ let abortRequested = false;
40
+ /** 本轮在飞请求的强杀器(runOnce 入口新建)。 */
41
+ let runAbort = null;
42
+ /** 中止宽限计时器(一次性)。 */
43
+ let graceTimer = null;
44
+ /** 本轮生效的中止宽限(启动时从 cfg 读定,已钳位)。 */
45
+ let graceMs = 15_000;
46
+ /**
47
+ * 中止宽限钳位。**硬界 30s**(用户定:中止必须在 30s 内强停)——无论 YAML 写了多少,
48
+ * 都不会超过这个值;未配置/非法取默认 15s。
49
+ */
50
+ function clampGraceMs(raw) {
51
+ const n = typeof raw === 'number' && Number.isFinite(raw) ? Math.round(raw) : 15_000;
52
+ return Math.min(30_000, Math.max(0, n));
53
+ }
54
+ /** 请求中止本轮(空闲时无副作用);返回后中止是**有界**的:最多等 graceMs。 */
55
+ export function cancelKeywordBackfill() {
56
+ if (!state.running)
57
+ return { cancelled: false, running: false };
58
+ abortRequested = true;
59
+ if (graceTimer === null) {
60
+ graceTimer = setTimeout(() => {
61
+ graceTimer = null;
62
+ if (state.running)
63
+ runAbort?.abort();
64
+ }, Math.max(0, graceMs));
65
+ }
66
+ return { cancelled: true, running: true };
67
+ }
13
68
  export function keywordBackfillState() {
14
69
  return { ...state };
15
70
  }
16
71
  // ── SYSTEM 提示词(定稿版,由 tools/keyword-distill.prompt.md 的 ```text 块迁移) ──
17
72
  const SYSTEM_PROMPT = [
18
- '你是记忆库的关键词与情景标注器。给定若干条记忆(每条含 id、标题、正文摘要),为每条提炼:',
73
+ '你是记忆库的关键词与情景标注器。给定**一条**记忆(含标题与正文),为它提炼:',
19
74
  '1) keywords:最能代表该记忆的检索词,用于精确命中;2) usage_scenario:未来「何时应当浮现这条记忆」的场景短语。',
20
75
  '',
21
76
  '严格规则:',
@@ -24,10 +79,9 @@ const SYSTEM_PROMPT = [
24
79
  '- keywords:2–5 个(宁少勿滥,够区分即可);每个 2–6 词或 1 个复合术语;不得与标题字面重复、不得互相近义堆叠、不收停用词/整句。',
25
80
  '- usage_scenario:1–3 条;每条 5–10 词的短场景描述(不是完整命令句);彼此必须可区分,相似的合并成更泛化的一条。',
26
81
  '- 语言跟随该记忆正文:中文记忆产出中文(技术标识符、API 名、错误码原样保留),英文记忆产出英文。',
27
- '- id 必须原样照抄,不得改写、截断或改用序号;一条记忆只产一组,不要拆分。',
28
82
  '',
29
- '输出:只输出一个 JSON 数组,不要多余文字、不要解释。每项形如:',
30
- '{"id":"<原id>","keywords":["…","…"],"usage_scenario":["…","…"]}',
83
+ '输出:只输出**一个 JSON 对象**(不是数组),不要多余文字、不要解释。形如:',
84
+ '{"keywords":["…","…"],"usage_scenario":["…","…"]}',
31
85
  ].join('\n');
32
86
  /** 启动后台关键词回填;已在运行返回 false(单飞)。 */
33
87
  export function startKeywordBackfill(deps) {
@@ -36,9 +90,20 @@ export function startKeywordBackfill(deps) {
36
90
  state.running = true;
37
91
  state.updated = 0;
38
92
  state.failed = 0;
93
+ state.candidates = 0;
94
+ state.processed = 0;
95
+ state.scanning = true;
96
+ state.batchesDone = 0;
97
+ state.batchTotal = 0;
98
+ state.deferred = 0;
99
+ state.cancelled = false;
100
+ abortRequested = false;
101
+ state.concurrency = clampConcurrency(deps.cfg.backfill?.concurrency);
39
102
  void runKeywordBackfillOnce(deps).finally(() => {
40
103
  state.running = false;
41
- deps.logger.info(`[memory] keyword 回填结束:成功 ${state.updated} 条,失败 ${state.failed} 条`);
104
+ state.scanning = false;
105
+ deps.logger.info(`[memory] keyword 回填结束:成功 ${state.updated} 条,失败 ${state.failed} 条`
106
+ + (state.deferred > 0 ? `,留待下次 ${state.deferred} 条` : ''));
42
107
  });
43
108
  return { started: true, running: true };
44
109
  }
@@ -48,18 +113,32 @@ export function startKeywordBackfill(deps) {
48
113
  */
49
114
  export async function runKeywordBackfillOnce(deps) {
50
115
  const { cfg, backend, logger } = deps;
116
+ // 新一轮入口清中止信号(同 redistill:直调 runOnce 的路径不经过 startX 的复位)。
117
+ abortRequested = false;
118
+ state.cancelled = false;
119
+ if (graceTimer !== null) {
120
+ clearTimeout(graceTimer);
121
+ graceTimer = null;
122
+ }
123
+ runAbort = new AbortController();
51
124
  if (!cfg.keyword?.enabled) {
52
125
  logger.info('[memory] keyword 回填跳过:keyword.enabled = false(功能关,不回填)');
53
126
  return;
54
127
  }
55
128
  const MAX_RECORDS = cfg.backfill?.maxRecords ?? DEFAULT_MAX_RECORDS;
56
129
  const TIME_BUDGET_MS = cfg.backfill?.timeBudgetMs ?? DEFAULT_TIME_BUDGET_MS;
130
+ graceMs = clampGraceMs(cfg.backfill?.cancelGraceMs);
131
+ const LIMIT = clampConcurrency(cfg.backfill?.concurrency);
132
+ state.concurrency = LIMIT;
57
133
  const startedAt = Date.now();
58
- // ── 扫描阶段(轻量投影,不反序列化正文)──
134
+ // ── 扫描阶段(轻量投影,不反序列化正文;分页 + 每页让位,不设墙钟)──
59
135
  const total = await backend.size();
60
136
  const pendingIds = [];
61
137
  let scanned = 0;
138
+ state.scanning = true;
62
139
  for (let offset = 0;; offset += PAGE) {
140
+ if (abortRequested)
141
+ break; // 中止:扫描段也当场退出(不把最后一页白扫完)
63
142
  const page = await backend.allLite(PAGE, offset);
64
143
  if (page.length === 0)
65
144
  break;
@@ -69,75 +148,138 @@ export async function runKeywordBackfillOnce(deps) {
69
148
  pendingIds.push(r.id);
70
149
  }
71
150
  scanned += page.length;
72
- if (Date.now() - startedAt > TIME_BUDGET_MS) {
73
- logger.info(`[memory] keyword 回填:墙钟预算(${TIME_BUDGET_MS / 1000}s)在扫描阶段用尽(已扫 ${scanned}/${total}),留待再触发`);
74
- return;
75
- }
151
+ state.candidates = pendingIds.length;
152
+ state.scanned = scanned;
153
+ state.scanTotal = total;
76
154
  await yieldLoop();
77
155
  }
78
- let budget = Math.min(pendingIds.length, MAX_RECORDS);
79
- logger.info(`[memory] keyword 回填启动:缺关键词 ${pendingIds.length} 条,本次上限 ${budget} 条`);
80
- // ── 标注阶段(补水→LLM→归一→写回)──
81
- for (let offset = 0; offset < pendingIds.length && budget > 0; offset += CHUNK) {
82
- if (Date.now() - startedAt > TIME_BUDGET_MS) {
83
- logger.info(`[memory] keyword 回填:墙钟预算用尽,剩余 ${pendingIds.length - offset} 条留待再触发`);
84
- break;
156
+ state.scanning = false;
157
+ const batch = pendingIds.slice(0, MAX_RECORDS);
158
+ state.candidates = batch.length;
159
+ state.batchTotal = batch.length;
160
+ state.deferred = Math.max(0, pendingIds.length - batch.length);
161
+ const callTimeoutMs = cfg.backfill?.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS;
162
+ logger.info(`[memory] keyword 回填启动:已扫 ${scanned}/${total} 行,缺关键词 ${pendingIds.length} 条,本次处理 ${batch.length} 条,并行度 ${LIMIT}`);
163
+ /** 一条一个请求:取一条 → 标注 → **即时写回**(含 fts 重索引)。 */
164
+ const labelAndCommit = async (id) => {
165
+ const [rec] = await backend.getByIds([id]);
166
+ if (!rec || !rec.content || rec.content.trim() === '') {
167
+ state.processed++;
168
+ state.batchesDone++;
169
+ return;
170
+ }
171
+ const labeled = await labelKeywordOne(deps.ctx, cfg, logger, rec, callTimeoutMs, runAbort?.signal);
172
+ if (!labeled) {
173
+ // 中止强杀导致的失败不计入 failed(不是模型/网络的错,是中止信号)
174
+ if (!abortRequested)
175
+ state.failed++;
85
176
  }
86
- const hydrated = await backend.getByIds(pendingIds.slice(offset, offset + CHUNK));
87
- const chunk = hydrated.filter((r) => r.content && r.content.trim() !== '');
88
- if (chunk.length === 0)
89
- continue;
90
- budget -= chunk.length;
91
- const labeled = await labelKeywordChunk(deps.ctx, cfg, logger, chunk);
92
- for (const { record, keywords, usageScenario } of labeled) {
93
- const meta = { ...(record.metadata ?? {}) };
94
- meta[KEYWORDS_METADATA_KEY] = keywords;
95
- if (usageScenario && usageScenario.length > 0)
96
- meta.dsh_usage_scenario = usageScenario;
97
- if (await backend.patchMetadataAndReindex(record.id, meta))
177
+ else {
178
+ const meta = { ...(rec.metadata ?? {}) };
179
+ meta[KEYWORDS_METADATA_KEY] = labeled.keywords;
180
+ if (labeled.usageScenario.length > 0)
181
+ meta.dsh_usage_scenario = labeled.usageScenario;
182
+ if (await backend.patchMetadataAndReindex(rec.id, meta))
98
183
  state.updated++;
99
184
  else
100
185
  state.failed++;
101
- await yieldLoop();
102
186
  }
187
+ // 完成才计数(同 redistill):否则“全失败”在面板上跟“全成功”长得一样。
188
+ state.processed++;
189
+ state.batchesDone++;
190
+ await yieldLoop();
191
+ };
192
+ /**
193
+ * 弹性工作池(与 redistill 同构):每完成一条重读一次**生效并发**,拖概览页滑块即时生效。
194
+ * 拖高立刻补 worker;拖低则各自干完手头那条后自然收工。缺省 getter = 启动值。
195
+ * 预算只在**条前**检查,中途不挖已烧 token 的那条。
196
+ */
197
+ const readLimit = () => {
198
+ const raw = deps.concurrency ? deps.concurrency() : undefined;
199
+ return clampConcurrency(raw ?? cfg.backfill?.concurrency);
200
+ };
201
+ let cursor = 0;
202
+ let budgetHit = false;
203
+ const work = async () => {
204
+ for (;;) {
205
+ if (abortRequested)
206
+ return; // 中止:不再取新活(在飞的那条自然跑完)
207
+ if (budgetHit || Date.now() - startedAt > TIME_BUDGET_MS) {
208
+ budgetHit = true;
209
+ return;
210
+ }
211
+ const i = cursor++;
212
+ if (i >= batch.length)
213
+ return;
214
+ await labelAndCommit(batch[i]);
215
+ }
216
+ };
217
+ const inFlight = new Set();
218
+ const spawn = () => {
219
+ const p = work().finally(() => inFlight.delete(p));
220
+ inFlight.add(p);
221
+ };
222
+ for (;;) {
223
+ const target = Math.max(1, Math.min(readLimit(), batch.length));
224
+ state.concurrency = target; // 面板「并行 N」跟实时值走
225
+ while (!budgetHit && !abortRequested && inFlight.size < target && cursor < batch.length)
226
+ spawn();
227
+ if (inFlight.size === 0)
228
+ break;
229
+ await Promise.race(inFlight);
230
+ }
231
+ if (abortRequested) {
232
+ state.cancelled = true;
233
+ state.deferred += Math.max(0, batch.length - state.processed);
234
+ logger.info(`[memory] keyword 回填:已按请求中止(在飞请求跑完即收工),本轮完成 ${state.processed} 条,剩 ${state.deferred} 条留待再触发`);
235
+ }
236
+ if (budgetHit) {
237
+ state.deferred += Math.max(0, batch.length - state.processed);
238
+ logger.info(`[memory] keyword 回填:墙钟预算(${Math.round(TIME_BUDGET_MS / 1000)}s)用尽,累计留待再触发 ${state.deferred} 条`);
103
239
  }
104
240
  }
105
- /** 关键词标注器(导出供单测)。 */
106
- export async function labelKeywordChunk(ctx, cfg, logger, chunk) {
107
- const list = chunk.map((r, i) => `${i + 1}. id=${r.id}\n标题:${r.name ?? ''}\n正文:${r.content.slice(0, 300)}`).join('\n\n');
108
- const user = '记忆列表:\n' + list;
241
+ /**
242
+ * 标注**一条**记忆的关键词与情景(一个请求只装一条)。失败/无合法关键词返回 null。
243
+ *
244
+ * 旧名 `labelKeywordChunk`(多条一次)已废:合并请求下一次截断/空输出就整批丢弃,
245
+ * 而逐条只花秒级。记账(state.failed 等)全部归调用方——标注器不再动全局状态,
246
+ * 否则同一条会被两处各计一次。
247
+ */
248
+ export async function labelKeywordOne(ctx, cfg, logger, rec, callTimeoutMs = DEFAULT_CALL_TIMEOUT_MS,
249
+ /** 本轮中止信号(可选):与单次超时**取或**,宽限到期后由它当场掐断请求。 */
250
+ externalSignal) {
251
+ const user = `记忆:\n标题:${rec.name ?? ''}\n正文:${rec.content.slice(0, 900)}`;
252
+ const t0 = Date.now();
253
+ const secs = () => Math.round((Date.now() - t0) / 10);
109
254
  try {
110
255
  const raw = await callLLM(ctx, cfg, {
111
256
  system: SYSTEM_PROMPT,
112
257
  user,
113
- maxTokens: 4096,
258
+ maxTokens: LABEL_MAX_TOKENS,
114
259
  layer: 'l1-extract',
260
+ // 关键词/情景标注是直答任务:显式关思考,否则思考模型会把 512 预算烧在
261
+ // reasoning 上、正文吐 0 字符(真机 156/162 次 `llm empty output` 皆为此)。
262
+ reasoningEffort: 'off',
115
263
  logger,
264
+ signal: (() => {
265
+ const t = AbortSignal.timeout(callTimeoutMs > 0 ? callTimeoutMs : DEFAULT_CALL_TIMEOUT_MS);
266
+ return externalSignal ? AbortSignal.any([t, externalSignal]) : t;
267
+ })(),
116
268
  });
269
+ logger.debug?.(`[memory] keyword 单条耗时 ${(Date.now() - t0) / 1000}s(id=${rec.id})`);
117
270
  const parsed = parseJsonLogged(raw, 'keyword 回填', logger);
118
- if (!Array.isArray(parsed))
119
- return [];
120
- const byId = new Map(chunk.map((r) => [r.id, r]));
121
- const out = [];
122
- for (const item of parsed) {
123
- const record = typeof item?.id === 'string' ? byId.get(item.id) : undefined;
124
- if (!record) {
125
- logger.warn(`[memory] keyword 回填:id 配对失败,已丢弃(id=${String(item?.id).slice(0, 40)})`);
126
- continue;
127
- }
128
- const keywords = normKeywords(item.keywords, 5);
129
- if (keywords.length === 0) {
130
- logger.warn(`[memory] keyword 回填:normKeywords 结果为空,已跳过(id=${record.id})`);
131
- continue;
132
- }
133
- const usageScenario = normUsageScenario(item.usage_scenario, 3);
134
- out.push({ record, keywords, usageScenario: usageScenario.length > 0 ? usageScenario : undefined });
271
+ const obj = Array.isArray(parsed) ? parsed[0] : parsed;
272
+ if (!obj || typeof obj !== 'object')
273
+ return null;
274
+ const keywords = normKeywords(obj.keywords, 5);
275
+ if (keywords.length === 0) {
276
+ logger.warn(`[memory] keyword 回填:normKeywords 结果为空,已跳过(id=${rec.id})`);
277
+ return null;
135
278
  }
136
- return out;
279
+ return { keywords, usageScenario: normUsageScenario(obj.usage_scenario, 3) };
137
280
  }
138
281
  catch (err) {
139
- logger.warn(`[memory] keyword 回填块失败(跳过 ${chunk.length} 条,原记录未改): ${err instanceof Error ? err.message : String(err)}`);
140
- state.failed += chunk.length;
141
- return [];
282
+ logger.warn(`[memory] keyword 单条失败(id=${rec.id},已等 ${secs() / 10}s,原记录未改): ${err instanceof Error ? err.message : String(err)}`);
283
+ return null;
142
284
  }
143
285
  }
package/dist/llm.d.ts CHANGED
@@ -17,6 +17,15 @@ export interface LlmCallOptions {
17
17
  maxTokens?: number;
18
18
  temperature?: number;
19
19
  signal?: AbortSignal;
20
+ /**
21
+ * **调用级思考档位覆盖**(可选)。非空时优先于路由/层链/全局解析出的档位。
22
+ *
23
+ * 为什么需要:回填批扫(整形/关键词/wing/room)是「重排 + 拎标识」的直答任务,
24
+ * 不需要推理;而思考模型默认开思考,会把整个 maxTokens 预算烧在 reasoning 上、
25
+ * 正文吐 0 字符(真机:156/162 次 `llm empty output` 都是这么来的)。
26
+ * 显式传 `off` 后预算全留给答案。档位仍过一遍模型能力钳制:模型不支持 off 时不传。
27
+ */
28
+ reasoningEffort?: string;
20
29
  /** 诊断日志(缺失时静默,不影响调用) */
21
30
  logger?: MemoryLogger;
22
31
  }
package/dist/llm.js CHANGED
@@ -393,8 +393,10 @@ async function callRoute(ctx, cfg, opts, route) {
393
393
  const { provider, model } = route;
394
394
  const signal = opts.signal ?? AbortSignal.timeout(cfg.llm.timeoutMs);
395
395
  // 档位按模型能力决策(跨供应商 effort 兼容):不支持的档位不传 + 告警一次,
396
- // 空配置 = 自动(模型默认 → high);路由的档位候选已在链解析时定好(条目 > 全局)
397
- const effort = await planDistillEffort(ctx, provider, model, route.effort, opts.logger);
396
+ // 空配置 = 自动(模型默认 → high);路由的档位候选已在链解析时定好(条目 > 全局)。
397
+ // 调用级覆盖(opts.reasoningEffort)优先——批扫靠它关掉思考,避免预算被 reasoning 吃光。
398
+ const routeEffort = opts.reasoningEffort ?? route.effort;
399
+ const effort = await planDistillEffort(ctx, provider, model, routeEffort, opts.logger);
398
400
  // 输入预算兜底:任何蒸馏调用的用户 prompt 不超过 maxInputChars
399
401
  // (L1 已在数据层分块,这里是 L2/L3 与异常场景的最后一道网)
400
402
  const user = opts.user.length > cfg.llm.maxInputChars
@@ -406,9 +408,10 @@ async function callRoute(ctx, cfg, opts, route) {
406
408
  // 配置本身就是高档时不再放大(防 ×16 双乘)
407
409
  const baseMaxTokens = opts.maxTokens ?? cfg.llm.maxTokens;
408
410
  const highTiers = HIGH_EFFORT_TIERS;
409
- const triggerEffort = opts.layer
411
+ // 触发档位也跟调用级覆盖走:显式 off 时不得被配置里的高档触发而 ×4。
412
+ const triggerEffort = opts.reasoningEffort ?? (opts.layer
410
413
  ? layerEffortTrigger(cfg, layerKeyFor(opts.layer))
411
- : cfg.llm.primaryEffort || cfg.llm.reasoningEffort;
414
+ : cfg.llm.primaryEffort || cfg.llm.reasoningEffort);
412
415
  const maxTokens = highTiers.includes(effort.effort) && !highTiers.includes(triggerEffort)
413
416
  ? layerMaxTokens(baseMaxTokens, 'high')
414
417
  : baseMaxTokens;
@@ -5,9 +5,12 @@
5
5
  * - Wing 合法性:metadata.hall 非空但不在词表(8 角 + general)→ 剥离非法值,转入 LLM 重标队列;
6
6
  * - 认知 hall:按 type 静态映射(cognitive-hall.ts)可派生而 metadata.cogHall 缺失/不一致 → 补写修正;
7
7
  * 2. **LLM 重标定**(有界批,受 wing.enabled 门控):
8
- * - 未打标 wing 的记录复用一键回填的标注器(labelWingChunk)补打;
8
+ * - 未打标 wing 的记录复用一键回填的标注器(labelWingOne)补打;
9
9
  * - 涌现标签(tags,Room 的前身):同一批记录由标注器顺带产出 1-3 个 slug 标签写 metadata.tags。
10
10
  *
11
+ * 形状约定(用户定):**按单元、串行、不合并** —— 一个请求只装一条记录
12
+ * (旧形状 20 条/请求,一次截断/空输出就带走整批)。
13
+ *
11
14
  * 安全边界与一键回填一致:LLM 失败只跳过当前块、原记录零改动;机械与 LLM 写回都有上限,防止单次跑飞。
12
15
  * 本阶段在反刍收尾(L2/L3 之后)执行,**任何失败不拖垮反刍整体**(只 warn + 计数)。
13
16
  */
@@ -38,27 +41,23 @@ export interface RelabelDeps {
38
41
  backend: MemoryBackend;
39
42
  logger: MemoryLogger;
40
43
  }
41
- /** 测试注入口:分别替换 wing 标注器与 tags 标注器(默认走真实 LLM 路径)。 */
44
+ /** 测试注入口:分别替换 wing 标注器与 tags 标注器(默认走真实 LLM 路径)。
45
+ * 两个标注器都是**逐条**形状(一个请求一条):成功返回结果,失败返回 null。 */
42
46
  export interface RelabelOverrides {
43
- wingLabeler?: (chunk: MemoryRecord[]) => Promise<Array<{
44
- record: MemoryRecord;
45
- wing: string;
46
- }>>;
47
- tagger?: (chunk: MemoryRecord[]) => Promise<Array<{
48
- record: MemoryRecord;
49
- tags: string[];
50
- }>>;
47
+ wingLabeler?: (rec: MemoryRecord) => Promise<string | null>;
48
+ tagger?: (rec: MemoryRecord) => Promise<string[] | null>;
51
49
  }
52
50
  export interface RelabelOpts {
53
51
  /**
54
52
  * 批次进度回调(relabeling 阶段的 detail/子进度由此驱动)。
55
53
  *
56
54
  * 第 4 参 `label` 用于**区分机械段与 LLM 段**:机械巡检按 200 条/批推进,
57
- * LLM 段按 20 条/批推进,两者粒度不同,面板必须能分辨(否则用户看到的是
55
+ * LLM 段**逐条**推进,两者粒度不同,面板必须能分辨(否则用户看到的是
58
56
  * 一个忽快忽慢的"重标定批次")。
59
57
  */
60
58
  progress?: (text: string, done: number, total: number, label?: string) => void;
61
- /** LLM 段墙钟预算(毫秒);超时停止,剩余计入 deferred 留待下次反刍。默认 90s。 */
59
+ /** LLM 段墙钟预算(毫秒);超时停止,剩余计入 deferred 留待下次反刍。默认 180s(逐条形状下
60
+ * 约 60 条 × 2.6s 的余量)。 */
62
61
  timeBudgetMs?: number;
63
62
  }
64
63
  export declare function relabelPass(deps: RelabelDeps, overrides?: RelabelOverrides, opts?: RelabelOpts): Promise<RelabelStats>;
@@ -1,18 +1,16 @@
1
1
  import { cognitiveHallOf, COG_HALL_METADATA_KEY } from '../cognitive-hall.js';
2
2
  import { normWingEnabled } from '../config.js';
3
3
  import { WING_FALLBACK } from '../types.js';
4
- import { labelWingChunk, tagChunk } from '../wing-backfill.js';
4
+ import { labelWingOne, tagOne } from '../wing-backfill.js';
5
5
  import { isWingId, normTags } from '../metadata-validators.js';
6
6
  import { yieldLoop } from '../util/yield.js';
7
7
  /** 机械写回上限(首次全量巡检可能上千条待补 cogHall;分次反刍消化,防单次跑飞)。 */
8
8
  const MECH_CAP = 800;
9
- /** LLM 重标定批上限(每块 20 条由标注器内部控制)。 */
9
+ /** LLM 重标定批上限(本次最多处理多少条待重标记录)。 */
10
10
  const LLM_CAP = 60;
11
- /** LLM 批大小(一次调用判定的记录数;批次进度/预算/让位都以此为粒度)。 */
12
- const LLM_CHUNK = 20;
13
11
  export async function relabelPass(deps, overrides = {}, opts = {}) {
14
12
  const { ctx, cfg, backend, logger } = deps;
15
- const timeBudgetMs = opts.timeBudgetMs ?? 90_000;
13
+ const timeBudgetMs = opts.timeBudgetMs ?? 180_000;
16
14
  const stats = {
17
15
  checked: 0,
18
16
  cogHallFixed: 0,
@@ -106,95 +104,85 @@ export async function relabelPass(deps, overrides = {}, opts = {}) {
106
104
  return stats;
107
105
  logger.info(`[memory] 反刍重标定:未打标/待重标 ${needWingIds.length} 条,本次 LLM 处理 ${batch.length} 条(候选 ${[...wingEnabled, WING_FALLBACK].join(' / ')})`);
108
106
  const wingLabeler = overrides.wingLabeler ??
109
- ((chunk) => labelWingChunk(ctx, cfg, logger, chunk, [...wingEnabled, WING_FALLBACK].join(' / ')));
110
- // ── LLM wing 重标定:20 条/批逐批推进,批间让位 + 墙钟预算 + 批次进度回显 ──
107
+ ((rec) => labelWingOne(ctx, cfg, logger, rec, [...wingEnabled, WING_FALLBACK].join(' / ')));
108
+ // ── LLM wing 重标定:逐条推进,条间让位 + 墙钟预算 + 条序进度回显 ──
111
109
  const startedAt = Date.now();
112
110
  const overBudget = () => Date.now() - startedAt > timeBudgetMs;
113
- const chunks = [];
114
- for (let i = 0; i < batch.length; i += LLM_CHUNK)
115
- chunks.push(batch.slice(i, i + LLM_CHUNK));
116
111
  let doneCount = 0;
117
112
  const taggedPool = [];
118
- for (let i = 0; i < chunks.length; i++) {
113
+ for (let i = 0; i < batch.length; i++) {
119
114
  if (overBudget()) {
120
115
  stats.deferred += batch.length - doneCount;
121
116
  opts.progress?.(`重标定:时间预算(${Math.round(timeBudgetMs / 1000)}s)用尽,剩余 ${batch.length - doneCount} 条留待下次反刍`, doneCount, batch.length);
122
117
  logger.info(`[memory] 反刍重标定:时间预算用尽,deferred ${stats.deferred} 条`);
123
118
  return stats;
124
119
  }
125
- opts.progress?.(`重标定:LLM 补 wing 第 ${i + 1}/${chunks.length} 批(已完成 ${doneCount}/${batch.length} 条)`, doneCount, batch.length, '补 Wing');
126
- let rows;
120
+ opts.progress?.(`重标定:LLM 补 wing 第 ${i + 1}/${batch.length} 条(已完成 ${doneCount}/${batch.length} 条)`, doneCount, batch.length, '补 Wing');
121
+ let wing;
127
122
  try {
128
- rows = await wingLabeler(chunks[i]);
123
+ wing = await wingLabeler(batch[i]);
129
124
  }
130
125
  catch (err) {
131
- // labelWingChunk 自身已兜底(不向调用方抛);能走到这里只可能是注入桩抛错。
126
+ // labelWingOne 自身已兜底(不向调用方抛);能走到这里只可能是注入桩抛错。
132
127
  // 即便如此也必须留痕——静默吞掉批次是本次「整批丢弃却无日志」的直接教训。
133
- stats.llmSkipped += chunks[i].length;
134
- doneCount += chunks[i].length;
135
- logger.warn(`[memory] 重标定第 ${i + 1}/${chunks.length} 批标注器抛错,跳过 ${chunks[i].length} 条: ${err instanceof Error ? err.message : String(err)}`);
128
+ stats.llmSkipped++;
129
+ doneCount++;
130
+ logger.warn(`[memory] 重标定第 ${i + 1}/${batch.length} 条标注器抛错,跳过: ${err instanceof Error ? err.message : String(err)}`);
136
131
  continue;
137
132
  }
138
- const dropped = chunks[i].length - rows.length;
139
- if (dropped > 0) {
140
- logger.warn(`[memory] 重标定第 ${i + 1}/${chunks.length} 批丢弃 ${dropped} 条(详见上方 wing 回填日志)`);
133
+ if (wing === null) {
134
+ // 标注器已 warn(字段缺失/非法值/单条失败),这里只记账。
135
+ stats.llmSkipped++;
136
+ doneCount++;
137
+ continue;
141
138
  }
142
- stats.llmSkipped += dropped;
143
- stats.wingLabeled += rows.length;
144
139
  // 写前重读:机械阶段可能已写过 cogHall,标注器持有旧副本——以库内最新为基线合并
145
- const freshRows = await backend.getByIds(rows.map(({ record }) => record.id));
146
- const freshById = new Map(freshRows.map((r) => [r.id, r]));
147
- for (const { record, wing } of rows) {
148
- const current = freshById.get(record.id) ?? record;
149
- const meta = { ...(current.metadata ?? {}) };
150
- meta.hall = wing;
151
- try {
152
- await backend.patchMetadata(current.id, meta);
153
- taggedPool.push(current);
154
- }
155
- catch {
156
- stats.wingLabeled--;
157
- stats.llmSkipped++;
158
- }
159
- await yieldLoop();
140
+ const [fresh] = await backend.getByIds([batch[i].id]);
141
+ const current = fresh ?? batch[i];
142
+ const meta = { ...(current.metadata ?? {}) };
143
+ meta.hall = wing;
144
+ try {
145
+ await backend.patchMetadata(current.id, meta);
146
+ stats.wingLabeled++;
147
+ taggedPool.push(current);
148
+ }
149
+ catch {
150
+ stats.llmSkipped++;
160
151
  }
161
- doneCount += chunks[i].length;
152
+ doneCount++;
153
+ await yieldLoop();
162
154
  }
163
- // ── 涌现标签(tags,Room 前身):同批有界 40 条,同样分批 + 预算 + 进度 ──
155
+ // ── 涌现标签(tags,Room 前身):同批有界 40 条,同样逐条 + 预算 + 进度 ──
164
156
  const tagBatch = taggedPool.slice(0, 40);
165
- const tagger = overrides.tagger ?? ((chunk) => tagChunk(ctx, cfg, logger, chunk));
166
- const tagChunks = [];
167
- for (let i = 0; i < tagBatch.length; i += LLM_CHUNK)
168
- tagChunks.push(tagBatch.slice(i, i + LLM_CHUNK));
169
- for (let i = 0; i < tagChunks.length; i++) {
157
+ const tagger = overrides.tagger ?? ((rec) => tagOne(ctx, cfg, logger, rec));
158
+ for (let i = 0; i < tagBatch.length; i++) {
170
159
  if (overBudget())
171
160
  break;
172
- opts.progress?.(`重标定:提炼涌现标签 第 ${i + 1}/${tagChunks.length} 批`, doneCount, batch.length, '提炼标签');
173
- let tagRows;
161
+ opts.progress?.(`重标定:提炼涌现标签 第 ${i + 1}/${tagBatch.length} 条`, doneCount, batch.length, '提炼标签');
162
+ let rawTags;
174
163
  try {
175
- tagRows = await tagger(tagChunks[i]);
164
+ rawTags = await tagger(tagBatch[i]);
176
165
  }
177
166
  catch (err) {
178
- logger.warn(`[memory] 重标定 tags 第 ${i + 1}/${tagChunks.length} 批标注器抛错,跳过 ${tagChunks[i].length} 条: ${err instanceof Error ? err.message : String(err)}`);
167
+ logger.warn(`[memory] 重标定 tags 第 ${i + 1}/${tagBatch.length} 条标注器抛错,跳过: ${err instanceof Error ? err.message : String(err)}`);
179
168
  continue;
180
169
  }
181
- const freshTagRows = await backend.getByIds(tagRows.map(({ record }) => record.id));
182
- const freshForTags = new Map(freshTagRows.map((r) => [r.id, r]));
183
- for (const { record, tags: rawTags } of tagRows) {
184
- const tags = normTags(rawTags); // 归一+校验在写回点强制:无论标签来自真实 LLM 还是注入桩
185
- if (tags.length === 0)
186
- continue;
187
- const current = freshForTags.get(record.id) ?? record;
188
- const meta = { ...(current.metadata ?? {}) };
189
- meta.tags = tags;
190
- try {
191
- await backend.patchMetadata(current.id, meta);
192
- stats.tagged++;
193
- await yieldLoop();
194
- }
195
- catch {
196
- /* 标签写失败不影响主流程 */
197
- }
170
+ if (!rawTags)
171
+ continue;
172
+ const tags = normTags(rawTags); // 归一+校验在写回点强制:无论标签来自真实 LLM 还是注入桩
173
+ if (tags.length === 0)
174
+ continue;
175
+ const [fresh] = await backend.getByIds([tagBatch[i].id]);
176
+ const current = fresh ?? tagBatch[i];
177
+ const meta = { ...(current.metadata ?? {}) };
178
+ meta.tags = tags;
179
+ try {
180
+ await backend.patchMetadata(current.id, meta);
181
+ stats.tagged++;
182
+ await yieldLoop();
183
+ }
184
+ catch {
185
+ /* 标签写失败不影响主流程 */
198
186
  }
199
187
  }
200
188
  return stats;