@aiwayds/dsh-topics-memory 0.21.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/README.zh.md +5 -0
- package/lib/commands.js +27 -11
- package/lib/config.d.ts +54 -1
- package/lib/config.js +38 -1
- package/lib/digest.d.ts +73 -0
- package/lib/digest.js +384 -0
- package/lib/distill.d.ts +39 -0
- package/lib/distill.js +250 -15
- package/lib/embedding.d.ts +113 -0
- package/lib/embedding.js +208 -0
- package/lib/ilog.d.ts +145 -0
- package/lib/ilog.js +236 -0
- package/lib/index.d.ts +8 -0
- package/lib/index.js +23 -6
- package/lib/pipeline-events.d.ts +36 -0
- package/lib/pipeline-events.js +121 -0
- package/lib/quality.d.ts +47 -7
- package/lib/quality.js +124 -20
- package/lib/retrieval.d.ts +119 -0
- package/lib/retrieval.js +290 -0
- package/lib/service.d.ts +60 -3
- package/lib/service.js +320 -44
- package/lib/store.d.ts +15 -12
- package/lib/store.js +43 -3
- package/lib/tools.js +6 -2
- package/package.json +2 -2
- package/src/commands.ts +35 -16
- package/src/config.ts +47 -1
- package/src/digest.ts +416 -0
- package/src/distill.ts +275 -14
- package/src/embedding.ts +294 -0
- package/src/ilog.ts +306 -0
- package/src/index.ts +23 -5
- package/src/pipeline-events.ts +132 -0
- package/src/quality.ts +145 -21
- package/src/retrieval.ts +357 -0
- package/src/service.ts +326 -52
- package/src/store.ts +46 -10
- package/src/tools.ts +6 -2
package/src/distill.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import * as okf from './okf.ts'
|
|
14
14
|
import type { TopicsService } from './service.ts'
|
|
15
|
+
import { recordPipelineEvent } from './pipeline-events.ts'
|
|
15
16
|
|
|
16
17
|
// dsh 0.1.7 removed the shared catch-all 'plugin' message-source kind:
|
|
17
18
|
// MessageSourceMap is now a merge-extensible sum type and every producer
|
|
@@ -48,6 +49,8 @@ export interface DistillOp {
|
|
|
48
49
|
depends?: string[]
|
|
49
50
|
open_questions?: string[]
|
|
50
51
|
impact?: string[]
|
|
52
|
+
/** Semantic-association targets (A2): existing slugs or same-batch op slugs. */
|
|
53
|
+
links?: string[]
|
|
51
54
|
conclusion?: string
|
|
52
55
|
recommendations?: string
|
|
53
56
|
status?: string
|
|
@@ -70,12 +73,13 @@ export const SYSTEM_PROMPT = [
|
|
|
70
73
|
'任务:把观察沉淀成 Topic 操作。只输出一个 JSON 对象,不要任何其他文字、Markdown 代码块或解释。',
|
|
71
74
|
'输出格式:{"ops": [...]},每个元素:',
|
|
72
75
|
' {"op":"create","title":"主题名","description":"一句话","tags":["小写标签"],"triggers":["应想起本条的触发词"],"depends":["前置topic的slug"],',
|
|
73
|
-
' "open_questions":["未决问题"],"impact":["影响面"],"conclusion":"当前结论(自含散文)","recommendations":"可执行建议","status":"draft",',
|
|
76
|
+
' "open_questions":["未决问题"],"impact":["影响面"],"links":["关联topic的slug"],"conclusion":"当前结论(自含散文)","recommendations":"可执行建议","status":"draft",',
|
|
74
77
|
' "observed_ids":["obs-..."]}',
|
|
75
|
-
' {"op":"update","slug":"已有slug","conclusion":"修订后的完整结论","open_questions":[...],"observed_ids":["obs-..."]}',
|
|
78
|
+
' {"op":"update","slug":"已有slug","conclusion":"修订后的完整结论","open_questions":[...],"links":["关联topic的slug"],"observed_ids":["obs-..."]}',
|
|
76
79
|
'规则:',
|
|
77
80
|
'- 硬性要求:每个 op 必须带 observed_ids 字段,值只能从本次输入「未蒸馏观察」里列出的 id 中逐字复制(形如 "obs-...");create 填它所综合依据的观察 id,update 填促使本次修订的观察 id。含列表之外 id 的条目会被过滤;observed_ids 缺失、为空或过滤后不剩任何有效 id 的 op 会被整体丢弃。',
|
|
78
|
-
'- create
|
|
81
|
+
'- 硬性要求:每个 create 必须带 description(一句话概括)与 triggers(恰好 3-8 个「什么输入出现时应该想起这条记忆」的短语,写具体名词/术语,禁止 dsh、配置、错误 之类的泛词);缺失或数量不符的 create 会被补问一次,补全后仍不合规的 op 会被丢弃并落账。',
|
|
82
|
+
'- links(可选输出,不给不惩罚):只建真实语义关联(依赖/同项目/同主题链),宁缺勿滥;每项只能是已存在 topic 的 slug 或本批其他 op 的 slug,列表之外的引用会被剔除并落账。没有真实关联就不要给 links。',
|
|
79
83
|
'- conclusion 必须自含(不依赖观察原文也能读懂),写「目前有效的结论」,不是流水账。',
|
|
80
84
|
'- 同一主题只允许一个 create;已有相近 topic 时用 update 修订它的 conclusion。',
|
|
81
85
|
'- 观察里有价值就沉淀,没价值就跳过该观察(被跳过观察的 id 不要出现在任何 op 里);没有可沉淀内容时输出 {"ops":[]}。',
|
|
@@ -113,6 +117,10 @@ interface BatchOutcome {
|
|
|
113
117
|
fatalReason?: 'model-error' | 'invalid-output'
|
|
114
118
|
/** Batch ids an op actually consumed — the GC's consumed set for this batch. */
|
|
115
119
|
consumedIds?: string[]
|
|
120
|
+
/** create ops dropped by the schema gate (A6 §5.1) this batch. */
|
|
121
|
+
invalidOps?: number
|
|
122
|
+
/** links stripped by the whitelist (A2) this batch. */
|
|
123
|
+
rejectedLinks?: number
|
|
116
124
|
detail?: string
|
|
117
125
|
/**
|
|
118
126
|
* Specific zero-consumption explanation for the stalled stop (undefined =
|
|
@@ -135,6 +143,48 @@ interface AppliedOps {
|
|
|
135
143
|
* observed_ids matched the batch — the corrective-retry trigger.
|
|
136
144
|
*/
|
|
137
145
|
droppedForIds: number
|
|
146
|
+
/** links stripped by the whitelist (A2) while applying this batch. */
|
|
147
|
+
rejectedLinks: number
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------
|
|
151
|
+
// A6 §5.1 op schema gate + A2 links whitelist (always on, no config key:
|
|
152
|
+
// the distill quality contract is not a behavior switch — failures are
|
|
153
|
+
// ledgered via pipeline-events, never swallowed silently).
|
|
154
|
+
// ---------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
/** create-op trigger bounds (the recall design's 3-8 strong-field contract). */
|
|
157
|
+
const TRIGGERS_MIN = 3
|
|
158
|
+
const TRIGGERS_MAX = 8
|
|
159
|
+
|
|
160
|
+
/** A create op dropped by the schema gate, with the ledger kind it earned. */
|
|
161
|
+
interface SchemaDrop {
|
|
162
|
+
/** Stable op key (slugified title / slug) for the ledger row. */
|
|
163
|
+
key: string
|
|
164
|
+
kind: 'distill-op-invalid' | 'distill-op-retry-exhausted'
|
|
165
|
+
missing: string
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Stable identity for matching an op against its schema-retry re-emission. */
|
|
169
|
+
function opKey(op: DistillOp): string {
|
|
170
|
+
if (op.op === 'update') return typeof op.slug === 'string' ? okf.slugify(op.slug) : ''
|
|
171
|
+
return typeof op.title === 'string' ? okf.slugify(op.title) : ''
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The A6 §5.1 schema check for create ops: description 必填 + triggers 3-8
|
|
176
|
+
* 条. Update ops keep their narrower contract (slug) — the prompt never
|
|
177
|
+
* required metadata fields on a conclusion revision, and gating those would
|
|
178
|
+
* drop every revision op.
|
|
179
|
+
*/
|
|
180
|
+
function checkCreateSchema(op: DistillOp): { valid: boolean; missing: string } {
|
|
181
|
+
const problems: string[] = []
|
|
182
|
+
if (typeof op.description !== 'string' || op.description.trim() === '') problems.push('description')
|
|
183
|
+
const triggers = Array.isArray(op.triggers) ? op.triggers.filter((t): t is string => typeof t === 'string' && t.trim() !== '') : []
|
|
184
|
+
if (triggers.length < TRIGGERS_MIN || triggers.length > TRIGGERS_MAX) {
|
|
185
|
+
problems.push(`triggers(实际 ${triggers.length},需 ${TRIGGERS_MIN}-${TRIGGERS_MAX})`)
|
|
186
|
+
}
|
|
187
|
+
return { valid: problems.length === 0, missing: problems.join('、') }
|
|
138
188
|
}
|
|
139
189
|
|
|
140
190
|
export class Distiller {
|
|
@@ -275,6 +325,8 @@ export class Distiller {
|
|
|
275
325
|
let failureDetail: string | undefined
|
|
276
326
|
let firstFetch = true
|
|
277
327
|
let filteredTotal = 0
|
|
328
|
+
let invalidTotal = 0
|
|
329
|
+
let rejectedTotal = 0
|
|
278
330
|
let stallDetail: string | undefined
|
|
279
331
|
// GC bookkeeping (store.recordUnconsumed): every id the model EVALUATED
|
|
280
332
|
// this run (parseable answer, however useless) minus the ids an op
|
|
@@ -305,6 +357,8 @@ export class Distiller {
|
|
|
305
357
|
calls += Math.max(1, outcome.callsUsed ?? 1)
|
|
306
358
|
for (const id of outcome.consumedIds ?? []) consumedRunIds.add(id)
|
|
307
359
|
filteredTotal += outcome.filteredIds ?? 0
|
|
360
|
+
invalidTotal += outcome.invalidOps ?? 0
|
|
361
|
+
rejectedTotal += outcome.rejectedLinks ?? 0
|
|
308
362
|
created.push(...outcome.created)
|
|
309
363
|
updated.push(...outcome.updated)
|
|
310
364
|
marked += outcome.marked
|
|
@@ -383,6 +437,9 @@ export class Distiller {
|
|
|
383
437
|
? `后续批次未消费任何观察,本轮停止${stallDetail !== undefined ? `:${stallDetail}` : ''}`
|
|
384
438
|
: undefined
|
|
385
439
|
const filteredNote = filteredTotal > 0 ? `;filtered ${filteredTotal} invalid observed_ids` : ''
|
|
440
|
+
const schemaNote = invalidTotal > 0 ? `;schema-dropped ${invalidTotal} create op(s)` : ''
|
|
441
|
+
const linkNote = rejectedTotal > 0 ? `;rejected ${rejectedTotal} invalid link ref(s)` : ''
|
|
442
|
+
const notes = `${filteredNote}${schemaNote}${linkNote}`
|
|
386
443
|
const base = head === undefined ? undefined : `partial: 已蒸馏标记 ${marked} 条观察(${calls} 次模型调用);${head}`
|
|
387
444
|
result = {
|
|
388
445
|
ok: true,
|
|
@@ -391,10 +448,10 @@ export class Distiller {
|
|
|
391
448
|
marked,
|
|
392
449
|
detail:
|
|
393
450
|
base === undefined
|
|
394
|
-
?
|
|
451
|
+
? notes === ''
|
|
395
452
|
? undefined
|
|
396
|
-
:
|
|
397
|
-
: `${base}${
|
|
453
|
+
: notes.replace(/^;/, '')
|
|
454
|
+
: `${base}${notes}`,
|
|
398
455
|
}
|
|
399
456
|
} else {
|
|
400
457
|
result = {
|
|
@@ -484,6 +541,29 @@ export class Distiller {
|
|
|
484
541
|
if (ops.length === 0) {
|
|
485
542
|
return { created: [], updated: [], marked: 0, noOps: true, callsUsed: 1 }
|
|
486
543
|
}
|
|
544
|
+
// A6 §5.1 schema gate BEFORE any write: deficient creates get exactly one
|
|
545
|
+
// 补问 retry (budget-guarded), still-deficient ops are dropped and
|
|
546
|
+
// ledgered — never a silent drop. Their observations simply stay
|
|
547
|
+
// unconsumed and walk the existing GC/corrective path (three strikes).
|
|
548
|
+
const schema = await this.enforceSchema(sessionId, user, ops, budgetLeft)
|
|
549
|
+
let callsUsed = 1 + schema.retrySpent
|
|
550
|
+
for (const drop of schema.drops) {
|
|
551
|
+
await recordPipelineEvent(this.service.store.root, {
|
|
552
|
+
at: new Date().toISOString(),
|
|
553
|
+
kind: drop.kind,
|
|
554
|
+
detail: { op: drop.key, missing: drop.missing },
|
|
555
|
+
})
|
|
556
|
+
}
|
|
557
|
+
if (schema.finalOps.length === 0) {
|
|
558
|
+
return {
|
|
559
|
+
created: [],
|
|
560
|
+
updated: [],
|
|
561
|
+
marked: 0,
|
|
562
|
+
callsUsed,
|
|
563
|
+
invalidOps: schema.drops.length,
|
|
564
|
+
stallDetail: `全部 create op 未通过 schema 校验(description 必填 + triggers 3-8 条),已丢弃并落账(${schema.drops.length} 个)`,
|
|
565
|
+
}
|
|
566
|
+
}
|
|
487
567
|
// observed_ids are the ONLY bridge between ops and marks — without a valid
|
|
488
568
|
// id the store cannot mark anything and the batch head can never advance
|
|
489
569
|
// (the real-machine zero-consumption livelock: topics landed, backlog
|
|
@@ -493,10 +573,17 @@ export class Distiller {
|
|
|
493
573
|
// corrective retry re-emits it with fixed ids.
|
|
494
574
|
const validIds = new Set(observations.map((o) => o.id))
|
|
495
575
|
const idList = observations.map((o) => o.id)
|
|
496
|
-
|
|
497
|
-
|
|
576
|
+
// Link whitelist universe (A2): slugs existing before this batch plus
|
|
577
|
+
// every same-batch op slug (predicted; the actual create slug joins as
|
|
578
|
+
// each op lands).
|
|
579
|
+
const knownSlugs = new Set<string>(metas.map((m) => m.slug))
|
|
580
|
+
for (const op of schema.finalOps) {
|
|
581
|
+
const key = opKey(op)
|
|
582
|
+
if (key !== '') knownSlugs.add(key)
|
|
583
|
+
}
|
|
584
|
+
let applied = await this.applyOps(schema.finalOps, validIds, knownSlugs)
|
|
498
585
|
if (applied.consumedIds.length === 0 && applied.droppedForIds > 0) {
|
|
499
|
-
if (budgetLeft <
|
|
586
|
+
if (budgetLeft < callsUsed + 1) {
|
|
500
587
|
// No free lane: the retry must fit the same run budget.
|
|
501
588
|
return {
|
|
502
589
|
created: [],
|
|
@@ -504,12 +591,14 @@ export class Distiller {
|
|
|
504
591
|
marked: 0,
|
|
505
592
|
callsUsed,
|
|
506
593
|
filteredIds: applied.filteredIds,
|
|
507
|
-
|
|
594
|
+
rejectedLinks: applied.rejectedLinks,
|
|
595
|
+
invalidOps: schema.drops.length,
|
|
596
|
+
stallDetail: `ops 未包含有效 observed_ids(已过滤 ${applied.filteredIds} 个无效 id),且模型调用预算不足以纠错重试(剩余 ${budgetLeft - callsUsed} 次)`,
|
|
508
597
|
}
|
|
509
598
|
}
|
|
510
599
|
const pass1 = applied
|
|
511
|
-
const { applied: pass2, why } = await this.correctiveRetry(sessionId, user, idList, validIds)
|
|
512
|
-
callsUsed
|
|
600
|
+
const { applied: pass2, why } = await this.correctiveRetry(sessionId, user, idList, validIds, knownSlugs)
|
|
601
|
+
callsUsed += 1
|
|
513
602
|
if (pass2 === undefined || pass2.consumedIds.length === 0) {
|
|
514
603
|
const tail =
|
|
515
604
|
pass2 === undefined
|
|
@@ -521,6 +610,8 @@ export class Distiller {
|
|
|
521
610
|
marked: 0,
|
|
522
611
|
callsUsed,
|
|
523
612
|
filteredIds: pass1.filteredIds + (pass2?.filteredIds ?? 0),
|
|
613
|
+
rejectedLinks: pass1.rejectedLinks + (pass2?.rejectedLinks ?? 0),
|
|
614
|
+
invalidOps: schema.drops.length,
|
|
524
615
|
stallDetail: `${tail};本批 ${observations.length} 条观察零消费`,
|
|
525
616
|
}
|
|
526
617
|
}
|
|
@@ -531,6 +622,7 @@ export class Distiller {
|
|
|
531
622
|
consumedSlugs: pass2.consumedSlugs,
|
|
532
623
|
filteredIds: pass1.filteredIds + pass2.filteredIds,
|
|
533
624
|
droppedForIds: pass2.droppedForIds,
|
|
625
|
+
rejectedLinks: pass1.rejectedLinks + pass2.rejectedLinks,
|
|
534
626
|
}
|
|
535
627
|
}
|
|
536
628
|
let marked = 0
|
|
@@ -544,6 +636,8 @@ export class Distiller {
|
|
|
544
636
|
marked,
|
|
545
637
|
callsUsed,
|
|
546
638
|
filteredIds: applied.filteredIds,
|
|
639
|
+
invalidOps: schema.drops.length,
|
|
640
|
+
rejectedLinks: applied.rejectedLinks,
|
|
547
641
|
consumedIds: applied.consumedIds,
|
|
548
642
|
}
|
|
549
643
|
}
|
|
@@ -561,6 +655,7 @@ export class Distiller {
|
|
|
561
655
|
originalUser: string,
|
|
562
656
|
idList: readonly string[],
|
|
563
657
|
validIds: ReadonlySet<string>,
|
|
658
|
+
knownSlugs: ReadonlySet<string>,
|
|
564
659
|
): Promise<{ applied?: AppliedOps; why: string }> {
|
|
565
660
|
const caller = this.caller
|
|
566
661
|
if (caller === undefined) return { why: 'distill caller unavailable' }
|
|
@@ -586,7 +681,115 @@ export class Distiller {
|
|
|
586
681
|
return { why: `输出无法解析:${String(e instanceof Error ? e.message : e).slice(0, 160)}` }
|
|
587
682
|
}
|
|
588
683
|
if (retried.length === 0) return { why: '模型返回空 ops' }
|
|
589
|
-
return { applied: await this.applyOps(retried, validIds), why: '' }
|
|
684
|
+
return { applied: await this.applyOps(retried, validIds, knownSlugs), why: '' }
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* The A6 §5.1 schema gate, applied to the parsed batch BEFORE any write:
|
|
689
|
+
* create ops must carry description + triggers 3-8 条 (the prompt's standing
|
|
690
|
+
* requirement, now enforced). Deficient creates get exactly ONE 补问 retry
|
|
691
|
+
* that re-asks for just those ops completed — budget-guarded exactly like
|
|
692
|
+
* the corrective ids retry (`retrySpent` reports the extra call). Every
|
|
693
|
+
* still-deficient op is dropped and comes back as a SchemaDrop carrying the
|
|
694
|
+
* ledger kind: `distill-op-invalid` after a used-but-unsuccessful retry,
|
|
695
|
+
* `distill-op-retry-exhausted` when no retry could run or the retry call
|
|
696
|
+
* itself failed. Dropped ops' observations stay unconsumed — the existing
|
|
697
|
+
* GC/corrective path accounts for them, never a silent loss. Update ops
|
|
698
|
+
* keep their narrower contract (slug only).
|
|
699
|
+
*/
|
|
700
|
+
private async enforceSchema(
|
|
701
|
+
sessionId: string | undefined,
|
|
702
|
+
originalUser: string,
|
|
703
|
+
ops: readonly DistillOp[],
|
|
704
|
+
budgetLeft: number,
|
|
705
|
+
): Promise<{ finalOps: DistillOp[]; drops: SchemaDrop[]; retrySpent: number }> {
|
|
706
|
+
const valid: DistillOp[] = []
|
|
707
|
+
const invalid: { op: DistillOp; key: string; missing: string }[] = []
|
|
708
|
+
for (const op of ops) {
|
|
709
|
+
if (op.op !== 'create') {
|
|
710
|
+
valid.push(op)
|
|
711
|
+
continue
|
|
712
|
+
}
|
|
713
|
+
const check = checkCreateSchema(op)
|
|
714
|
+
if (check.valid) valid.push(op)
|
|
715
|
+
else invalid.push({ op, key: opKey(op), missing: check.missing })
|
|
716
|
+
}
|
|
717
|
+
if (invalid.length === 0) return { finalOps: valid, drops: [], retrySpent: 0 }
|
|
718
|
+
if (budgetLeft < 2) {
|
|
719
|
+
return {
|
|
720
|
+
finalOps: valid,
|
|
721
|
+
drops: invalid.map((c) => ({
|
|
722
|
+
key: c.key,
|
|
723
|
+
kind: 'distill-op-retry-exhausted' as const,
|
|
724
|
+
missing: `${c.missing}(模型调用预算不足以补问)`,
|
|
725
|
+
})),
|
|
726
|
+
retrySpent: 0,
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
const rescued = await this.schemaRetryCall(sessionId, originalUser, invalid)
|
|
730
|
+
if (rescued === undefined) {
|
|
731
|
+
return {
|
|
732
|
+
finalOps: valid,
|
|
733
|
+
drops: invalid.map((c) => ({
|
|
734
|
+
key: c.key,
|
|
735
|
+
kind: 'distill-op-retry-exhausted' as const,
|
|
736
|
+
missing: `${c.missing}(补问调用失败)`,
|
|
737
|
+
})),
|
|
738
|
+
retrySpent: 1,
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
const drops: SchemaDrop[] = []
|
|
742
|
+
for (const c of invalid) {
|
|
743
|
+
const replacement = rescued.get(c.key)
|
|
744
|
+
if (replacement !== undefined && checkCreateSchema(replacement).valid) {
|
|
745
|
+
valid.push(replacement)
|
|
746
|
+
} else {
|
|
747
|
+
drops.push({ key: c.key, kind: 'distill-op-invalid', missing: c.missing })
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
return { finalOps: valid, drops, retrySpent: 1 }
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* The one schema 补问: re-ask for exactly the deficient creates, completed.
|
|
755
|
+
* Returns key→op for what the model re-emitted (matched by slugified title),
|
|
756
|
+
* or undefined when the call itself failed / output was unparseable — the
|
|
757
|
+
* caller then ledgeres `distill-op-retry-exhausted` for every deficient op.
|
|
758
|
+
*/
|
|
759
|
+
private async schemaRetryCall(
|
|
760
|
+
sessionId: string | undefined,
|
|
761
|
+
originalUser: string,
|
|
762
|
+
invalid: readonly { op: DistillOp; key: string; missing: string }[],
|
|
763
|
+
): Promise<Map<string, DistillOp> | undefined> {
|
|
764
|
+
const caller = this.caller
|
|
765
|
+
if (caller === undefined) return undefined
|
|
766
|
+
const list = invalid.map((c) => `- create「${typeof c.op.title === 'string' ? c.op.title : ''}」(缺:${c.missing})`).join('\n')
|
|
767
|
+
const user = [
|
|
768
|
+
originalUser,
|
|
769
|
+
'',
|
|
770
|
+
'---',
|
|
771
|
+
'schema 补问:你上一次返回的以下 create op 缺少必填字段:',
|
|
772
|
+
list,
|
|
773
|
+
'请只重新输出这些 op 的补全版本(严格 JSON,{"ops":[...]}):每个 create 必须带 description(一句话概括)与 triggers(恰好 3-8 条具体触发短语,禁止 dsh、配置、错误 之类的泛词),并保留原有的 title/conclusion/observed_ids 等字段。无法补全的 op 不要输出。',
|
|
774
|
+
].join('\n')
|
|
775
|
+
let raw: string
|
|
776
|
+
try {
|
|
777
|
+
raw = await caller({ system: SYSTEM_PROMPT, user, purpose: 'topics-distill', sessionId, maxTokens: 4000 })
|
|
778
|
+
} catch {
|
|
779
|
+
return undefined
|
|
780
|
+
}
|
|
781
|
+
let retried: DistillOp[]
|
|
782
|
+
try {
|
|
783
|
+
retried = parseOps(raw)
|
|
784
|
+
} catch {
|
|
785
|
+
return undefined
|
|
786
|
+
}
|
|
787
|
+
const map = new Map<string, DistillOp>()
|
|
788
|
+
for (const op of retried) {
|
|
789
|
+
const key = opKey(op)
|
|
790
|
+
if (key !== '') map.set(key, op)
|
|
791
|
+
}
|
|
792
|
+
return map
|
|
590
793
|
}
|
|
591
794
|
|
|
592
795
|
/**
|
|
@@ -600,8 +803,24 @@ export class Distiller {
|
|
|
600
803
|
* failures (missing fields, unknown update slug) keep the per-op try/catch
|
|
601
804
|
* isolation — one bad op never sinks the batch, and a stall they cause is
|
|
602
805
|
* NOT attributed to observed_ids (no corrective retry for those).
|
|
806
|
+
*
|
|
807
|
+
* A2 links: every link target is whitelist-checked against `knownSlugs`
|
|
808
|
+
* (existing slugs ∪ same-batch op slugs); rejected refs are stripped and
|
|
809
|
+
* ledgered (`link-ref-rejected`), survivors ride the op's topic write into
|
|
810
|
+
* the store transaction (先落 topic 再建边 — one write, no separate edge
|
|
811
|
+
* pass). The set is locally seeded with this call's predicted op slugs and
|
|
812
|
+
* grows as creates land, so same-batch references resolve in op order.
|
|
603
813
|
*/
|
|
604
|
-
private async applyOps(
|
|
814
|
+
private async applyOps(
|
|
815
|
+
ops: readonly DistillOp[],
|
|
816
|
+
validIds: ReadonlySet<string>,
|
|
817
|
+
knownSlugs: ReadonlySet<string>,
|
|
818
|
+
): Promise<AppliedOps> {
|
|
819
|
+
const slugs = new Set<string>(knownSlugs)
|
|
820
|
+
for (const op of ops) {
|
|
821
|
+
const key = opKey(op)
|
|
822
|
+
if (key !== '') slugs.add(key)
|
|
823
|
+
}
|
|
605
824
|
const applied: AppliedOps = {
|
|
606
825
|
created: [],
|
|
607
826
|
updated: [],
|
|
@@ -609,6 +828,7 @@ export class Distiller {
|
|
|
609
828
|
consumedSlugs: [],
|
|
610
829
|
filteredIds: 0,
|
|
611
830
|
droppedForIds: 0,
|
|
831
|
+
rejectedLinks: 0,
|
|
612
832
|
}
|
|
613
833
|
for (const op of ops) {
|
|
614
834
|
try {
|
|
@@ -629,6 +849,8 @@ export class Distiller {
|
|
|
629
849
|
continue
|
|
630
850
|
}
|
|
631
851
|
if (op.op === 'create' && typeof op.title === 'string' && typeof op.conclusion === 'string') {
|
|
852
|
+
const links = await this.acceptLinks(op, slugs)
|
|
853
|
+
applied.rejectedLinks += links.rejected
|
|
632
854
|
const res = await this.service.saveTopic({
|
|
633
855
|
title: op.title,
|
|
634
856
|
conclusion: op.conclusion,
|
|
@@ -641,12 +863,16 @@ export class Distiller {
|
|
|
641
863
|
recommendations: op.recommendations,
|
|
642
864
|
status: op.status === 'stable' ? 'stable' : 'draft',
|
|
643
865
|
source: 'distill',
|
|
866
|
+
links: links.targets,
|
|
644
867
|
})
|
|
645
868
|
applied.created.push(res.slug)
|
|
646
869
|
applied.consumedSlugs.push(res.slug)
|
|
870
|
+
slugs.add(res.slug)
|
|
647
871
|
} else if (op.op === 'update' && typeof op.slug === 'string') {
|
|
648
872
|
const existing = await this.service.store.readTopic(op.slug)
|
|
649
873
|
if (existing === undefined) continue
|
|
874
|
+
const links = await this.acceptLinks(op, slugs)
|
|
875
|
+
applied.rejectedLinks += links.rejected
|
|
650
876
|
const res = await this.service.saveTopic({
|
|
651
877
|
title: existing.fm.title,
|
|
652
878
|
conclusion: typeof op.conclusion === 'string' ? op.conclusion : undefined,
|
|
@@ -654,9 +880,11 @@ export class Distiller {
|
|
|
654
880
|
status: op.status === 'stable' || op.status === 'deprecated' ? op.status : undefined,
|
|
655
881
|
slug: op.slug,
|
|
656
882
|
source: 'distill',
|
|
883
|
+
links: links.targets,
|
|
657
884
|
})
|
|
658
885
|
applied.updated.push(res.slug)
|
|
659
886
|
applied.consumedSlugs.push(res.slug)
|
|
887
|
+
slugs.add(res.slug)
|
|
660
888
|
} else {
|
|
661
889
|
continue
|
|
662
890
|
}
|
|
@@ -667,6 +895,39 @@ export class Distiller {
|
|
|
667
895
|
}
|
|
668
896
|
return applied
|
|
669
897
|
}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* A2 links whitelist: a link survives only when its normalized target is a
|
|
901
|
+
* slug that already exists or belongs to this batch (`knownSlugs`), and not
|
|
902
|
+
* the op's own topic. Everything else is stripped and ledgered
|
|
903
|
+
* (`link-ref-rejected` — a dead edge must at least leave a trace). Returns
|
|
904
|
+
* the surviving targets (undefined when none — no Links section is written)
|
|
905
|
+
* plus the rejection count for the run detail.
|
|
906
|
+
*/
|
|
907
|
+
private async acceptLinks(
|
|
908
|
+
op: DistillOp,
|
|
909
|
+
knownSlugs: ReadonlySet<string>,
|
|
910
|
+
): Promise<{ targets?: string[]; rejected: number }> {
|
|
911
|
+
if (!Array.isArray(op.links) || op.links.length === 0) return { targets: undefined, rejected: 0 }
|
|
912
|
+
const self = opKey(op)
|
|
913
|
+
const accepted = new Set<string>()
|
|
914
|
+
let rejected = 0
|
|
915
|
+
for (const raw of op.links) {
|
|
916
|
+
if (typeof raw !== 'string') continue
|
|
917
|
+
const target = okf.normalizeLinkTarget(raw)
|
|
918
|
+
if (target === undefined || target === self || !knownSlugs.has(target)) {
|
|
919
|
+
await recordPipelineEvent(this.service.store.root, {
|
|
920
|
+
at: new Date().toISOString(),
|
|
921
|
+
kind: 'link-ref-rejected',
|
|
922
|
+
detail: { op: self, ref: raw },
|
|
923
|
+
})
|
|
924
|
+
rejected += 1
|
|
925
|
+
continue
|
|
926
|
+
}
|
|
927
|
+
accepted.add(target)
|
|
928
|
+
}
|
|
929
|
+
return { targets: accepted.size > 0 ? [...accepted] : undefined, rejected }
|
|
930
|
+
}
|
|
670
931
|
}
|
|
671
932
|
|
|
672
933
|
/** Extract the first balanced JSON object from model output (tolerates fences). */
|