dsh-lite-memory 0.18.42 → 0.18.44

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,1450 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import fs from 'node:fs';
3
- import os from 'node:os';
4
- import path from 'node:path';
5
-
6
- process.env.DSH_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-learning-'));
7
- const store = await import('../store.js');
8
- const learning = await import('../learning.js');
9
- const anchor = await import('../anchor.js');
10
- const state = await import('../state.js');
11
- const view = await import('../view.js');
12
-
13
- let passed = 0;
14
- const test = (name, fn) => {
15
- try {
16
- fn();
17
- passed += 1;
18
- } catch (error) {
19
- console.error(`FAIL ${name}`);
20
- throw error;
21
- }
22
- };
23
-
24
- const fresh = (label) => {
25
- const dir = path.join(os.tmpdir(), `lite-memory-learn-${label}-${Math.random().toString(16).slice(2, 8)}`);
26
- fs.mkdirSync(dir, { recursive: true });
27
- return store.scopeOf(dir);
28
- };
29
- const evolutionOf = (scope) => store.loadIndex(scope).__evolution;
30
- const ruleRows = (scope) => store.loadIndex(scope).entries.filter((row) => row.origin === 'project_evolution');
31
- const ruleOf = (scope, id) => ruleRows(scope).find((row) => row.id === id);
32
-
33
- const enable = (scope, label = 'on') => {
34
- const result = store.setEvolution(scope, true, { operationId: `on_${label}_${Math.random().toString(16).slice(2, 8)}` });
35
- assert.equal(result.ok, true, result.reason);
36
- return result;
37
- };
38
-
39
- /** 造一个已绑定作品,返回锚点与落盘后的作品版本。 */
40
- function seedEpisode(scope, overrides = {}) {
41
- const sessionId = overrides.sessionId || 's1';
42
- const ids = overrides.messageIds || ['m1'];
43
- const routed = anchor.routeTask(anchor.emptyTasks(), {
44
- sessionId,
45
- projectId: 'proj',
46
- query: overrides.query || '改一下这期口播开头',
47
- messageIds: ids,
48
- batchKey: anchor.requestBatchKey(ids),
49
- defaults: { taskKind: 'video', seriesId: '口播' },
50
- now: overrides.now || Date.now(),
51
- });
52
- const saved = store.saveTaskState(scope, routed.tasks, { operationId: `tasks_${sessionId}_${Math.random().toString(16).slice(2, 8)}` });
53
- assert.equal(saved.ok, true, saved.reason);
54
- return { anchor: routed.anchor, episode: routed.episode, sessionId, messageIds: ids };
55
- }
56
-
57
- function begin(scope, grown, overrides = {}) {
58
- const result = store.beginLearningAttempt(scope, {
59
- episodeId: grown.episode.episodeId,
60
- operationId: overrides.operationId || `start_${Math.random().toString(16).slice(2, 10)}`,
61
- now: overrides.now,
62
- });
63
- return result;
64
- }
65
-
66
- const NEW_CHANGE = {
67
- target: 'new',
68
- feedbackType: 'spec_change',
69
- topicKey: '口播开头',
70
- currentText: '开头三句内给结论。',
71
- applicability: 'conditional',
72
- conditions: [{ key: 'seriesId', value: '口播' }],
73
- evidence: ['u0'],
74
- persistence: 'project_candidate',
75
- };
76
-
77
- const RULES = [];
78
- const REFS = { feedback: { u0: 'm1' }, rules: {} };
79
- const RANGES = { taskKinds: ['video'], series: ['口播'] };
80
-
81
- function parsed(change, extra = {}) {
82
- return learning.parseOutput(JSON.stringify({ changes: [change] }), {
83
- finish: { kind: 'stop' },
84
- refs: extra.refs || REFS,
85
- rules: extra.rules || RULES,
86
- ranges: extra.ranges || RANGES,
87
- });
88
- }
89
-
90
- function commitNew(scope, grown, started, change = NEW_CHANGE, overrides = {}) {
91
- const check = parsed(change, overrides);
92
- assert.equal(check.ok, true, check.reason);
93
- return store.commitLearning(scope, {
94
- operationId: overrides.operationId || `learn_${Math.random().toString(16).slice(2, 10)}`,
95
- epoch: started.epoch,
96
- episodeId: grown.episode.episodeId,
97
- episodeUpdatedAt: grown.episode.updatedAt,
98
- projectId: 'proj',
99
- sessionId: grown.sessionId,
100
- messageIds: grown.messageIds,
101
- changes: check.changes,
102
- preparedTargets: [],
103
- usage: overrides.usage,
104
- explicitLongTerm: Boolean(overrides.explicitLongTerm),
105
- now: overrides.now,
106
- });
107
- }
108
-
109
- /** 对已有规则提交一次样例或检查点变化。 */
110
- function commitExisting(scope, grown, started, id, change, extra = {}) {
111
- const flat = ruleOf(scope, id);
112
- const rules = [{ ref: 'r0', id, revision: flat.revision, conditions: flat.conditions }];
113
- const refs = { feedback: { u0: grown.messageIds[0] }, rules: { r0: id } };
114
- const check = parsed(change, { rules, refs, ranges: { taskKinds: ['video'], series: ['口播', '字幕'] } });
115
- assert.equal(check.ok, true, check.reason);
116
- return store.commitLearning(scope, {
117
- operationId: extra.operationId || `learn_${Math.random().toString(16).slice(2, 10)}`,
118
- epoch: started.epoch,
119
- episodeId: grown.episode.episodeId,
120
- episodeUpdatedAt: grown.episode.updatedAt,
121
- projectId: 'proj',
122
- sessionId: grown.sessionId,
123
- messageIds: grown.messageIds,
124
- feedbackText: extra.feedbackText || '',
125
- dimension: extra.dimension || '',
126
- changes: check.changes,
127
- preparedTargets: [{ id, revision: flat.revision, specDigest: flat.learning.specDigest }],
128
- usage: extra.usage,
129
- });
130
- }
131
-
132
- test('P00 初始任务与致谢不算反馈', () => {
133
- assert.equal(learning.classifyFeedback('帮我做一期口播,开头要抓人').worth, false);
134
- assert.equal(learning.classifyFeedback('谢谢').worth, false);
135
- assert.equal(learning.classifyFeedback('').worth, false);
136
- });
137
-
138
- test('P00 明确长期要求分类为规范变化', () => {
139
- const cue = learning.classifyFeedback('以后口播开头都先给结论,别再铺垫');
140
- assert.equal(cue.worth, true);
141
- assert.equal(cue.type, 'spec_change');
142
- assert.equal(cue.explicitLongTerm, true);
143
- assert.equal(learning.isLongTerm('这次就先这样'), false);
144
- });
145
-
146
- test('P00 作品验收不直接形成长期规则', () => {
147
- const cue = learning.classifyFeedback('这版可以');
148
- assert.equal(cue.type, 'artifact_accept');
149
- assert.equal(parsed({ ...NEW_CHANGE, feedbackType: 'artifact_accept' }).ok, false);
150
- });
151
-
152
- test('P00 长期声明本身就算规范变化', () => {
153
- const cue = learning.classifyFeedback('这个项目默认用 pnpm');
154
- assert.equal(cue.worth, true);
155
- assert.equal(cue.type, 'spec_change');
156
- assert.equal(cue.explicitLongTerm, true);
157
- });
158
-
159
- test('P00 未绑定的作品可以回落到项目候选', () => {
160
- const human = (id, text) => ({ id, source: { kind: 'user' }, content: [{ type: 'text', text }] });
161
- const base = { episodeId: 'ep_x', status: 'active', updatedAt: '2026-01-01T00:00:00.000Z' };
162
- assert.equal(learning.candidateFrom({ messages: [human('m1', '以后开头先给结论')], anchor: { ...base, status: 'unbound' } }), null);
163
- const projectCandidate = learning.candidateFrom({
164
- messages: [human('m2', '以后这个项目默认用 pnpm')],
165
- anchor: { ...base, status: 'unbound' }, sessionId: 's', projectId: 'p',
166
- });
167
- assert.equal(projectCandidate.scope, 'project');
168
- assert.equal(projectCandidate.episodeId, '');
169
- assert.equal(projectCandidate.expectedTarget.scope, 'project');
170
- const correction = learning.candidateFrom({
171
- messages: [human('m3', '这里改成蓝色')], anchor: { ...base, status: 'unbound' }, projectId: 'p',
172
- });
173
- assert.equal(correction.scope, 'project');
174
- assert.equal(learning.candidateFrom({
175
- messages: [human('m4', '这次这里改成蓝色')], anchor: { ...base, status: 'unbound' }, projectId: 'p',
176
- }), null);
177
- assert.equal(learning.candidateFrom({ messages: [{ id: 'a1', source: { kind: 'assistant' }, content: '收到' }], anchor: base }), null);
178
- const candidate = learning.candidateFrom({ messages: [human('m1', '以后开头都先给结论,别再铺垫')], anchor: base, sessionId: 's', projectId: 'p' });
179
- assert.equal(candidate.episodeId, 'ep_x');
180
- assert.equal(candidate.messageIds[0], 'm1');
181
- assert.equal(candidate.cue.explicitLongTerm, true);
182
- });
183
-
184
- test('B01 调用输入不超过 8 KiB 且只有本地引用', () => {
185
- const long = '口播开头先给结论,'.repeat(800);
186
- const input = learning.buildCallInput({
187
- projectId: 'p'.repeat(200),
188
- episodeId: 'ep',
189
- taskKind: 'video',
190
- seriesId: '口播',
191
- phase: 'script',
192
- feedback: { text: long, messageIds: ['m1'], cue: { type: 'spec_change', explicitLongTerm: false } },
193
- rules: Array.from({ length: 10 }, (_, index) => ({
194
- id: `mem_${index}`,
195
- revision: 3,
196
- title: '标题'.repeat(50),
197
- summary: '摘要'.repeat(80),
198
- body: '正文'.repeat(300),
199
- conditions: [{ key: 'seriesId', value: '口播' }],
200
- learning: { state: 'trial' },
201
- })),
202
- ranges: { taskKinds: ['video'], series: ['口播'] },
203
- });
204
- assert.ok(input.bytes <= 8192, `bytes=${input.bytes}`);
205
- assert.equal(input.rules.length >= 1, true);
206
- assert.equal(input.rules.length <= 6, true);
207
- assert.equal(input.prompt.includes(long), false);
208
- assert.equal(input.refs.feedback.u0, 'm1');
209
- assert.equal(input.refs.rules.r0, 'mem_0');
210
- assert.equal(input.messages.length, 1);
211
- assert.doesNotMatch(input.system, /工具调用/);
212
- });
213
-
214
- test('B01 项目层整理输入不带作品身份', () => {
215
- const input = learning.buildCallInput({
216
- projectId: 'p',
217
- episodeId: '',
218
- feedback: { text: '以后这个项目默认用 pnpm', messageIds: ['m1'], cue: { type: 'spec_change', explicitLongTerm: true }, scope: 'project' },
219
- rules: [],
220
- });
221
- assert.match(input.prompt, /"scope":"project"/);
222
- assert.doesNotMatch(input.prompt, /"episodeId"/);
223
- assert.match(input.system, /待确认候选/);
224
- });
225
-
226
- test('B01 截断、工具调用、额外字段整批拒绝', () => {
227
- assert.equal(learning.parseOutput('{"changes":[{"target":"new"', { finish: { kind: 'stop' } }).ok, false);
228
- assert.equal(learning.parseOutput('<tool>memory_save</tool>', { finish: { kind: 'stop' } }).ok, false);
229
- assert.equal(learning.parseOutput('{"changes":[],"note":"x"}', { finish: { kind: 'stop' } }).ok, false);
230
- assert.equal(learning.parseOutput('{"changes":[]}', { finish: { kind: 'length' } }).ok, false);
231
- assert.equal(learning.parseOutput('{"changes":[]}', { finish: { kind: 'stop' } }).ok, true);
232
- });
233
-
234
- test('B01 未知字段和越界条件整批拒绝', () => {
235
- assert.equal(parsed({ ...NEW_CHANGE, surprise: 1 }).ok, false);
236
- assert.equal(parsed({ ...NEW_CHANGE, conditions: [{ key: 'taskKind', value: 'podcast' }] }).ok, false);
237
- assert.equal(parsed({ ...NEW_CHANGE, conditions: [{ key: 'seriesId', value: '直播' }] }).ok, false);
238
- assert.equal(parsed({ ...NEW_CHANGE, conditions: [{ key: 'seriesId' }] }).ok, false);
239
- const good = parsed(NEW_CHANGE);
240
- assert.equal(good.ok, true);
241
- assert.equal(good.changes[0].topicKey, '口播开头');
242
- });
243
-
244
- test('B01 证据必须来自本次反馈且不超过三条', () => {
245
- assert.equal(parsed({ ...NEW_CHANGE, evidence: ['u9'] }).ok, false);
246
- assert.equal(parsed({ ...NEW_CHANGE, evidence: [] }).ok, false);
247
- const refs = { feedback: { u0: 'm1', u1: 'm2', u2: 'm3', u3: 'm4' }, rules: {} };
248
- assert.equal(parsed({ ...NEW_CHANGE, evidence: ['u0', 'u1', 'u2', 'u3'] }, { refs }).ok, false);
249
- });
250
-
251
- test('B02 真实用量与缺失用量区分', () => {
252
- assert.deepEqual(learning.usageOf([]), { known: false });
253
- assert.deepEqual(learning.usageOf([{ type: 'text-delta', text: 'x' }]), { known: false });
254
- const usage = learning.usageOf([{ type: 'usage', inputTokens: 120, outputTokens: 40, cachedInputTokens: 80 }]);
255
- assert.equal(usage.known, true);
256
- assert.equal(usage.inputTokens, 120);
257
- assert.equal(usage.outputTokens, 40);
258
- assert.equal(usage.cachedInputTokens, 80);
259
- });
260
-
261
- test('B05 频次:每作品两次、每项目 24 小时六次,失败也计数', () => {
262
- const scope = fresh('quota');
263
- enable(scope);
264
- const grown = seedEpisode(scope);
265
- const first = begin(scope, grown);
266
- assert.equal(first.ok, true, first.reason);
267
- const second = begin(scope, grown);
268
- assert.equal(second.ok, true, second.reason);
269
- const third = begin(scope, grown);
270
- assert.equal(third.ok, false);
271
- assert.equal(third.reason, 'episode_limit');
272
- for (let index = 0; index < 4; index += 1) {
273
- const other = seedEpisode(scope, { sessionId: `s${index + 2}`, messageIds: [`m${index + 2}`] });
274
- assert.equal(begin(scope, other).ok, true);
275
- }
276
- const overflow = seedEpisode(scope, { sessionId: 's9', messageIds: ['m9'] });
277
- const blocked = begin(scope, overflow);
278
- assert.equal(blocked.ok, false);
279
- assert.equal(blocked.reason, 'project_limit');
280
- });
281
-
282
- test('P10 执行没做到时只补检查点,不改规范正文', () => {
283
- const scope = fresh('checkpoint');
284
- enable(scope);
285
- const grown = seedEpisode(scope);
286
- const started = begin(scope, grown);
287
- assert.equal(commitNew(scope, grown, started, NEW_CHANGE, { explicitLongTerm: true }).ok, true);
288
- const id = ruleRows(scope)[0].id;
289
- const before = ruleOf(scope, id);
290
- const next = begin(scope, grown);
291
- const result = commitExisting(scope, grown, next, id, {
292
- target: 'r0', feedbackType: 'execution_miss', currentText: '交付前先核对开头是否先给结论。', evidence: ['u0'],
293
- });
294
- assert.equal(result.ok, true, result.reason);
295
- const after = ruleOf(scope, id);
296
- assert.equal(after.body, before.body);
297
- assert.equal(after.revision, before.revision + 1);
298
- assert.equal(after.learning.state, 'active');
299
- assert.equal(after.learning.checkpoints.length, 1);
300
- assert.match(after.learning.checkpoints[0].text, /核对/);
301
- assert.equal(after.learning.evidence.length, before.learning.evidence.length);
302
- assert.equal(after.learning.samples, undefined);
303
- });
304
-
305
- test('P11 认可作品只记有限样例,不抬规则证据也不改正文', () => {
306
- const scope = fresh('sample');
307
- enable(scope);
308
- const grown = seedEpisode(scope);
309
- const started = begin(scope, grown);
310
- assert.equal(commitNew(scope, grown, started, NEW_CHANGE, { explicitLongTerm: true }).ok, true);
311
- const id = ruleRows(scope)[0].id;
312
- const before = ruleOf(scope, id);
313
- const next = begin(scope, grown);
314
- const result = commitExisting(scope, grown, next, id, {
315
- target: 'r0', feedbackType: 'artifact_accept', evidence: ['u0'],
316
- }, { dimension: '开头' });
317
- assert.equal(result.ok, true, result.reason);
318
- const after = ruleOf(scope, id);
319
- assert.equal(after.body, before.body);
320
- assert.equal(after.revision, before.revision + 1);
321
- assert.equal(after.learning.samples.length, 1);
322
- assert.equal(after.learning.samples[0].dimension, '开头');
323
- assert.equal(after.learning.samples[0].verified, false);
324
- assert.equal(after.learning.samples[0].artifactVersion, grown.episode.updatedAt);
325
- assert.equal(after.learning.evidence.length, before.learning.evidence.length);
326
- assert.equal(after.learning.supportedEpisodeIds.length, before.learning.supportedEpisodeIds.length);
327
- });
328
-
329
- test('P12 样例只能挂已有规则,临时例外仍不形成规则', () => {
330
- const scope = fresh('sample-new');
331
- enable(scope);
332
- const grown = seedEpisode(scope);
333
- const started = begin(scope, grown);
334
- const rejected = parsed({
335
- target: 'new', feedbackType: 'artifact_accept', currentText: '这次就这版。', evidence: ['u0'],
336
- });
337
- assert.equal(rejected.ok, false);
338
- assert.match(rejected.reason, /已有规则/);
339
- assert.equal(parsed({ target: 'new', feedbackType: 'task_exception', currentText: '这次例外。', evidence: ['u0'] }).ok, false);
340
- assert.equal(parsed({ target: 'new', feedbackType: 'unclear', currentText: '说不上。', evidence: ['u0'] }).ok, false);
341
- assert.equal(commitNew(scope, grown, started, NEW_CHANGE).ok, true);
342
- const id = ruleRows(scope)[0].id;
343
- const second = begin(scope, grown);
344
- const exception = parsed({ target: 'r0', feedbackType: 'task_exception', currentText: '这次例外。', evidence: ['u0'] }, {
345
- rules: [{ ref: 'r0', id, revision: ruleOf(scope, id).revision, conditions: ruleOf(scope, id).conditions }],
346
- refs: { feedback: { u0: grown.messageIds[0] }, rules: { r0: id } },
347
- });
348
- assert.equal(exception.ok, false);
349
- assert.equal(second.ok, true);
350
- });
351
-
352
- test('P13 样例与检查点按上限保留', () => {
353
- const scope = fresh('sample-caps');
354
- enable(scope);
355
- const grown = seedEpisode(scope);
356
- const started = begin(scope, grown);
357
- assert.equal(commitNew(scope, grown, started, NEW_CHANGE, { explicitLongTerm: true }).ok, true);
358
- const id = ruleRows(scope)[0].id;
359
- for (const dimension of ['开头', '字幕', '节奏', '配乐', '镜头']) {
360
- assert.equal(commitExisting(scope, grown, { epoch: evolutionOf(scope).epoch }, id, {
361
- target: 'r0', feedbackType: 'artifact_accept', evidence: ['u0'],
362
- }, { dimension }).ok, true);
363
- }
364
- assert.equal(ruleOf(scope, id).learning.samples.length, 4);
365
- for (const text of ['检查一。', '检查二。', '检查三。', '检查四。']) {
366
- assert.equal(commitExisting(scope, grown, { epoch: evolutionOf(scope).epoch }, id, {
367
- target: 'r0', feedbackType: 'execution_miss', currentText: text, evidence: ['u0'],
368
- }).ok, true);
369
- }
370
- assert.equal(ruleOf(scope, id).learning.checkpoints.length, 3);
371
- });
372
-
373
- test('P01 新规则落盘带来源、条件与待复核状态', () => {
374
- const scope = fresh('commit-new');
375
- enable(scope);
376
- const grown = seedEpisode(scope);
377
- const started = begin(scope, grown);
378
- const result = commitNew(scope, grown, started, NEW_CHANGE, { explicitLongTerm: true, usage: { known: true, inputTokens: 90, outputTokens: 20 } });
379
- assert.equal(result.ok, true, result.reason);
380
- assert.equal(result.touched, 1);
381
- const rows = ruleRows(scope);
382
- assert.equal(rows.length, 1);
383
- const flat = store.loadIndex(scope).entries.find((row) => row.id === rows[0].id);
384
- assert.equal(flat.conditions[0].value, '口播');
385
- assert.equal(flat.learning.state, 'active');
386
- assert.equal(flat.learning.evidence.at(-1).episodeId, grown.episode.episodeId);
387
- assert.equal(flat.learning.evidence.at(-1).sourceType, 'human_feedback');
388
- assert.equal(flat.learning.evidence.at(-1).evidenceRef, undefined);
389
- const ledger = evolutionOf(scope).learning;
390
- assert.equal(ledger.receipts.at(-1).status, 'applied');
391
- assert.equal(ledger.receipts.at(-1).usage.inputTokens, 90);
392
- });
393
-
394
- test('P01 同一 operationId 重放返回原回执,不重复写', () => {
395
- const scope = fresh('replay');
396
- enable(scope);
397
- const grown = seedEpisode(scope);
398
- const started = begin(scope, grown);
399
- const op = 'learn_replay_1';
400
- const first = commitNew(scope, grown, started, NEW_CHANGE, { operationId: op });
401
- assert.equal(first.ok, true, first.reason);
402
- const again = commitNew(scope, grown, started, NEW_CHANGE, { operationId: op });
403
- assert.equal(again.ok, true);
404
- assert.equal(again.replayed, true);
405
- assert.equal(again.receipt.status, 'applied');
406
- assert.equal(ruleRows(scope).length, 1);
407
- });
408
-
409
- test('B06 目标规则版本变了整批拒绝', () => {
410
- const scope = fresh('stale-target');
411
- enable(scope);
412
- const grown = seedEpisode(scope);
413
- const started = begin(scope, grown);
414
- assert.equal(commitNew(scope, grown, started, NEW_CHANGE).ok, true);
415
- const id = ruleRows(scope)[0].id;
416
- const version = ruleOf(scope, id).revision;
417
- const rules = [{ ref: 'r0', id, revision: version, conditions: [{ key: 'seriesId', value: '口播' }] }];
418
- const refs = { feedback: { u0: 'm1' }, rules: { r0: id } };
419
- const update = { target: 'r0', feedbackType: 'spec_change', currentText: '开头两句内给结论。', evidence: ['u0'] };
420
- const check = parsed(update, { rules, refs });
421
- assert.equal(check.ok, true, check.reason);
422
- const second = begin(scope, grown);
423
- assert.equal(second.ok, true, second.reason);
424
- // 另一次整理先改了同一条
425
- const rival = parsed({ ...update, currentText: '开头一句内给结论。' }, { rules, refs });
426
- const applied = store.commitLearning(scope, {
427
- operationId: 'learn_rival',
428
- epoch: second.epoch,
429
- episodeId: grown.episode.episodeId,
430
- episodeUpdatedAt: grown.episode.updatedAt,
431
- projectId: 'proj',
432
- sessionId: grown.sessionId,
433
- messageIds: grown.messageIds,
434
- changes: rival.changes,
435
- preparedTargets: [{ id, revision: version, specDigest: ruleOf(scope, id).learning.specDigest }],
436
- });
437
- assert.equal(applied.ok, true, applied.reason);
438
- const stale = store.commitLearning(scope, {
439
- operationId: 'learn_stale',
440
- epoch: second.epoch,
441
- episodeId: grown.episode.episodeId,
442
- episodeUpdatedAt: grown.episode.updatedAt,
443
- projectId: 'proj',
444
- sessionId: grown.sessionId,
445
- messageIds: grown.messageIds,
446
- changes: check.changes,
447
- preparedTargets: [{ id, revision: version, specDigest: ruleOf(scope, id).learning.specDigest }],
448
- });
449
- assert.equal(stale.ok, false);
450
- assert.match(stale.reason, /版本|改过/);
451
- assert.match(ruleOf(scope, id).body, /一句/);
452
- });
453
-
454
- test('P06 作品版本变了旧整理整批作废', () => {
455
- const scope = fresh('stale-episode');
456
- enable(scope);
457
- const grown = seedEpisode(scope);
458
- const started = begin(scope, grown);
459
- const later = anchor.routeTask(store.loadIndex(scope).__tasks, {
460
- sessionId: grown.sessionId,
461
- projectId: 'proj',
462
- query: '这期改成另一条',
463
- messageIds: ['m2'],
464
- batchKey: anchor.requestBatchKey(['m2']),
465
- now: Date.now() + 1000,
466
- });
467
- store.saveTaskState(scope, later.tasks, { operationId: 'tasks_later' });
468
- const stale = commitNew(scope, grown, started, NEW_CHANGE, { now: Date.now() + 2000 });
469
- assert.equal(stale.ok, false);
470
- assert.match(stale.reason, /换到别的作品|改过这期|失效|没采用/);
471
- assert.equal(ruleRows(scope).length, 0);
472
- });
473
-
474
- test('P05 关闭实验后旧提案不能写入', () => {
475
- const scope = fresh('off-stale');
476
- enable(scope);
477
- const grown = seedEpisode(scope);
478
- const started = begin(scope, grown);
479
- store.setEvolution(scope, false, { operationId: 'off_1' });
480
- const offline = commitNew(scope, grown, started);
481
- assert.equal(offline.ok, false);
482
- assert.equal(ruleRows(scope).length, 0);
483
- });
484
-
485
- test('P05 epoch 变了旧提案作废', () => {
486
- const scope = fresh('epoch-stale');
487
- enable(scope);
488
- const grown = seedEpisode(scope);
489
- const started = begin(scope, grown);
490
- store.setEvolution(scope, false, { operationId: 'off_2' });
491
- store.setEvolution(scope, true, { operationId: 'on_2' });
492
- const stale = commitNew(scope, grown, started);
493
- assert.equal(stale.ok, false);
494
- assert.match(stale.reason, /重新开启|作废/);
495
- });
496
-
497
- test('P02 两个独立作品后升为已生效,单次只到试用', () => {
498
- const scope = fresh('promote');
499
- enable(scope);
500
- const one = seedEpisode(scope, { sessionId: 'a', messageIds: ['a1'] });
501
- const first = commitNew(scope, one, begin(scope, one), NEW_CHANGE);
502
- assert.equal(first.ok, true, first.reason);
503
- let id = ruleRows(scope)[0].id;
504
- assert.equal(ruleOf(scope, id).learning.state, 'trial');
505
- const two = seedEpisode(scope, { sessionId: 'b', messageIds: ['b1'] });
506
- const version = ruleOf(scope, id).revision;
507
- const rules = [{ ref: 'r0', id, revision: version, conditions: [{ key: 'seriesId', value: '口播' }] }];
508
- const refs = { feedback: { u0: 'b1' }, rules: { r0: id } };
509
- const check = parsed({ target: 'r0', feedbackType: 'spec_change', currentText: NEW_CHANGE.currentText, evidence: ['u0'] }, { rules, refs });
510
- const second = store.commitLearning(scope, {
511
- operationId: 'learn_promote',
512
- epoch: begin(scope, two).epoch,
513
- episodeId: two.episode.episodeId,
514
- episodeUpdatedAt: two.episode.updatedAt,
515
- projectId: 'proj',
516
- sessionId: two.sessionId,
517
- messageIds: two.messageIds,
518
- changes: check.changes,
519
- preparedTargets: [{ id, revision: version, specDigest: ruleOf(scope, id).learning.specDigest }],
520
- });
521
- assert.equal(second.ok, true, second.reason);
522
- id = ruleRows(scope)[0].id;
523
- assert.equal(ruleOf(scope, id).learning.state, 'active');
524
- assert.equal(ruleOf(scope, id).learning.supportedEpisodeIds.length, 2);
525
- });
526
-
527
- test('D01 撤回过且没有上一版时拒撤回', () => {
528
- const scope = fresh('undo-empty');
529
- enable(scope);
530
- const grown = seedEpisode(scope);
531
- assert.equal(commitNew(scope, grown, begin(scope, grown)).ok, true);
532
- const id = ruleRows(scope)[0].id;
533
- const result = store.undoLearningChange(scope, id, { expectedRevision: ruleOf(scope, id).revision, operationId: 'undo_1' });
534
- assert.equal(result.ok, false);
535
- assert.match(result.reason, /上一版/);
536
- });
537
-
538
- test('D02 版本对不上就拒绝撤回,旧正文不覆盖新版本', () => {
539
- const scope = fresh('undo-stale');
540
- enable(scope);
541
- const grown = seedEpisode(scope);
542
- assert.equal(commitNew(scope, grown, begin(scope, grown)).ok, true);
543
- const id = ruleRows(scope)[0].id;
544
- const version = ruleOf(scope, id).revision;
545
- const rules = [{ ref: 'r0', id, revision: version, conditions: [{ key: 'seriesId', value: '口播' }] }];
546
- const refs = { feedback: { u0: 'm1' }, rules: { r0: id } };
547
- const check = parsed({ target: 'r0', feedbackType: 'spec_change', currentText: '开头两句内给结论。', evidence: ['u0'] }, { rules, refs });
548
- const updated = store.commitLearning(scope, {
549
- operationId: 'learn_undo_newer',
550
- epoch: begin(scope, grown).epoch,
551
- episodeId: grown.episode.episodeId,
552
- episodeUpdatedAt: grown.episode.updatedAt,
553
- projectId: 'proj',
554
- sessionId: grown.sessionId,
555
- messageIds: grown.messageIds,
556
- changes: check.changes,
557
- preparedTargets: [{ id, revision: version, specDigest: ruleOf(scope, id).learning.specDigest }],
558
- });
559
- assert.equal(updated.ok, true, updated.reason);
560
- const stale = store.undoLearningChange(scope, id, { expectedRevision: version, operationId: 'undo_stale' });
561
- assert.equal(stale.ok, false);
562
- assert.match(stale.reason, /刷新/);
563
- assert.match(ruleOf(scope, id).body, /两句/);
564
- const undo = store.undoLearningChange(scope, id, { expectedRevision: ruleOf(scope, id).revision, operationId: 'undo_ok' });
565
- assert.equal(undo.ok, true, undo.reason);
566
- assert.match(ruleOf(scope, id).body, /三句/);
567
- assert.equal(ruleOf(scope, id).learning.state, 'paused');
568
- });
569
-
570
- test('D03 负反馈先本地暂停,恢复只回到试用', () => {
571
- const scope = fresh('pause');
572
- enable(scope);
573
- const grown = seedEpisode(scope);
574
- assert.equal(commitNew(scope, grown, begin(scope, grown)).ok, true);
575
- const id = ruleRows(scope)[0].id;
576
- const paused = store.pauseLearningRules(scope, [id], { operationId: 'pause_1', by: 'feedback', reason: 'feedback_pending' });
577
- assert.equal(paused.ok, true);
578
- assert.equal(paused.changed, true);
579
- assert.equal(ruleOf(scope, id).learning.state, 'paused');
580
- const resumed = store.resumeLearningRule(scope, id, { operationId: 'resume_1' });
581
- assert.equal(resumed.ok, true, resumed.reason);
582
- assert.equal(ruleOf(scope, id).learning.state, 'trial');
583
- assert.equal(ruleOf(scope, id).learning.blockedReason, undefined);
584
- });
585
-
586
- test('D05 候选缺值或未知键不写入', () => {
587
- assert.equal(parsed({ ...NEW_CHANGE, topicKey: undefined }).ok, false);
588
- assert.equal(parsed({ ...NEW_CHANGE, currentText: undefined }).ok, false);
589
- assert.equal(parsed({ ...NEW_CHANGE, currentText: 'x'.repeat(241) }).ok, false);
590
- assert.equal(parsed({ ...NEW_CHANGE, applicability: 'sometimes' }).ok, false);
591
- assert.equal(parsed({ ...NEW_CHANGE, persistence: 'global' }).ok, false);
592
- });
593
-
594
- test('D06 同名规则条件不同就各存一条', () => {
595
- const scope = fresh('no-merge');
596
- enable(scope);
597
- const one = seedEpisode(scope, { sessionId: 'c1', messageIds: ['c1'] });
598
- assert.equal(commitNew(scope, one, begin(scope, one), NEW_CHANGE).ok, true);
599
- const two = seedEpisode(scope, { sessionId: 'c2', messageIds: ['c2'] });
600
- const refs = { feedback: { u0: 'c2' }, rules: {} };
601
- const changed = { ...NEW_CHANGE, conditions: [{ key: 'phase', value: 'edit' }] };
602
- const check = learning.parseOutput(JSON.stringify({ changes: [changed] }), { finish: { kind: 'stop' }, refs, rules: [], ranges: RANGES });
603
- assert.equal(check.ok, true, check.reason);
604
- const second = store.commitLearning(scope, {
605
- operationId: 'learn_no_merge',
606
- epoch: begin(scope, two).epoch,
607
- episodeId: two.episode.episodeId,
608
- episodeUpdatedAt: two.episode.updatedAt,
609
- projectId: 'proj',
610
- sessionId: two.sessionId,
611
- messageIds: two.messageIds,
612
- changes: check.changes,
613
- preparedTargets: [],
614
- });
615
- assert.equal(second.ok, true, second.reason);
616
- assert.equal(ruleRows(scope).length, 2);
617
- });
618
-
619
- test('B06 待确认候选到上限后拒绝新建', () => {
620
- const scope = fresh('pending-cap');
621
- enable(scope);
622
- for (let index = 0; index < learning.LIMITS.pending; index += 1) {
623
- const grown = seedEpisode(scope, { sessionId: `p${index}`, messageIds: [`pm${index}`] });
624
- const change = { ...NEW_CHANGE, topicKey: `主题${index}`, applicability: 'unresolved', conditions: [] };
625
- const result = commitNew(scope, grown, begin(scope, grown), change);
626
- assert.equal(result.ok, true, `${index}:${result.reason}`);
627
- }
628
- const overflow = seedEpisode(scope, { sessionId: 'p99', messageIds: ['pm99'] });
629
- const blocked = commitNew(scope, overflow, begin(scope, overflow), { ...NEW_CHANGE, topicKey: '主题溢出', applicability: 'unresolved', conditions: [] });
630
- assert.equal(blocked.ok, false);
631
- assert.match(blocked.reason, /上限/);
632
- });
633
-
634
- test('B06 失败调用只留回执不写正文', () => {
635
- const scope = fresh('failure');
636
- enable(scope);
637
- const grown = seedEpisode(scope);
638
- const started = begin(scope, grown);
639
- const result = store.recordLearningFailure(scope, {
640
- operationId: 'learn_fail_1',
641
- episodeId: grown.episode.episodeId,
642
- epoch: started.epoch,
643
- reason: '整理没有返回内容',
644
- usage: { known: false },
645
- });
646
- assert.equal(result.ok, true, result.reason);
647
- assert.equal(ruleRows(scope).length, 0);
648
- const receipt = evolutionOf(scope).learning.receipts.at(-1);
649
- assert.equal(receipt.status, 'failed');
650
- assert.equal(receipt.usage.known, false);
651
- assert.equal(receipt.reason, '整理没有返回内容');
652
- });
653
-
654
- test('P07 项目层候选无需作品也能落规则', () => {
655
- const scope = fresh('project-rule');
656
- enable(scope);
657
- const started = store.beginLearningAttempt(scope, { episodeId: '', operationId: 'start_project' });
658
- assert.equal(started.ok, true, started.reason);
659
- const check = parsed(NEW_CHANGE);
660
- assert.equal(check.ok, true, check.reason);
661
- const result = store.commitLearning(scope, {
662
- operationId: 'learn_project',
663
- epoch: started.epoch,
664
- episodeId: '',
665
- projectId: scope.key,
666
- sessionId: 's1',
667
- messageIds: ['m1'],
668
- changes: check.changes,
669
- preparedTargets: [],
670
- });
671
- assert.equal(result.ok, true, result.reason);
672
- assert.equal(ruleRows(scope).length, 1);
673
- assert.equal(ruleRows(scope)[0].ownerProjectId, scope.key);
674
- assert.equal(ruleRows(scope)[0].learning.state, 'trial');
675
- });
676
-
677
- test('P07 两条同批变化一次写入', () => {
678
- const scope = fresh('two-changes');
679
- enable(scope);
680
- const grown = seedEpisode(scope);
681
- const check = learning.parseOutput(JSON.stringify({
682
- changes: [NEW_CHANGE, { ...NEW_CHANGE, topicKey: '口播结尾', currentText: '结尾留一句行动提示。' }],
683
- }), { finish: { kind: 'stop' }, refs: REFS, rules: [], ranges: RANGES });
684
- assert.equal(check.ok, true, check.reason);
685
- const result = store.commitLearning(scope, {
686
- operationId: 'learn_two',
687
- epoch: begin(scope, grown).epoch,
688
- episodeId: grown.episode.episodeId,
689
- episodeUpdatedAt: grown.episode.updatedAt,
690
- projectId: 'proj',
691
- sessionId: grown.sessionId,
692
- messageIds: grown.messageIds,
693
- changes: check.changes,
694
- preparedTargets: [],
695
- });
696
- assert.equal(result.ok, true, result.reason);
697
- assert.equal(result.touched, 2);
698
- assert.equal(ruleRows(scope).length, 2);
699
- });
700
-
701
- test('P08 超过两条变化整批拒绝', () => {
702
- const scope = fresh('too-many');
703
- enable(scope);
704
- const grown = seedEpisode(scope);
705
- const check = learning.parseOutput(JSON.stringify({
706
- changes: [NEW_CHANGE, { ...NEW_CHANGE, topicKey: 'b' }, { ...NEW_CHANGE, topicKey: 'c' }],
707
- }), { finish: { kind: 'stop' }, refs: REFS, rules: [], ranges: RANGES });
708
- assert.equal(check.ok, false);
709
- const result = store.commitLearning(scope, {
710
- operationId: 'learn_many',
711
- epoch: begin(scope, grown).epoch,
712
- episodeId: grown.episode.episodeId,
713
- episodeUpdatedAt: grown.episode.updatedAt,
714
- projectId: 'proj',
715
- sessionId: grown.sessionId,
716
- messageIds: grown.messageIds,
717
- changes: [NEW_CHANGE, NEW_CHANGE, NEW_CHANGE],
718
- preparedTargets: [],
719
- });
720
- assert.equal(result.ok, false);
721
- assert.equal(ruleRows(scope).length, 0);
722
- });
723
-
724
- test('P09 生命周期清理过期试用但保留固定规则', () => {
725
- const scope = fresh('lifecycle');
726
- enable(scope);
727
- const grown = seedEpisode(scope);
728
- assert.equal(commitNew(scope, grown, begin(scope, grown)).ok, true);
729
- const id = ruleRows(scope)[0].id;
730
- const now = Date.now() + learning.LIMITS.trialMs + 1000;
731
- const report = store.expireLearning(scope, { operationId: 'expire_1', now });
732
- assert.equal(report.ok, true, report.reason);
733
- assert.equal(ruleOf(scope, id).learning.state, 'paused');
734
- assert.equal(ruleOf(scope, id).learning.blockedReason, 'trial_expired');
735
- const later = now + learning.LIMITS.pausedMs + 1000;
736
- store.expireLearning(scope, { operationId: 'expire_2', now: later });
737
- assert.equal(ruleOf(scope, id), undefined);
738
- });
739
-
740
- test('D04 新状态格式不抹掉实验标记', () => {
741
- const scope = fresh('d04');
742
- enable(scope);
743
- const raw = fs.readFileSync(path.join(scope.dir, 'state.json'), 'utf8');
744
- assert.equal(JSON.parse(raw).format, 'dsh-lite-memory/state@4');
745
- const oldReader = (text) => {
746
- const data = JSON.parse(text);
747
- if (data.format !== 'dsh-lite-memory/state@2') throw new state.StateError('invalid', '旧版不认这个格式');
748
- return data.payload;
749
- };
750
- assert.throws(() => oldReader(raw), /旧版不认/);
751
- assert.equal(evolutionOf(scope).enabled, true);
752
- assert.equal(evolutionOf(scope).policyVersion, learning.POLICY_VERSION);
753
- });
754
-
755
- test('D07 关闭后仍可查看、固定与删除成果', () => {
756
- const scope = fresh('manage-off');
757
- enable(scope);
758
- const grown = seedEpisode(scope);
759
- assert.equal(commitNew(scope, grown, begin(scope, grown)).ok, true);
760
- const id = ruleRows(scope)[0].id;
761
- const off = store.setEvolution(scope, false, { operationId: 'off_manage' });
762
- assert.equal(off.ok, true, off.reason);
763
- const snapshot = store.learningSnapshot(scope);
764
- assert.equal(snapshot.enabled, false);
765
- assert.equal(snapshot.rules.some((rule) => rule.id === id), true);
766
- const pinned = store.pinLearningRule(scope, id, { pinned: true, expectedRevision: ruleOf(scope, id).revision, operationId: 'pin_off' });
767
- assert.equal(pinned.ok, true, pinned.reason);
768
- assert.equal(ruleOf(scope, id).learning.pinned, true);
769
- const removed = store.removeLearningRule(scope, id, { expectedRevision: ruleOf(scope, id).revision, operationId: 'delete_off' });
770
- assert.equal(removed.ok, true, removed.reason);
771
- assert.equal(store.loadIndex(scope).archived.some((row) => row.id === id), true);
772
- assert.equal(store.learningSnapshot(scope).rules.some((rule) => rule.id === id), false);
773
- });
774
-
775
- test('D08 删除按预期版本,旧版本不能误删', () => {
776
- const scope = fresh('delete-stale');
777
- enable(scope);
778
- const grown = seedEpisode(scope);
779
- assert.equal(commitNew(scope, grown, begin(scope, grown)).ok, true);
780
- const id = ruleRows(scope)[0].id;
781
- const before = ruleOf(scope, id).revision;
782
- const stale = store.removeLearningRule(scope, id, { expectedRevision: before - 1, operationId: 'delete_stale' });
783
- assert.equal(stale.ok, false);
784
- assert.match(stale.reason, /刷新/);
785
- const done = store.removeLearningRule(scope, id, { expectedRevision: before, operationId: 'delete_ok' });
786
- assert.equal(done.ok, true, done.reason);
787
- });
788
-
789
- test('D09 用量缺失只报未知,不补零', () => {
790
- const scope = fresh('economy-unknown');
791
- enable(scope);
792
- const grown = seedEpisode(scope);
793
- assert.equal(store.recordLearningFailure(scope, {
794
- operationId: 'learn_failed',
795
- episodeId: grown.episode.episodeId,
796
- reason: '模型超时',
797
- }).ok, true);
798
- const snapshot = store.learningSnapshot(scope);
799
- assert.equal(snapshot.economy.calls, 1);
800
- assert.equal(snapshot.economy.knownCalls, 0);
801
- assert.equal(snapshot.economy.inputTokens, null);
802
- assert.equal(snapshot.economy.outputTokens, null);
803
- });
804
-
805
- test('D10 效果摘要只算最近 64 条,未知用量不补零', () => {
806
- const scope = fresh('effect-window');
807
- enable(scope);
808
- const grown = seedEpisode(scope);
809
- assert.equal(commitNew(scope, grown, begin(scope, grown), NEW_CHANGE, {
810
- usage: { known: true, inputTokens: 30, outputTokens: 7 },
811
- }).ok, true);
812
- assert.equal(store.recordLearningFailure(scope, { operationId: 'effect_fail', episodeId: grown.episode.episodeId, reason: '模型超时' }).ok, true);
813
- const first = store.learningSnapshot(scope).effect;
814
- assert.equal(first.window, 2);
815
- assert.equal(first.applied, 1);
816
- assert.equal(first.failed, 1);
817
- assert.equal(first.knownCalls, 1);
818
- assert.equal(first.inputTokens, 30);
819
- for (let i = 0; i < 70; i += 1) {
820
- assert.equal(store.recordLearningFailure(scope, { operationId: `effect_more_${i}`, episodeId: grown.episode.episodeId, reason: '超时' }).ok, true);
821
- }
822
- const after = store.learningSnapshot(scope).effect;
823
- assert.equal(after.window, 64);
824
- assert.equal(after.knownCalls, 0);
825
- assert.equal(after.inputTokens, null);
826
- assert.equal(after.outputTokens, null);
827
- });
828
-
829
- test('L01 没有作品绑定的长期项目要求仍是待确认候选', () => {
830
- const message = (id, text) => ({ id, source: { kind: 'user' }, content: [{ type: 'text', text }] });
831
- const candidate = learning.candidateFrom({
832
- messages: [message('m1', '以后数据库查询都改成参数化查询')],
833
- anchor: null,
834
- sessionId: 's1',
835
- projectId: 'proj_a',
836
- now: 1000,
837
- });
838
- assert.ok(candidate);
839
- assert.equal(candidate.scope, 'project');
840
- assert.equal(candidate.state, 'pending');
841
- assert.equal(candidate.episodeId, '');
842
- assert.equal(candidate.seriesId, '');
843
- assert.deepEqual(candidate.messageIds, ['m1']);
844
- assert.equal(candidate.expectedTarget.scope, 'project');
845
- assert.equal(candidate.expectedTarget.projectId, 'proj_a');
846
- assert.equal(candidate.expectedTarget.applicability, 'unresolved');
847
- assert.match(candidate.sourceExcerpt, /参数化查询/);
848
- /** 跨项目一律不接收;没有项目身份的裸反馈也不进候选。 */
849
- assert.equal(learning.candidateFrom({
850
- messages: [message('m1', '以后数据库查询都改成参数化查询')],
851
- anchor: { episodeId: 'e0', projectId: 'proj_b', status: 'active' },
852
- projectId: 'proj_a',
853
- }), null);
854
- assert.equal(learning.candidateFrom({ messages: [message('m1', '以后都参数化查询')] }), null);
855
- /** 短期例外即使没绑定作品也不当项目规范。 */
856
- assert.equal(learning.candidateFrom({
857
- messages: [message('m1', '这次开头先别放字幕')],
858
- anchor: null,
859
- projectId: 'proj_a',
860
- }), null);
861
- /** 有作品绑定仍走作品作用域,且不把项目当成单条栏目。 */
862
- const bound = learning.candidateFrom({
863
- messages: [message('m1', '以后字幕都改成底部安全区')],
864
- anchor: { episodeId: 'e1', projectId: 'proj_a', status: 'active', taskKind: 'video', seriesId: 's1' },
865
- sessionId: 's1',
866
- projectId: 'proj_a',
867
- });
868
- assert.equal(bound.scope, 'episode');
869
- assert.equal(bound.episodeId, 'e1');
870
- assert.equal(bound.expectedTarget.episodeId, 'e1');
871
- assert.equal(bound.seriesId, 's1');
872
- });
873
-
874
- test('L02 先按相关性选规则,再截断,字幕规则不会被挤掉', () => {
875
- const rows = [];
876
- for (let index = 0; index < 6; index += 1) {
877
- rows.push({
878
- id: `mem_a${index}`, topicKey: `配色${index}`, title: `配色${index}`,
879
- body: `配色方案${index}`, summary: `配色经验${index}`,
880
- conditions: [], applicability: 'all_in_scope',
881
- learning: { state: 'active' }, updated: '2026-01-01T00:00:00.000Z',
882
- });
883
- }
884
- rows.push({
885
- id: 'mem_sub', topicKey: '字幕样式', title: '字幕样式', body: '字幕放底部安全区',
886
- summary: '字幕经验', conditions: [{ key: 'seriesId', value: '字幕' }],
887
- applicability: 'conditional', learning: { state: 'trial' }, updated: '2025-01-01T00:00:00.000Z',
888
- });
889
- rows.push({
890
- id: 'mem_old', topicKey: '标题', title: '标题', body: '标题要短',
891
- summary: '标题经验', conditions: [], applicability: 'all_in_scope',
892
- learning: { state: 'pending' }, updated: '2025-01-01T00:00:00.000Z',
893
- });
894
- rows.push({
895
- id: 'mem_bgm', topicKey: '配乐', title: '配乐', body: '配乐低一点',
896
- summary: '配乐经验', conditions: [], applicability: 'all_in_scope',
897
- learning: { state: 'active' }, updated: '2025-01-01T00:00:00.000Z',
898
- });
899
- rows.push({
900
- id: 'mem_safe', topicKey: '字幕安全区', title: '字幕安全区', body: '字幕不要贴边',
901
- summary: '字幕留安全区', conditions: [{ key: 'seriesId', value: '字幕' }],
902
- applicability: 'conditional', learning: { state: 'active' }, updated: '2024-01-01T00:00:00.000Z',
903
- });
904
- const context = { query: '字幕位置太低,以后字幕都放底部安全区', dimensions: ['字幕'] };
905
- const ranked = learning.rankRules(rows, context);
906
- assert.equal(ranked[0].id, 'mem_safe');
907
- assert.equal(ranked[1].id, 'mem_sub');
908
- const selected = learning.selectRules(rows, { ...context, limit: 6 });
909
- assert.equal(selected.length, 6);
910
- assert.equal(selected[0].id, 'mem_safe');
911
- assert.equal(selected[1].id, 'mem_sub');
912
- assert.deepEqual(learning.rankRules(rows, context).map((row) => row.id), ranked.map((row) => row.id));
913
- /** buildCallInput 的输入上限是 6,但字幕规则必须在里面。 */
914
- const input = learning.buildCallInput({
915
- projectId: 'proj', episodeId: 'e1', taskKind: 'video', seriesId: '字幕', phase: 'p1',
916
- feedback: { text: '字幕位置太低,以后字幕都放底部安全区', cue: { dimension: '字幕' }, messageIds: ['m1'] },
917
- rules: rows, ranges: RANGES,
918
- });
919
- assert.ok(input.rules.length <= 6);
920
- const ids = new Set(Object.values(input.refs.rules));
921
- assert.ok(ids.has('mem_safe'));
922
- assert.ok(ids.has('mem_sub'));
923
- });
924
-
925
- test('L03 反馈类型限制动作:例外/检查点/认可不能写规范', () => {
926
- const rules = [{ ref: 'r0', id: 'mem_1', revision: 2, conditions: [{ key: 'seriesId', value: '口播' }] }];
927
- const refs = { feedback: { u0: 'm1' }, rules: { r0: 'mem_1' } };
928
- const check = (change) => learning.validateChange(change, { refs, rules, ranges: RANGES });
929
- assert.equal(check({ ...NEW_CHANGE, target: 'new', feedbackType: 'task_exception' }).ok, false);
930
- assert.equal(check({ ...NEW_CHANGE, target: 'new', feedbackType: 'unclear' }).ok, false);
931
- const checkpoint = check({ target: 'r0', feedbackType: 'execution_miss', currentText: '发布前核对字幕安全区', evidence: ['u0'] });
932
- assert.equal(checkpoint.ok, true, checkpoint.reason);
933
- const rewritten = check({ target: 'r0', feedbackType: 'execution_miss', currentText: '新正文', title: '新标题' });
934
- assert.equal(rewritten.ok, false);
935
- const newAccept = check({ target: 'new', feedbackType: 'aspect_accept', topicKey: '字幕', currentText: '字幕放底部' });
936
- assert.equal(newAccept.ok, false);
937
- const accept = check({ target: 'r0', feedbackType: 'aspect_accept', conditions: [{ key: 'seriesId', value: '口播' }], evidence: ['u0'] });
938
- assert.equal(accept.ok, true, accept.reason);
939
- const mismatched = check({ target: 'r0', feedbackType: 'aspect_accept', conditions: [{ key: 'seriesId', value: '字幕' }] });
940
- assert.equal(mismatched.ok, false);
941
- assert.equal(mismatched.reason, '认可条件与目标规则不一致');
942
- const rewrite = check({ target: 'r0', feedbackType: 'aspect_accept', currentText: '顺手把正文改了' });
943
- assert.equal(rewrite.ok, false);
944
- /** 同类型走 nextLearningState 也不抬状态。 */
945
- const active = { id: 'mem_1', revision: 2, topicKey: '字幕', body: '字幕放底部', applicability: 'conditional', conditions: [{ key: 'seriesId', value: '口播' }], learning: { state: 'active', supportedEpisodeIds: ['e1', 'e2'], evidence: [] } };
946
- for (const feedbackType of ['execution_miss', 'aspect_accept', 'artifact_accept', 'task_exception', 'unclear']) {
947
- const next = learning.nextLearningState({ existing: active, change: { id: 'mem_1', feedbackType }, evidence: { episodeId: 'e3' }, now: 1000 });
948
- assert.equal(next.state, 'active', feedbackType);
949
- assert.deepEqual(next.supportedEpisodeIds, ['e1', 'e2'], feedbackType);
950
- const created = learning.nextLearningState({ existing: null, change: { feedbackType }, evidence: { episodeId: 'e1' }, now: 1000 });
951
- assert.notEqual(created.state, 'active', feedbackType);
952
- }
953
- });
954
-
955
- test('L03b 明确长期要求整理为空时也留下规则', () => {
956
- const change = learning.pendingChangeFrom({ text: '以后这个项目默认用 pnpm。', cue: { explicitLongTerm: true } });
957
- assert.equal(change.target, 'new');
958
- assert.equal(change.feedbackType, 'spec_change');
959
- assert.equal(change.currentText, '以后这个项目默认用 pnpm。');
960
- assert.equal(change.evidence[0], 'u0');
961
- const check = learning.validateChange(change, { refs: { feedback: { u0: 'm1' }, rules: {} }, rules: [], ranges: RANGES });
962
- assert.equal(check.ok, true, check.reason);
963
- assert.equal(learning.pendingChangeFrom({ text: ' ' }), null);
964
- });
965
-
966
- test('L03c 兜底不把没说范围的要求抬成全项目规则', () => {
967
- const vague = learning.pendingChangeFrom({ text: '以后口播开头先给结论。', cue: { explicitLongTerm: true } });
968
- assert.equal(vague.applicability, 'unresolved');
969
- /** 「所有口播」限定的是栏目对象,不是整个项目;没有可信栏目就留待确认,不能扩成全项目。 */
970
- const allLines = learning.pendingChangeFrom({ text: '以后所有口播都先给结论。', cue: { explicitLongTerm: true } });
971
- assert.equal(allLines.applicability, 'unresolved', '「所有口播」不能当全项目授权');
972
- const always = learning.pendingChangeFrom({ text: '以后一律先给结论。', cue: { explicitLongTerm: true } });
973
- assert.equal(always.applicability, 'unresolved', '「一律」不能单独当项目范围');
974
- assert.deepEqual(vague.conditions, []);
975
- const scoped = learning.pendingChangeFrom({ text: '以后口播开头先给结论。', cue: { explicitLongTerm: true, taskKind: 'video' } });
976
- assert.equal(scoped.applicability, 'conditional');
977
- assert.deepEqual(scoped.conditions, [{ key: 'taskKind', value: 'video' }]);
978
- const global = learning.pendingChangeFrom({ text: '以后这个项目默认用 pnpm。', cue: { explicitLongTerm: true } });
979
- assert.equal(global.applicability, 'all_in_scope');
980
- assert.deepEqual(global.conditions, []);
981
- });
982
-
983
- test('L03d 作用域在收集时固定,整理只能沿用或收窄', () => {
984
- const human = (id, text) => ({ id, source: { kind: 'user' }, content: [{ type: 'text', text }] });
985
- const anchor = { episodeId: 'ep_x', status: 'unbound', updatedAt: '2026-01-01T00:00:00.000Z' };
986
- const narrow = learning.candidateFrom({ messages: [human('m1', '以后开头先给结论')], anchor, projectId: 'p' });
987
- assert.equal(narrow.expectedTarget.applicability, 'unresolved');
988
- const wide = learning.candidateFrom({ messages: [human('m2', '以后这个项目默认用 pnpm')], anchor, projectId: 'p' });
989
- assert.equal(wide.expectedTarget.applicability, 'all_in_scope');
990
-
991
- const refs = { feedback: { u0: 'm1' }, rules: {} };
992
- const base = { target: 'new', feedbackType: 'spec_change', topicKey: '开头', currentText: '开头先给结论', evidence: ['u0'] };
993
- const needsScope = learning.validateChange({ ...base, applicability: 'all_in_scope' }, { refs, rules: [], ranges: RANGES, expectedTarget: narrow.expectedTarget });
994
- assert.equal(needsScope.ok, false);
995
- assert.match(needsScope.reason, /不能.*全项目/);
996
- assert.equal(learning.validateChange({ ...base, applicability: 'unresolved' }, { refs, rules: [], ranges: RANGES, expectedTarget: narrow.expectedTarget }).ok, true);
997
- assert.equal(learning.validateChange({ ...base, applicability: 'all_in_scope' }, { refs, rules: [], ranges: RANGES, expectedTarget: wide.expectedTarget }).ok, true);
998
-
999
- const video = { scope: 'episode', episodeId: 'ep_x', taskKind: 'video' };
1000
- /** 作品里学到的规范:想说成全项目时收窄成任务条件,而不是整条作废。 */
1001
- const episodeWide = learning.validateChange({ ...base, applicability: 'all_in_scope' }, { refs, rules: [], ranges: RANGES, expectedTarget: video });
1002
- assert.equal(episodeWide.ok, true, episodeWide.reason);
1003
- assert.equal(episodeWide.change.applicability, 'conditional');
1004
- assert.deepEqual(episodeWide.change.conditions, [{ key: 'taskKind', value: 'video' }]);
1005
- const droppedKind = learning.validateChange({ ...base, applicability: 'conditional', conditions: [{ key: 'phase', value: 'script' }] }, { refs, rules: [], ranges: RANGES, expectedTarget: video });
1006
- assert.equal(droppedKind.ok, true, droppedKind.reason);
1007
- assert.deepEqual(droppedKind.change.conditions, [{ key: 'phase', value: 'script' }, { key: 'taskKind', value: 'video' }], '缺的任务范围自动补回,不能丢');
1008
- const keptKind = learning.validateChange({ ...base, applicability: 'conditional', conditions: [{ key: 'taskKind', value: 'video' }, { key: 'phase', value: 'script' }] }, { refs, rules: [], ranges: RANGES, expectedTarget: video });
1009
- assert.equal(keptKind.ok, true, keptKind.reason);
1010
- assert.deepEqual(keptKind.change.conditions, [{ key: 'taskKind', value: 'video' }, { key: 'phase', value: 'script' }]);
1011
-
1012
- const fallback = learning.pendingChangeFrom({ text: '以后所有开头都先给结论。', cue: { explicitLongTerm: true }, expectedTarget: narrow.expectedTarget });
1013
- assert.equal(fallback.applicability, 'unresolved', '兜底不重新推导作用域');
1014
- assert.equal(learning.pendingChangeFrom({ text: '以后开头都先给结论。', cue: { explicitLongTerm: true }, expectedTarget: wide.expectedTarget }).applicability, 'all_in_scope');
1015
- });
1016
-
1017
- test('L03d2 代码任务学到的规则不会在数据库任务里生效', () => {
1018
- const refs = { feedback: { u0: 'm1' }, rules: {} };
1019
- const parsed = learning.parseOutput(JSON.stringify({
1020
- changes: [{
1021
- target: 'new', feedbackType: 'spec_change', topicKey: '迁移',
1022
- currentText: '先跑迁移再改代码', applicability: 'all_in_scope', evidence: ['u0'],
1023
- }],
1024
- }), {
1025
- finish: { kind: 'stop' }, refs, rules: [], ranges: { ...RANGES, taskKinds: ['code', 'database'] },
1026
- expectedTarget: { scope: 'episode', projectId: 'p', episodeId: 'ep_code', taskKind: 'code' },
1027
- });
1028
- assert.equal(parsed.ok, true, parsed.reason);
1029
- const change = parsed.changes[0];
1030
- assert.equal(change.applicability, 'conditional');
1031
- assert.deepEqual(change.conditions, [{ key: 'taskKind', value: 'code' }]);
1032
- const code = view.eligibility({ id: 'mem_1', title: '迁移', status: 'active', origin: 'project_evolution', conditions: change.conditions, learning: { state: 'active' } }, { task: { kind: 'code' } }, 'auto');
1033
- const db = view.eligibility({ id: 'mem_1', title: '迁移', status: 'active', origin: 'project_evolution', conditions: change.conditions, learning: { state: 'active' } }, { task: { kind: 'database' } }, 'auto');
1034
- assert.equal(code.ok, true);
1035
- assert.equal(db.ok, false);
1036
- assert.equal(db.reason, 'condition_mismatch');
1037
- });
1038
-
1039
- test('L03d3 空项目的第一条反馈能带上真实任务范围保存', () => {
1040
- const parsed = learning.parseOutput(JSON.stringify({
1041
- changes: [{
1042
- target: 'new', feedbackType: 'spec_change', topicKey: '前端边框',
1043
- currentText: '前端页面默认采用细边框', applicability: 'all_in_scope', evidence: ['u0'],
1044
- }],
1045
- }), {
1046
- finish: { kind: 'stop' }, refs: { feedback: { u0: 'm1' }, rules: {} }, rules: [],
1047
- ranges: { taskKinds: [], series: [], projects: ['p'] },
1048
- expectedTarget: { scope: 'episode', projectId: 'p', episodeId: 'ep1', taskKind: 'code', phase: 'brief' },
1049
- });
1050
- assert.equal(parsed.ok, true, parsed.reason);
1051
- assert.equal(parsed.changes[0].applicability, 'conditional');
1052
- assert.deepEqual(parsed.changes[0].conditions, [{ key: 'taskKind', value: 'code' }], '采集阶段不该锁死长期适用范围');
1053
- });
1054
-
1055
- test('L03d4 模型条件与采集任务范围冲突时拒绝,不生成永不生效的规则', () => {
1056
- const parsed = learning.parseOutput(JSON.stringify({
1057
- changes: [{
1058
- target: 'new', feedbackType: 'spec_change', topicKey: '迁移',
1059
- currentText: '先跑迁移再改代码', applicability: 'conditional',
1060
- conditions: [{ key: 'taskKind', value: 'database' }], evidence: ['u0'],
1061
- }],
1062
- }), {
1063
- finish: { kind: 'stop' }, refs: { feedback: { u0: 'm1' }, rules: {} }, rules: [],
1064
- ranges: { ...RANGES, taskKinds: ['code', 'database'] },
1065
- expectedTarget: { scope: 'episode', projectId: 'p', episodeId: 'ep1', taskKind: 'code' },
1066
- });
1067
- assert.equal(parsed.ok, false);
1068
- assert.match(parsed.reason, /冲突/);
1069
- });
1070
-
1071
- test('L03d5 同一条件键的两个值不能都留下', () => {
1072
- const parsed = learning.parseOutput(JSON.stringify({
1073
- changes: [{
1074
- target: 'new', feedbackType: 'spec_change', topicKey: '阶段冲突',
1075
- currentText: '只在脚本阶段生效', applicability: 'conditional',
1076
- conditions: [{ key: 'phase', value: 'script' }, { key: 'phase', value: 'brief' }], evidence: ['u0'],
1077
- }],
1078
- }), {
1079
- finish: { kind: 'stop' }, refs: { feedback: { u0: 'm1' }, rules: {} }, rules: [],
1080
- ranges: { ...RANGES },
1081
- expectedTarget: { scope: 'episode', projectId: 'p', episodeId: 'ep1', taskKind: 'code', phase: 'brief' },
1082
- });
1083
- assert.equal(parsed.ok, false);
1084
- assert.match(parsed.reason, /条件/);
1085
- });
1086
-
1087
- test('L03e 先按相关性排序再截断,输入顺序不挤掉真正相关的旧规则', () => {
1088
- const rules = [];
1089
- for (let index = 0; index < 32; index += 1) {
1090
- rules.push({
1091
- id: `mem_${String(index).padStart(2, '0')}`,
1092
- state: 'active',
1093
- updated: `2026-01-${String((index % 28) + 1).padStart(2, '0')}T00:00:00.000Z`,
1094
- title: index === 31 ? '字幕字号' : `无关规则${index}`,
1095
- summary: '',
1096
- body: index === 31 ? '字幕固定 32 号' : '',
1097
- conditions: [],
1098
- });
1099
- }
1100
- const selected = learning.selectRules(rules, { query: '字幕太小了,改大一点', dimensions: ['字幕'], limit: 6 });
1101
- assert.equal(selected.length, 6);
1102
- assert.ok(selected.some((row) => row.id === 'mem_31'), '相关但排在最后的规则要进候选');
1103
- assert.equal(learning.selectRules(rules, { limit: 32 }).length, 32, '候选池上限是 32 条,不是先砍到 6 条');
1104
- });
1105
-
1106
- test('L03f 整理器规则先按状态和最近使用排序再取 8 条', () => {
1107
- const rules = [];
1108
- for (let index = 0; index < 12; index += 1) {
1109
- rules.push({
1110
- id: `mem_${String(index).padStart(2, '0')}`,
1111
- state: index === 11 ? 'active' : index === 10 ? 'trial' : 'paused',
1112
- updatedAt: `2026-01-${String(index + 1).padStart(2, '0')}T00:00:00.000Z`,
1113
- title: `规则${index}`,
1114
- });
1115
- }
1116
- const ordered = learning.orderOrganizerRules(rules);
1117
- assert.equal(ordered.length, learning.LIMITS.organizerRules);
1118
- assert.equal(ordered[0].id, 'mem_11', '生效规则优先于暂停规则');
1119
- assert.equal(ordered[1].id, 'mem_10', '试用规则排在暂停之前');
1120
- });
1121
-
1122
- test('L04 先算目标状态再查配额,过期候选可恢复', () => {
1123
- const base = { existing: null, evidence: { episodeId: 'e1', messageIds: ['m1'], sourceExcerpt: '以后都用参数化查询' }, pending: 12, rules: 0, now: 1000 };
1124
- const active = learning.admitChange({ ...base, change: NEW_CHANGE, explicitLongTerm: true });
1125
- assert.equal(active.ok, true, active.reason);
1126
- assert.equal(active.learning.state, 'active');
1127
- assert.equal(active.merged.current.body, NEW_CHANGE.currentText);
1128
- const candidate = learning.admitChange({
1129
- ...base,
1130
- pending: 12,
1131
- change: { ...NEW_CHANGE, conditions: [], applicability: 'unresolved' },
1132
- });
1133
- assert.equal(candidate.ok, false);
1134
- assert.equal(candidate.reason, '待确认候选已经到上限');
1135
- const full = learning.admitChange({ ...base, rules: learning.LIMITS.rules, pending: 0, change: NEW_CHANGE });
1136
- assert.equal(full.ok, false);
1137
- assert.equal(full.reason, '项目实验规则已经到上限');
1138
- const existing = {
1139
- id: 'mem_1', revision: 3, topicKey: '口播开头', body: '开头三句内给结论。',
1140
- applicability: 'conditional', conditions: [{ key: 'seriesId', value: '口播' }],
1141
- learning: {
1142
- state: 'trial',
1143
- specDigest: learning.specDigestOf({ topicKey: '口播开头', body: '开头三句内给结论。', applicability: 'conditional', conditions: [{ key: 'seriesId', value: '口播' }] }),
1144
- supportedEpisodeIds: [],
1145
- evidence: [],
1146
- },
1147
- };
1148
- const update = learning.admitChange({
1149
- existing, change: { ...NEW_CHANGE, target: 'r0' }, evidence: { episodeId: 'e2' }, pending: 99, rules: 99, now: 1000,
1150
- });
1151
- assert.equal(update.ok, true, update.reason);
1152
- assert.equal(update.learning.state, 'trial');
1153
- /** 过期待确认既不是永久候选,也不该被删;能重新唤起,且不占有效名额。 */
1154
- const stale = { id: 'mem_stale', origin: 'project_evolution', learning: { state: 'pending', createdAt: new Date(1000).toISOString() } };
1155
- const now = 1000 + learning.LIMITS.trialMs + 1000;
1156
- assert.equal(learning.candidateExpired(stale.learning, now), true);
1157
- assert.equal(learning.pendingCount([stale], now), 0);
1158
- assert.equal(learning.pendingCount([stale], 1000), 1);
1159
- const plan = learning.lifecycle([stale], now);
1160
- assert.deepEqual(plan.purge, []);
1161
- assert.deepEqual(plan.recoverable, ['mem_stale']);
1162
- const revived = learning.reviveCandidate(stale.learning, { now });
1163
- assert.equal(revived.state, 'pending');
1164
- assert.equal(learning.candidateExpired(revived, now), false);
1165
- });
1166
-
1167
- test('L05 规范或目标变了旧证据清零,来源与版本绑定留在规则上', () => {
1168
- const conditions = [{ key: 'seriesId', value: '口播' }];
1169
- const body = '开头三句内给结论。';
1170
- const existing = {
1171
- id: 'mem_1', revision: 4, topicKey: '口播开头', title: '口播开头', body,
1172
- applicability: 'conditional', conditions,
1173
- learning: {
1174
- state: 'active', specDigest: learning.specDigestOf({ topicKey: '口播开头', body, applicability: 'conditional', conditions }),
1175
- supportedEpisodeIds: ['e1', 'e2'],
1176
- evidence: [{ episodeId: 'e1' }, { episodeId: 'e2' }],
1177
- samples: [{ episodeId: 'e1', dimension: '字幕' }],
1178
- },
1179
- };
1180
- const evidence = { episodeId: 'e3', messageIds: ['m1', 'm2', 'm3', 'm4'], sourceExcerpt: '原始反馈:以后开头先给结论' };
1181
- const changed = learning.nextLearningState({
1182
- existing,
1183
- change: { id: 'mem_1', currentText: '开头五句内给结论。', feedbackType: 'spec_change' },
1184
- evidence: { ...evidence, explicitLongTerm: true },
1185
- specChanged: true,
1186
- now: 2000,
1187
- });
1188
- assert.deepEqual(changed.supportedEpisodeIds, ['e3']);
1189
- assert.equal(changed.evidence.length, 1);
1190
- assert.equal(changed.evidenceResetReason, 'spec_changed');
1191
- assert.equal(changed.samples, undefined);
1192
- assert.equal(changed.sourceExcerpt, evidence.sourceExcerpt);
1193
- assert.deepEqual(changed.binding.messageIds, ['m2', 'm3', 'm4']);
1194
- assert.equal(changed.binding.targetId, 'mem_1');
1195
- assert.equal(changed.binding.baseRevision, 4);
1196
- assert.equal(changed.state, 'active');
1197
- /** 没有规范变化时保留证据,只追加本次作品。 */
1198
- const kept = learning.nextLearningState({
1199
- existing,
1200
- change: { id: 'mem_1', currentText: body, feedbackType: 'spec_change' },
1201
- evidence: { episodeId: 'e9', sourceExcerpt: '补充' },
1202
- specChanged: false,
1203
- now: 2000,
1204
- });
1205
- assert.deepEqual(kept.supportedEpisodeIds, ['e1', 'e2', 'e9']);
1206
- assert.equal(kept.evidenceResetReason, undefined);
1207
- /** 目标对象换了也清零。 */
1208
- const retargeted = learning.nextLearningState({
1209
- existing,
1210
- change: { id: 'mem_2', feedbackType: 'spec_change' },
1211
- evidence: { episodeId: 'e3' },
1212
- specChanged: false,
1213
- now: 2000,
1214
- });
1215
- assert.deepEqual(retargeted.supportedEpisodeIds, ['e3']);
1216
- assert.equal(retargeted.evidenceResetReason, 'target_changed');
1217
- });
1218
-
1219
- test('L06 运行上下文只认传入身份,未绑定的 episodeId 不挡项目候选', () => {
1220
- const message = (id, text) => ({ id, source: { kind: 'user' }, content: [{ type: 'text', text }] });
1221
- const messages = [message('m1', '以后数据库查询都改成参数化查询')];
1222
- const runtime = learning.runtimeContextOf({
1223
- projectId: 'proj_a',
1224
- sessionId: 's1',
1225
- requestId: 'req_1',
1226
- anchor: { episodeId: 'e1', status: 'active', projectId: 'proj_a', taskKind: 'video', seriesId: '口播', phase: 'script' },
1227
- });
1228
- assert.deepEqual(
1229
- [runtime.projectId, runtime.sessionId, runtime.requestId, runtime.episodeId, runtime.bound],
1230
- ['proj_a', 's1', 'req_1', 'e1', true],
1231
- );
1232
- const unbound = learning.runtimeContextOf({ projectId: 'proj_a', anchor: { episodeId: 'e9', status: 'unbound', projectId: 'proj_a' } });
1233
- assert.equal(unbound.bound, false);
1234
- assert.equal(unbound.episodeId, 'e9');
1235
- /** 未绑定但任务里留着 episodeId:仍按项目候选收集,而不是「绑定失效」整条丢掉。 */
1236
- for (const anchorRow of [
1237
- { episodeId: 'e9', status: 'unbound', projectId: 'proj_a' },
1238
- { episodeId: 'e9', projectId: 'proj_a' },
1239
- { episodeId: 'e9', status: 'paused', projectId: 'proj_a' },
1240
- ]) {
1241
- const candidate = learning.candidateFrom({ messages, anchor: anchorRow, projectId: 'proj_a', sessionId: 's1', now: 1000 });
1242
- assert.ok(candidate, JSON.stringify(anchorRow));
1243
- assert.equal(candidate.scope, 'project', JSON.stringify(anchorRow));
1244
- assert.equal(candidate.episodeId, '');
1245
- assert.equal(candidate.projectId, 'proj_a');
1246
- }
1247
- /** 上下文之外的任何身份都不补:没传项目就是没有。 */
1248
- assert.equal(learning.runtimeContextOf({ anchor: { episodeId: 'e1', status: 'active' } }).projectId, '');
1249
- assert.equal(learning.candidateFrom({ messages }), null);
1250
- });
1251
-
1252
- test('L07 sourceExcerpt 从候选到调用输入到兜底变化全程不丢', () => {
1253
- const message = (id, text) => ({ id, source: { kind: 'user' }, content: [{ type: 'text', text }] });
1254
- const text = '以后字幕都改成底部安全区,别再顶到画面中间';
1255
- const candidate = learning.candidateFrom({
1256
- messages: [message('m1', text), message('m2', '这期先这样')],
1257
- anchor: { episodeId: 'e1', status: 'active', projectId: 'proj_a' },
1258
- projectId: 'proj_a',
1259
- sessionId: 's1',
1260
- now: 1000,
1261
- });
1262
- assert.ok(candidate);
1263
- assert.match(candidate.sourceExcerpt, /底部安全区/);
1264
- const input = learning.buildCallInput({
1265
- projectId: 'proj_a',
1266
- episodeId: 'e1',
1267
- feedback: candidate,
1268
- rules: [],
1269
- ranges: RANGES,
1270
- });
1271
- assert.equal(input.overLimit, undefined);
1272
- assert.equal(input.sourceExcerpt, candidate.sourceExcerpt);
1273
- assert.deepEqual(input.messageIds, ['m1', 'm2']);
1274
- const rendered = JSON.parse(input.prompt);
1275
- assert.equal(rendered.feedback.sourceExcerpt, candidate.sourceExcerpt);
1276
- assert.deepEqual(rendered.feedback.messageIds, ['m1', 'm2']);
1277
- assert.deepEqual(input.refs.feedback, { u0: 'm1' });
1278
- /** 整理器返回空时的兜底变化也带着同一段来源。 */
1279
- const change = learning.pendingChangeFrom(candidate);
1280
- assert.equal(change.sourceExcerpt, candidate.sourceExcerpt);
1281
- const checked = learning.validateChange(change, { refs: input.refs, rules: [], ranges: RANGES });
1282
- assert.equal(checked.ok, true, checked.reason);
1283
- assert.equal(checked.change.sourceExcerpt, candidate.sourceExcerpt);
1284
- assert.equal(learning.pendingChangeFrom({ text: ' ' }), null);
1285
- });
1286
-
1287
- test('L08 admissionOf 统一收集与展示,启发式推断不等于用户确认', () => {
1288
- const message = (id, text) => ({ id, source: { kind: 'user' }, content: [{ type: 'text', text }] });
1289
- const bound = learning.candidateFrom({
1290
- messages: [message('m1', '以后字幕都改成底部安全区')],
1291
- anchor: { episodeId: 'e1', status: 'active', projectId: 'proj_a', taskKind: 'video' },
1292
- projectId: 'proj_a',
1293
- sessionId: 's1',
1294
- now: 1000,
1295
- });
1296
- for (const purpose of ['collect', 'display']) {
1297
- const admission = learning.admissionOf(bound, {
1298
- projectId: 'proj_a',
1299
- sessionId: 's1',
1300
- anchor: { episodeId: 'e1', status: 'active', projectId: 'proj_a' },
1301
- }, purpose);
1302
- assert.equal(admission.ok, true, admission.reason);
1303
- assert.equal(admission.scope, 'episode');
1304
- assert.equal(admission.episodeId, 'e1');
1305
- assert.equal(admission.confirmed, false);
1306
- assert.equal(admission.requiresUserConfirmation, true);
1307
- assert.equal(admission.persistence, 'project_candidate');
1308
- assert.equal(admission.visibility, 'candidate');
1309
- }
1310
- /** 明确长期要求也只是候选:用户说过“以后”,不等于点过“记住”。 */
1311
- const longTerm = learning.candidateFrom({
1312
- messages: [message('m1', '以后数据库查询都改成参数化查询')],
1313
- anchor: null,
1314
- projectId: 'proj_a',
1315
- now: 1000,
1316
- });
1317
- assert.equal(learning.admissionOf(longTerm, { projectId: 'proj_a' }).confirmed, false);
1318
- /** 未绑定但带着 episodeId 的任务:作品候选失效,项目候选照常准入。 */
1319
- const unbound = learning.admissionOf(bound, {
1320
- projectId: 'proj_a',
1321
- anchor: { episodeId: 'e1', status: 'unbound', projectId: 'proj_a' },
1322
- });
1323
- assert.equal(unbound.ok, false);
1324
- assert.equal(unbound.reason, '作品绑定已经失效');
1325
- assert.equal(learning.admissionOf({ ...longTerm }, {
1326
- projectId: 'proj_a',
1327
- anchor: { episodeId: 'e9', status: 'unbound', projectId: 'proj_a' },
1328
- }).ok, true);
1329
- assert.equal(learning.admissionOf({ ...bound, confirmed: true }, {
1330
- projectId: 'proj_a',
1331
- anchor: { episodeId: 'e1', status: 'active', projectId: 'proj_a' },
1332
- }).requiresUserConfirmation, false);
1333
- assert.equal(learning.admissionOf(bound, { projectId: 'proj_a', anchor: { episodeId: 'e1', status: 'active' } }, 'commit').ok, false);
1334
- assert.equal(learning.admissionOf(null).ok, false);
1335
- assert.equal(learning.admissionOf(bound, { projectId: 'proj_b', anchor: { episodeId: 'e1', status: 'active' } }).ok, false);
1336
- });
1337
-
1338
- test('L09 项目滚动额度与单次去重分离,开关不重置已用额度', () => {
1339
- let ledger = {};
1340
- const base = Date.parse('2026-01-01T00:00:00.000Z');
1341
- /** 同一期最多两次:去重按 episode 行记,滚动 recent 清空也不放宽。 */
1342
- ledger = learning.recordAttempt(ledger, { episodeId: 'e1', now: base });
1343
- assert.equal(learning.quota(ledger, { episodeId: 'e1', now: base }).ok, true);
1344
- ledger = learning.recordAttempt(ledger, { episodeId: 'e1', now: base + 1000 });
1345
- const episodeLimit = learning.quota(ledger, { episodeId: 'e1', now: base + 2000 });
1346
- assert.equal(episodeLimit.reason, 'episode_limit');
1347
- assert.equal(episodeLimit.episodeCount, learning.LIMITS.attemptsPerEpisode);
1348
- /** 模拟实验关开:state.applyEvolution 会清空 recent,attempts 保留。 */
1349
- const toggled = { ...learning.recordAttempt(ledger, { episodeId: 'e2', now: base + 3000 }), recent: [] };
1350
- assert.equal(learning.quota(toggled, { episodeId: 'e1', now: base + 4000 }).reason, 'episode_limit');
1351
- assert.equal(learning.quota(toggled, { episodeId: 'e2', now: base + 4000 }).episodeCount, 1);
1352
- /** 跨作品项目上限按窗口内总次数算,不依赖 recent。 */
1353
- let project = {};
1354
- for (const episodeId of ['p1', 'p1', 'p2', 'p2', 'p3', 'p3']) {
1355
- project = learning.recordAttempt(project, { episodeId, now: base });
1356
- }
1357
- project = { ...project, recent: [] };
1358
- const window = learning.attemptWindowOf(project, { now: base + 1000 });
1359
- assert.equal(window.projectCount, learning.LIMITS.attemptsPerProject);
1360
- assert.equal(learning.quota(project, { episodeId: 'p4', now: base + 1000 }).reason, 'project_limit');
1361
- /** 只按窗口自然衰减,不因开关或去重重置。 */
1362
- const later = base + learning.LIMITS.windowMs + 1;
1363
- assert.equal(learning.quota(project, { episodeId: 'p4', now: later }).ok, true);
1364
- assert.equal(learning.attemptWindowOf(project, { now: later }).projectCount, 0);
1365
- /** 项目层候选按 project:<key> 记账:不能被“每期两次”终身卡死,只受滚动项目额度约束。 */
1366
- let pseudo = {};
1367
- for (let index = 0; index < 3; index += 1) pseudo = learning.recordAttempt(pseudo, { episodeId: 'project:proj_a', now: base + index });
1368
- const pseudoQuota = learning.quota(pseudo, { episodeId: 'project:proj_a', now: base + 10 });
1369
- assert.equal(pseudoQuota.ok, true, JSON.stringify(pseudoQuota));
1370
- assert.equal(pseudoQuota.episodeCount, 0);
1371
- assert.equal(learning.quota(pseudo, { episodeId: '', now: base + 10 }).ok, true);
1372
- /** 两条不同作品各自占一次额度,单条去重不合并跨期调用。 */
1373
- const distinct = learning.recordAttempt(learning.recordAttempt({}, { episodeId: 'q1', now: base }), { episodeId: 'q2', now: base });
1374
- assert.equal(learning.quota(distinct, { episodeId: 'q3', now: base }).projectCount, 2);
1375
- });
1376
-
1377
- test('L10 整理器规则先稳定排序再取 8', () => {
1378
- const rules = [];
1379
- for (let index = 0; index < 9; index += 1) {
1380
- rules.push({
1381
- id: `mem_${index}`,
1382
- status: 'active',
1383
- learning: { state: 'pending', updatedAt: new Date(Date.UTC(2026, 0, 1, 0, index)).toISOString() },
1384
- updated: new Date(Date.UTC(2026, 0, 1, 0, index)).toISOString(),
1385
- });
1386
- }
1387
- /** 真正可用的旧经验排在输入最后:先截断就会丢掉它。 */
1388
- rules.push({
1389
- id: 'mem_star',
1390
- status: 'active',
1391
- learning: { state: 'active', lastUsedAt: '2025-12-31T00:00:00.000Z', updatedAt: '2025-01-01T00:00:00.000Z' },
1392
- updated: '2025-01-01T00:00:00.000Z',
1393
- });
1394
- assert.equal(rules.slice(0, 8).some((row) => row.id === 'mem_star'), false);
1395
- const ordered = learning.orderOrganizerRules(rules);
1396
- assert.equal(ordered.length, learning.LIMITS.organizerRules);
1397
- assert.equal(ordered[0].id, 'mem_star');
1398
- assert.equal(ordered.some((row) => row.id === 'mem_star'), true);
1399
- /** 同样输入换个顺序结果一样;同状态按最近使用、再按时间、最后按 id。 */
1400
- const shuffled = [...rules].reverse();
1401
- assert.deepEqual(learning.orderOrganizerRules(shuffled).map((row) => row.id), ordered.map((row) => row.id));
1402
- const sameState = [
1403
- { id: 'b', learning: { state: 'active', updatedAt: '2025-01-01T00:00:00.000Z' } },
1404
- { id: 'a', learning: { state: 'active', lastUsedAt: '2026-01-01T00:00:00.000Z', updatedAt: '2025-01-01T00:00:00.000Z' } },
1405
- { id: 'c', learning: { state: 'active', updatedAt: '2025-01-01T00:00:00.000Z' } },
1406
- ];
1407
- assert.deepEqual(learning.orderOrganizerRules(sameState, { limit: 2 }).map((row) => row.id), ['a', 'b']);
1408
- assert.equal(learning.orderOrganizerRules([], { limit: 3 }).length, 0);
1409
- });
1410
-
1411
- /* 收敛:连续纠正同一主题只留一条现行规则;换栏目不适用;关掉再开还是同一现行版。 */
1412
- test('B30 连续纠正同一规则收敛到一条现行版', () => {
1413
- const scope = fresh('converge');
1414
- enable(scope);
1415
- const firstEpisode = seedEpisode(scope, { sessionId: 'conv1', messageIds: ['cm1'] });
1416
- const firstStart = begin(scope, firstEpisode);
1417
- assert.equal(commitNew(scope, firstEpisode, firstStart, NEW_CHANGE).ok, true);
1418
- const id = ruleRows(scope)[0].id;
1419
- const bodies = ['开头两句内给结论。', '开头一句内给结论。'];
1420
- bodies.forEach((body, index) => {
1421
- const episode = seedEpisode(scope, { sessionId: `conv${index + 2}`, messageIds: [`cm${index + 2}`] });
1422
- const started = begin(scope, episode);
1423
- assert.equal(started.ok, true, started.reason);
1424
- const result = commitExisting(scope, episode, started, id, {
1425
- target: 'r0', feedbackType: 'spec_change', currentText: body, evidence: ['u0'],
1426
- });
1427
- assert.equal(result.ok, true, result.reason);
1428
- });
1429
- const rows = ruleRows(scope);
1430
- assert.equal(rows.length, 1, '同一主题不堆多份现行规则');
1431
- const current = rows[0];
1432
- assert.equal(current.id, id);
1433
- assert.match(current.body, /一句内给结论/);
1434
- assert.ok((current.versions || []).length <= 2, '旧版有上限');
1435
- const wrongColumn = view.eligibility(current, {
1436
- projectId: scope.key,
1437
- context: { seriesId: '字幕' },
1438
- task: { taskKind: 'video', seriesId: '字幕' },
1439
- }, 'auto');
1440
- assert.equal(wrongColumn.ok, false, '换栏目不适用');
1441
- store.setMemoryEnabled(scope, false, { operationId: 'conv-close' });
1442
- assert.equal(ruleRows(scope).length, 1, '关闭不清成果');
1443
- store.setMemoryEnabled(scope, true, { operationId: 'conv-open' });
1444
- const reopened = ruleOf(scope, id);
1445
- assert.equal(reopened.body, current.body);
1446
- assert.equal(reopened.revision, current.revision);
1447
- assert.equal(reopened.learning.state, current.learning.state);
1448
- });
1449
-
1450
- console.log(`learning.test.mjs: ${passed} passed`);