@aalis/plugin-user-relation 0.11.0 → 0.12.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/dist/service.js CHANGED
@@ -37,6 +37,31 @@ export class RelationService {
37
37
  else
38
38
  console.warn(msg);
39
39
  }
40
+ // ─── 派生回写统一写口(落笔核实况)─────────────────────────────
41
+ // consolidate 的批量范式(决策与应用共用 pass 开头快照)与「真合并物理删节点」
42
+ // 组合出过一类事故:真合并删除节点后,同 pass 后续阶段拿快照旧拷贝做补丁式回写
43
+ // (embedding hash / summary / PageRank),把死节点整个写回(复活)。僵尸节点使
44
+ // consolidate 每轮重判同一批对、反复铸 part-of 边(2026-08「表情包星形」事故)。
45
+ // 凡「以快照旧拷贝为基底 spread + 补丁」的节点回写一律走这两个写口:
46
+ // 1. 落笔前单点核实节点在库中仍存活,死了就跳过——「删了就是删了」由此机器保证;
47
+ // 2. 以**库中活文档**为基底套补丁(而非旧拷贝)——否则回写还会用陈旧字段
48
+ // 压掉并发合并刚并入的 aliases 等新状态。
49
+ // 豁免路径:facade 新建/强化、renameNode 等「先单点读活文档再改写」的读改写路径;
50
+ // evictByQuota 内 decay 回写(其快照在本函数自身删除动作之前 fresh 加载,无复活窗)。
51
+ async writeBackEntityIfLive(node, patch) {
52
+ const live = await this.store.getEntity(node.id);
53
+ if (!live)
54
+ return false;
55
+ await this.store.upsertEntity({ ...live, ...patch });
56
+ return true;
57
+ }
58
+ async writeBackEventIfLive(node, patch) {
59
+ const live = await this.store.getEvent(node.id);
60
+ if (!live)
61
+ return false;
62
+ await this.store.upsertEvent({ ...live, ...patch });
63
+ return true;
64
+ }
40
65
  /** 查询最近一次 consolidation 运行时间、触发源与结果摘要 */
