@aiwayds/dsh-topics-memory 0.24.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/distill.d.ts +54 -3
- package/lib/distill.js +257 -59
- package/lib/pipeline-events.d.ts +5 -2
- package/lib/pipeline-events.js +1 -0
- package/lib/service.js +4 -4
- package/package.json +2 -2
- package/src/distill.ts +281 -55
- package/src/pipeline-events.ts +6 -2
- package/src/service.ts +4 -4
package/lib/distill.d.ts
CHANGED
|
@@ -42,6 +42,16 @@ export interface DistillOp {
|
|
|
42
42
|
conclusion?: string;
|
|
43
43
|
recommendations?: string;
|
|
44
44
|
status?: string;
|
|
45
|
+
/** Lesson distill branch (B2): lesson-kind create op — a behavior lesson. */
|
|
46
|
+
kind?: string;
|
|
47
|
+
/** Lesson scope (B2 §6.2, lesson 必带; the write defaults absent → workspace). */
|
|
48
|
+
scope?: string;
|
|
49
|
+
/**
|
|
50
|
+
* Conflict self-check markers (B2 决议 5, baseline): established lesson
|
|
51
|
+
* slugs from the prompt's baseline list that this draft contradicts. The
|
|
52
|
+
* draft still lands — the flag is ledgered for the B4 approval gate.
|
|
53
|
+
*/
|
|
54
|
+
conflictsWith?: string[];
|
|
45
55
|
observed_ids?: string[];
|
|
46
56
|
}
|
|
47
57
|
export interface DistillResult {
|
|
@@ -55,6 +65,17 @@ export interface DistillResult {
|
|
|
55
65
|
detail?: string;
|
|
56
66
|
}
|
|
57
67
|
export declare const SYSTEM_PROMPT: string;
|
|
68
|
+
/**
|
|
69
|
+
* The lesson distill branch's system prompt (B2 决议 3①, spec 卷二 §3.1):
|
|
70
|
+
* fed structured signals (`kind:'signal'` + `source:'fed'`, feature lines
|
|
71
|
+
* only — 对话原文结构上进不来) distill into behavior LESSONS, not factual
|
|
72
|
+
* topics. The 判定问句 is the gate: a signal batch that cannot yield an
|
|
73
|
+
* executable behavioral rule must come back EMPTY (宁缺勿滥 — a fabricated
|
|
74
|
+
* lesson is noise the approval gate then has to burn down). The lane stamps
|
|
75
|
+
* confidence='hint' + source='distilled' itself; promotion to established is
|
|
76
|
+
* the B4 approval gate's exclusive write path (setLessonStatus).
|
|
77
|
+
*/
|
|
78
|
+
export declare const LESSON_PROMPT: string;
|
|
58
79
|
export declare class Distiller {
|
|
59
80
|
private inFlight;
|
|
60
81
|
private readonly service;
|
|
@@ -91,11 +112,36 @@ export declare class Distiller {
|
|
|
91
112
|
private runInner;
|
|
92
113
|
/**
|
|
93
114
|
* One model call (plus at most one corrective retry) over one batch:
|
|
94
|
-
* payload build → ops → topic writes → marks. `budgetLeft` is the
|
|
95
|
-
* still available to the run including this batch's first call (≥ 1);
|
|
96
|
-
* corrective retry only fires when it can stay inside that budget.
|
|
115
|
+
* payload build → ops → topic/lesson writes → marks. `budgetLeft` is the
|
|
116
|
+
* calls still available to the run including this batch's first call (≥ 1);
|
|
117
|
+
* the corrective retry only fires when it can stay inside that budget.
|
|
118
|
+
* `branch` picks the prompt and the payload: the topic branch carries the
|
|
119
|
+
* topic index (ops may update slugs), the lesson branch carries the
|
|
120
|
+
* established-lesson baseline for the conflict self-check instead — lesson
|
|
121
|
+
* ops never reference the topic roster. Both branches share the
|
|
122
|
+
* observed_ids sanitation, the schema gate, the corrective retry, the GC
|
|
123
|
+
* and the marks; only the payload/system/op-shape differ.
|
|
97
124
|
*/
|
|
98
125
|
private runBatch;
|
|
126
|
+
/**
|
|
127
|
+
* The conflict self-check baseline (B2 决议 5): established lessons ONLY —
|
|
128
|
+
* hint drafts are not yet norms, so there is nothing for a candidate to
|
|
129
|
+
* contradict. Each brief carries the lesson's condition surface (title +
|
|
130
|
+
* triggers) and its slug (the only legal conflictsWith reference). Bounded
|
|
131
|
+
* reads like the topic index (first 100 roster entries); established
|
|
132
|
+
* lessons are post-gate artifacts and stay a handful in practice.
|
|
133
|
+
*/
|
|
134
|
+
private establishedLessonBriefs;
|
|
135
|
+
/**
|
|
136
|
+
* B2 conflict self-check, LEDGER-ONLY disposition: a draft that declares
|
|
137
|
+
* `conflictsWith` still lands as a hint draft (新覆旧 is the B4 approval
|
|
138
|
+
* gate's call, never the distill lane's). Each declared slug is
|
|
139
|
+
* whitelist-checked against the established baseline the prompt carried;
|
|
140
|
+
* roster-unknown slugs are model noise and are dropped without an event (a
|
|
141
|
+
* hallucinated slug must not fabricate a conflict). The event is a flag for
|
|
142
|
+
* the approval surface — no lifecycle state changes here.
|
|
143
|
+
*/
|
|
144
|
+
private ledgerLessonConflicts;
|
|
99
145
|
/**
|
|
100
146
|
* The ONE corrective retry for a zero-valid-observed_ids batch: re-ask with
|
|
101
147
|
* the batch's legal id list spelled out verbatim. It spends a second call
|
|
@@ -144,6 +190,11 @@ export declare class Distiller {
|
|
|
144
190
|
* the store transaction (先落 topic 再建边 — one write, no separate edge
|
|
145
191
|
* pass). The set is locally seeded with this call's predicted op slugs and
|
|
146
192
|
* grows as creates land, so same-batch references resolve in op order.
|
|
193
|
+
*
|
|
194
|
+
* Lesson creates (B2) ride the same sanitation and consume their
|
|
195
|
+
* observations through the same observed_ids bridge; the write goes through
|
|
196
|
+
* saveTopic's lesson branch (kind:'lesson', confidence hint, distilled
|
|
197
|
+
* provenance) — the observation→mark closure is identical to topics.
|
|
147
198
|
*/
|
|
148
199
|
private applyOps;
|
|
149
200
|
/**
|
package/lib/distill.js
CHANGED
|
@@ -28,6 +28,41 @@ export const SYSTEM_PROMPT = [
|
|
|
28
28
|
'- 观察里有价值就沉淀,没价值就跳过该观察(被跳过观察的 id 不要出现在任何 op 里);没有可沉淀内容时输出 {"ops":[]}。',
|
|
29
29
|
'- 中文主题用中文写;tags 全小写。',
|
|
30
30
|
].join('\n');
|
|
31
|
+
/**
|
|
32
|
+
* The lesson distill branch's system prompt (B2 决议 3①, spec 卷二 §3.1):
|
|
33
|
+
* fed structured signals (`kind:'signal'` + `source:'fed'`, feature lines
|
|
34
|
+
* only — 对话原文结构上进不来) distill into behavior LESSONS, not factual
|
|
35
|
+
* topics. The 判定问句 is the gate: a signal batch that cannot yield an
|
|
36
|
+
* executable behavioral rule must come back EMPTY (宁缺勿滥 — a fabricated
|
|
37
|
+
* lesson is noise the approval gate then has to burn down). The lane stamps
|
|
38
|
+
* confidence='hint' + source='distilled' itself; promotion to established is
|
|
39
|
+
* the B4 approval gate's exclusive write path (setLessonStatus).
|
|
40
|
+
*/
|
|
41
|
+
export const LESSON_PROMPT = [
|
|
42
|
+
'你是 lesson 记忆库的蒸馏引擎。输入是若干条来自改进回路的结构化信号观察(JSON,只含特征与计数,无对话原文)和现有 established lesson 清单。',
|
|
43
|
+
'任务:把信号提炼成行为教训(lesson)操作。只输出一个 JSON 对象,不要任何其他文字、Markdown 代码块或解释。',
|
|
44
|
+
'判定问句:「违反它会犯错误吗?」——只有当信号指向一条可执行的行为规则(遵守或违反它会直接改变做事的对错)才立 lesson;只是事实、环境描述或给不出行动指令的信号批没有教训可立:明确输出 {"ops":[]}(宁缺勿滥,禁止为凑数编造教训)。',
|
|
45
|
+
'输出格式:{"ops": [...]},每个元素只允许 create:',
|
|
46
|
+
' {"op":"create","kind":"lesson","title":"条件式短句(什么情境下)","triggers":["condition 触发词,恰好 3-8 个具体短语"],',
|
|
47
|
+
' "conclusion":"action:怎么做,祈使句,自含可执行","scope":"workspace 或 global","observed_ids":["obs-..."]}',
|
|
48
|
+
'规则:',
|
|
49
|
+
'- 硬性要求:每个 op 必须带 observed_ids 字段,值只能从本次输入「信号观察」里列出的 id 中逐字复制(形如 "obs-...");含列表之外 id 的条目会被过滤;observed_ids 缺失、为空或过滤后不剩任何有效 id 的 op 会被整体丢弃。',
|
|
50
|
+
'- 硬性要求:每个 create 必须带 triggers(恰好 3-8 个「什么情境出现时应该想起这条教训」的 condition 触发短语)与 conclusion(祈使句行动指令);缺失或数量不符的 create 会被补问一次,补全后仍不合规的 op 会被丢弃并落账。',
|
|
51
|
+
'- title 写情境侧(条件式短句),conclusion 写行动侧(怎么做,祈使句);confidence 一律 hint、来源一律 distilled,由系统落库时固定,不要输出这两个字段。',
|
|
52
|
+
'- scope:只在本工作区适用的教训用 "workspace"(默认),跨项目普适的才用 "global"。',
|
|
53
|
+
'- 矛盾自查:输出前对照输入里的「现有 established lesson 清单」——若候选教训与某条已有教训在同一情境下给出相反做法,照常输出草稿并加 "conflictsWith":["该教训的slug"] 标记(只能引用清单里的 slug;是否新覆旧由审批门裁决,不要因此放弃输出)。',
|
|
54
|
+
'- 同一情境只允许一个 create;中文写。',
|
|
55
|
+
].join('\n');
|
|
56
|
+
/**
|
|
57
|
+
* The B-line routing predicate (B2 决议 3①): the write seam stamps fed
|
|
58
|
+
* structured signals `kind:'signal'` + `source:'fed'` (feedObservations);
|
|
59
|
+
* exactly that combination rides the lesson prompt branch. Every other
|
|
60
|
+
* observation keeps the topic prompt — the topic lane's behavior is
|
|
61
|
+
* byte-for-byte unchanged.
|
|
62
|
+
*/
|
|
63
|
+
function isFedSignal(o) {
|
|
64
|
+
return o.source === 'fed' && o.kind === 'signal';
|
|
65
|
+
}
|
|
31
66
|
/** Adaptive-batch floor — below this, shrinking cannot rescue an output limit. */
|
|
32
67
|
const MIN_BATCH_SIZE = 5;
|
|
33
68
|
/** Fallbacks when the config fields are absent (bare test harnesses). */
|
|
@@ -63,6 +98,29 @@ function checkCreateSchema(op) {
|
|
|
63
98
|
}
|
|
64
99
|
return { valid: problems.length === 0, missing: problems.join('、') };
|
|
65
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* The lesson-branch schema check (B2): a lesson create must carry the
|
|
103
|
+
* condition (triggers 3-8 — the same strong-field contract as topics) and the
|
|
104
|
+
* action (conclusion, the 祈使句 imperative). `description` is NOT required
|
|
105
|
+
* on lessons — the §6.2 frontmatter contract is scope/confidence/source and
|
|
106
|
+
* the title carries the condition side. An ABSENT scope is not a defect (the
|
|
107
|
+
* write defaults it to workspace — lesson 必带 scope is a storage invariant
|
|
108
|
+
* enforced at saveTopic, not a model-output demand); a present-but-illegal
|
|
109
|
+
* scope is.
|
|
110
|
+
*/
|
|
111
|
+
function checkLessonSchema(op) {
|
|
112
|
+
const problems = [];
|
|
113
|
+
if (typeof op.conclusion !== 'string' || op.conclusion.trim() === '')
|
|
114
|
+
problems.push('conclusion(action)');
|
|
115
|
+
const triggers = Array.isArray(op.triggers) ? op.triggers.filter((t) => typeof t === 'string' && t.trim() !== '') : [];
|
|
116
|
+
if (triggers.length < TRIGGERS_MIN || triggers.length > TRIGGERS_MAX) {
|
|
117
|
+
problems.push(`triggers(实际 ${triggers.length},需 ${TRIGGERS_MIN}-${TRIGGERS_MAX})`);
|
|
118
|
+
}
|
|
119
|
+
if (op.scope !== undefined && op.scope !== 'workspace' && op.scope !== 'global') {
|
|
120
|
+
problems.push('scope(需 workspace|global)');
|
|
121
|
+
}
|
|
122
|
+
return { valid: problems.length === 0, missing: problems.join('、') };
|
|
123
|
+
}
|
|
66
124
|
export class Distiller {
|
|
67
125
|
inFlight = new Map();
|
|
68
126
|
service;
|
|
@@ -194,6 +252,7 @@ export class Distiller {
|
|
|
194
252
|
let filteredTotal = 0;
|
|
195
253
|
let invalidTotal = 0;
|
|
196
254
|
let rejectedTotal = 0;
|
|
255
|
+
let conflictTotal = 0;
|
|
197
256
|
let stallDetail;
|
|
198
257
|
// GC bookkeeping (store.recordUnconsumed): every id the model EVALUATED
|
|
199
258
|
// this run (parseable answer, however useless) minus the ids an op
|
|
@@ -218,15 +277,26 @@ export class Distiller {
|
|
|
218
277
|
stopped = 'budget';
|
|
219
278
|
break;
|
|
220
279
|
}
|
|
280
|
+
// B-line routing (lesson distill branch): fed+signal observations carry
|
|
281
|
+
// the self-improve structured signals and distill through the lesson
|
|
282
|
+
// prompt branch into lesson ops; every other observation keeps the topic
|
|
283
|
+
// prompt byte-for-byte. A mixed window runs ONE sub-pool per iteration —
|
|
284
|
+
// the topic sub-pool first (the topic lane keeps its claim on the batch
|
|
285
|
+
// head and its existing stop semantics); the other sub-pool stays
|
|
286
|
+
// pending in the pool and is fetched by the next iteration.
|
|
287
|
+
const topicObs = observations.filter((o) => !isFedSignal(o));
|
|
288
|
+
const branch = topicObs.length > 0 ? 'topic' : 'lesson';
|
|
289
|
+
const pool = topicObs.length > 0 ? topicObs : observations;
|
|
221
290
|
// The batch spends 1 call, or 2 when the corrective observed_ids retry
|
|
222
291
|
// fires — the budget check above already guaranteed room for both.
|
|
223
|
-
const outcome = await this.runBatch(sessionId,
|
|
292
|
+
const outcome = await this.runBatch(sessionId, pool, maxCalls - calls, branch);
|
|
224
293
|
calls += Math.max(1, outcome.callsUsed ?? 1);
|
|
225
294
|
for (const id of outcome.consumedIds ?? [])
|
|
226
295
|
consumedRunIds.add(id);
|
|
227
296
|
filteredTotal += outcome.filteredIds ?? 0;
|
|
228
297
|
invalidTotal += outcome.invalidOps ?? 0;
|
|
229
298
|
rejectedTotal += outcome.rejectedLinks ?? 0;
|
|
299
|
+
conflictTotal += outcome.conflictsFlagged ?? 0;
|
|
230
300
|
created.push(...outcome.created);
|
|
231
301
|
updated.push(...outcome.updated);
|
|
232
302
|
marked += outcome.marked;
|
|
@@ -247,7 +317,7 @@ export class Distiller {
|
|
|
247
317
|
// Floor reached: repeated output-limit finishes ARE an evaluation
|
|
248
318
|
// verdict on this content (the model saw it and could not fit an
|
|
249
319
|
// answer) — the batch counts toward the GC.
|
|
250
|
-
for (const o of
|
|
320
|
+
for (const o of pool)
|
|
251
321
|
fedIds.add(o.id);
|
|
252
322
|
stopped = 'max-tokens';
|
|
253
323
|
failureDetail = `批次无法再缩小(当前 ${this.batchSize} 条)仍触发输出上限:${outcome.detail ?? 'model finish: max-tokens'}`;
|
|
@@ -264,7 +334,7 @@ export class Distiller {
|
|
|
264
334
|
}
|
|
265
335
|
// Parseable answer (ops, empty or not): the batch was genuinely
|
|
266
336
|
// evaluated — its unconsumed ids gain one failed attempt.
|
|
267
|
-
for (const o of
|
|
337
|
+
for (const o of pool)
|
|
268
338
|
fedIds.add(o.id);
|
|
269
339
|
if (outcome.noOps) {
|
|
270
340
|
stopped = 'no-ops';
|
|
@@ -310,7 +380,8 @@ export class Distiller {
|
|
|
310
380
|
const filteredNote = filteredTotal > 0 ? `;filtered ${filteredTotal} invalid observed_ids` : '';
|
|
311
381
|
const schemaNote = invalidTotal > 0 ? `;schema-dropped ${invalidTotal} create op(s)` : '';
|
|
312
382
|
const linkNote = rejectedTotal > 0 ? `;rejected ${rejectedTotal} invalid link ref(s)` : '';
|
|
313
|
-
const
|
|
383
|
+
const conflictNote = conflictTotal > 0 ? `;conflict-flagged ${conflictTotal} lesson draft(s)` : '';
|
|
384
|
+
const notes = `${filteredNote}${schemaNote}${linkNote}${conflictNote}`;
|
|
314
385
|
const base = head === undefined ? undefined : `partial: 已蒸馏标记 ${marked} 条观察(${calls} 次模型调用);${head}`;
|
|
315
386
|
result = {
|
|
316
387
|
ok: true,
|
|
@@ -347,35 +418,64 @@ export class Distiller {
|
|
|
347
418
|
}
|
|
348
419
|
/**
|
|
349
420
|
* One model call (plus at most one corrective retry) over one batch:
|
|
350
|
-
* payload build → ops → topic writes → marks. `budgetLeft` is the
|
|
351
|
-
* still available to the run including this batch's first call (≥ 1);
|
|
352
|
-
* corrective retry only fires when it can stay inside that budget.
|
|
421
|
+
* payload build → ops → topic/lesson writes → marks. `budgetLeft` is the
|
|
422
|
+
* calls still available to the run including this batch's first call (≥ 1);
|
|
423
|
+
* the corrective retry only fires when it can stay inside that budget.
|
|
424
|
+
* `branch` picks the prompt and the payload: the topic branch carries the
|
|
425
|
+
* topic index (ops may update slugs), the lesson branch carries the
|
|
426
|
+
* established-lesson baseline for the conflict self-check instead — lesson
|
|
427
|
+
* ops never reference the topic roster. Both branches share the
|
|
428
|
+
* observed_ids sanitation, the schema gate, the corrective retry, the GC
|
|
429
|
+
* and the marks; only the payload/system/op-shape differ.
|
|
353
430
|
*/
|
|
354
|
-
async runBatch(sessionId, observations, budgetLeft) {
|
|
431
|
+
async runBatch(sessionId, observations, budgetLeft, branch) {
|
|
432
|
+
const lesson = branch === 'lesson';
|
|
433
|
+
const system = lesson ? LESSON_PROMPT : SYSTEM_PROMPT;
|
|
355
434
|
const metas = await this.service.store.listTopics();
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
435
|
+
let establishedSlugs = new Set();
|
|
436
|
+
let user;
|
|
437
|
+
if (lesson) {
|
|
438
|
+
// Lesson branch payload: the conflict self-check baseline (B2 决议 5)
|
|
439
|
+
// rides IN the prompt — a same-prompt self-check, not a separate slow
|
|
440
|
+
// call; the B4 gate owns any 新覆旧 disposition afterwards.
|
|
441
|
+
const established = await this.establishedLessonBriefs(metas);
|
|
442
|
+
establishedSlugs = new Set(established.map((l) => l.slug));
|
|
443
|
+
const observationsPayload = observations.map((o) => ({ id: o.id, kind: o.kind, source: o.source, text: o.text }));
|
|
444
|
+
user = [
|
|
445
|
+
`现有 established lesson 清单(${established.length} 条):`,
|
|
446
|
+
JSON.stringify(established),
|
|
447
|
+
'',
|
|
448
|
+
`信号观察(${observationsPayload.length} 条):`,
|
|
449
|
+
JSON.stringify(observationsPayload),
|
|
450
|
+
'',
|
|
451
|
+
'请输出蒸馏结果(严格 JSON,{"ops":[...]}):',
|
|
452
|
+
].join('\n');
|
|
453
|
+
}
|
|
454
|
+
else {
|
|
455
|
+
// Real open questions need the docs; fetch for index (bounded).
|
|
456
|
+
const indexDetailed = [];
|
|
457
|
+
for (const m of metas.slice(0, 100)) {
|
|
458
|
+
const doc = await this.service.store.readTopic(m.slug).catch(() => undefined);
|
|
459
|
+
indexDetailed.push({
|
|
460
|
+
slug: m.slug,
|
|
461
|
+
title: m.title,
|
|
462
|
+
status: m.status,
|
|
463
|
+
tags: m.tags,
|
|
464
|
+
open_questions: doc?.fm.open_questions ?? [],
|
|
465
|
+
conclusion: (okf.firstParagraph(okf.sectionOf(doc?.body ?? '', okf.CONCLUSION_HEADING) ?? '')).slice(0, 200),
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
const observationsPayload = observations.map((o) => ({ id: o.id, kind: o.kind, source: o.source, text: o.text }));
|
|
469
|
+
user = [
|
|
470
|
+
`现有 topic 索引(${indexDetailed.length} 个):`,
|
|
471
|
+
JSON.stringify(indexDetailed),
|
|
472
|
+
'',
|
|
473
|
+
`未蒸馏观察(${observationsPayload.length} 条):`,
|
|
474
|
+
JSON.stringify(observationsPayload),
|
|
475
|
+
'',
|
|
476
|
+
'请输出蒸馏结果(严格 JSON,{"ops":[...]}):',
|
|
477
|
+
].join('\n');
|
|
368
478
|
}
|
|
369
|
-
const observationsPayload = observations.map((o) => ({ id: o.id, kind: o.kind, source: o.source, text: o.text }));
|
|
370
|
-
const user = [
|
|
371
|
-
`现有 topic 索引(${indexDetailed.length} 个):`,
|
|
372
|
-
JSON.stringify(indexDetailed),
|
|
373
|
-
'',
|
|
374
|
-
`未蒸馏观察(${observationsPayload.length} 条):`,
|
|
375
|
-
JSON.stringify(observationsPayload),
|
|
376
|
-
'',
|
|
377
|
-
'请输出蒸馏结果(严格 JSON,{"ops":[...]}):',
|
|
378
|
-
].join('\n');
|
|
379
479
|
let raw;
|
|
380
480
|
const caller = this.caller;
|
|
381
481
|
if (caller === undefined) {
|
|
@@ -384,7 +484,7 @@ export class Distiller {
|
|
|
384
484
|
return { created: [], updated: [], marked: 0, fatalReason: 'model-error', detail: 'distill caller unavailable' };
|
|
385
485
|
}
|
|
386
486
|
try {
|
|
387
|
-
raw = await caller({ system
|
|
487
|
+
raw = await caller({ system, user, purpose: 'topics-distill', sessionId, maxTokens: 4000 });
|
|
388
488
|
}
|
|
389
489
|
catch (e) {
|
|
390
490
|
const detail = String(e instanceof Error ? e.message : e).slice(0, 200);
|
|
@@ -405,6 +505,15 @@ export class Distiller {
|
|
|
405
505
|
detail: String(e instanceof Error ? e.message : e).slice(0, 200),
|
|
406
506
|
};
|
|
407
507
|
}
|
|
508
|
+
if (lesson) {
|
|
509
|
+
// The lesson branch drafts NEW lessons only — a lesson "update" would
|
|
510
|
+
// rewrite another lane's lifecycle state (confidence/status transitions
|
|
511
|
+
// are the B4 approval gate's exclusive write path), so non-create shapes
|
|
512
|
+
// are dropped before the gate. Their observations stay unconsumed and
|
|
513
|
+
// walk the existing GC path; an all-update batch resolves to the honest
|
|
514
|
+
// no-ops stop.
|
|
515
|
+
ops = ops.filter((o) => o.op === 'create');
|
|
516
|
+
}
|
|
408
517
|
if (ops.length === 0) {
|
|
409
518
|
return { created: [], updated: [], marked: 0, noOps: true, callsUsed: 1 };
|
|
410
519
|
}
|
|
@@ -412,7 +521,7 @@ export class Distiller {
|
|
|
412
521
|
// 补问 retry (budget-guarded), still-deficient ops are dropped and
|
|
413
522
|
// ledgered — never a silent drop. Their observations simply stay
|
|
414
523
|
// unconsumed and walk the existing GC/corrective path (three strikes).
|
|
415
|
-
const schema = await this.enforceSchema(sessionId, user, ops, budgetLeft);
|
|
524
|
+
const schema = await this.enforceSchema(sessionId, system, user, ops, budgetLeft, branch);
|
|
416
525
|
let callsUsed = 1 + schema.retrySpent;
|
|
417
526
|
for (const drop of schema.drops) {
|
|
418
527
|
await recordPipelineEvent(this.service.store.root, {
|
|
@@ -428,9 +537,16 @@ export class Distiller {
|
|
|
428
537
|
marked: 0,
|
|
429
538
|
callsUsed,
|
|
430
539
|
invalidOps: schema.drops.length,
|
|
431
|
-
stallDetail:
|
|
540
|
+
stallDetail: lesson
|
|
541
|
+
? `全部 lesson op 未通过 schema 校验(triggers 3-8 条 + conclusion(action) 必填 + scope 枚举),已丢弃并落账(${schema.drops.length} 个)`
|
|
542
|
+
: `全部 create op 未通过 schema 校验(description 必填 + triggers 3-8 条),已丢弃并落账(${schema.drops.length} 个)`,
|
|
432
543
|
};
|
|
433
544
|
}
|
|
545
|
+
// B2 conflict self-check ledgering: the draft still lands as a hint —
|
|
546
|
+
// the flag is for the approval surface, never a lifecycle write.
|
|
547
|
+
let conflictsFlagged = 0;
|
|
548
|
+
if (lesson)
|
|
549
|
+
conflictsFlagged = await this.ledgerLessonConflicts(schema.finalOps, establishedSlugs);
|
|
434
550
|
// observed_ids are the ONLY bridge between ops and marks — without a valid
|
|
435
551
|
// id the store cannot mark anything and the batch head can never advance
|
|
436
552
|
// (the real-machine zero-consumption livelock: topics landed, backlog
|
|
@@ -465,7 +581,7 @@ export class Distiller {
|
|
|
465
581
|
};
|
|
466
582
|
}
|
|
467
583
|
const pass1 = applied;
|
|
468
|
-
const { applied: pass2, why } = await this.correctiveRetry(sessionId, user, idList, validIds, knownSlugs);
|
|
584
|
+
const { applied: pass2, why } = await this.correctiveRetry(sessionId, system, user, idList, validIds, knownSlugs);
|
|
469
585
|
callsUsed += 1;
|
|
470
586
|
if (pass2 === undefined || pass2.consumedIds.length === 0) {
|
|
471
587
|
const tail = pass2 === undefined
|
|
@@ -505,9 +621,60 @@ export class Distiller {
|
|
|
505
621
|
filteredIds: applied.filteredIds,
|
|
506
622
|
invalidOps: schema.drops.length,
|
|
507
623
|
rejectedLinks: applied.rejectedLinks,
|
|
624
|
+
conflictsFlagged,
|
|
508
625
|
consumedIds: applied.consumedIds,
|
|
509
626
|
};
|
|
510
627
|
}
|
|
628
|
+
/**
|
|
629
|
+
* The conflict self-check baseline (B2 决议 5): established lessons ONLY —
|
|
630
|
+
* hint drafts are not yet norms, so there is nothing for a candidate to
|
|
631
|
+
* contradict. Each brief carries the lesson's condition surface (title +
|
|
632
|
+
* triggers) and its slug (the only legal conflictsWith reference). Bounded
|
|
633
|
+
* reads like the topic index (first 100 roster entries); established
|
|
634
|
+
* lessons are post-gate artifacts and stay a handful in practice.
|
|
635
|
+
*/
|
|
636
|
+
async establishedLessonBriefs(metas) {
|
|
637
|
+
const out = [];
|
|
638
|
+
for (const m of metas.slice(0, 100)) {
|
|
639
|
+
const doc = await this.service.store.readTopic(m.slug).catch(() => undefined);
|
|
640
|
+
if (doc === undefined)
|
|
641
|
+
continue;
|
|
642
|
+
if (okf.topicKindOf(doc.fm.type) !== 'lesson')
|
|
643
|
+
continue;
|
|
644
|
+
if ((doc.fm.confidence ?? 'hint') !== 'established')
|
|
645
|
+
continue;
|
|
646
|
+
out.push({ slug: m.slug, title: m.title, triggers: doc.fm.triggers ?? [] });
|
|
647
|
+
}
|
|
648
|
+
return out;
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* B2 conflict self-check, LEDGER-ONLY disposition: a draft that declares
|
|
652
|
+
* `conflictsWith` still lands as a hint draft (新覆旧 is the B4 approval
|
|
653
|
+
* gate's call, never the distill lane's). Each declared slug is
|
|
654
|
+
* whitelist-checked against the established baseline the prompt carried;
|
|
655
|
+
* roster-unknown slugs are model noise and are dropped without an event (a
|
|
656
|
+
* hallucinated slug must not fabricate a conflict). The event is a flag for
|
|
657
|
+
* the approval surface — no lifecycle state changes here.
|
|
658
|
+
*/
|
|
659
|
+
async ledgerLessonConflicts(ops, establishedSlugs) {
|
|
660
|
+
let flagged = 0;
|
|
661
|
+
for (const op of ops) {
|
|
662
|
+
if (!Array.isArray(op.conflictsWith) || op.conflictsWith.length === 0)
|
|
663
|
+
continue;
|
|
664
|
+
const valid = [...new Set(op.conflictsWith)].filter((s) => typeof s === 'string' && establishedSlugs.has(s));
|
|
665
|
+
if (valid.length === 0)
|
|
666
|
+
continue;
|
|
667
|
+
await recordPipelineEvent(this.service.store.root, {
|
|
668
|
+
at: new Date().toISOString(),
|
|
669
|
+
kind: 'lesson-conflict-flagged',
|
|
670
|
+
// detail values are scalar-only (pipeline-events whitelist): the slug
|
|
671
|
+
// list rides as a comma join, the raw declaration count rides as a number.
|
|
672
|
+
detail: { op: opKey(op), conflictsWith: valid.join(','), declared: op.conflictsWith.length },
|
|
673
|
+
});
|
|
674
|
+
flagged += valid.length;
|
|
675
|
+
}
|
|
676
|
+
return flagged;
|
|
677
|
+
}
|
|
511
678
|
/**
|
|
512
679
|
* The ONE corrective retry for a zero-valid-observed_ids batch: re-ask with
|
|
513
680
|
* the batch's legal id list spelled out verbatim. It spends a second call
|
|
@@ -516,7 +683,7 @@ export class Distiller {
|
|
|
516
683
|
* stop — a max-tokens correction does NOT trigger the batch halving (that
|
|
517
684
|
* escape hatch belongs to the main pass).
|
|
518
685
|
*/
|
|
519
|
-
async correctiveRetry(sessionId, originalUser, idList, validIds, knownSlugs) {
|
|
686
|
+
async correctiveRetry(sessionId, system, originalUser, idList, validIds, knownSlugs) {
|
|
520
687
|
const caller = this.caller;
|
|
521
688
|
if (caller === undefined)
|
|
522
689
|
return { why: 'distill caller unavailable' };
|
|
@@ -531,7 +698,7 @@ export class Distiller {
|
|
|
531
698
|
].join('\n');
|
|
532
699
|
let raw;
|
|
533
700
|
try {
|
|
534
|
-
raw = await caller({ system
|
|
701
|
+
raw = await caller({ system, user, purpose: 'topics-distill', sessionId, maxTokens: 4000 });
|
|
535
702
|
}
|
|
536
703
|
catch (e) {
|
|
537
704
|
return { why: `模型调用失败:${String(e instanceof Error ? e.message : e).slice(0, 160)}` };
|
|
@@ -560,7 +727,9 @@ export class Distiller {
|
|
|
560
727
|
* GC/corrective path accounts for them, never a silent loss. Update ops
|
|
561
728
|
* keep their narrower contract (slug only).
|
|
562
729
|
*/
|
|
563
|
-
async enforceSchema(sessionId, originalUser, ops, budgetLeft) {
|
|
730
|
+
async enforceSchema(sessionId, system, originalUser, ops, budgetLeft, branch) {
|
|
731
|
+
const lesson = branch === 'lesson';
|
|
732
|
+
const check = lesson ? checkLessonSchema : checkCreateSchema;
|
|
564
733
|
const valid = [];
|
|
565
734
|
const invalid = [];
|
|
566
735
|
for (const op of ops) {
|
|
@@ -568,11 +737,11 @@ export class Distiller {
|
|
|
568
737
|
valid.push(op);
|
|
569
738
|
continue;
|
|
570
739
|
}
|
|
571
|
-
const
|
|
572
|
-
if (
|
|
740
|
+
const verdict = check(op);
|
|
741
|
+
if (verdict.valid)
|
|
573
742
|
valid.push(op);
|
|
574
743
|
else
|
|
575
|
-
invalid.push({ op, key: opKey(op), missing:
|
|
744
|
+
invalid.push({ op, key: opKey(op), missing: verdict.missing });
|
|
576
745
|
}
|
|
577
746
|
if (invalid.length === 0)
|
|
578
747
|
return { finalOps: valid, drops: [], retrySpent: 0 };
|
|
@@ -587,7 +756,7 @@ export class Distiller {
|
|
|
587
756
|
retrySpent: 0,
|
|
588
757
|
};
|
|
589
758
|
}
|
|
590
|
-
const rescued = await this.schemaRetryCall(sessionId, originalUser, invalid);
|
|
759
|
+
const rescued = await this.schemaRetryCall(sessionId, system, originalUser, invalid, branch);
|
|
591
760
|
if (rescued === undefined) {
|
|
592
761
|
return {
|
|
593
762
|
finalOps: valid,
|
|
@@ -602,7 +771,7 @@ export class Distiller {
|
|
|
602
771
|
const drops = [];
|
|
603
772
|
for (const c of invalid) {
|
|
604
773
|
const replacement = rescued.get(c.key);
|
|
605
|
-
if (replacement !== undefined &&
|
|
774
|
+
if (replacement !== undefined && check(replacement).valid) {
|
|
606
775
|
valid.push(replacement);
|
|
607
776
|
}
|
|
608
777
|
else {
|
|
@@ -617,22 +786,27 @@ export class Distiller {
|
|
|
617
786
|
* or undefined when the call itself failed / output was unparseable — the
|
|
618
787
|
* caller then ledgeres `distill-op-retry-exhausted` for every deficient op.
|
|
619
788
|
*/
|
|
620
|
-
async schemaRetryCall(sessionId, originalUser, invalid) {
|
|
789
|
+
async schemaRetryCall(sessionId, system, originalUser, invalid, branch) {
|
|
621
790
|
const caller = this.caller;
|
|
622
791
|
if (caller === undefined)
|
|
623
792
|
return undefined;
|
|
624
793
|
const list = invalid.map((c) => `- create「${typeof c.op.title === 'string' ? c.op.title : ''}」(缺:${c.missing})`).join('\n');
|
|
794
|
+
// The completion demand names the branch's own required fields; the topic
|
|
795
|
+
// line is byte-identical to its pre-lesson-branch form.
|
|
796
|
+
const instruction = branch === 'lesson'
|
|
797
|
+
? '请只重新输出这些 op 的补全版本(严格 JSON,{"ops":[...]}):每个 create 必须带 triggers(恰好 3-8 个 condition 触发短语)与 conclusion(祈使句行动指令),scope 只能是 workspace 或 global,并保留原有的 title/observed_ids 等字段。无法补全的 op 不要输出。'
|
|
798
|
+
: '请只重新输出这些 op 的补全版本(严格 JSON,{"ops":[...]}):每个 create 必须带 description(一句话概括)与 triggers(恰好 3-8 条具体触发短语,禁止 dsh、配置、错误 之类的泛词),并保留原有的 title/conclusion/observed_ids 等字段。无法补全的 op 不要输出。';
|
|
625
799
|
const user = [
|
|
626
800
|
originalUser,
|
|
627
801
|
'',
|
|
628
802
|
'---',
|
|
629
803
|
'schema 补问:你上一次返回的以下 create op 缺少必填字段:',
|
|
630
804
|
list,
|
|
631
|
-
|
|
805
|
+
instruction,
|
|
632
806
|
].join('\n');
|
|
633
807
|
let raw;
|
|
634
808
|
try {
|
|
635
|
-
raw = await caller({ system
|
|
809
|
+
raw = await caller({ system, user, purpose: 'topics-distill', sessionId, maxTokens: 4000 });
|
|
636
810
|
}
|
|
637
811
|
catch {
|
|
638
812
|
return undefined;
|
|
@@ -670,6 +844,11 @@ export class Distiller {
|
|
|
670
844
|
* the store transaction (先落 topic 再建边 — one write, no separate edge
|
|
671
845
|
* pass). The set is locally seeded with this call's predicted op slugs and
|
|
672
846
|
* grows as creates land, so same-batch references resolve in op order.
|
|
847
|
+
*
|
|
848
|
+
* Lesson creates (B2) ride the same sanitation and consume their
|
|
849
|
+
* observations through the same observed_ids bridge; the write goes through
|
|
850
|
+
* saveTopic's lesson branch (kind:'lesson', confidence hint, distilled
|
|
851
|
+
* provenance) — the observation→mark closure is identical to topics.
|
|
673
852
|
*/
|
|
674
853
|
async applyOps(ops, validIds, knownSlugs) {
|
|
675
854
|
const slugs = new Set(knownSlugs);
|
|
@@ -708,20 +887,39 @@ export class Distiller {
|
|
|
708
887
|
if (op.op === 'create' && typeof op.title === 'string' && typeof op.conclusion === 'string') {
|
|
709
888
|
const links = await this.acceptLinks(op, slugs);
|
|
710
889
|
applied.rejectedLinks += links.rejected;
|
|
711
|
-
const res =
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
890
|
+
const res = op.kind === 'lesson'
|
|
891
|
+
? // Lesson data plane (B2 §6.2): the draft lands as a hint with
|
|
892
|
+
// distilled provenance — status derives from confidence, and
|
|
893
|
+
// promotion to established is the B4 approval gate's exclusive
|
|
894
|
+
// write path (setLessonStatus), so `status` is NOT taken from
|
|
895
|
+
// the op. Scope: the op's explicit choice, default workspace
|
|
896
|
+
// (lesson 必带 scope, global 需显式).
|
|
897
|
+
await this.service.saveTopic({
|
|
898
|
+
title: op.title,
|
|
899
|
+
kind: 'lesson',
|
|
900
|
+
conclusion: op.conclusion,
|
|
901
|
+
description: op.description,
|
|
902
|
+
triggers: Array.isArray(op.triggers) ? op.triggers.filter((t) => typeof t === 'string') : undefined,
|
|
903
|
+
scope: op.scope === 'global' ? 'global' : 'workspace',
|
|
904
|
+
confidence: 'hint',
|
|
905
|
+
lessonSource: 'distilled',
|
|
906
|
+
source: 'distill',
|
|
907
|
+
links: links.targets,
|
|
908
|
+
})
|
|
909
|
+
: await this.service.saveTopic({
|
|
910
|
+
title: op.title,
|
|
911
|
+
conclusion: op.conclusion,
|
|
912
|
+
description: op.description,
|
|
913
|
+
tags: op.tags,
|
|
914
|
+
triggers: Array.isArray(op.triggers) ? op.triggers.filter((t) => typeof t === 'string') : undefined,
|
|
915
|
+
depends: op.depends,
|
|
916
|
+
openQuestions: op.open_questions,
|
|
917
|
+
impact: op.impact,
|
|
918
|
+
recommendations: op.recommendations,
|
|
919
|
+
status: op.status === 'stable' ? 'stable' : 'draft',
|
|
920
|
+
source: 'distill',
|
|
921
|
+
links: links.targets,
|
|
922
|
+
});
|
|
725
923
|
applied.created.push(res.slug);
|
|
726
924
|
applied.consumedSlugs.push(res.slug);
|
|
727
925
|
slugs.add(res.slug);
|
package/lib/pipeline-events.d.ts
CHANGED
|
@@ -17,8 +17,11 @@
|
|
|
17
17
|
* the slow lane's per-run disposition row (quality.ts); the distill/link
|
|
18
18
|
* kinds are the A2/A6 producers' contract — they import this module. The
|
|
19
19
|
* two `seam-*` kinds are the B-line dual-write audit trail (卷二 §6.2:
|
|
20
|
-
* caller-identity audit — opts.actor — rides every seam call).
|
|
21
|
-
|
|
20
|
+
* caller-identity audit — opts.actor — rides every seam call).
|
|
21
|
+
* `lesson-conflict-flagged` is the lesson distill branch's conflict
|
|
22
|
+
* self-check flag (B2 决议 5): informational for the B4 approval surface —
|
|
23
|
+
* the draft still lands as hint, no lifecycle state changes on this event. */
|
|
24
|
+
export type PipelineEventKind = 'slow-lane-outcome' | 'distill-op-invalid' | 'distill-op-retry-exhausted' | 'link-ref-rejected' | 'seam-observations-fed' | 'seam-lesson-status' | 'lesson-conflict-flagged';
|
|
22
25
|
/** File NAME inside the bundle's meta dir (path via pipelineEventsFile). */
|
|
23
26
|
export declare const PIPELINE_EVENTS_FILE = "pipeline-events.jsonl";
|
|
24
27
|
export interface PipelineEvent {
|
package/lib/pipeline-events.js
CHANGED
package/lib/service.js
CHANGED
|
@@ -307,10 +307,10 @@ export class TopicsService {
|
|
|
307
307
|
let lines = [];
|
|
308
308
|
try {
|
|
309
309
|
const st = statSync(file);
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
310
|
+
const all = readFileSync(file, 'utf8').split('\n').filter((l) => l !== '');
|
|
311
|
+
// Append, never clobber: existing lines are always carried forward;
|
|
312
|
+
// compaction (keep newest 3/4) only bites past the 512KB cap.
|
|
313
|
+
lines = st.size > 512 * 1024 ? all.slice(Math.floor(all.length / 4)) : all;
|
|
314
314
|
}
|
|
315
315
|
catch { /* first write */ }
|
|
316
316
|
lines.push(JSON.stringify(entry));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiwayds/dsh-topics-memory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"description": "dsh-plugin: quality-first topic memory for dsh — a topic keeps the starting question, conclusion, impact and dependencies, not the process; local-first git-traced OKF bundle, budgeted LLM-free injection",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"build": "tsc -p tsconfig.json",
|
|
43
43
|
"check": "tsc --noEmit -p tsconfig.json",
|
|
44
44
|
"pretest": "tsc -p tsconfig.json",
|
|
45
|
-
"test": "node --test test/yaml.test.mjs test/okf.test.mjs test/retrieval.test.mjs test/digest.test.mjs test/digest-package.test.mjs test/ilog.test.mjs test/store.test.mjs test/paths.test.mjs test/git.test.mjs test/sync.test.mjs test/tools.test.mjs test/dedup.test.mjs test/commands.test.mjs test/onboard.test.mjs test/delegation.test.mjs test/observer.test.mjs test/distill.test.mjs test/consolidate.test.mjs test/viz.test.mjs test/corpus.test.mjs test/llm-scope.test.mjs test/quality.test.mjs test/replay-gate.test.mjs test/depends-repair.test.mjs test/skill.test.mjs test/fast-lane.test.mjs test/legacy-import.test.mjs test/jev-config.test.mjs test/jev-gate.test.mjs test/jev-client.test.mjs test/jev-log.test.mjs test/jev-dual.test.mjs test/jev-fastgate.test.mjs test/zero-open-decay.test.mjs test/embedding.test.mjs test/embedding-index.test.mjs test/query-sanitize.test.mjs test/semantic-rerank.test.mjs test/inject-config.test.mjs test/distill-links.test.mjs test/store-links.test.mjs test/lesson-seams.test.mjs",
|
|
45
|
+
"test": "node --test test/yaml.test.mjs test/okf.test.mjs test/retrieval.test.mjs test/digest.test.mjs test/digest-package.test.mjs test/ilog.test.mjs test/store.test.mjs test/paths.test.mjs test/git.test.mjs test/sync.test.mjs test/tools.test.mjs test/dedup.test.mjs test/commands.test.mjs test/onboard.test.mjs test/delegation.test.mjs test/observer.test.mjs test/distill.test.mjs test/consolidate.test.mjs test/viz.test.mjs test/corpus.test.mjs test/llm-scope.test.mjs test/quality.test.mjs test/replay-gate.test.mjs test/depends-repair.test.mjs test/skill.test.mjs test/fast-lane.test.mjs test/legacy-import.test.mjs test/jev-config.test.mjs test/jev-gate.test.mjs test/jev-client.test.mjs test/jev-log.test.mjs test/jev-dual.test.mjs test/jev-fastgate.test.mjs test/zero-open-decay.test.mjs test/embedding.test.mjs test/embedding-index.test.mjs test/query-sanitize.test.mjs test/semantic-rerank.test.mjs test/inject-config.test.mjs test/distill-links.test.mjs test/distill-lesson.test.mjs test/store-links.test.mjs test/lesson-seams.test.mjs",
|
|
46
46
|
"smoke": "node scripts/smoke-boot.mjs"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {},
|
package/src/distill.ts
CHANGED
|
@@ -54,6 +54,16 @@ export interface DistillOp {
|
|
|
54
54
|
conclusion?: string
|
|
55
55
|
recommendations?: string
|
|
56
56
|
status?: string
|
|
57
|
+
/** Lesson distill branch (B2): lesson-kind create op — a behavior lesson. */
|
|
58
|
+
kind?: string
|
|
59
|
+
/** Lesson scope (B2 §6.2, lesson 必带; the write defaults absent → workspace). */
|
|
60
|
+
scope?: string
|
|
61
|
+
/**
|
|
62
|
+
* Conflict self-check markers (B2 决议 5, baseline): established lesson
|
|
63
|
+
* slugs from the prompt's baseline list that this draft contradicts. The
|
|
64
|
+
* draft still lands — the flag is ledgered for the B4 approval gate.
|
|
65
|
+
*/
|
|
66
|
+
conflictsWith?: string[]
|
|
57
67
|
observed_ids?: string[]
|
|
58
68
|
}
|
|
59
69
|
|
|
@@ -86,6 +96,47 @@ export const SYSTEM_PROMPT = [
|
|
|
86
96
|
'- 中文主题用中文写;tags 全小写。',
|
|
87
97
|
].join('\n')
|
|
88
98
|
|
|
99
|
+
/**
|
|
100
|
+
* The lesson distill branch's system prompt (B2 决议 3①, spec 卷二 §3.1):
|
|
101
|
+
* fed structured signals (`kind:'signal'` + `source:'fed'`, feature lines
|
|
102
|
+
* only — 对话原文结构上进不来) distill into behavior LESSONS, not factual
|
|
103
|
+
* topics. The 判定问句 is the gate: a signal batch that cannot yield an
|
|
104
|
+
* executable behavioral rule must come back EMPTY (宁缺勿滥 — a fabricated
|
|
105
|
+
* lesson is noise the approval gate then has to burn down). The lane stamps
|
|
106
|
+
* confidence='hint' + source='distilled' itself; promotion to established is
|
|
107
|
+
* the B4 approval gate's exclusive write path (setLessonStatus).
|
|
108
|
+
*/
|
|
109
|
+
export const LESSON_PROMPT = [
|
|
110
|
+
'你是 lesson 记忆库的蒸馏引擎。输入是若干条来自改进回路的结构化信号观察(JSON,只含特征与计数,无对话原文)和现有 established lesson 清单。',
|
|
111
|
+
'任务:把信号提炼成行为教训(lesson)操作。只输出一个 JSON 对象,不要任何其他文字、Markdown 代码块或解释。',
|
|
112
|
+
'判定问句:「违反它会犯错误吗?」——只有当信号指向一条可执行的行为规则(遵守或违反它会直接改变做事的对错)才立 lesson;只是事实、环境描述或给不出行动指令的信号批没有教训可立:明确输出 {"ops":[]}(宁缺勿滥,禁止为凑数编造教训)。',
|
|
113
|
+
'输出格式:{"ops": [...]},每个元素只允许 create:',
|
|
114
|
+
' {"op":"create","kind":"lesson","title":"条件式短句(什么情境下)","triggers":["condition 触发词,恰好 3-8 个具体短语"],',
|
|
115
|
+
' "conclusion":"action:怎么做,祈使句,自含可执行","scope":"workspace 或 global","observed_ids":["obs-..."]}',
|
|
116
|
+
'规则:',
|
|
117
|
+
'- 硬性要求:每个 op 必须带 observed_ids 字段,值只能从本次输入「信号观察」里列出的 id 中逐字复制(形如 "obs-...");含列表之外 id 的条目会被过滤;observed_ids 缺失、为空或过滤后不剩任何有效 id 的 op 会被整体丢弃。',
|
|
118
|
+
'- 硬性要求:每个 create 必须带 triggers(恰好 3-8 个「什么情境出现时应该想起这条教训」的 condition 触发短语)与 conclusion(祈使句行动指令);缺失或数量不符的 create 会被补问一次,补全后仍不合规的 op 会被丢弃并落账。',
|
|
119
|
+
'- title 写情境侧(条件式短句),conclusion 写行动侧(怎么做,祈使句);confidence 一律 hint、来源一律 distilled,由系统落库时固定,不要输出这两个字段。',
|
|
120
|
+
'- scope:只在本工作区适用的教训用 "workspace"(默认),跨项目普适的才用 "global"。',
|
|
121
|
+
'- 矛盾自查:输出前对照输入里的「现有 established lesson 清单」——若候选教训与某条已有教训在同一情境下给出相反做法,照常输出草稿并加 "conflictsWith":["该教训的slug"] 标记(只能引用清单里的 slug;是否新覆旧由审批门裁决,不要因此放弃输出)。',
|
|
122
|
+
'- 同一情境只允许一个 create;中文写。',
|
|
123
|
+
].join('\n')
|
|
124
|
+
|
|
125
|
+
/** Which prompt branch a batch rides: the standing topic lane or the B-line
|
|
126
|
+
* lesson lane (fed+signal observations only). */
|
|
127
|
+
type DistillBranch = 'topic' | 'lesson'
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The B-line routing predicate (B2 决议 3①): the write seam stamps fed
|
|
131
|
+
* structured signals `kind:'signal'` + `source:'fed'` (feedObservations);
|
|
132
|
+
* exactly that combination rides the lesson prompt branch. Every other
|
|
133
|
+
* observation keeps the topic prompt — the topic lane's behavior is
|
|
134
|
+
* byte-for-byte unchanged.
|
|
135
|
+
*/
|
|
136
|
+
function isFedSignal(o: ObservationLike): boolean {
|
|
137
|
+
return o.source === 'fed' && o.kind === 'signal'
|
|
138
|
+
}
|
|
139
|
+
|
|
89
140
|
/** Adaptive-batch floor — below this, shrinking cannot rescue an output limit. */
|
|
90
141
|
const MIN_BATCH_SIZE = 5
|
|
91
142
|
/** Fallbacks when the config fields are absent (bare test harnesses). */
|
|
@@ -121,6 +172,8 @@ interface BatchOutcome {
|
|
|
121
172
|
invalidOps?: number
|
|
122
173
|
/** links stripped by the whitelist (A2) this batch. */
|
|
123
174
|
rejectedLinks?: number
|
|
175
|
+
/** lesson-conflict-flagged events ledgered this batch (lesson branch only). */
|
|
176
|
+
conflictsFlagged?: number
|
|
124
177
|
detail?: string
|
|
125
178
|
/**
|
|
126
179
|
* Specific zero-consumption explanation for the stalled stop (undefined =
|
|
@@ -187,6 +240,29 @@ function checkCreateSchema(op: DistillOp): { valid: boolean; missing: string } {
|
|
|
187
240
|
return { valid: problems.length === 0, missing: problems.join('、') }
|
|
188
241
|
}
|
|
189
242
|
|
|
243
|
+
/**
|
|
244
|
+
* The lesson-branch schema check (B2): a lesson create must carry the
|
|
245
|
+
* condition (triggers 3-8 — the same strong-field contract as topics) and the
|
|
246
|
+
* action (conclusion, the 祈使句 imperative). `description` is NOT required
|
|
247
|
+
* on lessons — the §6.2 frontmatter contract is scope/confidence/source and
|
|
248
|
+
* the title carries the condition side. An ABSENT scope is not a defect (the
|
|
249
|
+
* write defaults it to workspace — lesson 必带 scope is a storage invariant
|
|
250
|
+
* enforced at saveTopic, not a model-output demand); a present-but-illegal
|
|
251
|
+
* scope is.
|
|
252
|
+
*/
|
|
253
|
+
function checkLessonSchema(op: DistillOp): { valid: boolean; missing: string } {
|
|
254
|
+
const problems: string[] = []
|
|
255
|
+
if (typeof op.conclusion !== 'string' || op.conclusion.trim() === '') problems.push('conclusion(action)')
|
|
256
|
+
const triggers = Array.isArray(op.triggers) ? op.triggers.filter((t): t is string => typeof t === 'string' && t.trim() !== '') : []
|
|
257
|
+
if (triggers.length < TRIGGERS_MIN || triggers.length > TRIGGERS_MAX) {
|
|
258
|
+
problems.push(`triggers(实际 ${triggers.length},需 ${TRIGGERS_MIN}-${TRIGGERS_MAX})`)
|
|
259
|
+
}
|
|
260
|
+
if (op.scope !== undefined && op.scope !== 'workspace' && op.scope !== 'global') {
|
|
261
|
+
problems.push('scope(需 workspace|global)')
|
|
262
|
+
}
|
|
263
|
+
return { valid: problems.length === 0, missing: problems.join('、') }
|
|
264
|
+
}
|
|
265
|
+
|
|
190
266
|
export class Distiller {
|
|
191
267
|
private inFlight = new Map<string, Promise<DistillResult>>()
|
|
192
268
|
private readonly service: TopicsService
|
|
@@ -327,6 +403,7 @@ export class Distiller {
|
|
|
327
403
|
let filteredTotal = 0
|
|
328
404
|
let invalidTotal = 0
|
|
329
405
|
let rejectedTotal = 0
|
|
406
|
+
let conflictTotal = 0
|
|
330
407
|
let stallDetail: string | undefined
|
|
331
408
|
// GC bookkeeping (store.recordUnconsumed): every id the model EVALUATED
|
|
332
409
|
// this run (parseable answer, however useless) minus the ids an op
|
|
@@ -351,14 +428,25 @@ export class Distiller {
|
|
|
351
428
|
stopped = 'budget'
|
|
352
429
|
break
|
|
353
430
|
}
|
|
431
|
+
// B-line routing (lesson distill branch): fed+signal observations carry
|
|
432
|
+
// the self-improve structured signals and distill through the lesson
|
|
433
|
+
// prompt branch into lesson ops; every other observation keeps the topic
|
|
434
|
+
// prompt byte-for-byte. A mixed window runs ONE sub-pool per iteration —
|
|
435
|
+
// the topic sub-pool first (the topic lane keeps its claim on the batch
|
|
436
|
+
// head and its existing stop semantics); the other sub-pool stays
|
|
437
|
+
// pending in the pool and is fetched by the next iteration.
|
|
438
|
+
const topicObs = observations.filter((o) => !isFedSignal(o))
|
|
439
|
+
const branch: DistillBranch = topicObs.length > 0 ? 'topic' : 'lesson'
|
|
440
|
+
const pool = topicObs.length > 0 ? topicObs : observations
|
|
354
441
|
// The batch spends 1 call, or 2 when the corrective observed_ids retry
|
|
355
442
|
// fires — the budget check above already guaranteed room for both.
|
|
356
|
-
const outcome = await this.runBatch(sessionId,
|
|
443
|
+
const outcome = await this.runBatch(sessionId, pool, maxCalls - calls, branch)
|
|
357
444
|
calls += Math.max(1, outcome.callsUsed ?? 1)
|
|
358
445
|
for (const id of outcome.consumedIds ?? []) consumedRunIds.add(id)
|
|
359
446
|
filteredTotal += outcome.filteredIds ?? 0
|
|
360
447
|
invalidTotal += outcome.invalidOps ?? 0
|
|
361
448
|
rejectedTotal += outcome.rejectedLinks ?? 0
|
|
449
|
+
conflictTotal += outcome.conflictsFlagged ?? 0
|
|
362
450
|
created.push(...outcome.created)
|
|
363
451
|
updated.push(...outcome.updated)
|
|
364
452
|
marked += outcome.marked
|
|
@@ -379,7 +467,7 @@ export class Distiller {
|
|
|
379
467
|
// Floor reached: repeated output-limit finishes ARE an evaluation
|
|
380
468
|
// verdict on this content (the model saw it and could not fit an
|
|
381
469
|
// answer) — the batch counts toward the GC.
|
|
382
|
-
for (const o of
|
|
470
|
+
for (const o of pool) fedIds.add(o.id)
|
|
383
471
|
stopped = 'max-tokens'
|
|
384
472
|
failureDetail = `批次无法再缩小(当前 ${this.batchSize} 条)仍触发输出上限:${outcome.detail ?? 'model finish: max-tokens'}`
|
|
385
473
|
break
|
|
@@ -395,7 +483,7 @@ export class Distiller {
|
|
|
395
483
|
}
|
|
396
484
|
// Parseable answer (ops, empty or not): the batch was genuinely
|
|
397
485
|
// evaluated — its unconsumed ids gain one failed attempt.
|
|
398
|
-
for (const o of
|
|
486
|
+
for (const o of pool) fedIds.add(o.id)
|
|
399
487
|
if (outcome.noOps) {
|
|
400
488
|
stopped = 'no-ops'
|
|
401
489
|
break
|
|
@@ -439,7 +527,8 @@ export class Distiller {
|
|
|
439
527
|
const filteredNote = filteredTotal > 0 ? `;filtered ${filteredTotal} invalid observed_ids` : ''
|
|
440
528
|
const schemaNote = invalidTotal > 0 ? `;schema-dropped ${invalidTotal} create op(s)` : ''
|
|
441
529
|
const linkNote = rejectedTotal > 0 ? `;rejected ${rejectedTotal} invalid link ref(s)` : ''
|
|
442
|
-
const
|
|
530
|
+
const conflictNote = conflictTotal > 0 ? `;conflict-flagged ${conflictTotal} lesson draft(s)` : ''
|
|
531
|
+
const notes = `${filteredNote}${schemaNote}${linkNote}${conflictNote}`
|
|
443
532
|
const base = head === undefined ? undefined : `partial: 已蒸馏标记 ${marked} 条观察(${calls} 次模型调用);${head}`
|
|
444
533
|
result = {
|
|
445
534
|
ok: true,
|
|
@@ -478,39 +567,68 @@ export class Distiller {
|
|
|
478
567
|
|
|
479
568
|
/**
|
|
480
569
|
* One model call (plus at most one corrective retry) over one batch:
|
|
481
|
-
* payload build → ops → topic writes → marks. `budgetLeft` is the
|
|
482
|
-
* still available to the run including this batch's first call (≥ 1);
|
|
483
|
-
* corrective retry only fires when it can stay inside that budget.
|
|
570
|
+
* payload build → ops → topic/lesson writes → marks. `budgetLeft` is the
|
|
571
|
+
* calls still available to the run including this batch's first call (≥ 1);
|
|
572
|
+
* the corrective retry only fires when it can stay inside that budget.
|
|
573
|
+
* `branch` picks the prompt and the payload: the topic branch carries the
|
|
574
|
+
* topic index (ops may update slugs), the lesson branch carries the
|
|
575
|
+
* established-lesson baseline for the conflict self-check instead — lesson
|
|
576
|
+
* ops never reference the topic roster. Both branches share the
|
|
577
|
+
* observed_ids sanitation, the schema gate, the corrective retry, the GC
|
|
578
|
+
* and the marks; only the payload/system/op-shape differ.
|
|
484
579
|
*/
|
|
485
580
|
private async runBatch(
|
|
486
581
|
sessionId: string | undefined,
|
|
487
582
|
observations: readonly ObservationLike[],
|
|
488
583
|
budgetLeft: number,
|
|
584
|
+
branch: DistillBranch,
|
|
489
585
|
): Promise<BatchOutcome> {
|
|
586
|
+
const lesson = branch === 'lesson'
|
|
587
|
+
const system = lesson ? LESSON_PROMPT : SYSTEM_PROMPT
|
|
490
588
|
const metas = await this.service.store.listTopics()
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
589
|
+
let establishedSlugs: ReadonlySet<string> = new Set<string>()
|
|
590
|
+
let user: string
|
|
591
|
+
if (lesson) {
|
|
592
|
+
// Lesson branch payload: the conflict self-check baseline (B2 决议 5)
|
|
593
|
+
// rides IN the prompt — a same-prompt self-check, not a separate slow
|
|
594
|
+
// call; the B4 gate owns any 新覆旧 disposition afterwards.
|
|
595
|
+
const established = await this.establishedLessonBriefs(metas)
|
|
596
|
+
establishedSlugs = new Set(established.map((l) => l.slug))
|
|
597
|
+
const observationsPayload = observations.map((o) => ({ id: o.id, kind: o.kind, source: o.source, text: o.text }))
|
|
598
|
+
user = [
|
|
599
|
+
`现有 established lesson 清单(${established.length} 条):`,
|
|
600
|
+
JSON.stringify(established),
|
|
601
|
+
'',
|
|
602
|
+
`信号观察(${observationsPayload.length} 条):`,
|
|
603
|
+
JSON.stringify(observationsPayload),
|
|
604
|
+
'',
|
|
605
|
+
'请输出蒸馏结果(严格 JSON,{"ops":[...]}):',
|
|
606
|
+
].join('\n')
|
|
607
|
+
} else {
|
|
608
|
+
// Real open questions need the docs; fetch for index (bounded).
|
|
609
|
+
const indexDetailed = []
|
|
610
|
+
for (const m of metas.slice(0, 100)) {
|
|
611
|
+
const doc = await this.service.store.readTopic(m.slug).catch(() => undefined)
|
|
612
|
+
indexDetailed.push({
|
|
613
|
+
slug: m.slug,
|
|
614
|
+
title: m.title,
|
|
615
|
+
status: m.status,
|
|
616
|
+
tags: m.tags,
|
|
617
|
+
open_questions: doc?.fm.open_questions ?? [],
|
|
618
|
+
conclusion: (okf.firstParagraph(okf.sectionOf(doc?.body ?? '', okf.CONCLUSION_HEADING) ?? '')).slice(0, 200),
|
|
619
|
+
})
|
|
620
|
+
}
|
|
621
|
+
const observationsPayload = observations.map((o) => ({ id: o.id, kind: o.kind, source: o.source, text: o.text }))
|
|
622
|
+
user = [
|
|
623
|
+
`现有 topic 索引(${indexDetailed.length} 个):`,
|
|
624
|
+
JSON.stringify(indexDetailed),
|
|
625
|
+
'',
|
|
626
|
+
`未蒸馏观察(${observationsPayload.length} 条):`,
|
|
627
|
+
JSON.stringify(observationsPayload),
|
|
628
|
+
'',
|
|
629
|
+
'请输出蒸馏结果(严格 JSON,{"ops":[...]}):',
|
|
630
|
+
].join('\n')
|
|
503
631
|
}
|
|
504
|
-
const observationsPayload = observations.map((o) => ({ id: o.id, kind: o.kind, source: o.source, text: o.text }))
|
|
505
|
-
const user = [
|
|
506
|
-
`现有 topic 索引(${indexDetailed.length} 个):`,
|
|
507
|
-
JSON.stringify(indexDetailed),
|
|
508
|
-
'',
|
|
509
|
-
`未蒸馏观察(${observationsPayload.length} 条):`,
|
|
510
|
-
JSON.stringify(observationsPayload),
|
|
511
|
-
'',
|
|
512
|
-
'请输出蒸馏结果(严格 JSON,{"ops":[...]}):',
|
|
513
|
-
].join('\n')
|
|
514
632
|
let raw: string
|
|
515
633
|
const caller = this.caller
|
|
516
634
|
if (caller === undefined) {
|
|
@@ -519,7 +637,7 @@ export class Distiller {
|
|
|
519
637
|
return { created: [], updated: [], marked: 0, fatalReason: 'model-error', detail: 'distill caller unavailable' }
|
|
520
638
|
}
|
|
521
639
|
try {
|
|
522
|
-
raw = await caller({ system
|
|
640
|
+
raw = await caller({ system, user, purpose: 'topics-distill', sessionId, maxTokens: 4000 })
|
|
523
641
|
} catch (e) {
|
|
524
642
|
const detail = String(e instanceof Error ? e.message : e).slice(0, 200)
|
|
525
643
|
return isMaxTokens(e)
|
|
@@ -538,6 +656,15 @@ export class Distiller {
|
|
|
538
656
|
detail: String(e instanceof Error ? e.message : e).slice(0, 200),
|
|
539
657
|
}
|
|
540
658
|
}
|
|
659
|
+
if (lesson) {
|
|
660
|
+
// The lesson branch drafts NEW lessons only — a lesson "update" would
|
|
661
|
+
// rewrite another lane's lifecycle state (confidence/status transitions
|
|
662
|
+
// are the B4 approval gate's exclusive write path), so non-create shapes
|
|
663
|
+
// are dropped before the gate. Their observations stay unconsumed and
|
|
664
|
+
// walk the existing GC path; an all-update batch resolves to the honest
|
|
665
|
+
// no-ops stop.
|
|
666
|
+
ops = ops.filter((o) => o.op === 'create')
|
|
667
|
+
}
|
|
541
668
|
if (ops.length === 0) {
|
|
542
669
|
return { created: [], updated: [], marked: 0, noOps: true, callsUsed: 1 }
|
|
543
670
|
}
|
|
@@ -545,7 +672,7 @@ export class Distiller {
|
|
|
545
672
|
// 补问 retry (budget-guarded), still-deficient ops are dropped and
|
|
546
673
|
// ledgered — never a silent drop. Their observations simply stay
|
|
547
674
|
// unconsumed and walk the existing GC/corrective path (three strikes).
|
|
548
|
-
const schema = await this.enforceSchema(sessionId, user, ops, budgetLeft)
|
|
675
|
+
const schema = await this.enforceSchema(sessionId, system, user, ops, budgetLeft, branch)
|
|
549
676
|
let callsUsed = 1 + schema.retrySpent
|
|
550
677
|
for (const drop of schema.drops) {
|
|
551
678
|
await recordPipelineEvent(this.service.store.root, {
|
|
@@ -561,9 +688,15 @@ export class Distiller {
|
|
|
561
688
|
marked: 0,
|
|
562
689
|
callsUsed,
|
|
563
690
|
invalidOps: schema.drops.length,
|
|
564
|
-
stallDetail:
|
|
691
|
+
stallDetail: lesson
|
|
692
|
+
? `全部 lesson op 未通过 schema 校验(triggers 3-8 条 + conclusion(action) 必填 + scope 枚举),已丢弃并落账(${schema.drops.length} 个)`
|
|
693
|
+
: `全部 create op 未通过 schema 校验(description 必填 + triggers 3-8 条),已丢弃并落账(${schema.drops.length} 个)`,
|
|
565
694
|
}
|
|
566
695
|
}
|
|
696
|
+
// B2 conflict self-check ledgering: the draft still lands as a hint —
|
|
697
|
+
// the flag is for the approval surface, never a lifecycle write.
|
|
698
|
+
let conflictsFlagged = 0
|
|
699
|
+
if (lesson) conflictsFlagged = await this.ledgerLessonConflicts(schema.finalOps, establishedSlugs)
|
|
567
700
|
// observed_ids are the ONLY bridge between ops and marks — without a valid
|
|
568
701
|
// id the store cannot mark anything and the batch head can never advance
|
|
569
702
|
// (the real-machine zero-consumption livelock: topics landed, backlog
|
|
@@ -597,7 +730,7 @@ export class Distiller {
|
|
|
597
730
|
}
|
|
598
731
|
}
|
|
599
732
|
const pass1 = applied
|
|
600
|
-
const { applied: pass2, why } = await this.correctiveRetry(sessionId, user, idList, validIds, knownSlugs)
|
|
733
|
+
const { applied: pass2, why } = await this.correctiveRetry(sessionId, system, user, idList, validIds, knownSlugs)
|
|
601
734
|
callsUsed += 1
|
|
602
735
|
if (pass2 === undefined || pass2.consumedIds.length === 0) {
|
|
603
736
|
const tail =
|
|
@@ -638,10 +771,65 @@ export class Distiller {
|
|
|
638
771
|
filteredIds: applied.filteredIds,
|
|
639
772
|
invalidOps: schema.drops.length,
|
|
640
773
|
rejectedLinks: applied.rejectedLinks,
|
|
774
|
+
conflictsFlagged,
|
|
641
775
|
consumedIds: applied.consumedIds,
|
|
642
776
|
}
|
|
643
777
|
}
|
|
644
778
|
|
|
779
|
+
/**
|
|
780
|
+
* The conflict self-check baseline (B2 决议 5): established lessons ONLY —
|
|
781
|
+
* hint drafts are not yet norms, so there is nothing for a candidate to
|
|
782
|
+
* contradict. Each brief carries the lesson's condition surface (title +
|
|
783
|
+
* triggers) and its slug (the only legal conflictsWith reference). Bounded
|
|
784
|
+
* reads like the topic index (first 100 roster entries); established
|
|
785
|
+
* lessons are post-gate artifacts and stay a handful in practice.
|
|
786
|
+
*/
|
|
787
|
+
private async establishedLessonBriefs(
|
|
788
|
+
metas: readonly { slug: string; title: string }[],
|
|
789
|
+
): Promise<{ slug: string; title: string; triggers: string[] }[]> {
|
|
790
|
+
const out: { slug: string; title: string; triggers: string[] }[] = []
|
|
791
|
+
for (const m of metas.slice(0, 100)) {
|
|
792
|
+
const doc = await this.service.store.readTopic(m.slug).catch(() => undefined)
|
|
793
|
+
if (doc === undefined) continue
|
|
794
|
+
if (okf.topicKindOf(doc.fm.type) !== 'lesson') continue
|
|
795
|
+
if ((doc.fm.confidence ?? 'hint') !== 'established') continue
|
|
796
|
+
out.push({ slug: m.slug, title: m.title, triggers: doc.fm.triggers ?? [] })
|
|
797
|
+
}
|
|
798
|
+
return out
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* B2 conflict self-check, LEDGER-ONLY disposition: a draft that declares
|
|
803
|
+
* `conflictsWith` still lands as a hint draft (新覆旧 is the B4 approval
|
|
804
|
+
* gate's call, never the distill lane's). Each declared slug is
|
|
805
|
+
* whitelist-checked against the established baseline the prompt carried;
|
|
806
|
+
* roster-unknown slugs are model noise and are dropped without an event (a
|
|
807
|
+
* hallucinated slug must not fabricate a conflict). The event is a flag for
|
|
808
|
+
* the approval surface — no lifecycle state changes here.
|
|
809
|
+
*/
|
|
810
|
+
private async ledgerLessonConflicts(
|
|
811
|
+
ops: readonly DistillOp[],
|
|
812
|
+
establishedSlugs: ReadonlySet<string>,
|
|
813
|
+
): Promise<number> {
|
|
814
|
+
let flagged = 0
|
|
815
|
+
for (const op of ops) {
|
|
816
|
+
if (!Array.isArray(op.conflictsWith) || op.conflictsWith.length === 0) continue
|
|
817
|
+
const valid = [...new Set(op.conflictsWith)].filter(
|
|
818
|
+
(s): s is string => typeof s === 'string' && establishedSlugs.has(s),
|
|
819
|
+
)
|
|
820
|
+
if (valid.length === 0) continue
|
|
821
|
+
await recordPipelineEvent(this.service.store.root, {
|
|
822
|
+
at: new Date().toISOString(),
|
|
823
|
+
kind: 'lesson-conflict-flagged',
|
|
824
|
+
// detail values are scalar-only (pipeline-events whitelist): the slug
|
|
825
|
+
// list rides as a comma join, the raw declaration count rides as a number.
|
|
826
|
+
detail: { op: opKey(op), conflictsWith: valid.join(','), declared: op.conflictsWith.length },
|
|
827
|
+
})
|
|
828
|
+
flagged += valid.length
|
|
829
|
+
}
|
|
830
|
+
return flagged
|
|
831
|
+
}
|
|
832
|
+
|
|
645
833
|
/**
|
|
646
834
|
* The ONE corrective retry for a zero-valid-observed_ids batch: re-ask with
|
|
647
835
|
* the batch's legal id list spelled out verbatim. It spends a second call
|
|
@@ -652,6 +840,7 @@ export class Distiller {
|
|
|
652
840
|
*/
|
|
653
841
|
private async correctiveRetry(
|
|
654
842
|
sessionId: string | undefined,
|
|
843
|
+
system: string,
|
|
655
844
|
originalUser: string,
|
|
656
845
|
idList: readonly string[],
|
|
657
846
|
validIds: ReadonlySet<string>,
|
|
@@ -670,7 +859,7 @@ export class Distiller {
|
|
|
670
859
|
].join('\n')
|
|
671
860
|
let raw: string
|
|
672
861
|
try {
|
|
673
|
-
raw = await caller({ system
|
|
862
|
+
raw = await caller({ system, user, purpose: 'topics-distill', sessionId, maxTokens: 4000 })
|
|
674
863
|
} catch (e) {
|
|
675
864
|
return { why: `模型调用失败:${String(e instanceof Error ? e.message : e).slice(0, 160)}` }
|
|
676
865
|
}
|
|
@@ -699,10 +888,14 @@ export class Distiller {
|
|
|
699
888
|
*/
|
|
700
889
|
private async enforceSchema(
|
|
701
890
|
sessionId: string | undefined,
|
|
891
|
+
system: string,
|
|
702
892
|
originalUser: string,
|
|
703
893
|
ops: readonly DistillOp[],
|
|
704
894
|
budgetLeft: number,
|
|
895
|
+
branch: DistillBranch,
|
|
705
896
|
): Promise<{ finalOps: DistillOp[]; drops: SchemaDrop[]; retrySpent: number }> {
|
|
897
|
+
const lesson = branch === 'lesson'
|
|
898
|
+
const check = lesson ? checkLessonSchema : checkCreateSchema
|
|
706
899
|
const valid: DistillOp[] = []
|
|
707
900
|
const invalid: { op: DistillOp; key: string; missing: string }[] = []
|
|
708
901
|
for (const op of ops) {
|
|
@@ -710,9 +903,9 @@ export class Distiller {
|
|
|
710
903
|
valid.push(op)
|
|
711
904
|
continue
|
|
712
905
|
}
|
|
713
|
-
const
|
|
714
|
-
if (
|
|
715
|
-
else invalid.push({ op, key: opKey(op), missing:
|
|
906
|
+
const verdict = check(op)
|
|
907
|
+
if (verdict.valid) valid.push(op)
|
|
908
|
+
else invalid.push({ op, key: opKey(op), missing: verdict.missing })
|
|
716
909
|
}
|
|
717
910
|
if (invalid.length === 0) return { finalOps: valid, drops: [], retrySpent: 0 }
|
|
718
911
|
if (budgetLeft < 2) {
|
|
@@ -726,7 +919,7 @@ export class Distiller {
|
|
|
726
919
|
retrySpent: 0,
|
|
727
920
|
}
|
|
728
921
|
}
|
|
729
|
-
const rescued = await this.schemaRetryCall(sessionId, originalUser, invalid)
|
|
922
|
+
const rescued = await this.schemaRetryCall(sessionId, system, originalUser, invalid, branch)
|
|
730
923
|
if (rescued === undefined) {
|
|
731
924
|
return {
|
|
732
925
|
finalOps: valid,
|
|
@@ -741,7 +934,7 @@ export class Distiller {
|
|
|
741
934
|
const drops: SchemaDrop[] = []
|
|
742
935
|
for (const c of invalid) {
|
|
743
936
|
const replacement = rescued.get(c.key)
|
|
744
|
-
if (replacement !== undefined &&
|
|
937
|
+
if (replacement !== undefined && check(replacement).valid) {
|
|
745
938
|
valid.push(replacement)
|
|
746
939
|
} else {
|
|
747
940
|
drops.push({ key: c.key, kind: 'distill-op-invalid', missing: c.missing })
|
|
@@ -758,23 +951,31 @@ export class Distiller {
|
|
|
758
951
|
*/
|
|
759
952
|
private async schemaRetryCall(
|
|
760
953
|
sessionId: string | undefined,
|
|
954
|
+
system: string,
|
|
761
955
|
originalUser: string,
|
|
762
956
|
invalid: readonly { op: DistillOp; key: string; missing: string }[],
|
|
957
|
+
branch: DistillBranch,
|
|
763
958
|
): Promise<Map<string, DistillOp> | undefined> {
|
|
764
959
|
const caller = this.caller
|
|
765
960
|
if (caller === undefined) return undefined
|
|
766
961
|
const list = invalid.map((c) => `- create「${typeof c.op.title === 'string' ? c.op.title : ''}」(缺:${c.missing})`).join('\n')
|
|
962
|
+
// The completion demand names the branch's own required fields; the topic
|
|
963
|
+
// line is byte-identical to its pre-lesson-branch form.
|
|
964
|
+
const instruction =
|
|
965
|
+
branch === 'lesson'
|
|
966
|
+
? '请只重新输出这些 op 的补全版本(严格 JSON,{"ops":[...]}):每个 create 必须带 triggers(恰好 3-8 个 condition 触发短语)与 conclusion(祈使句行动指令),scope 只能是 workspace 或 global,并保留原有的 title/observed_ids 等字段。无法补全的 op 不要输出。'
|
|
967
|
+
: '请只重新输出这些 op 的补全版本(严格 JSON,{"ops":[...]}):每个 create 必须带 description(一句话概括)与 triggers(恰好 3-8 条具体触发短语,禁止 dsh、配置、错误 之类的泛词),并保留原有的 title/conclusion/observed_ids 等字段。无法补全的 op 不要输出。'
|
|
767
968
|
const user = [
|
|
768
969
|
originalUser,
|
|
769
970
|
'',
|
|
770
971
|
'---',
|
|
771
972
|
'schema 补问:你上一次返回的以下 create op 缺少必填字段:',
|
|
772
973
|
list,
|
|
773
|
-
|
|
974
|
+
instruction,
|
|
774
975
|
].join('\n')
|
|
775
976
|
let raw: string
|
|
776
977
|
try {
|
|
777
|
-
raw = await caller({ system
|
|
978
|
+
raw = await caller({ system, user, purpose: 'topics-distill', sessionId, maxTokens: 4000 })
|
|
778
979
|
} catch {
|
|
779
980
|
return undefined
|
|
780
981
|
}
|
|
@@ -810,6 +1011,11 @@ export class Distiller {
|
|
|
810
1011
|
* the store transaction (先落 topic 再建边 — one write, no separate edge
|
|
811
1012
|
* pass). The set is locally seeded with this call's predicted op slugs and
|
|
812
1013
|
* grows as creates land, so same-batch references resolve in op order.
|
|
1014
|
+
*
|
|
1015
|
+
* Lesson creates (B2) ride the same sanitation and consume their
|
|
1016
|
+
* observations through the same observed_ids bridge; the write goes through
|
|
1017
|
+
* saveTopic's lesson branch (kind:'lesson', confidence hint, distilled
|
|
1018
|
+
* provenance) — the observation→mark closure is identical to topics.
|
|
813
1019
|
*/
|
|
814
1020
|
private async applyOps(
|
|
815
1021
|
ops: readonly DistillOp[],
|
|
@@ -851,20 +1057,40 @@ export class Distiller {
|
|
|
851
1057
|
if (op.op === 'create' && typeof op.title === 'string' && typeof op.conclusion === 'string') {
|
|
852
1058
|
const links = await this.acceptLinks(op, slugs)
|
|
853
1059
|
applied.rejectedLinks += links.rejected
|
|
854
|
-
const res =
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
1060
|
+
const res =
|
|
1061
|
+
op.kind === 'lesson'
|
|
1062
|
+
? // Lesson data plane (B2 §6.2): the draft lands as a hint with
|
|
1063
|
+
// distilled provenance — status derives from confidence, and
|
|
1064
|
+
// promotion to established is the B4 approval gate's exclusive
|
|
1065
|
+
// write path (setLessonStatus), so `status` is NOT taken from
|
|
1066
|
+
// the op. Scope: the op's explicit choice, default workspace
|
|
1067
|
+
// (lesson 必带 scope, global 需显式).
|
|
1068
|
+
await this.service.saveTopic({
|
|
1069
|
+
title: op.title,
|
|
1070
|
+
kind: 'lesson',
|
|
1071
|
+
conclusion: op.conclusion,
|
|
1072
|
+
description: op.description,
|
|
1073
|
+
triggers: Array.isArray(op.triggers) ? op.triggers.filter((t): t is string => typeof t === 'string') : undefined,
|
|
1074
|
+
scope: op.scope === 'global' ? 'global' : 'workspace',
|
|
1075
|
+
confidence: 'hint',
|
|
1076
|
+
lessonSource: 'distilled',
|
|
1077
|
+
source: 'distill',
|
|
1078
|
+
links: links.targets,
|
|
1079
|
+
})
|
|
1080
|
+
: await this.service.saveTopic({
|
|
1081
|
+
title: op.title,
|
|
1082
|
+
conclusion: op.conclusion,
|
|
1083
|
+
description: op.description,
|
|
1084
|
+
tags: op.tags,
|
|
1085
|
+
triggers: Array.isArray(op.triggers) ? op.triggers.filter((t): t is string => typeof t === 'string') : undefined,
|
|
1086
|
+
depends: op.depends,
|
|
1087
|
+
openQuestions: op.open_questions,
|
|
1088
|
+
impact: op.impact,
|
|
1089
|
+
recommendations: op.recommendations,
|
|
1090
|
+
status: op.status === 'stable' ? 'stable' : 'draft',
|
|
1091
|
+
source: 'distill',
|
|
1092
|
+
links: links.targets,
|
|
1093
|
+
})
|
|
868
1094
|
applied.created.push(res.slug)
|
|
869
1095
|
applied.consumedSlugs.push(res.slug)
|
|
870
1096
|
slugs.add(res.slug)
|
package/src/pipeline-events.ts
CHANGED
|
@@ -23,8 +23,11 @@ import { metaDir } from './paths.ts'
|
|
|
23
23
|
* the slow lane's per-run disposition row (quality.ts); the distill/link
|
|
24
24
|
* kinds are the A2/A6 producers' contract — they import this module. The
|
|
25
25
|
* two `seam-*` kinds are the B-line dual-write audit trail (卷二 §6.2:
|
|
26
|
-
* caller-identity audit — opts.actor — rides every seam call).
|
|
27
|
-
|
|
26
|
+
* caller-identity audit — opts.actor — rides every seam call).
|
|
27
|
+
* `lesson-conflict-flagged` is the lesson distill branch's conflict
|
|
28
|
+
* self-check flag (B2 决议 5): informational for the B4 approval surface —
|
|
29
|
+
* the draft still lands as hint, no lifecycle state changes on this event. */
|
|
30
|
+
export type PipelineEventKind = 'slow-lane-outcome' | 'distill-op-invalid' | 'distill-op-retry-exhausted' | 'link-ref-rejected' | 'seam-observations-fed' | 'seam-lesson-status' | 'lesson-conflict-flagged'
|
|
28
31
|
|
|
29
32
|
/** File NAME inside the bundle's meta dir (path via pipelineEventsFile). */
|
|
30
33
|
export const PIPELINE_EVENTS_FILE = 'pipeline-events.jsonl'
|
|
@@ -51,6 +54,7 @@ const KNOWN_KINDS: ReadonlySet<string> = new Set<string>([
|
|
|
51
54
|
'link-ref-rejected',
|
|
52
55
|
'seam-observations-fed',
|
|
53
56
|
'seam-lesson-status',
|
|
57
|
+
'lesson-conflict-flagged',
|
|
54
58
|
])
|
|
55
59
|
|
|
56
60
|
export function pipelineEventsFile(bundleDir: string): string {
|
package/src/service.ts
CHANGED
|
@@ -393,10 +393,10 @@ export class TopicsService {
|
|
|
393
393
|
let lines: string[] = []
|
|
394
394
|
try {
|
|
395
395
|
const st = statSync(file)
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
396
|
+
const all = readFileSync(file, 'utf8').split('\n').filter((l) => l !== '')
|
|
397
|
+
// Append, never clobber: existing lines are always carried forward;
|
|
398
|
+
// compaction (keep newest 3/4) only bites past the 512KB cap.
|
|
399
|
+
lines = st.size > 512 * 1024 ? all.slice(Math.floor(all.length / 4)) : all
|
|
400
400
|
} catch { /* first write */ }
|
|
401
401
|
lines.push(JSON.stringify(entry))
|
|
402
402
|
writeFileSync(file, lines.join('\n') + '\n')
|