41
66
  getLastConsolidateInfo() {
42
67
  return {
@@ -1287,8 +1312,8 @@ export class RelationService {
1287
1312
  const cid = list && list.length > 0 ? list[0].id : undefined;
1288
1313
  if (score === undefined && cid === undefined)
1289
1314
  continue;
1290
- await this.store.upsertEvent({
1291
- ...ev,
1315
+ // 派生回写走活性写口(after 虽为 fresh 加载,统一收口以防未来插入删除动作后复活)
1316
+ await this.writeBackEventIfLive(ev, {
1292
1317
  ...(score !== undefined ? { lastPageRank: score, lastPageRankAt: now } : {}),
1293
1318
  ...(cid !== undefined ? { communityId: cid, communityIdAt: now, communityMemberships: list } : {}),
1294
1319
  });
@@ -1299,8 +1324,7 @@ export class RelationService {
1299
1324
  const cid = list && list.length > 0 ? list[0].id : undefined;
1300
1325
  if (score === undefined && cid === undefined)
1301
1326
  continue;
1302
- await this.store.upsertEntity({
1303
- ...en,
1327
+ await this.writeBackEntityIfLive(en, {
1304
1328
  ...(score !== undefined ? { lastPageRank: score, lastPageRankAt: now } : {}),
1305
1329
  ...(cid !== undefined ? { communityId: cid, communityIdAt: now, communityMemberships: list } : {}),
1306
1330
  });
@@ -2104,10 +2128,14 @@ export class RelationService {
2104
2128
  shouldMerge = false;
2105
2129
  if (v.hierarchy) {
2106
2130
  const { parentId, childId } = v.hierarchy;
2107
- const partOfBuilt = await this._upsertPartOfEdgeIfAbsent(snapshot.edges, childId, parentId, `consolidate LLM 判定 hierarchy:${v.reason}`);
2131
+ const partOfOutcome = await this._ensureHierarchyEdge(childId, parentId, `consolidate LLM 判定 hierarchy:${v.reason}`);
2108
2132
  if (opts.llm.ctx.logger) {
2109
- opts.llm.ctx.logger.info(`[user-relation] consolidate LLM 判定 hierarchy ${childId} part-of ${parentId}: ${v.reason}` +
2110
- `${partOfBuilt ? '(已新建 part-of 边)' : '(part-of 边已存在)'}`);
2133
+ const outcomeText = partOfOutcome === 'built'
2134
+ ? '(已新建 part-of 边)'
2135
+ : partOfOutcome === 'exists'
2136
+ ? '(part-of 边已存在)'
2137
+ : '(端点已被合并,跳过落边)';
2138
+ opts.llm.ctx.logger.info(`[user-relation] consolidate LLM 判定 hierarchy ${childId} part-of ${parentId}: ${v.reason}${outcomeText}`);
2111
2139
  }
2112
2140
  }
2113
2141
  else if (opts.llm.ctx.logger) {
@@ -2222,8 +2250,15 @@ export class RelationService {
2222
2250
  embedCache.set(en.id, null);
2223
2251
  return null;
2224
2252
  }
2253
+ // 落笔核实况:en 是 pass 快照旧拷贝,节点可能已被本 pass 真合并删除——
2254
+ // 死节点不写回(防复活)、不参与相似度召回,也**不写向量**:向量命名空间
2255
+ // 只有按节点 key 的级联删除,死 uuid 的向量一旦写入永不可回收(~96KB/条)。
2256
+ // hash 先于向量落盘的窗口由自愈路径兜底(hash 命中但取不到向量 → 重算)。
2257
+ if (!(await this.writeBackEntityIfLive(en, { embeddingHash: expectedHash }))) {
2258
+ embedCache.set(en.id, null);
2259
+ return null;
2260
+ }
2225
2261
  await this.store.upsertVector('entity', en.id, vec, expectedHash);
2226
- await this.store.upsertEntity({ ...en, embeddingHash: expectedHash });
2227
2262
  en.embeddingHash = expectedHash;
2228
2263
  embedCache.set(en.id, vec);
2229
2264
  return vec;
@@ -2462,10 +2497,14 @@ export class RelationService {
2462
2497
  // 避免母概念(如「三角洲行动」)被并入子概念(如「三角洲行动·绝密航天」)。
2463
2498
  if (v.hierarchy) {
2464
2499
  const { parentId, childId } = v.hierarchy;
2465
- const partOfBuilt = await this._upsertPartOfEdgeIfAbsent(snapshot.edges, childId, parentId, `consolidate LLM 判定 hierarchy:${v.reason}`);
2500
+ const partOfOutcome = await this._ensureHierarchyEdge(childId, parentId, `consolidate LLM 判定 hierarchy:${v.reason}`);
2466
2501
  if (opts.llm.ctx.logger) {
2467
- opts.llm.ctx.logger.info(`[user-relation] consolidate LLM 判定 hierarchy(宽召回)${childId} part-of ${parentId}: ${v.reason}` +
2468
- `${partOfBuilt ? '(已新建 part-of 边)' : '(part-of 边已存在)'}`);
2502
+ const outcomeText = partOfOutcome === 'built'
2503
+ ? '(已新建 part-of 边)'
2504
+ : partOfOutcome === 'exists'
2505
+ ? '(part-of 边已存在)'
2506
+ : '(端点已被合并,跳过落边)';
2507
+ opts.llm.ctx.logger.info(`[user-relation] consolidate LLM 判定 hierarchy(宽召回)${childId} part-of ${parentId}: ${v.reason}${outcomeText}`);
2469
2508
  }
2470
2509
  }
2471
2510
  else if (opts.llm.ctx.logger) {
@@ -2564,9 +2603,15 @@ export class RelationService {
2564
2603
  }
2565
2604
  }
2566
2605
  }
2606
+ // ─── 旧账整理共用快照刷新:(3)/(3b)/(3c) 一律基于**合并后的实况**重载 ───
2607
+ // 旧形态吃 pass 开头快照,而 (1)/(1.5) 真合并已「保 id 改端点」重写/删除了边——
2608
+ // 按旧端点重建 dedup key 会「删活边 + 把死端点写回」(对抗审计端到端复现:
2609
+ // 事件对已删实体 X 的 part-of/about 两条边被折叠成指向死 X 的悬空边,
2610
+ // 活的 事件→canonical 挂载净丢失,随后被 pruneOrphans 当幽灵边清掉)。
2611
+ const normSnapshot = await this.store.loadAll();
2567
2612
  // ─── (3) PersonEventEdge 旧账整理:对每对 (person,event) 跑一次吸收规则
2568
2613
  const pairs = new Map();
2569
- for (const e of snapshot.edges) {
2614
+ for (const e of normSnapshot.edges) {
2570
2615
  if (e.kind !== 'person-event')
2571
2616
  continue;
2572
2617
  const k = `${e.fromPersonId}|${e.toEventId}`;
@@ -2599,7 +2644,7 @@ export class RelationService {
2599
2644
  // ─── (3b) PersonEntityEdge 旧账整理:同一 (person,entity) 多条不同 role 行 → 留最强 role
2600
2645
  // 存量里可能有未做 (from,to) 级别去重的边;此处按当前 addPersonEntityEdge 规则修旧账。
2601
2646
  const peEntityPairs = new Map();
2602
- for (const e of snapshot.edges) {
2647
+ for (const e of normSnapshot.edges) {
2603
2648
  if (e.kind !== 'person-entity')
2604
2649
  continue;
2605
2650
  const k = `${e.fromPersonId}|${e.toEntityId}`;
@@ -2652,7 +2697,7 @@ export class RelationService {
2652
2697
  // · 若同强度有多条 → 取 weight 最大者
2653
2698
  // · weight 取所有被合并边的最大值(保留强化记录)
2654
2699
  const eePairs = new Map();
2655
- for (const e of snapshot.edges) {
2700
+ for (const e of normSnapshot.edges) {
2656
2701
  if (e.kind !== 'event-entity')
2657
2702
  continue;
2658
2703
  const k = `${e.fromEventId}|${e.toEntityId}`;
@@ -2750,8 +2795,10 @@ export class RelationService {
2750
2795
  relatedPersons: relatedPersons.slice(0, 8),
2751
2796
  }, llmDisableThinking);
2752
2797
  if (newSummary && newSummary !== ent.summary) {
2753
- await this.store.upsertEntity({ ...ent, summary: newSummary, lastReinforcedAt: Date.now() });
2754
- summariesRewritten++;
2798
+ // 落笔核实况:ent 来自阶段快照,可能已被真合并删除(死节点不复活)
2799
+ if (await this.writeBackEntityIfLive(ent, { summary: newSummary, lastReinforcedAt: Date.now() })) {
2800
+ summariesRewritten++;
2801
+ }
2755
2802
  }
2756
2803
  }
2757
2804
  }
@@ -3101,7 +3148,11 @@ export class RelationService {
3101
3148
  }
3102
3149
  // 写回持久化:向量入独立命名空间,节点只更新 hash
3103
3150
  await this.store.upsertVector('event', ev.id, vec, expectedHash);
3104
- await this.store.upsertEvent({ ...ev, embeddingHash: expectedHash });
3151
+ // 落笔核实况:ev pass 快照旧拷贝,死节点不写回(防复活)、不参与召回
3152
+ if (!(await this.writeBackEventIfLive(ev, { embeddingHash: expectedHash }))) {
3153
+ eventVecCache.set(ev.id, null);
3154
+ return null;
3155
+ }
3105
3156
  ev.embeddingHash = expectedHash;
3106
3157
  embeddedCount++;
3107
3158
  eventVecCache.set(ev.id, vec);
@@ -3578,9 +3629,10 @@ export class RelationService {
3578
3629
  }
3579
3630
  }
3580
3631
  catch (err) {
3581
- // 真合并失败不应阻塞调用链(边已经 rewire 成功,最差情况退化为旧的"路由+壳子"行为)
3582
- // eslint-disable-next-line no-console
3583
- console.warn(`[user-relation] mergeAlias: 删除 alias 壳子失败 alias=${aliasId} canonical=${canonicalId} kind=${opts.kind}`, err);
3632
+ // 真合并失败不应阻塞调用链(边已经 rewire 成功,最差情况退化为旧的"路由+壳子"行为)。
3633
+ // 必须走 _audit 进日志文件:这条曾是 console.warn,排障期间完全隐形(僵尸节点
3634
+ // 复活事故的诊断成本直接来源之一),静默失败不可接受。
3635
+ this._audit(`[user-relation] mergeAlias: 删除 alias 壳子失败 alias=${aliasId} canonical=${canonicalId} kind=${opts.kind}: ${err instanceof Error ? err.message : String(err)}`);
3584
3636
  }
3585
3637
  return {
3586
3638
  effectiveCanonicalId: canonicalId,
@@ -3635,33 +3687,32 @@ export class RelationService {
3635
3687
  }
3636
3688
  }
3637
3689
  /**
3638
- * consolidate hierarchy 守门用:若 child→parent entity-entity[part-of] 边缺失则新建;
3639
- * 已存在(任一方向:part-of 或反向 contains)则跳过,返回 false。
3640
- * 不强化既有边、不做证据合并——hierarchy 守门只负责"不被错误地 alias-merge 掉",
3641
- * 后续走正规 inferEntityHierarchy / extractor 写边路径补全/强化。
3690
+ * hierarchy 判定落边的唯一入口。曾经的形态(查 pass 快照 + store 直写)出过双重事故:
3691
+ * 旧快照查重看不见本 pass 刚建的边 → 同一对同 pass 重复铸边(实测同边 ×9);
3692
+ * 直写绕过 addEntityEntityEdge 门面 → 写入层实时查重完全失效。
3693
+ * 现约束:一切以**库中实时状态**为准——先核两端节点仍存活(stale 工单里的已合并
3694
+ * 节点不再落边),再实时查 part-of/contains 双向已存在,写入走门面复用其
3695
+ * 实时查重 + 强化语义。
3642
3696
  */
3643
- async _upsertPartOfEdgeIfAbsent(edgesSnapshot, childId, parentId, description) {
3644
- const existing = edgesSnapshot.some(e => e.kind === 'entity-entity' &&
3697
+ async _ensureHierarchyEdge(childId, parentId, description) {
3698
+ const [child, parent] = await Promise.all([this.store.getEntity(childId), this.store.getEntity(parentId)]);
3699
+ if (!child || !parent)
3700
+ return 'skipped-dead';
3701
+ const snapshot = await this.store.loadAll();
3702
+ const existing = snapshot.edges.some(e => e.kind === 'entity-entity' &&
3645
3703
  (e.relationType === 'part-of' || e.relationType === 'contains') &&
3646
3704
  ((e.fromEntityId === childId && e.toEntityId === parentId) ||
3647
3705
  (e.fromEntityId === parentId && e.toEntityId === childId)));
3648
3706
  if (existing)
3649
- return false;
3650
- const now = Date.now();
3651
- await this.store.upsertEdge({
3652
- id: globalThis.crypto.randomUUID(),
3653
- kind: 'entity-entity',
3707
+ return 'exists';
3708
+ await this.addEntityEntityEdge({
3654
3709
  fromEntityId: childId,
3655
3710
  toEntityId: parentId,
3656
3711
  relationType: 'part-of',
3657
- directed: true,
3658
3712
  weight: 0.6,
3659
3713
  description: description.slice(0, 80),
3660
- firstSeenAt: now,
3661
- lastReinforcedAt: now,
3662
- evidence: [],
3663
3714
  });
3664
- return true;
3715
+ return 'built';
3665
3716
  }
3666
3717
  /**
3667
3718
  * 物理删除一条边(带保护门,供 agent 调用)。alias 边(is-alias-of / alt-account-of)禁删;