@aalis/plugin-user-relation 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/dist/actions.d.ts +13 -0
  3. package/dist/actions.d.ts.map +1 -0
  4. package/dist/actions.js +705 -0
  5. package/dist/actions.js.map +1 -0
  6. package/dist/commands.d.ts +46 -0
  7. package/dist/commands.d.ts.map +1 -0
  8. package/dist/commands.js +601 -0
  9. package/dist/commands.js.map +1 -0
  10. package/dist/consolidate-llm.d.ts +150 -0
  11. package/dist/consolidate-llm.d.ts.map +1 -0
  12. package/dist/consolidate-llm.js +373 -0
  13. package/dist/consolidate-llm.js.map +1 -0
  14. package/dist/extractor.d.ts +308 -0
  15. package/dist/extractor.d.ts.map +1 -0
  16. package/dist/extractor.js +1356 -0
  17. package/dist/extractor.js.map +1 -0
  18. package/dist/index.d.ts +33 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +626 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/middleware.d.ts +41 -0
  23. package/dist/middleware.d.ts.map +1 -0
  24. package/dist/middleware.js +281 -0
  25. package/dist/middleware.js.map +1 -0
  26. package/dist/rename-watcher.d.ts +16 -0
  27. package/dist/rename-watcher.d.ts.map +1 -0
  28. package/dist/rename-watcher.js +16 -0
  29. package/dist/rename-watcher.js.map +1 -0
  30. package/dist/service.d.ts +1108 -0
  31. package/dist/service.d.ts.map +1 -0
  32. package/dist/service.js +4462 -0
  33. package/dist/service.js.map +1 -0
  34. package/dist/store.d.ts +89 -0
  35. package/dist/store.d.ts.map +1 -0
  36. package/dist/store.js +207 -0
  37. package/dist/store.js.map +1 -0
  38. package/dist/tools.d.ts +48 -0
  39. package/dist/tools.d.ts.map +1 -0
  40. package/dist/tools.js +1630 -0
  41. package/dist/tools.js.map +1 -0
  42. package/dist/types.d.ts +432 -0
  43. package/dist/types.d.ts.map +1 -0
  44. package/dist/types.js +94 -0
  45. package/dist/types.js.map +1 -0
  46. package/dist/utils.d.ts +419 -0
  47. package/dist/utils.d.ts.map +1 -0
  48. package/dist/utils.js +1630 -0
  49. package/dist/utils.js.map +1 -0
  50. package/package.json +57 -0
@@ -0,0 +1,4462 @@
1
+ import { inferEntityHierarchy, inferMissingParent, resolveConsolidateModel, rewriteEntitySummary, verifyAliasPair, verifyEventPair, } from './consolidate-llm.js';
2
+ import { getKnownPlatformsLower, isPlaceholderSelfPersonId } from './extractor.js';
3
+ import { buildAdjacency, chooseCanonicalDirection, clamp01, clusterEntitiesByPairs, commonPrefix, computeAdaptiveResolution, computeEntityEdgeStats, computeEntityEmbeddingHash, computeEventEdgeStats, computeEventEmbeddingHash, computeLeiden, computeLouvain, computeModularity, computePageRank, computeSlpa, cosineSimilarity, edgeDedupKey, edgeInvolvesBoth, edgeReferences, effectiveWeight, eventPairJaccard, flipDirectedEdge, getEdgeOtherEnd, isAliasEdgeDirectionCorrect, isAliasMarkerEdge, isDirectedEntityEntityRelation, isDirectedEventEventRelation, isEdgeSelfLoop, isEvidenceFullyCovered, mergeAliases, mergeTwoEdges, normalizeName, normalizeRelationType, PERSON_ENTITY_ROLE_RANK, PERSON_EVENT_ROLE_RANK, pickCanonicalByMergeScore, pickCanonicalForEvents, reinforceWeight, rewriteEdgeIds, roleDefaultWeight, trimDescription, trimEvidence, } from './utils.js';
4
+ export class RelationService {
5
+ store;
6
+ ctx;
7
+ /** 由 extractor 注入;actions 层通过 triggerExtraction() 调用 */
8
+ triggerExtractionHandler;
9
+ /** 最近一次 consolidate() 完成的时间戳(ms);未运行时为 undefined */
10
+ _lastConsolidateAt;
11
+ /** 最近一次 consolidate() 结果的简短摘要 */
12
+ _lastConsolidateResultSummary;
13
+ /** 最近一次 consolidate() 的触发来源:manual | eviction | api */
14
+ _lastConsolidateTrigger;
15
+ constructor(store,
16
+ /** 可选 ctx:仅用于写 logger 审计(deleteNode / mergeNodes / changeEntityKind 等 agent 写入路径)。测试不传则 fallback 到 console。 */
17
+ ctx) {
18
+ this.store = store;
19
+ this.ctx = ctx;
20
+ }
21
+ /** 写 audit 日志;ctx 存在走 logger.warn,否则 fallback console.warn(主要照顾单元测试)。 */
22
+ _audit(msg) {
23
+ if (this.ctx)
24
+ this.ctx.logger.warn(msg);
25
+ else
26
+ console.warn(msg);
27
+ }
28
+ /** 查询最近一次 consolidation 运行时间、触发源与结果摘要 */
29
+ getLastConsolidateInfo() {
30
+ return {
31
+ lastRunAt: this._lastConsolidateAt,
32
+ summary: this._lastConsolidateResultSummary,
33
+ trigger: this._lastConsolidateTrigger,
34
+ };
35
+ }
36
+ static personId(platform, userId) {
37
+ return `${platform}:${userId}`;
38
+ }
39
+ /** 由 extractor 在 start() 后注入 */
40
+ setTriggerExtractionHandler(fn) {
41
+ this.triggerExtractionHandler = fn;
42
+ }
43
+ /** 手动触发某 session 的 LLM 提取;extractor 未挂载时返回 error */
44
+ triggerExtraction(sessionId) {
45
+ if (!this.triggerExtractionHandler) {
46
+ return Promise.resolve({ status: 'error', reason: 'extractor 未启用(请检查 enabled / 模型配置)' });
47
+ }
48
+ return this.triggerExtractionHandler(sessionId);
49
+ }
50
+ // ----- Person -----
51
+ async observePerson(platform, userId, displayName) {
52
+ const now = Date.now();
53
+ const existing = await this.store.getPerson(platform, userId);
54
+ const node = existing
55
+ ? {
56
+ ...existing,
57
+ displayName: displayName ?? existing.displayName,
58
+ lastSeenAt: now,
59
+ lastMentionedAt: now,
60
+ mentionCount: (existing.mentionCount ?? 0) + 1,
61
+ }
62
+ : {
63
+ id: RelationService.personId(platform, userId),
64
+ platform,
65
+ userId,
66
+ displayName,
67
+ firstSeenAt: now,
68
+ lastSeenAt: now,
69
+ lastMentionedAt: now,
70
+ mentionCount: 1,
71
+ };
72
+ await this.store.upsertPerson(node);
73
+ return node;
74
+ }
75
+ getPerson(platform, userId) {
76
+ return this.store.getPerson(platform, userId);
77
+ }
78
+ /**
79
+ * 同步平台 displayName 到 Person 节点:仅当节点已存在且 displayName 与传入不同时才 upsert。
80
+ * 不创建新节点(避免水群幽灵);不动 mentionCount / firstSeenAt / lastMentionedAt(与
81
+ * 「显式提及」语义区分);仅刷新 lastSeenAt。返回是否真的发生了改名。
82
+ *
83
+ * 调用方:rename-watcher(订阅 inbound:message:archived,从 metadata.nickname 同步)。
84
+ */
85
+ async syncDisplayName(platform, userId, displayName) {
86
+ const existing = await this.store.getPerson(platform, userId);
87
+ if (!existing)
88
+ return false;
89
+ if (existing.displayName === displayName)
90
+ return false;
91
+ await this.store.upsertPerson({
92
+ ...existing,
93
+ displayName,
94
+ lastSeenAt: Date.now(),
95
+ });
96
+ return true;
97
+ }
98
+ deletePerson(platform, userId) {
99
+ return this.store.deletePersonCascade(platform, userId);
100
+ }
101
+ /**
102
+ * 统一节点查找入口:给定任意节点 ID(person `<platform>:<userId>` 或 event/entity UUID)
103
+ * 返回 { kind, name };不存在返回 null。供 tools 层做存在性校验+友好报错使用。
104
+ */
105
+ async findNodeById(id) {
106
+ if (!id)
107
+ return null;
108
+ if (id.includes(':')) {
109
+ const idx = id.indexOf(':');
110
+ const platform = id.slice(0, idx);
111
+ const userId = id.slice(idx + 1);
112
+ const p = await this.store.getPerson(platform, userId);
113
+ if (p)
114
+ return { kind: 'person', name: p.displayName ?? p.id };
115
+ return null;
116
+ }
117
+ const ev = await this.store.getEvent(id);
118
+ if (ev)
119
+ return { kind: 'event', name: ev.title };
120
+ const ent = await this.store.getEntity(id);
121
+ if (ent)
122
+ return { kind: 'entity', name: ent.name };
123
+ return null;
124
+ }
125
+ // ----- Event -----
126
+ /**
127
+ * 新建事件。严格按 normalized title 去重:若已存在同名事件,**强制合并**到旧节点
128
+ * (追加 evidence、累加权重 += 0.3、occurrences 追加当前时间戳),返回旧节点。
129
+ * 这样保证「同一件事被反复提及」不会产生重复 event,但通过 occurrences[] 保留时间维度。
130
+ */
131
+ async createEvent(input) {
132
+ const now = Date.now();
133
+ // sessionScope 优先取显式传入;其次从 evidence[0].sessionId 推断;最终兜底 'global'。
134
+ // 'global' 哨兵表示「显式跨会话事件」,与"老数据 undefined"区分开(后者表示来源不明)。
135
+ // 若调用方真的没法给出 scope(如批处理脚本),落 'global' 并 audit warn 以便排查。
136
+ let scope = input.sessionScope ?? input.evidence?.[0]?.sessionId;
137
+ if (scope === undefined) {
138
+ scope = 'global';
139
+ this._audit(`[user-relation] createEvent 缺失 sessionScope,回落 'global';title="${input.title}"`);
140
+ }
141
+ const dup = await this.findEventByTitle(input.title, scope);
142
+ if (dup) {
143
+ const merged = {
144
+ ...dup,
145
+ summary: input.summary ?? dup.summary,
146
+ category: input.category ?? dup.category,
147
+ // 只在原节点 scope 为空(老数据)时才回填新 scope,避免覆盖已有隔离。
148
+ sessionScope: dup.sessionScope ?? scope,
149
+ lastReinforcedAt: now,
150
+ lastMentionedAt: now,
151
+ mentionCount: (dup.mentionCount ?? 0) + 1,
152
+ evidence: trimEvidence([...(input.evidence ?? []), ...dup.evidence]),
153
+ occurrences: [...(dup.occurrences ?? [dup.firstSeenAt]), now],
154
+ weight: clamp01((dup.weight ?? 0.5) + 0.3),
155
+ };
156
+ await this.store.upsertEvent(merged);
157
+ return merged;
158
+ }
159
+ const node = {
160
+ id: globalThis.crypto.randomUUID(),
161
+ title: input.title,
162
+ summary: input.summary,
163
+ category: input.category,
164
+ sessionScope: scope,
165
+ firstSeenAt: now,
166
+ lastReinforcedAt: now,
167
+ lastMentionedAt: now,
168
+ mentionCount: 1,
169
+ evidence: trimEvidence(input.evidence ?? []),
170
+ occurrences: [now],
171
+ weight: 0.5,
172
+ };
173
+ await this.store.upsertEvent(node);
174
+ return node;
175
+ }
176
+ /**
177
+ * 按 normalized title 精确匹配(不区分大小写、压缩空白)查找已有事件。
178
+ * 用于 createEvent 入口去重。
179
+ *
180
+ * 若传入 scope:遵循「同名 + 同 scope 才是同事件」原则;只接受
181
+ * (a) 两者 scope 相同,或 (b) 旧节点 scope 为 undefined(老数据通配)。
182
+ * 不传 scope:只看 title,保留老行为(供手动调用 / 测试 / 迁移)。
183
+ */
184
+ async findEventByTitle(title, scope) {
185
+ const target = normalizeName(title);
186
+ if (!target)
187
+ return undefined;
188
+ const snap = await this.store.loadAll();
189
+ return snap.events.find(e => {
190
+ if (normalizeName(e.title) !== target)
191
+ return false;
192
+ if (scope === undefined)
193
+ return true;
194
+ // 新数据需严格隔离;旧节点 scope=undefined 视为通配。
195
+ return e.sessionScope === undefined || e.sessionScope === scope;
196
+ });
197
+ }
198
+ /**
199
+ * 强化已有事件:追加 evidence、更新 lastReinforcedAt,可选更新 summary/title/category。
200
+ *
201
+ * 跨 sessionScope 软护栏:如果新 evidence 全部来自与 existing.sessionScope 不同的会话,
202
+ * 且 existing 既不是 'global' 也不是未限定 scope,则记录审计但**继续执行**(warn 不阻断)。
203
+ * 与 addEventEventEdge 的 is-alias-of 跨 scope 硬阻断对应——reinforce 走 warn,
204
+ * 因为它在不少正常路径(如 entity 共现、is-alias-of 后回写)也会自然跨 scope 触发。
205
+ */
206
+ async reinforceEvent(eventId, patch) {
207
+ const existing = await this.store.getEvent(eventId);
208
+ if (!existing)
209
+ return undefined;
210
+ const existingScope = existing.sessionScope;
211
+ const isScopedEvent = existingScope && existingScope !== 'global';
212
+ if (isScopedEvent && patch.evidence && patch.evidence.length > 0) {
213
+ const newSessionIds = new Set(patch.evidence.map(e => e.sessionId).filter((s) => Boolean(s)));
214
+ const allCross = newSessionIds.size > 0 && !newSessionIds.has(existingScope);
215
+ if (allCross) {
216
+ this._audit(`[user-relation] reinforceEvent 跨 sessionScope 警告:event=${eventId} scope="${existingScope}" 收到来自 [${[...newSessionIds].join(',')}] 的 evidence,已继续合并;如非预期请核查 LLM 抽取或 alias 合并路径`);
217
+ }
218
+ }
219
+ // P4: title 改动留 audit(不阻断,事件标题本来就比较模糊;但要可追溯)
220
+ const patchTitle = patch.title?.trim();
221
+ if (patchTitle && patchTitle !== existing.title) {
222
+ this._audit(`[user-relation] reinforceEvent 改写 title:event=${eventId} ` +
223
+ `existing.title="${existing.title}" → new.title="${patchTitle}"`);
224
+ }
225
+ const merged = {
226
+ ...existing,
227
+ title: patchTitle || existing.title,
228
+ summary: patch.summary ?? existing.summary,
229
+ category: patch.category ?? existing.category,
230
+ lastReinforcedAt: Date.now(),
231
+ evidence: trimEvidence([...(patch.evidence ?? []), ...existing.evidence]),
232
+ };
233
+ await this.store.upsertEvent(merged);
234
+ return merged;
235
+ }
236
+ getEvent(eventId) {
237
+ return this.store.getEvent(eventId);
238
+ }
239
+ deleteEvent(eventId) {
240
+ return this.store.deleteEventCascade(eventId);
241
+ }
242
+ // ----- Entity -----
243
+ /**
244
+ * 新建实体。严格按 (entityKind, normalized name) 去重:若已存在同 kind 同名实体,
245
+ * **强制合并**到旧节点(追加 evidence、合并 aliases、累加权重 += 0.3),返回旧节点。
246
+ */
247
+ async createEntity(input) {
248
+ const now = Date.now();
249
+ const dup = await this.findEntityByKindAndName(input.entityKind, input.name);
250
+ if (dup) {
251
+ const merged = {
252
+ ...dup,
253
+ aliases: mergeAliases(dup.aliases, input.aliases, dup.name),
254
+ summary: input.summary ?? dup.summary,
255
+ lastReinforcedAt: now,
256
+ lastMentionedAt: now,
257
+ mentionCount: (dup.mentionCount ?? 0) + 1,
258
+ evidence: trimEvidence([...(input.evidence ?? []), ...dup.evidence]),
259
+ weight: clamp01((dup.weight ?? 0.5) + 0.3),
260
+ };
261
+ await this.store.upsertEntity(merged);
262
+ return merged;
263
+ }
264
+ const node = {
265
+ id: globalThis.crypto.randomUUID(),
266
+ entityKind: input.entityKind,
267
+ name: input.name,
268
+ aliases: mergeAliases(undefined, input.aliases, input.name),
269
+ summary: input.summary,
270
+ firstSeenAt: now,
271
+ lastReinforcedAt: now,
272
+ lastMentionedAt: now,
273
+ mentionCount: 1,
274
+ evidence: trimEvidence(input.evidence ?? []),
275
+ weight: 0.5,
276
+ };
277
+ await this.store.upsertEntity(node);
278
+ return node;
279
+ }
280
+ /**
281
+ * 强化已有实体:追加 evidence、更新 lastReinforcedAt,可选更新字段。
282
+ *
283
+ * **静默改 kind / 改 name 已被禁用**(详见 patch 字段注释),LLM 反复输出"重名异 kind"
284
+ * 时不会把已有节点偷偷翻转身份,只会留下 audit 日志。如确需 rename/换 kind,请走
285
+ * rename-watcher / consolidate verify / 显式 merge 工具。
286
+ */
287
+ async reinforceEntity(entityId, patch) {
288
+ const existing = await this.store.getEntity(entityId);
289
+ if (!existing)
290
+ return undefined;
291
+ // P1: 锁 entityKind —— LLM 传不同 kind 时 audit 后忽略
292
+ if (patch.entityKind && patch.entityKind !== existing.entityKind) {
293
+ this._audit(`[user-relation] reinforceEntity 忽略 entityKind 改动:entity=${entityId} name="${existing.name}" ` +
294
+ `existing.kind="${existing.entityKind}" 收到 patch.kind="${patch.entityKind}";kind 静默翻转已被禁止,` +
295
+ `请走 consolidate verify 或 merge tools`);
296
+ }
297
+ // P2: 锁 name —— normalize 等价允许覆写 display 写法;不等价记审计后保留 existing.name
298
+ let finalName = existing.name;
299
+ if (patch.name) {
300
+ const trimmedPatch = patch.name.trim();
301
+ if (trimmedPatch) {
302
+ if (normalizeName(trimmedPatch) === normalizeName(existing.name)) {
303
+ finalName = trimmedPatch; // 只是换写法,允许更新 display
304
+ }
305
+ else {
306
+ this._audit(`[user-relation] reinforceEntity 忽略 name 改动:entity=${entityId} kind="${existing.entityKind}" ` +
307
+ `existing.name="${existing.name}" 收到 patch.name="${trimmedPatch}";rename 请走 rename-watcher / merge tools`);
308
+ }
309
+ }
310
+ }
311
+ const merged = {
312
+ ...existing,
313
+ name: finalName,
314
+ aliases: mergeAliases(existing.aliases, patch.aliases, finalName),
315
+ summary: patch.summary ?? existing.summary,
316
+ // entityKind 锁死:无视 patch.entityKind
317
+ lastReinforcedAt: Date.now(),
318
+ evidence: trimEvidence([...(patch.evidence ?? []), ...existing.evidence]),
319
+ };
320
+ await this.store.upsertEntity(merged);
321
+ return merged;
322
+ }
323
+ getEntity(entityId) {
324
+ return this.store.getEntity(entityId);
325
+ }
326
+ deleteEntity(entityId) {
327
+ return this.store.deleteEntityCascade(entityId);
328
+ }
329
+ /**
330
+ * 按 name / aliases 精确匹配(不区分大小写)查找已有实体。
331
+ * 用于抽取阶段去重 —— LLM 提取出"三角洲"时优先复用已存在的同名实体。
332
+ */
333
+ async findEntityByName(name) {
334
+ const target = normalizeName(name);
335
+ if (!target)
336
+ return undefined;
337
+ const snap = await this.store.loadAll();
338
+ return snap.entities.find(e => normalizeName(e.name) === target || (e.aliases ?? []).some(a => normalizeName(a) === target));
339
+ }
340
+ /**
341
+ * 按 (entityKind, normalized name) 精确匹配查找已有实体;用于 createEntity 入口去重。
342
+ * 比 findEntityByName 更严格(要求 kind 一致),避免「同名不同类」误合并(如游戏《北京》vs 地点北京)。
343
+ */
344
+ async findEntityByKindAndName(kind, name) {
345
+ const target = normalizeName(name);
346
+ if (!target)
347
+ return undefined;
348
+ const snap = await this.store.loadAll();
349
+ return snap.entities.find(e => e.entityKind === kind && normalizeName(e.name) === target);
350
+ }
351
+ // ----- Edge: person → entity -----
352
+ async addPersonEntityEdge(input) {
353
+ const snapshot = await this.store.loadAll();
354
+ const sameLink = snapshot.edges.filter((e) => e.kind === 'person-entity' && e.fromPersonId === input.fromPersonId && e.toEntityId === input.toEntityId);
355
+ const now = Date.now();
356
+ if (sameLink.length === 0) {
357
+ const fresh = {
358
+ id: globalThis.crypto.randomUUID(),
359
+ kind: 'person-entity',
360
+ fromPersonId: input.fromPersonId,
361
+ toEntityId: input.toEntityId,
362
+ role: input.role,
363
+ sentiment: input.sentiment,
364
+ weight: clamp01(input.weight ?? roleDefaultWeight('person-entity', input.role)),
365
+ description: trimDescription(input.description),
366
+ firstSeenAt: now,
367
+ lastReinforcedAt: now,
368
+ evidence: trimEvidence(input.evidence ?? []),
369
+ };
370
+ await this.store.upsertEdge(fresh);
371
+ return fresh;
372
+ }
373
+ // 选出当前已存在的最强 role 边作为保留者
374
+ const strongest = sameLink.reduce((a, b) => (PERSON_ENTITY_ROLE_RANK[a.role] ?? 0) >= (PERSON_ENTITY_ROLE_RANK[b.role] ?? 0) ? a : b);
375
+ const inputRank = PERSON_ENTITY_ROLE_RANK[input.role] ?? 0;
376
+ const strongestRank = PERSON_ENTITY_ROLE_RANK[strongest.role] ?? 0;
377
+ const finalRole = inputRank > strongestRank ? input.role : strongest.role;
378
+ // 如果证据已被覆盖且 role 未变,则原样返回
379
+ if (finalRole === strongest.role &&
380
+ isEvidenceFullyCovered(input.evidence ?? [], strongest.evidence) &&
381
+ sameLink.length === 1 &&
382
+ !input.description) {
383
+ return strongest;
384
+ }
385
+ // 合并 evidence + 加权
386
+ const allEvidence = trimEvidence([...(input.evidence ?? []), ...sameLink.flatMap(e => e.evidence)]);
387
+ const merged = {
388
+ ...strongest,
389
+ role: finalRole,
390
+ sentiment: input.sentiment ?? strongest.sentiment,
391
+ weight: clamp01(reinforceWeight(strongest.weight, input.weight ?? 0.1)),
392
+ description: trimDescription(input.description) ?? strongest.description,
393
+ lastReinforcedAt: now,
394
+ evidence: allEvidence,
395
+ };
396
+ // 删除其余 weaker 同对边
397
+ for (const e of sameLink) {
398
+ if (e.id !== strongest.id)
399
+ await this.store.deleteEdge(e.id);
400
+ }
401
+ await this.store.upsertEdge(merged);
402
+ return merged;
403
+ }
404
+ async findPersonEntityEdge(fromPersonId, toEntityId, role) {
405
+ const snapshot = await this.store.loadAll();
406
+ return snapshot.edges.find((e) => e.kind === 'person-entity' && e.fromPersonId === fromPersonId && e.toEntityId === toEntityId && e.role === role);
407
+ }
408
+ // ----- Edge: event → event -----
409
+ async addEventEventEdge(input) {
410
+ let normalizedType = input.relationType.trim().toLowerCase().replace(/\s+/g, '-');
411
+ // 跨 sessionScope 的 event 严禁通过 is-alias-of 合并:例如「群A 聊三角洲」与「群B 聊三角洲」
412
+ // 标题撞车不代表是同一件事。允许 global hub 与任意 scope 别名挂接(global 表示已显式跨会话)。
413
+ // 触发条件:两端 scope 都已知 / 都非 global / 且不相等 → 降级为 'related'(弱关联仍保留)+ 审计。
414
+ if (normalizedType === 'is-alias-of') {
415
+ const fromEv = await this.store.getEvent(input.fromEventId);
416
+ const toEv = await this.store.getEvent(input.toEventId);
417
+ const fromScope = fromEv?.sessionScope;
418
+ const toScope = toEv?.sessionScope;
419
+ const isCrossScope = fromScope !== undefined &&
420
+ toScope !== undefined &&
421
+ fromScope !== 'global' &&
422
+ toScope !== 'global' &&
423
+ fromScope !== toScope;
424
+ if (isCrossScope) {
425
+ this._audit(`[user-relation] 拒绝跨 sessionScope 的 event is-alias-of 合并:` +
426
+ `${input.fromEventId}(scope=${fromScope}) ↔ ${input.toEventId}(scope=${toScope}),已降级为 'related'`);
427
+ normalizedType = 'related';
428
+ }
429
+ }
430
+ const directed = input.directed ?? isDirectedEventEventRelation(normalizedType);
431
+ const existing = await this.findEventEventEdge(input.fromEventId, input.toEventId, normalizedType, directed);
432
+ const now = Date.now();
433
+ if (existing) {
434
+ if (isEvidenceFullyCovered(input.evidence ?? [], existing.evidence) && !input.description)
435
+ return existing;
436
+ const merged = {
437
+ ...existing,
438
+ weight: clamp01(reinforceWeight(existing.weight, input.weight ?? 0.1)),
439
+ description: trimDescription(input.description) ?? existing.description,
440
+ lastReinforcedAt: now,
441
+ evidence: trimEvidence([...(input.evidence ?? []), ...existing.evidence]),
442
+ };
443
+ await this.store.upsertEdge(merged);
444
+ return merged;
445
+ }
446
+ const fresh = {
447
+ id: globalThis.crypto.randomUUID(),
448
+ kind: 'event-event',
449
+ fromEventId: input.fromEventId,
450
+ toEventId: input.toEventId,
451
+ relationType: normalizedType,
452
+ directed,
453
+ weight: clamp01(input.weight ?? roleDefaultWeight('event-event', normalizedType)),
454
+ description: trimDescription(input.description),
455
+ firstSeenAt: now,
456
+ lastReinforcedAt: now,
457
+ evidence: trimEvidence(input.evidence ?? []),
458
+ };
459
+ await this.store.upsertEdge(fresh);
460
+ if (normalizedType === 'is-alias-of') {
461
+ await this.mergeAlias({ aliasId: fresh.fromEventId, canonicalId: fresh.toEventId, kind: 'event' });
462
+ }
463
+ return fresh;
464
+ }
465
+ async findEventEventEdge(fromEventId, toEventId, relationType, directed) {
466
+ const snapshot = await this.store.loadAll();
467
+ return snapshot.edges.find((e) => {
468
+ if (e.kind !== 'event-event')
469
+ return false;
470
+ if (e.relationType !== relationType)
471
+ return false;
472
+ if (directed)
473
+ return e.fromEventId === fromEventId && e.toEventId === toEventId;
474
+ return ((e.fromEventId === fromEventId && e.toEventId === toEventId) ||
475
+ (e.fromEventId === toEventId && e.toEventId === fromEventId));
476
+ });
477
+ }
478
+ // ----- Edge: event → entity -----
479
+ async addEventEntityEdge(input) {
480
+ const normalizedType = input.relationType.trim().toLowerCase().replace(/\s+/g, '-');
481
+ const existing = await this.findEventEntityEdge(input.fromEventId, input.toEntityId, normalizedType);
482
+ const now = Date.now();
483
+ if (existing) {
484
+ if (isEvidenceFullyCovered(input.evidence ?? [], existing.evidence) && !input.description)
485
+ return existing;
486
+ const merged = {
487
+ ...existing,
488
+ weight: clamp01(reinforceWeight(existing.weight, input.weight ?? 0.1)),
489
+ description: trimDescription(input.description) ?? existing.description,
490
+ lastReinforcedAt: now,
491
+ evidence: trimEvidence([...(input.evidence ?? []), ...existing.evidence]),
492
+ };
493
+ await this.store.upsertEdge(merged);
494
+ return merged;
495
+ }
496
+ const fresh = {
497
+ id: globalThis.crypto.randomUUID(),
498
+ kind: 'event-entity',
499
+ fromEventId: input.fromEventId,
500
+ toEntityId: input.toEntityId,
501
+ relationType: normalizedType,
502
+ directed: true,
503
+ weight: clamp01(input.weight ?? roleDefaultWeight('event-entity', normalizedType)),
504
+ description: trimDescription(input.description),
505
+ firstSeenAt: now,
506
+ lastReinforcedAt: now,
507
+ evidence: trimEvidence(input.evidence ?? []),
508
+ };
509
+ await this.store.upsertEdge(fresh);
510
+ return fresh;
511
+ }
512
+ async findEventEntityEdge(fromEventId, toEntityId, relationType) {
513
+ const snapshot = await this.store.loadAll();
514
+ return snapshot.edges.find((e) => e.kind === 'event-entity' &&
515
+ e.relationType === relationType &&
516
+ e.fromEventId === fromEventId &&
517
+ e.toEntityId === toEntityId);
518
+ }
519
+ // ----- Edge: entity → entity -----
520
+ async addEntityEntityEdge(input) {
521
+ if (input.fromEntityId === input.toEntityId) {
522
+ throw new Error('addEntityEntityEdge: 不允许实体自环');
523
+ }
524
+ const normalizedType = input.relationType.trim().toLowerCase().replace(/\s+/g, '-');
525
+ const directed = input.directed ?? isDirectedEntityEntityRelation(normalizedType);
526
+ const existing = await this.findEntityEntityEdge(input.fromEntityId, input.toEntityId, normalizedType, directed);
527
+ const now = Date.now();
528
+ if (existing) {
529
+ if (isEvidenceFullyCovered(input.evidence ?? [], existing.evidence) && !input.description)
530
+ return existing;
531
+ const merged = {
532
+ ...existing,
533
+ weight: clamp01(reinforceWeight(existing.weight, input.weight ?? 0.1)),
534
+ description: trimDescription(input.description) ?? existing.description,
535
+ lastReinforcedAt: now,
536
+ evidence: trimEvidence([...(input.evidence ?? []), ...existing.evidence]),
537
+ };
538
+ await this.store.upsertEdge(merged);
539
+ return merged;
540
+ }
541
+ const fresh = {
542
+ id: globalThis.crypto.randomUUID(),
543
+ kind: 'entity-entity',
544
+ fromEntityId: input.fromEntityId,
545
+ toEntityId: input.toEntityId,
546
+ relationType: normalizedType,
547
+ directed,
548
+ weight: clamp01(input.weight ?? roleDefaultWeight('entity-entity', normalizedType)),
549
+ description: trimDescription(input.description),
550
+ firstSeenAt: now,
551
+ lastReinforcedAt: now,
552
+ evidence: trimEvidence(input.evidence ?? []),
553
+ };
554
+ await this.store.upsertEdge(fresh);
555
+ if (normalizedType === 'is-alias-of') {
556
+ await this.mergeAlias({ aliasId: fresh.fromEntityId, canonicalId: fresh.toEntityId, kind: 'entity' });
557
+ }
558
+ return fresh;
559
+ }
560
+ async findEntityEntityEdge(fromEntityId, toEntityId, relationType, directed) {
561
+ const snapshot = await this.store.loadAll();
562
+ return snapshot.edges.find((e) => {
563
+ if (e.kind !== 'entity-entity')
564
+ return false;
565
+ if (e.relationType !== relationType)
566
+ return false;
567
+ if (directed)
568
+ return e.fromEntityId === fromEntityId && e.toEntityId === toEntityId;
569
+ return ((e.fromEntityId === fromEntityId && e.toEntityId === toEntityId) ||
570
+ (e.fromEntityId === toEntityId && e.toEntityId === fromEntityId));
571
+ });
572
+ }
573
+ // ----- Edge: person → event -----
574
+ async addPersonEventEdge(input) {
575
+ const snapshot = await this.store.loadAll();
576
+ const sameLink = snapshot.edges.filter((e) => e.kind === 'person-event' && e.fromPersonId === input.fromPersonId && e.toEventId === input.toEventId);
577
+ const now = Date.now();
578
+ // ─── 第 1 步:按 role 分桶,去除同 role 重复行 ───
579
+ const byRole = new Map();
580
+ for (const e of sameLink) {
581
+ const prev = byRole.get(e.role);
582
+ if (!prev) {
583
+ byRole.set(e.role, e);
584
+ }
585
+ else {
586
+ // 同 role 多条:保留较早的,合并 evidence/权重,删除新的
587
+ const folded = {
588
+ ...prev,
589
+ firstSeenAt: Math.min(prev.firstSeenAt, e.firstSeenAt),
590
+ lastReinforcedAt: Math.max(prev.lastReinforcedAt, e.lastReinforcedAt),
591
+ weight: clamp01(Math.max(prev.weight, e.weight)),
592
+ sentiment: prev.sentiment ?? e.sentiment,
593
+ description: prev.description ?? e.description,
594
+ evidence: trimEvidence([...prev.evidence, ...e.evidence]),
595
+ };
596
+ byRole.set(e.role, folded);
597
+ await this.store.deleteEdge(e.id);
598
+ }
599
+ }
600
+ // ─── 第 2 步:决定 input 的归属(吸收规则) ───
601
+ // R1: initiator 吸收 participant —— 若加入 input 后两者并存,participant 并入 initiator
602
+ // R2: 任何非 witness 角色吸收 witness —— witness 仅在「单独存在」时保留
603
+ // 其它角色 (target / reporter) 与 initiator / participant 可独立并存
604
+ const absorberFor = (role, others) => {
605
+ if (role === 'participant' && others.has('initiator'))
606
+ return 'initiator';
607
+ if (role === 'witness') {
608
+ const candidates = [...others].filter(r => r !== 'witness');
609
+ if (candidates.length > 0) {
610
+ return candidates.reduce((a, b) => (PERSON_EVENT_ROLE_RANK[a] ?? 0) >= (PERSON_EVENT_ROLE_RANK[b] ?? 0) ? a : b);
611
+ }
612
+ }
613
+ return role;
614
+ };
615
+ const presentRoles = new Set(byRole.keys());
616
+ // 把 input 也加入一起考虑
617
+ const all = new Set(presentRoles);
618
+ all.add(input.role);
619
+ // 先解决 input.role
620
+ const inputTargetRole = absorberFor(input.role, all);
621
+ // ─── 第 3 步:把已存在的旧 role 中需要被吸收的也并入 ───
622
+ const finalRoles = new Set();
623
+ finalRoles.add(inputTargetRole);
624
+ for (const r of presentRoles) {
625
+ const tgt = absorberFor(r, all);
626
+ if (tgt === r)
627
+ finalRoles.add(r);
628
+ else {
629
+ // r 被吸收到 tgt:把这条 role 的 evidence 转移给吸收者
630
+ const absorbed = byRole.get(r);
631
+ const absorber = byRole.get(tgt);
632
+ if (absorbed && absorber) {
633
+ const merged = {
634
+ ...absorber,
635
+ firstSeenAt: Math.min(absorber.firstSeenAt, absorbed.firstSeenAt),
636
+ lastReinforcedAt: Math.max(absorber.lastReinforcedAt, absorbed.lastReinforcedAt),
637
+ weight: clamp01(Math.max(absorber.weight, absorbed.weight)),
638
+ sentiment: absorber.sentiment ?? absorbed.sentiment,
639
+ description: absorber.description ?? absorbed.description,
640
+ evidence: trimEvidence([...absorber.evidence, ...absorbed.evidence]),
641
+ };
642
+ byRole.set(tgt, merged);
643
+ }
644
+ else if (absorbed && !absorber) {
645
+ // 吸收者尚不存在(input 即将创建),先临时改 role 让它存到 inputTargetRole 上
646
+ byRole.set(tgt, { ...absorbed, role: tgt });
647
+ }
648
+ if (absorbed)
649
+ await this.store.deleteEdge(absorbed.id);
650
+ byRole.delete(r);
651
+ finalRoles.add(tgt);
652
+ }
653
+ }
654
+ // ─── 第 4 步:把 input 写入 inputTargetRole 对应的边 ───
655
+ const target = byRole.get(inputTargetRole);
656
+ if (!target) {
657
+ const fresh = {
658
+ id: globalThis.crypto.randomUUID(),
659
+ kind: 'person-event',
660
+ fromPersonId: input.fromPersonId,
661
+ toEventId: input.toEventId,
662
+ role: inputTargetRole,
663
+ sentiment: input.sentiment,
664
+ weight: clamp01(input.weight ?? roleDefaultWeight('person-event', inputTargetRole)),
665
+ description: trimDescription(input.description),
666
+ firstSeenAt: now,
667
+ lastReinforcedAt: now,
668
+ evidence: trimEvidence(input.evidence ?? []),
669
+ };
670
+ await this.store.upsertEdge(fresh);
671
+ return fresh;
672
+ }
673
+ // 命中已有 role 行:强化
674
+ if (isEvidenceFullyCovered(input.evidence ?? [], target.evidence) &&
675
+ !input.description &&
676
+ input.sentiment === undefined) {
677
+ if (target.role !== inputTargetRole) {
678
+ const fixed = { ...target, role: inputTargetRole };
679
+ await this.store.upsertEdge(fixed);
680
+ return fixed;
681
+ }
682
+ return target;
683
+ }
684
+ const merged = {
685
+ ...target,
686
+ role: inputTargetRole,
687
+ sentiment: input.sentiment ?? target.sentiment,
688
+ weight: clamp01(reinforceWeight(target.weight, input.weight ?? 0.1)),
689
+ description: trimDescription(input.description) ?? target.description,
690
+ lastReinforcedAt: now,
691
+ evidence: trimEvidence([...(input.evidence ?? []), ...target.evidence]),
692
+ };
693
+ await this.store.upsertEdge(merged);
694
+ return merged;
695
+ }
696
+ // ----- Edge: person → person -----
697
+ async addPersonPersonEdge(input) {
698
+ const normalizedType = normalizeRelationType(input.relationType);
699
+ // 始终视为单向声明:A 说"和 B 是朋友"≠ B 也认同。
700
+ // 若需双向,B 自己再写一条 B → A 即可;UI 渲染时检测对偶边显示双向箭头。
701
+ const directed = input.directed ?? true;
702
+ // 防孤儿:to 必须已存在 PersonNode(避免指向"被提及但从未发言"的幽灵 id)
703
+ const snapshot = await this.store.loadAll();
704
+ const toExists = snapshot.persons.some(p => p.id === input.toPersonId);
705
+ if (!toExists) {
706
+ throw new Error(`addPersonPersonEdge: toPersonId ${input.toPersonId} 不存在为 PersonNode(防止孤儿边)`);
707
+ }
708
+ const existing = await this.findPersonPersonEdge(input.fromPersonId, input.toPersonId, normalizedType, directed);
709
+ const now = Date.now();
710
+ // ── familiar 占位自动废除 ──
711
+ // 'familiar' 是行为观察兜底标签("两人常一起说话但不知道具体关系");
712
+ // 一旦同一对人之间出现任何**身份性**关系(friend/cp/mentor/colleague/rival/...),
713
+ // familiar 就不再有信息量。在新建或加强非 familiar 关系时顺手删除同对的 familiar 边,
714
+ // 避免视觉冗余。不动 is-alias-of / alt-account-of(别名声明正交于亲密度)。
715
+ if (normalizedType !== 'familiar' && normalizedType !== 'is-alias-of' && normalizedType !== 'alt-account-of') {
716
+ for (const e of snapshot.edges) {
717
+ if (e.kind !== 'person-person')
718
+ continue;
719
+ if (e.relationType !== 'familiar')
720
+ continue;
721
+ const sameDyad = (e.fromPersonId === input.fromPersonId && e.toPersonId === input.toPersonId) ||
722
+ (e.fromPersonId === input.toPersonId && e.toPersonId === input.fromPersonId);
723
+ if (!sameDyad)
724
+ continue;
725
+ await this.store.deleteEdge(e.id);
726
+ }
727
+ }
728
+ if (existing) {
729
+ if (isEvidenceFullyCovered(input.evidence ?? [], existing.evidence) && !input.description)
730
+ return existing;
731
+ const merged = {
732
+ ...existing,
733
+ weight: clamp01(reinforceWeight(existing.weight, input.weight ?? 0.1)),
734
+ description: trimDescription(input.description) ?? existing.description,
735
+ // hierarchy 使用「后来者覆盖 unknown」策略:如果之前是 unknown / 未填、
736
+ // 今次增量证据给出了具体值,则采纳;反之已有具体值不被 unknown 覆盖。
737
+ hierarchy: input.hierarchy && input.hierarchy !== 'unknown' ? input.hierarchy : (existing.hierarchy ?? input.hierarchy),
738
+ lastReinforcedAt: now,
739
+ evidence: trimEvidence([...(input.evidence ?? []), ...existing.evidence]),
740
+ };
741
+ await this.store.upsertEdge(merged);
742
+ return merged;
743
+ }
744
+ const fresh = {
745
+ id: globalThis.crypto.randomUUID(),
746
+ kind: 'person-person',
747
+ fromPersonId: input.fromPersonId,
748
+ toPersonId: input.toPersonId,
749
+ relationType: normalizedType,
750
+ directed,
751
+ hierarchy: input.hierarchy,
752
+ weight: clamp01(input.weight ?? roleDefaultWeight('person-person', normalizedType)),
753
+ description: trimDescription(input.description),
754
+ firstSeenAt: now,
755
+ lastReinforcedAt: now,
756
+ evidence: trimEvidence(input.evidence ?? []),
757
+ };
758
+ await this.store.upsertEdge(fresh);
759
+ if (normalizedType === 'is-alias-of' || normalizedType === 'alt-account-of') {
760
+ await this.mergeAlias({ aliasId: fresh.fromPersonId, canonicalId: fresh.toPersonId, kind: 'person' });
761
+ }
762
+ return fresh;
763
+ }
764
+ // ----- 边查询 -----
765
+ async findPersonEventEdge(fromPersonId, toEventId, role) {
766
+ const snapshot = await this.store.loadAll();
767
+ return snapshot.edges.find((e) => e.kind === 'person-event' && e.fromPersonId === fromPersonId && e.toEventId === toEventId && e.role === role);
768
+ }
769
+ /**
770
+ * 查找等价的人-人边。对于对称关系 (directed=false),(A→B, friend) 与 (B→A, friend)
771
+ * 视为同一条边;只看其中一种方向即可命中。
772
+ */
773
+ async findPersonPersonEdge(fromPersonId, toPersonId, relationType, directed) {
774
+ const snapshot = await this.store.loadAll();
775
+ return snapshot.edges.find((e) => {
776
+ if (e.kind !== 'person-person')
777
+ return false;
778
+ if (e.relationType !== relationType)
779
+ return false;
780
+ if (directed) {
781
+ return e.fromPersonId === fromPersonId && e.toPersonId === toPersonId;
782
+ }
783
+ // 对称:任一方向匹配即可
784
+ return ((e.fromPersonId === fromPersonId && e.toPersonId === toPersonId) ||
785
+ (e.fromPersonId === toPersonId && e.toPersonId === fromPersonId));
786
+ });
787
+ }
788
+ deleteEdge(edgeId) {
789
+ return this.store.deleteEdge(edgeId);
790
+ }
791
+ // ----- 图查询 -----
792
+ loadAll() {
793
+ return this.store.loadAll();
794
+ }
795
+ /**
796
+ * 廉价预判:图中任一类节点 / 边的当前数量是否已达到 `evictByQuota` 的滞回触发阈值
797
+ * (`count >= ceil(cap · (1 + hysteresisPct))`),即下一次 `evictByQuota` **真的会删东西**。
798
+ *
799
+ * 用于 extractor 的"写后顺手老化"路径节流:避免每次提取都跑 PageRank / consolidate。
800
+ * 公式与 {@link evictByQuota} 内部判定一致;任一类超阈即返回 true(与 evict 的分类独立判定对齐)。
801
+ *
802
+ * `cap <= 0` 的类视为不限,跳过检查;`evictionEnabled` 由调用方负责。
803
+ */
804
+ async isOverQuota(quota) {
805
+ const hysteresisPct = Math.max(quota.hysteresisPct ?? 0.2, 0);
806
+ const trigger = (cap) => Math.ceil(cap * (1 + hysteresisPct));
807
+ const snap = await this.store.loadAll();
808
+ if ((quota.maxPersons ?? 0) > 0 && snap.persons.length >= trigger(quota.maxPersons))
809
+ return true;
810
+ if ((quota.maxEvents ?? 0) > 0 && snap.events.length >= trigger(quota.maxEvents))
811
+ return true;
812
+ if ((quota.maxEntities ?? 0) > 0 && snap.entities.length >= trigger(quota.maxEntities))
813
+ return true;
814
+ if ((quota.maxEdges ?? 0) > 0 && snap.edges.length >= trigger(quota.maxEdges))
815
+ return true;
816
+ return false;
817
+ }
818
+ /**
819
+ * 清理孤儿节点:删除所有"没有任何边引用"的 person / event / entity。
820
+ *
821
+ * 设计原则(v3,最简):
822
+ * - **没有任何边端点引用 = 孤儿**,三类节点一视同仁。边的 6 种 kind 中只要节点
823
+ * 出现在任一 from/to 字段上就算"被引用"。
824
+ * - **person 孤儿也清**:observePerson 按 (platform, userId) upsert,删掉的"水群幽灵"
825
+ * 下次发言时会自动重建,所以删除安全。
826
+ * - **零保护**:weight/evidence 门槛属于配额淘汰阶段的事,与孤儿无关;
827
+ * 孤儿的语义就是"没人指向",无条件清。
828
+ * - **零参数**:刻意不暴露任何 opts,避免重新引入误用。
829
+ *
830
+ * 返回被删除的 id 列表,便于 caller 打日志/报告。
831
+ */
832
+ async pruneOrphans() {
833
+ const snap = await this.store.loadAll();
834
+ // 先建节点 id 集合(用于悬空边检测)
835
+ const personIdSet = new Set(snap.persons.map(p => p.id));
836
+ const eventIdSet = new Set(snap.events.map(e => e.id));
837
+ const entityIdSet = new Set(snap.entities.map(e => e.id));
838
+ // 清理悬空边:端点指向不存在节点的边(节点被绕过 cascade 删除时可能残留)
839
+ let deletedDanglingEdges = 0;
840
+ for (const e of snap.edges) {
841
+ let dangling = false;
842
+ switch (e.kind) {
843
+ case 'person-event':
844
+ dangling = !personIdSet.has(e.fromPersonId) || !eventIdSet.has(e.toEventId);
845
+ break;
846
+ case 'person-entity':
847
+ dangling = !personIdSet.has(e.fromPersonId) || !entityIdSet.has(e.toEntityId);
848
+ break;
849
+ case 'person-person':
850
+ dangling = !personIdSet.has(e.fromPersonId) || !personIdSet.has(e.toPersonId);
851
+ break;
852
+ case 'event-event':
853
+ dangling = !eventIdSet.has(e.fromEventId) || !eventIdSet.has(e.toEventId);
854
+ break;
855
+ case 'event-entity':
856
+ dangling = !eventIdSet.has(e.fromEventId) || !entityIdSet.has(e.toEntityId);
857
+ break;
858
+ case 'entity-entity':
859
+ dangling = !entityIdSet.has(e.fromEntityId) || !entityIdSet.has(e.toEntityId);
860
+ break;
861
+ }
862
+ if (dangling) {
863
+ await this.store.deleteEdge(e.id);
864
+ deletedDanglingEdges++;
865
+ }
866
+ }
867
+ const referencedPersonIds = new Set();
868
+ const referencedEventIds = new Set();
869
+ const referencedEntityIds = new Set();
870
+ for (const e of snap.edges) {
871
+ switch (e.kind) {
872
+ case 'person-event':
873
+ referencedPersonIds.add(e.fromPersonId);
874
+ referencedEventIds.add(e.toEventId);
875
+ break;
876
+ case 'person-entity':
877
+ referencedPersonIds.add(e.fromPersonId);
878
+ referencedEntityIds.add(e.toEntityId);
879
+ break;
880
+ case 'person-person':
881
+ referencedPersonIds.add(e.fromPersonId);
882
+ referencedPersonIds.add(e.toPersonId);
883
+ break;
884
+ case 'event-event':
885
+ referencedEventIds.add(e.fromEventId);
886
+ referencedEventIds.add(e.toEventId);
887
+ break;
888
+ case 'event-entity':
889
+ referencedEventIds.add(e.fromEventId);
890
+ referencedEntityIds.add(e.toEntityId);
891
+ break;
892
+ case 'entity-entity':
893
+ referencedEntityIds.add(e.fromEntityId);
894
+ referencedEntityIds.add(e.toEntityId);
895
+ break;
896
+ }
897
+ }
898
+ const deletedPersonIds = [];
899
+ const deletedEventIds = [];
900
+ const deletedEntityIds = [];
901
+ for (const p of snap.persons) {
902
+ if (!referencedPersonIds.has(p.id)) {
903
+ await this.store.deletePersonCascade(p.platform, p.userId);
904
+ deletedPersonIds.push(p.id);
905
+ }
906
+ }
907
+ for (const ev of snap.events) {
908
+ if (!referencedEventIds.has(ev.id)) {
909
+ await this.store.deleteEventCascade(ev.id);
910
+ deletedEventIds.push(ev.id);
911
+ }
912
+ }
913
+ for (const en of snap.entities) {
914
+ if (!referencedEntityIds.has(en.id)) {
915
+ await this.store.deleteEntityCascade(en.id);
916
+ deletedEntityIds.push(en.id);
917
+ }
918
+ }
919
+ return {
920
+ deletedPersons: deletedPersonIds.length,
921
+ deletedEvents: deletedEventIds.length,
922
+ deletedEntities: deletedEntityIds.length,
923
+ deletedPersonIds,
924
+ deletedEventIds,
925
+ deletedEntityIds,
926
+ deletedDanglingEdges,
927
+ };
928
+ }
929
+ /**
930
+ * Eager 时间衰减回写:把所有 event/entity 节点与所有边的 `weight` 字段
931
+ * 物理改写为当前 `effectiveWeight`,并把 `lastReinforcedAt` 重置为 now
932
+ * (作为"新的衰减基准"——否则下次 rewrite 会基于同一基准再次衰减,
933
+ * raw 被反复折半到 0)。
934
+ *
935
+ * 设计动机(lazy → eager 切换):
936
+ * - 原 lazy 模式 DB 永远存 raw 累积值,effectiveWeight 仅查询时实时算。
937
+ * 问题:raw=1.0 的边被衰减到 effW=0.3 后再次 reinforce 一次,
938
+ * `reinforceWeight(1.0, 0.1) = 1.0`——**永远卡死在 1**,effW 从 0.3
939
+ * 瞬间跳回 1.0,离散跳跃,不符合"老朋友重逢慢慢回温"的人类直觉。
940
+ * - Eager 回写后:raw 物理变成 0.3,下次 reinforce 从 0.3 出发 → 0.37 →
941
+ * 0.43 …**渐进恢复**,活跃关系靠持续 reinforce 维持高位、长期不活跃的
942
+ * 关系自然回落到 floor 附近。每日压缩前调用一次,DB 字段就能反映
943
+ * "当下真实强度",便于调试 / 观察 / 跨时间快照对比。
944
+ *
945
+ * 语义合并(务实简化,避免新增 `lastDecayedAt` 字段):
946
+ * - `lastReinforcedAt` 在 eager 模式下含义统一为"上次 weight 字段被更新的
947
+ * 时间"——reinforce* 方法与 rewriteWeights 都写它。物理上是衰减基准。
948
+ * - 调用方应理解:稳态下"长期不活跃节点"的 lastReinforcedAt 会被每日
949
+ * rewrite 推到最近一次压缩时间,age 分子≈0;但它们的 effW 已收敛到 floor,
950
+ * ageScore 排序退化为 `1 / (floor × PR)`——PR 边缘的依旧最先被淘汰。
951
+ *
952
+ * 副作用与正交性:
953
+ * - Person 节点不参与(无 weight 字段,由 mentionCount / lastSeenAt 表达活跃度)。
954
+ * - `halfLifeDays <= 0`(衰减关闭)时 short-circuit 返回,零写盘开销;
955
+ * 单测默认配置 `{ halfLifeDays: 0 }` 走此路径,**不影响现有测试行为**。
956
+ * - 增量阈值 `|new - raw| < 1e-6` 跳过,避免对几乎无变化的节点做无谓写盘
957
+ * (也保证幂等:rewrite 后第二次立即调用本方法所有节点都命中阈值跳过)。
958
+ * - 活跃节点保护:被 reinforce 过的节点 lastReinforcedAt > 上次 rewrite 时间,
959
+ * age 更小受保护——"最近活跃的更新鲜,更应保留"。
960
+ *
961
+ * 调用方:
962
+ * - `evictByQuota` 入口自动调用一次(与每日 scheduler 压缩对齐)。
963
+ * - `/relation rewrite-weights` 手动命令。
964
+ *
965
+ * 返回各类写回计数,便于日志 / 测试断言。
966
+ */
967
+ async rewriteWeights(decay, opts = {}) {
968
+ if (decay.halfLifeDays <= 0) {
969
+ return { events: 0, entities: 0, edges: 0, skipped: true };
970
+ }
971
+ const now = opts.now ?? Date.now();
972
+ const snap = await this.store.loadAll();
973
+ let events = 0;
974
+ let entities = 0;
975
+ let edges = 0;
976
+ const EPS = 1e-6;
977
+ for (const ev of snap.events) {
978
+ const raw = ev.weight ?? 0.5;
979
+ const newW = effectiveWeight(raw, ev.lastReinforcedAt, now, decay);
980
+ if (Math.abs(newW - raw) < EPS)
981
+ continue;
982
+ await this.store.upsertEvent({ ...ev, weight: newW, lastReinforcedAt: now });
983
+ events++;
984
+ }
985
+ for (const en of snap.entities) {
986
+ const raw = en.weight ?? 0.5;
987
+ const newW = effectiveWeight(raw, en.lastReinforcedAt, now, decay);
988
+ if (Math.abs(newW - raw) < EPS)
989
+ continue;
990
+ await this.store.upsertEntity({ ...en, weight: newW, lastReinforcedAt: now });
991
+ entities++;
992
+ }
993
+ for (const e of snap.edges) {
994
+ const newW = effectiveWeight(e.weight, e.lastReinforcedAt, now, decay);
995
+ if (Math.abs(newW - e.weight) < EPS)
996
+ continue;
997
+ await this.store.upsertEdge({ ...e, weight: newW, lastReinforcedAt: now });
998
+ edges++;
999
+ }
1000
+ return { events, entities, edges, skipped: false };
1001
+ }
1002
+ /**
1003
+ * 自动老化:按配额淘汰过多节点。模仿 profile 的"写后顺手扫"风格,不开独立调度器。
1004
+ *
1005
+ * 优先级(每次仅在超额时执行):
1006
+ * 1. **孤儿节点**先删(无任何边引用的 person / event / entity;委托 `pruneOrphans()`)。
1007
+ * 孤儿清理与配额无关,旧账噪声任何时候都清。
1008
+ * 2. 仍超额时按 `(now - lastReinforcedAt) / (max(effW,0.05) · max(PR,ε))` **降序**删;
1009
+ * 即"老旧 + 低权重 + 在 PageRank 上无人指向"的优先丢。
1010
+ * 3. **不再有硬豁免**(evidence≥3 / effW≥0.8):避免老节点永久占住名额。
1011
+ * 重要性完全由 effW + PageRank 表达:高 evidence/weight 节点自然在打分尾部,
1012
+ * 并且随时间衰减后仍可以让出名额。Person 节点同样进入排序,
1013
+ * 依靠 PR 个性化向量的人偶偏置(person seed=2 / entity=1.5 / event=1)自然偏保护。
1014
+ * 4. **滞回(hysteresis)**:仅当 count > quota·(1+hysteresisPct) 时才触发,
1015
+ * 触发后一次性裁到 floor(quota·targetPct)。默认 hysteresis=0.2, target=0.8 ——
1016
+ * quota=500 时会在 600 触发并裁到 400,相当于一次清理 ~200 条;不会每写一条就裁。
1017
+ * 5. 边也按配额删——保留 `weight · 端点PR平均` 最高的,让"弱权但连接重要节点"的边受保护。
1018
+ *
1019
+ * 副作用:每次调用都会把 PageRank 写回三类节点的 `lastPageRank` / `lastPageRankAt`,
1020
+ * 用于 WebUI 展示"图重要性"。
1021
+ *
1022
+ * PageRank 个性化向量按 kind 加权(默认 person=2 / entity=1.5 / event=1),从而"重要性 人>物>事"
1023
+ * 直接体现为分数偏置:人物附近的事件/实体更难被淘汰。
1024
+ * 另外 utils.computePageRank 在 person→event / person→entity 单向边上加了半权反向虚拟边(系数 0.5),
1025
+ * 让"参与重要事件 / 关注热门实体"的人 PR 能拉开差距,避免无 person-person 边的人退化到 seed 常数。
1026
+ *
1027
+ * 返回各类删除计数,便于日志/测试断言。
1028
+ */
1029
+ async evictByQuota(quota) {
1030
+ const damping = quota.pagerankDamping ?? 0.85;
1031
+ const maxIter = quota.pagerankIterations ?? 20;
1032
+ const epsilon = quota.pagerankEpsilon ?? 1e-4;
1033
+ const hysteresisPct = Math.max(quota.hysteresisPct ?? 0.2, 0);
1034
+ const targetPct = Math.min(Math.max(quota.targetPct ?? 0.8, 0.1), 1);
1035
+ const personSeed = quota.personSeed ?? 2;
1036
+ const entitySeed = quota.entitySeed ?? 1.5;
1037
+ const eventSeed = quota.eventSeed ?? 1;
1038
+ const reverseEdgeFactor = quota.reverseEdgeFactor ?? 0.5;
1039
+ const pagerankComponentScale = quota.pagerankComponentScale ?? true;
1040
+ const decayCfg = quota.decay ?? { halfLifeDays: 0, floor: 0.3 };
1041
+ let deletedPersons = 0;
1042
+ let deletedEvents = 0;
1043
+ let deletedEntities = 0;
1044
+ let deletedEdges = 0;
1045
+ // 0) Eager 时间衰减回写:把 raw weight 折算成"当下真实强度"再做后续淘汰。
1046
+ // halfLifeDays<=0 时 rewriteWeights 内部 short-circuit,零写盘开销。
1047
+ // 与 ageScore 公式正交:rewrite 后未活跃节点共享同一 lastReinforcedAt 基线(分子≈0、相互按 weight 排),
1048
+ // 被 reinforce 过的节点 lastReinforcedAt > rewrite 时间,age 更小受保护——
1049
+ // "最近活跃的更新鲜,更应保留"。详见 rewriteWeights 注释。
1050
+ await this.rewriteWeights(decayCfg);
1051
+ // 1) 先做孤儿清理(与配额无关,旧账噪声总是清;person / event / entity 一视同仁)
1052
+ const orphanResult = await this.pruneOrphans();
1053
+ deletedPersons += orphanResult.deletedPersons;
1054
+ deletedEvents += orphanResult.deletedEvents;
1055
+ deletedEntities += orphanResult.deletedEntities;
1056
+ // 之后再加载快照(pruneOrphans 已写入存储)
1057
+ const snap = await this.store.loadAll();
1058
+ // 2) 超额:用 PageRank 评估节点重要性,把"老旧 + 低权 + PR 边缘"的优先丢
1059
+ // PageRank 个性化向量给人/物/事不同的种子权重,让"重要性 人>物>事"直接体现在分数偏置上。
1060
+ // 无需 sqrt(degree+1) 之类启发式 —— 全图 PR 同时反映"度"和"被高重要节点引用"。
1061
+ const now = Date.now();
1062
+ const pr = computePageRank(snap, {
1063
+ damping,
1064
+ maxIter,
1065
+ epsilon,
1066
+ personSeed,
1067
+ entitySeed,
1068
+ eventSeed,
1069
+ reverseEdgeFactor,
1070
+ componentScale: pagerankComponentScale,
1071
+ });
1072
+ const ageScore = (n) => {
1073
+ // 使用 effectiveWeight:raw weight 经过时间衰减后,老节点的"分母"自动变小,
1074
+ // 让 ageScore 进一步抬高、更早进入淘汰候选;新被强化过的节点 effW 接近 raw,被保护。
1075
+ // evidence count 作为软加权:证据越多越不易淘汰,但不是硬豁免。
1076
+ const evBoost = 1 + Math.log1p(n.evidence?.length ?? 0);
1077
+ const w = Math.max(effectiveWeight(n.weight ?? 0.5, n.lastReinforcedAt, now, decayCfg), 0.05);
1078
+ const p = Math.max(pr.get(n.id) ?? 0, 1e-6);
1079
+ return (now - n.lastReinforcedAt) / (w * p * evBoost);
1080
+ };
1081
+ // Person 的 ageScore:无 weight/evidence,依靠 mentionCount / lastSeenAt / PR。
1082
+ // PR 种子权 person seed=2 会让人在排序里自然偏位保护,但不豁免。
1083
+ const personAgeScore = (p) => {
1084
+ const lastActive = p.lastMentionedAt ?? p.lastSeenAt;
1085
+ // mentionCount 起到"软 weight"作用;未被提及过的人仅留一个底值。
1086
+ const mc = Math.max(p.mentionCount ?? 0, 1);
1087
+ const pr0 = Math.max(pr.get(p.id) ?? 0, 1e-6);
1088
+ return (now - lastActive) / (mc * pr0);
1089
+ };
1090
+ // ─── 裸 event 加权:无 part-of 实体锚 且 其参与人之间无 person-person 边 → 优先淘汰
1091
+ // 背景:纯人际事件应配 person-person 关系;既无 entity 锚也无人际边的 event = 噪声
1092
+ // 实现:用绝对偏移(Number.MAX_SAFE_INTEGER 量级)作为分桶标记,确保裸 event 始终排在非裸前面,
1093
+ // 不依赖时间差/PageRank 比值,避免毫秒级测试与小图场景下相对差被噪声淹没
1094
+ const nakedTier = Number.MAX_SAFE_INTEGER / 2;
1095
+ const eventPartOfCount = new Map();
1096
+ const eventParticipants = new Map();
1097
+ for (const e of snap.edges) {
1098
+ if (e.kind === 'event-entity' && e.relationType === 'part-of') {
1099
+ eventPartOfCount.set(e.fromEventId, (eventPartOfCount.get(e.fromEventId) ?? 0) + 1);
1100
+ }
1101
+ else if (e.kind === 'person-event') {
1102
+ if (!eventParticipants.has(e.toEventId))
1103
+ eventParticipants.set(e.toEventId, new Set());
1104
+ eventParticipants.get(e.toEventId).add(e.fromPersonId);
1105
+ }
1106
+ }
1107
+ const personPersonPairs = new Set();
1108
+ for (const e of snap.edges) {
1109
+ if (e.kind === 'person-person') {
1110
+ const k = [e.fromPersonId, e.toPersonId].sort().join('|');
1111
+ personPersonPairs.add(k);
1112
+ }
1113
+ }
1114
+ const isNakedEvent = (ev) => {
1115
+ if ((eventPartOfCount.get(ev.id) ?? 0) > 0)
1116
+ return false;
1117
+ const parts = [...(eventParticipants.get(ev.id) ?? [])];
1118
+ for (let i = 0; i < parts.length; i++) {
1119
+ for (let j = i + 1; j < parts.length; j++) {
1120
+ const k = [parts[i], parts[j]].sort().join('|');
1121
+ if (personPersonPairs.has(k))
1122
+ return false;
1123
+ }
1124
+ }
1125
+ return true;
1126
+ };
1127
+ const eventEvictScore = (ev) => (isNakedEvent(ev) ? ageScore(ev) + nakedTier : ageScore(ev));
1128
+ // 滞回:仅当超出 quota·(1+hysteresisPct) 才裁;裁到 floor(quota·targetPct)
1129
+ const triggerCount = (cap) => Math.ceil(cap * (1 + hysteresisPct));
1130
+ const targetCount = (cap) => Math.floor(cap * targetPct);
1131
+ const maxPersons = quota.maxPersons ?? 0;
1132
+ if (maxPersons > 0) {
1133
+ const remainingPersons = (await this.store.loadAll()).persons;
1134
+ if (remainingPersons.length >= triggerCount(maxPersons)) {
1135
+ const toDelete = remainingPersons.length - targetCount(maxPersons);
1136
+ if (toDelete > 0) {
1137
+ const sorted = [...remainingPersons].sort((a, b) => personAgeScore(b) - personAgeScore(a));
1138
+ for (const p of sorted.slice(0, toDelete)) {
1139
+ await this.store.deletePersonCascade(p.platform, p.userId);
1140
+ deletedPersons++;
1141
+ }
1142
+ }
1143
+ }
1144
+ }
1145
+ if (quota.maxEvents > 0) {
1146
+ const remainingEvents = (await this.store.loadAll()).events;
1147
+ if (remainingEvents.length >= triggerCount(quota.maxEvents)) {
1148
+ const toDelete = remainingEvents.length - targetCount(quota.maxEvents);
1149
+ if (toDelete > 0) {
1150
+ const sorted = [...remainingEvents].sort((a, b) => eventEvictScore(b) - eventEvictScore(a));
1151
+ for (const ev of sorted.slice(0, toDelete)) {
1152
+ await this.store.deleteEventCascade(ev.id);
1153
+ deletedEvents++;
1154
+ }
1155
+ }
1156
+ }
1157
+ }
1158
+ if (quota.maxEntities > 0) {
1159
+ const remainingEntities = (await this.store.loadAll()).entities;
1160
+ if (remainingEntities.length >= triggerCount(quota.maxEntities)) {
1161
+ const toDelete = remainingEntities.length - targetCount(quota.maxEntities);
1162
+ if (toDelete > 0) {
1163
+ const sorted = [...remainingEntities].sort((a, b) => ageScore(b) - ageScore(a));
1164
+ for (const en of sorted.slice(0, toDelete)) {
1165
+ await this.store.deleteEntityCascade(en.id);
1166
+ deletedEntities++;
1167
+ }
1168
+ }
1169
+ }
1170
+ }
1171
+ // 3) 边配额:按 `weight · 端点PR平均` 升序删(弱权且连接边缘节点的边优先丢)
1172
+ if (quota.maxEdges > 0) {
1173
+ const refreshed = await this.store.loadAll();
1174
+ if (refreshed.edges.length >= triggerCount(quota.maxEdges)) {
1175
+ const toDelete = refreshed.edges.length - targetCount(quota.maxEdges);
1176
+ if (toDelete > 0) {
1177
+ const edgeEndpoints = (e) => {
1178
+ switch (e.kind) {
1179
+ case 'person-event':
1180
+ return [e.fromPersonId, e.toEventId];
1181
+ case 'person-entity':
1182
+ return [e.fromPersonId, e.toEntityId];
1183
+ case 'person-person':
1184
+ return [e.fromPersonId, e.toPersonId];
1185
+ case 'event-event':
1186
+ return [e.fromEventId, e.toEventId];
1187
+ case 'event-entity':
1188
+ return [e.fromEventId, e.toEntityId];
1189
+ case 'entity-entity':
1190
+ return [e.fromEntityId, e.toEntityId];
1191
+ }
1192
+ };
1193
+ const edgeScore = (e) => {
1194
+ const [a, b] = edgeEndpoints(e);
1195
+ const prAvg = ((pr.get(a) ?? 0) + (pr.get(b) ?? 0)) / 2;
1196
+ // 边的 lastReinforcedAt 与节点同理;effW 反映"近期强度",老边自然向尾部沉淀
1197
+ const effW = effectiveWeight(e.weight, e.lastReinforcedAt, now, decayCfg);
1198
+ return effW * Math.max(prAvg, 1e-6);
1199
+ };
1200
+ const sorted = [...refreshed.edges].sort((a, b) => edgeScore(a) - edgeScore(b));
1201
+ for (const e of sorted.slice(0, toDelete)) {
1202
+ await this.store.deleteEdge(e.id);
1203
+ deletedEdges++;
1204
+ }
1205
+ }
1206
+ }
1207
+ }
1208
+ // 3.5) cascade 删节点会把关联边一并移除,但边另一端的节点不会连带删;
1209
+ // 直接 deleteEdge(edge 配额)更是只删边不删端点。
1210
+ // 因此步骤 2-3 结束后必然残留新孤儿,在 PageRank 写回前清理,
1211
+ // 使写回阶段的 loadAll() 快照干净,避免对孤儿节点做无用写回。
1212
+ await this.pruneOrphans();
1213
+ // 4) 把 PageRank 写回三类节点,供 WebUI 展示"图重要性";
1214
+ // 顺手跑社群发现(Louvain / Leiden / SLPA 可切换),把 communityId + communityMemberships 一并写回(同一批 snapshot 保证可比)。
1215
+ // 存储 schema 统一:louvain/leiden 永远写单元素 memberships=[{id, weight:1}],slpa 写全多元素;
1216
+ // communityId 始终等于 memberships[0].id(主社群,向下兼容旧消费方)。
1217
+ {
1218
+ const after = await this.store.loadAll();
1219
+ const alg = quota.communityAlgorithm ?? 'louvain';
1220
+ // 统一到 Map<nodeId, CommunityMembership[]>。
1221
+ let memberships;
1222
+ if (alg === 'slpa') {
1223
+ memberships = computeSlpa(after);
1224
+ }
1225
+ else {
1226
+ const com = alg === 'leiden' ? computeLeiden(after) : computeLouvain(after);
1227
+ memberships = new Map();
1228
+ for (const [id, cid] of com)
1229
+ memberships.set(id, [{ id: cid, weight: 1 }]);
1230
+ }
1231
+ // 取主社群 id(memberships[0].id)做 modularity 与社群计数。
1232
+ // modularity 只在硬划分算法下有标准定义;SLPA 用主社群作为近似参考值(仅诊断不作质量裁决)。
1233
+ const primary = new Map();
1234
+ const allCommIds = new Set();
1235
+ for (const [id, list] of memberships) {
1236
+ if (list.length > 0)
1237
+ primary.set(id, list[0].id);
1238
+ for (const m of list)
1239
+ allCommIds.add(m.id);
1240
+ }
1241
+ const q = computeModularity(after, primary);
1242
+ this.ctx?.logger?.info(`[user-relation] community algorithm=${alg} Q=${q.toFixed(4)} communities=${allCommIds.size} (nodes=${after.persons.length + after.events.length + after.entities.length})`);
1243
+ for (const p of after.persons) {
1244
+ const score = pr.get(p.id);
1245
+ const list = memberships.get(p.id);
1246
+ const cid = list && list.length > 0 ? list[0].id : undefined;
1247
+ if (score === undefined && cid === undefined)
1248
+ continue;
1249
+ await this.store.upsertPerson({
1250
+ ...p,
1251
+ ...(score !== undefined ? { lastPageRank: score, lastPageRankAt: now } : {}),
1252
+ ...(cid !== undefined ? { communityId: cid, communityIdAt: now, communityMemberships: list } : {}),
1253
+ });
1254
+ }
1255
+ for (const ev of after.events) {
1256
+ const score = pr.get(ev.id);
1257
+ const list = memberships.get(ev.id);
1258
+ const cid = list && list.length > 0 ? list[0].id : undefined;
1259
+ if (score === undefined && cid === undefined)
1260
+ continue;
1261
+ await this.store.upsertEvent({
1262
+ ...ev,
1263
+ ...(score !== undefined ? { lastPageRank: score, lastPageRankAt: now } : {}),
1264
+ ...(cid !== undefined ? { communityId: cid, communityIdAt: now, communityMemberships: list } : {}),
1265
+ });
1266
+ }
1267
+ for (const en of after.entities) {
1268
+ const score = pr.get(en.id);
1269
+ const list = memberships.get(en.id);
1270
+ const cid = list && list.length > 0 ? list[0].id : undefined;
1271
+ if (score === undefined && cid === undefined)
1272
+ continue;
1273
+ await this.store.upsertEntity({
1274
+ ...en,
1275
+ ...(score !== undefined ? { lastPageRank: score, lastPageRankAt: now } : {}),
1276
+ ...(cid !== undefined ? { communityId: cid, communityIdAt: now, communityMemberships: list } : {}),
1277
+ });
1278
+ }
1279
+ }
1280
+ return {
1281
+ deletedPersons,
1282
+ deletedEvents,
1283
+ deletedEntities,
1284
+ deletedEdges,
1285
+ orphanSamples: {
1286
+ persons: orphanResult.deletedPersonIds.slice(0, 50),
1287
+ events: orphanResult.deletedEventIds.slice(0, 50),
1288
+ entities: orphanResult.deletedEntityIds.slice(0, 50),
1289
+ },
1290
+ };
1291
+ }
1292
+ /** 查询某人涉及的所有事件 + 实体 + 直连人际关系(深度 1 快捷方法) */
1293
+ async getNeighborhood(personId) {
1294
+ const snapshot = await this.store.loadAll();
1295
+ const person = snapshot.persons.find(p => p.id === personId);
1296
+ const relatedEdges = snapshot.edges.filter(e => {
1297
+ if (e.kind === 'person-event')
1298
+ return e.fromPersonId === personId;
1299
+ if (e.kind === 'person-entity')
1300
+ return e.fromPersonId === personId;
1301
+ if (e.kind === 'person-person')
1302
+ return e.fromPersonId === personId || e.toPersonId === personId;
1303
+ return false; // event-event 不算 person 邻接
1304
+ });
1305
+ const eventIds = new Set(relatedEdges.filter((e) => e.kind === 'person-event').map(e => e.toEventId));
1306
+ const entityIds = new Set(relatedEdges.filter((e) => e.kind === 'person-entity').map(e => e.toEntityId));
1307
+ const events = snapshot.events.filter(ev => eventIds.has(ev.id));
1308
+ const entities = snapshot.entities.filter(ent => entityIds.has(ent.id));
1309
+ return { person, events, entities, edges: relatedEdges };
1310
+ }
1311
+ /**
1312
+ * 按 BFS 抽取以指定 person 为起点的子图。
1313
+ *
1314
+ * - **maxDepth**:探求层数(0 = 仅起点;1 = 起点 + 直接邻居;以此类推)。
1315
+ * 人 → 事件 / 人 → 人 各算 1 跳;事件 → 人也算 1 跳,因此 depth=2 可触达"同事件其他参与者"。
1316
+ * - **maxBreadth**:单个节点在 BFS 中最多展开的邻居数,按边 weight 降序选取。
1317
+ * - **visited**:以 nodeId 集合去重,防止环 / 重复展开(同一节点最多被加入队列一次)。
1318
+ *
1319
+ * 返回子图包含访问过的节点之间的全部已存在边(不仅 BFS 树边),便于上层渲染完整局部结构。
1320
+ */
1321
+ async traverseSubgraph(opts) {
1322
+ const empty = { persons: [], events: [], entities: [], edges: [] };
1323
+ const starts = opts.startNodeIds ?? [];
1324
+ if (opts.maxDepth < 0 || opts.maxBreadth < 0 || starts.length === 0)
1325
+ return empty;
1326
+ // 0 = 不限,内部映射为足够大的有限数(避免 Infinity 与 BFS 深度比较出错)
1327
+ const effectiveDepth = opts.maxDepth === 0 ? Number.MAX_SAFE_INTEGER : opts.maxDepth;
1328
+ const effectiveBreadth = opts.maxBreadth === 0 ? Number.MAX_SAFE_INTEGER : opts.maxBreadth;
1329
+ const snapshot = await this.store.loadAll();
1330
+ const { peByPerson, ppByPerson, peByEvent, pentByPerson, pentByEntity, eeByEvent, eentByEvent, eentByEntity, ententByEntity, } = buildAdjacency(snapshot.edges);
1331
+ // 起点 kind 推断:先查 persons/events/entities 集合
1332
+ const personIdSet0 = new Set(snapshot.persons.map(p => p.id));
1333
+ const eventIdSet0 = new Set(snapshot.events.map(e => e.id));
1334
+ const entityIdSet0 = new Set(snapshot.entities.map(e => e.id));
1335
+ const inferKind = (id) => {
1336
+ if (personIdSet0.has(id))
1337
+ return 'person';
1338
+ if (eventIdSet0.has(id))
1339
+ return 'event';
1340
+ if (entityIdSet0.has(id))
1341
+ return 'entity';
1342
+ // 兜底:含冒号当 person(兼容 platform:userId 即便尚未入库)
1343
+ return id.includes(':') ? 'person' : undefined;
1344
+ };
1345
+ const visited = new Set();
1346
+ const queue = [];
1347
+ for (const sid of starts) {
1348
+ if (visited.has(sid))
1349
+ continue;
1350
+ const k = inferKind(sid);
1351
+ if (!k)
1352
+ continue;
1353
+ visited.add(sid);
1354
+ queue.push({ id: sid, kind: k, depth: 0 });
1355
+ }
1356
+ while (queue.length > 0) {
1357
+ const cur = queue.shift();
1358
+ if (!cur)
1359
+ break;
1360
+ if (cur.depth >= effectiveDepth)
1361
+ continue;
1362
+ const neighbors = [];
1363
+ if (cur.kind === 'person') {
1364
+ for (const e of ppByPerson.get(cur.id) ?? []) {
1365
+ const other = e.fromPersonId === cur.id ? e.toPersonId : e.fromPersonId;
1366
+ neighbors.push({ id: other, kind: 'person', weight: e.weight });
1367
+ }
1368
+ for (const e of peByPerson.get(cur.id) ?? []) {
1369
+ neighbors.push({ id: e.toEventId, kind: 'event', weight: e.weight });
1370
+ }
1371
+ for (const e of pentByPerson.get(cur.id) ?? []) {
1372
+ neighbors.push({ id: e.toEntityId, kind: 'entity', weight: e.weight });
1373
+ }
1374
+ }
1375
+ else if (cur.kind === 'event') {
1376
+ for (const e of peByEvent.get(cur.id) ?? []) {
1377
+ neighbors.push({ id: e.fromPersonId, kind: 'person', weight: e.weight });
1378
+ }
1379
+ for (const e of eeByEvent.get(cur.id) ?? []) {
1380
+ const other = e.fromEventId === cur.id ? e.toEventId : e.fromEventId;
1381
+ neighbors.push({ id: other, kind: 'event', weight: e.weight });
1382
+ }
1383
+ for (const e of eentByEvent.get(cur.id) ?? []) {
1384
+ neighbors.push({ id: e.toEntityId, kind: 'entity', weight: e.weight });
1385
+ }
1386
+ }
1387
+ else {
1388
+ // entity
1389
+ for (const e of pentByEntity.get(cur.id) ?? []) {
1390
+ neighbors.push({ id: e.fromPersonId, kind: 'person', weight: e.weight });
1391
+ }
1392
+ for (const e of eentByEntity.get(cur.id) ?? []) {
1393
+ neighbors.push({ id: e.fromEventId, kind: 'event', weight: e.weight });
1394
+ }
1395
+ for (const e of ententByEntity.get(cur.id) ?? []) {
1396
+ const other = e.fromEntityId === cur.id ? e.toEntityId : e.fromEntityId;
1397
+ neighbors.push({ id: other, kind: 'entity', weight: e.weight });
1398
+ }
1399
+ }
1400
+ neighbors.sort((a, b) => b.weight - a.weight);
1401
+ let added = 0;
1402
+ for (const n of neighbors) {
1403
+ if (visited.has(n.id))
1404
+ continue;
1405
+ visited.add(n.id);
1406
+ queue.push({ id: n.id, kind: n.kind, depth: cur.depth + 1 });
1407
+ added++;
1408
+ if (added >= effectiveBreadth)
1409
+ break;
1410
+ }
1411
+ }
1412
+ const persons = snapshot.persons.filter(p => visited.has(p.id));
1413
+ const events = snapshot.events.filter(e => visited.has(e.id));
1414
+ const entities = snapshot.entities.filter(e => visited.has(e.id));
1415
+ const edges = snapshot.edges.filter(e => {
1416
+ if (e.kind === 'person-event')
1417
+ return visited.has(e.fromPersonId) && visited.has(e.toEventId);
1418
+ if (e.kind === 'person-entity')
1419
+ return visited.has(e.fromPersonId) && visited.has(e.toEntityId);
1420
+ if (e.kind === 'event-event')
1421
+ return visited.has(e.fromEventId) && visited.has(e.toEventId);
1422
+ if (e.kind === 'event-entity')
1423
+ return visited.has(e.fromEventId) && visited.has(e.toEntityId);
1424
+ if (e.kind === 'entity-entity')
1425
+ return visited.has(e.fromEntityId) && visited.has(e.toEntityId);
1426
+ return visited.has(e.fromPersonId) && visited.has(e.toPersonId);
1427
+ });
1428
+ return { persons, events, entities, edges };
1429
+ }
1430
+ /**
1431
+ * 寻找两个人之间的最短关系链。BFS,事件节点作为中间桥(A→事件→B 算 2 跳)。
1432
+ * - maxDepth:路径最大边数;超过返回 null。
1433
+ * - 返回 { nodes, edges } 节点列表按路径顺序排列;找不到返回 null。
1434
+ */
1435
+ async findPath(fromNodeId, toNodeId, maxDepth) {
1436
+ if (maxDepth < 1)
1437
+ return null;
1438
+ const snapshot = await this.store.loadAll();
1439
+ const personById = new Map(snapshot.persons.map(p => [p.id, p]));
1440
+ const eventById = new Map(snapshot.events.map(e => [e.id, e]));
1441
+ const entityById = new Map(snapshot.entities.map(e => [e.id, e]));
1442
+ if (fromNodeId === toNodeId) {
1443
+ const n = personById.get(fromNodeId) ?? eventById.get(fromNodeId) ?? entityById.get(fromNodeId);
1444
+ return n ? { nodes: [n], edges: [] } : null;
1445
+ }
1446
+ const adj = new Map();
1447
+ const addAdj = (a, b, edge) => {
1448
+ const arr = adj.get(a);
1449
+ if (arr)
1450
+ arr.push({ next: b, edge });
1451
+ else
1452
+ adj.set(a, [{ next: b, edge }]);
1453
+ };
1454
+ for (const e of snapshot.edges) {
1455
+ if (e.kind === 'person-event') {
1456
+ addAdj(e.fromPersonId, e.toEventId, e);
1457
+ addAdj(e.toEventId, e.fromPersonId, e);
1458
+ }
1459
+ else if (e.kind === 'person-entity') {
1460
+ addAdj(e.fromPersonId, e.toEntityId, e);
1461
+ addAdj(e.toEntityId, e.fromPersonId, e);
1462
+ }
1463
+ else if (e.kind === 'event-event') {
1464
+ addAdj(e.fromEventId, e.toEventId, e);
1465
+ if (!e.directed)
1466
+ addAdj(e.toEventId, e.fromEventId, e);
1467
+ }
1468
+ else if (e.kind === 'event-entity') {
1469
+ addAdj(e.fromEventId, e.toEntityId, e);
1470
+ addAdj(e.toEntityId, e.fromEventId, e);
1471
+ }
1472
+ else if (e.kind === 'entity-entity') {
1473
+ addAdj(e.fromEntityId, e.toEntityId, e);
1474
+ if (!e.directed)
1475
+ addAdj(e.toEntityId, e.fromEntityId, e);
1476
+ }
1477
+ else {
1478
+ addAdj(e.fromPersonId, e.toPersonId, e);
1479
+ if (!e.directed)
1480
+ addAdj(e.toPersonId, e.fromPersonId, e);
1481
+ }
1482
+ }
1483
+ const prev = new Map();
1484
+ const visited = new Set([fromNodeId]);
1485
+ const queue = [{ id: fromNodeId, depth: 0 }];
1486
+ let found = false;
1487
+ bfs: while (queue.length > 0) {
1488
+ const cur = queue.shift();
1489
+ if (!cur)
1490
+ break;
1491
+ if (cur.depth >= maxDepth)
1492
+ continue;
1493
+ for (const { next, edge } of adj.get(cur.id) ?? []) {
1494
+ if (visited.has(next))
1495
+ continue;
1496
+ visited.add(next);
1497
+ prev.set(next, { from: cur.id, edge });
1498
+ if (next === toNodeId) {
1499
+ found = true;
1500
+ break bfs;
1501
+ }
1502
+ queue.push({ id: next, depth: cur.depth + 1 });
1503
+ }
1504
+ }
1505
+ if (!found)
1506
+ return null;
1507
+ const pathNodeIds = [toNodeId];
1508
+ const pathEdges = [];
1509
+ let cursor = toNodeId;
1510
+ while (cursor !== fromNodeId) {
1511
+ const p = prev.get(cursor);
1512
+ if (!p)
1513
+ return null;
1514
+ pathEdges.unshift(p.edge);
1515
+ pathNodeIds.unshift(p.from);
1516
+ cursor = p.from;
1517
+ }
1518
+ const nodes = pathNodeIds
1519
+ .map(id => personById.get(id) ?? eventById.get(id) ?? entityById.get(id))
1520
+ .filter((n) => !!n);
1521
+ return { nodes, edges: pathEdges };
1522
+ }
1523
+ /**
1524
+ * 计算两节点间联系强度(方向感知版)。
1525
+ *
1526
+ * **方向语义模型**:
1527
+ * - **桥型边**(person-event / person-entity / event-entity):事件/实体没有主观能动,
1528
+ * 仅作中介出现 → 邻接表里总是双向(无视 edge.directed)
1529
+ * - **主体间边**(person-person / event-event / entity-entity):
1530
+ * - `directed=false` → 双向(如 event "related" event)
1531
+ * - `directed=true` → 严格 from→to 单向(如 A "admirer" B:B 不一定认识 A)
1532
+ *
1533
+ * **mode 参数**:
1534
+ * - `'symmetric'`(默认)= **联系紧密度**。跑 a→b 与 b→a 两遍取 max。
1535
+ * 语义:"存在任意方向的关系连通"。单方面声明至少会从一侧贡献。
1536
+ * - `'directed'` = **关注/影响传播度**。仅跑 fromNodeId → toNodeId 一次。
1537
+ * 语义:"从 A 出发能否通过主动声明触达 B"。适用于"A 都关心了谁/A 的影响波及谁"。
1538
+ *
1539
+ * **kindMultiplier**(待数据观察调整,目前为直觉估计):
1540
+ * - person-person = 1.0(社会语义最强)
1541
+ * - person-event = 0.8(事件 = 真实互动)
1542
+ * - person-entity = 0.5(兴趣共鸣 < 真实互动)
1543
+ * - event-event = 0.4
1544
+ * - event-entity = 0.4
1545
+ * - entity-entity = 0.3(内容关联,非社会信号)
1546
+ *
1547
+ * **算法**:限深简单路径枚举(Katz 风格) + Adamic-Adar 共同邻居
1548
+ * - contrib = β^|p| × Π w_e × (len==1 ? 1.5 : 1) — 直接连接 boost
1549
+ * - common = Σ 1/log(deg(C)+1.7) — 惩罚高度共同节点(群聊噪声)
1550
+ * - raw = katz + 0.3 × common;score = tanh(raw) ∈ [0, 1]
1551
+ *
1552
+ * **未来扩展点**(在 opts 里预留):hierarchy 反向降权、relationType 加权、时间衰减…
1553
+ */
1554
+ async scoreBetween(fromNodeId, toNodeId, opts = {}) {
1555
+ const mode = opts.mode ?? 'symmetric';
1556
+ const maxDepth = Math.max(1, Math.min(6, opts.maxDepth ?? 4));
1557
+ const beta = Math.max(0.05, Math.min(1, opts.beta ?? 0.5));
1558
+ const topK = Math.max(1, Math.min(20, opts.topPaths ?? 3));
1559
+ const snapshot = opts._snapshot ?? (await this.store.loadAll());
1560
+ const personById = new Map(snapshot.persons.map(p => [p.id, p]));
1561
+ const eventById = new Map(snapshot.events.map(e => [e.id, e]));
1562
+ const entityById = new Map(snapshot.entities.map(e => [e.id, e]));
1563
+ const nodeOf = (id) => personById.get(id) ?? eventById.get(id) ?? entityById.get(id);
1564
+ const empty = (score, katz, shortest) => ({
1565
+ fromId: fromNodeId,
1566
+ toId: toNodeId,
1567
+ mode,
1568
+ score,
1569
+ rawScore: katz,
1570
+ katzScore: katz,
1571
+ commonNeighborsScore: 0,
1572
+ pathsConsidered: 0,
1573
+ shortestLength: shortest,
1574
+ directlyConnected: false,
1575
+ forwardKatzScore: katz,
1576
+ backwardKatzScore: 0,
1577
+ topPaths: [],
1578
+ commonNeighbors: [],
1579
+ });
1580
+ if (fromNodeId === toNodeId) {
1581
+ const present = !!nodeOf(fromNodeId);
1582
+ return empty(present ? 1 : 0, present ? 1 : 0, present ? 0 : null);
1583
+ }
1584
+ if (!nodeOf(fromNodeId) || !nodeOf(toNodeId))
1585
+ return empty(0, 0, null);
1586
+ // ---- kind 缩放(待数据观察调整) ----
1587
+ const kindMultiplier = (kind) => {
1588
+ switch (kind) {
1589
+ case 'person-person':
1590
+ return 1.0;
1591
+ case 'person-event':
1592
+ return 0.8;
1593
+ case 'person-entity':
1594
+ return 0.5;
1595
+ case 'event-event':
1596
+ case 'event-entity':
1597
+ return 0.4;
1598
+ default:
1599
+ return 0.3; // entity-entity
1600
+ }
1601
+ };
1602
+ const effectiveWeight = (e) => Math.max(1e-6, e.weight) * kindMultiplier(e.kind);
1603
+ // ---- 邻接表(方向感知) ----
1604
+ // 桥型边:双向;主体边按 directed 字段决定。
1605
+ // commonNeighbors 也用同一张表,保持方向语义一致(单向声明的 admirer 不算共同邻居)。
1606
+ const adj = new Map();
1607
+ const addArc = (a, b, edge) => {
1608
+ const arr = adj.get(a);
1609
+ if (arr)
1610
+ arr.push({ next: b, edge });
1611
+ else
1612
+ adj.set(a, [{ next: b, edge }]);
1613
+ };
1614
+ const addBoth = (a, b, edge) => {
1615
+ addArc(a, b, edge);
1616
+ addArc(b, a, edge);
1617
+ };
1618
+ for (const e of snapshot.edges) {
1619
+ if (e.kind === 'person-event')
1620
+ addBoth(e.fromPersonId, e.toEventId, e);
1621
+ else if (e.kind === 'person-entity')
1622
+ addBoth(e.fromPersonId, e.toEntityId, e);
1623
+ else if (e.kind === 'event-entity')
1624
+ addBoth(e.fromEventId, e.toEntityId, e);
1625
+ else {
1626
+ // 主体边:person-person / event-event / entity-entity
1627
+ const directed = e.directed !== false;
1628
+ let f;
1629
+ let t;
1630
+ if (e.kind === 'person-person') {
1631
+ f = e.fromPersonId;
1632
+ t = e.toPersonId;
1633
+ }
1634
+ else if (e.kind === 'event-event') {
1635
+ f = e.fromEventId;
1636
+ t = e.toEventId;
1637
+ }
1638
+ else {
1639
+ f = e.fromEntityId;
1640
+ t = e.toEntityId;
1641
+ }
1642
+ if (directed)
1643
+ addArc(f, t, e);
1644
+ else
1645
+ addBoth(f, t, e);
1646
+ }
1647
+ }
1648
+ // ---- DFS 限深简单路径枚举 ----
1649
+ const enumPaths = (start, end) => {
1650
+ const result = [];
1651
+ const visited = new Set([start]);
1652
+ const curEdges = [];
1653
+ const curNodes = [start];
1654
+ const dfs = (cur, depth) => {
1655
+ if (cur === end) {
1656
+ let prod = 1;
1657
+ for (const e of curEdges)
1658
+ prod *= effectiveWeight(e);
1659
+ result.push({ edges: [...curEdges], nodeIds: [...curNodes], weightProduct: prod });
1660
+ return;
1661
+ }
1662
+ if (depth >= maxDepth)
1663
+ return;
1664
+ for (const { next, edge } of adj.get(cur) ?? []) {
1665
+ if (visited.has(next))
1666
+ continue;
1667
+ visited.add(next);
1668
+ curEdges.push(edge);
1669
+ curNodes.push(next);
1670
+ dfs(next, depth + 1);
1671
+ curEdges.pop();
1672
+ curNodes.pop();
1673
+ visited.delete(next);
1674
+ }
1675
+ };
1676
+ dfs(start, 0);
1677
+ return result;
1678
+ };
1679
+ const contribOf = (len, prod) => beta ** len * prod * (len === 1 ? 1.5 : 1);
1680
+ const forwardPaths = enumPaths(fromNodeId, toNodeId);
1681
+ const backwardPaths = mode === 'symmetric' ? enumPaths(toNodeId, fromNodeId) : [];
1682
+ let forwardKatz = 0;
1683
+ let backwardKatz = 0;
1684
+ let shortestF = Number.POSITIVE_INFINITY;
1685
+ let shortestB = Number.POSITIVE_INFINITY;
1686
+ for (const p of forwardPaths) {
1687
+ forwardKatz += contribOf(p.edges.length, p.weightProduct);
1688
+ if (p.edges.length < shortestF)
1689
+ shortestF = p.edges.length;
1690
+ }
1691
+ for (const p of backwardPaths) {
1692
+ backwardKatz += contribOf(p.edges.length, p.weightProduct);
1693
+ if (p.edges.length < shortestB)
1694
+ shortestB = p.edges.length;
1695
+ }
1696
+ // ---- Adamic-Adar 共同邻居 ----
1697
+ // AA 衡量"两端共同接触的第三方",是无方向概念(A 关注 C / D 关注 A 都让 A 与 C/D 相邻)。
1698
+ // 用 出邻居 ∪ 入邻居 构造无向邻居集;度数也取无向度,避免与 Katz 方向语义混淆。
1699
+ const undirectedAdj = new Map();
1700
+ const linkUndir = (a, b) => {
1701
+ let sa = undirectedAdj.get(a);
1702
+ if (!sa) {
1703
+ sa = new Set();
1704
+ undirectedAdj.set(a, sa);
1705
+ }
1706
+ sa.add(b);
1707
+ let sb = undirectedAdj.get(b);
1708
+ if (!sb) {
1709
+ sb = new Set();
1710
+ undirectedAdj.set(b, sb);
1711
+ }
1712
+ sb.add(a);
1713
+ };
1714
+ for (const [a, arcs] of adj) {
1715
+ for (const { next } of arcs)
1716
+ linkUndir(a, next);
1717
+ }
1718
+ const nFrom = undirectedAdj.get(fromNodeId) ?? new Set();
1719
+ const nTo = undirectedAdj.get(toNodeId) ?? new Set();
1720
+ const commonNeighborsList = [];
1721
+ let commonNeighborsScore = 0;
1722
+ for (const c of nFrom) {
1723
+ if (!nTo.has(c) || c === fromNodeId || c === toNodeId)
1724
+ continue;
1725
+ const node = nodeOf(c);
1726
+ if (!node)
1727
+ continue;
1728
+ const deg = undirectedAdj.get(c)?.size ?? 0;
1729
+ const aa = 1 / Math.log(deg + 1.7);
1730
+ commonNeighborsScore += aa;
1731
+ commonNeighborsList.push({ node, degree: deg, aaContribution: aa });
1732
+ }
1733
+ commonNeighborsList.sort((a, b) => b.aaContribution - a.aaContribution);
1734
+ if (forwardPaths.length === 0 && backwardPaths.length === 0 && commonNeighborsScore === 0) {
1735
+ return empty(0, 0, null);
1736
+ }
1737
+ // ---- 汇总 ----
1738
+ const katzScore = mode === 'directed' ? forwardKatz : Math.max(forwardKatz, backwardKatz);
1739
+ const rawScore = katzScore + 0.3 * commonNeighborsScore;
1740
+ const score = Math.tanh(rawScore);
1741
+ const sl = mode === 'directed' ? shortestF : Math.min(shortestF, shortestB);
1742
+ const shortestLength = sl === Number.POSITIVE_INFINITY ? null : sl;
1743
+ const topPaths = [
1744
+ ...forwardPaths.map(p => ({
1745
+ direction: 'forward',
1746
+ p,
1747
+ len: p.edges.length,
1748
+ contribution: contribOf(p.edges.length, p.weightProduct),
1749
+ })),
1750
+ ...backwardPaths.map(p => ({
1751
+ direction: 'backward',
1752
+ p,
1753
+ len: p.edges.length,
1754
+ contribution: contribOf(p.edges.length, p.weightProduct),
1755
+ })),
1756
+ ]
1757
+ .sort((a, b) => b.contribution - a.contribution)
1758
+ .slice(0, topK)
1759
+ .map(({ direction, p, len, contribution }) => ({
1760
+ direction,
1761
+ nodes: p.nodeIds.map(id => nodeOf(id)).filter((n) => !!n),
1762
+ edges: p.edges,
1763
+ length: len,
1764
+ weightProduct: p.weightProduct,
1765
+ contribution,
1766
+ }));
1767
+ return {
1768
+ fromId: fromNodeId,
1769
+ toId: toNodeId,
1770
+ mode,
1771
+ score,
1772
+ rawScore,
1773
+ katzScore,
1774
+ commonNeighborsScore,
1775
+ pathsConsidered: forwardPaths.length + backwardPaths.length,
1776
+ shortestLength,
1777
+ directlyConnected: shortestLength === 1,
1778
+ forwardKatzScore: forwardKatz,
1779
+ backwardKatzScore: backwardKatz,
1780
+ topPaths,
1781
+ commonNeighbors: commonNeighborsList.slice(0, topK),
1782
+ };
1783
+ }
1784
+ /**
1785
+ * 按关键词搜索事件(substring,标题 + summary,不区分大小写)。
1786
+ * - days:仅返回 lastReinforcedAt 在 N 天内的事件;0/未传 → 不限
1787
+ * - limit:返回上限(默认 20)
1788
+ */
1789
+ async searchEvents(opts) {
1790
+ const snapshot = await this.store.loadAll();
1791
+ const cutoff = opts.days && opts.days > 0 ? Date.now() - opts.days * 86400_000 : 0;
1792
+ const kw = opts.keyword?.trim().toLowerCase() ?? '';
1793
+ const res = snapshot.events.filter(e => {
1794
+ if (e.lastReinforcedAt < cutoff)
1795
+ return false;
1796
+ if (!kw)
1797
+ return true;
1798
+ const hay = `${e.title} ${e.summary ?? ''}`.toLowerCase();
1799
+ return hay.includes(kw);
1800
+ });
1801
+ res.sort((a, b) => b.lastReinforcedAt - a.lastReinforcedAt);
1802
+ const limit = opts.limit && opts.limit > 0 ? opts.limit : 20;
1803
+ return res.slice(0, limit);
1804
+ }
1805
+ /**
1806
+ * 按关键词搜索人物。匹配 displayName / userId / aliases / id(substring,不区分大小写)。
1807
+ * - platform:可选,仅返回该平台下的人物
1808
+ * - limit:返回上限(默认 20)
1809
+ */
1810
+ async searchPersons(opts) {
1811
+ const snapshot = await this.store.loadAll();
1812
+ const kw = opts.keyword?.trim().toLowerCase() ?? '';
1813
+ const plat = opts.platform?.trim() || undefined;
1814
+ const res = snapshot.persons.filter(p => {
1815
+ if (plat && p.platform !== plat)
1816
+ return false;
1817
+ if (!kw)
1818
+ return true;
1819
+ const hay = `${p.displayName ?? ''} ${p.userId} ${p.id}`.toLowerCase();
1820
+ return hay.includes(kw);
1821
+ });
1822
+ // 排序:按 lastMentionedAt 降序;缺失则按 lastSeenAt 兜底
1823
+ res.sort((a, b) => (b.lastMentionedAt ?? b.lastSeenAt ?? 0) - (a.lastMentionedAt ?? a.lastSeenAt ?? 0));
1824
+ const limit = opts.limit && opts.limit > 0 ? opts.limit : 20;
1825
+ return res.slice(0, limit);
1826
+ }
1827
+ /**
1828
+ * 按关键词搜索实体。匹配 name / aliases / summary / id(substring,不区分大小写)。
1829
+ * - kind:可选,仅返回指定 entityKind
1830
+ * - limit:返回上限(默认 20)
1831
+ */
1832
+ async searchEntities(opts) {
1833
+ const snapshot = await this.store.loadAll();
1834
+ const kw = opts.keyword?.trim().toLowerCase() ?? '';
1835
+ const res = snapshot.entities.filter(e => {
1836
+ if (opts.kind && e.entityKind !== opts.kind)
1837
+ return false;
1838
+ if (!kw)
1839
+ return true;
1840
+ const hay = `${e.name} ${(e.aliases ?? []).join(' ')} ${e.summary ?? ''} ${e.id}`.toLowerCase();
1841
+ return hay.includes(kw);
1842
+ });
1843
+ res.sort((a, b) => b.lastReinforcedAt - a.lastReinforcedAt);
1844
+ const limit = opts.limit && opts.limit > 0 ? opts.limit : 20;
1845
+ return res.slice(0, limit);
1846
+ }
1847
+ /**
1848
+ * 列出符合过滤条件的边。所有过滤器是 AND 关系;不传任何过滤器 = 返回全部(受 limit 限制)。
1849
+ * - kinds:边大类(person-event / person-person / person-entity / event-event / event-entity / entity-entity)
1850
+ * - relationTypes:仅对带 relationType 的边生效(person-person / event-event / event-entity / entity-entity)
1851
+ * - roles:仅对带 role 的边生效(person-event / person-entity)
1852
+ * - nodeId:边的任一端等于该 id(用于"这条边和某节点相关")
1853
+ * - fromId/toId:方向敏感(注意无向边的 from/to 由 LLM 提取时给定,未必符合直觉)
1854
+ * - days:仅返回 lastReinforcedAt 在 N 天内的;0/未传 → 不限
1855
+ * - limit:返回上限(默认 50)
1856
+ * 按 lastReinforcedAt 降序。
1857
+ */
1858
+ async listEdges(opts) {
1859
+ const snapshot = await this.store.loadAll();
1860
+ const cutoff = opts.days && opts.days > 0 ? Date.now() - opts.days * 86400_000 : 0;
1861
+ const kindSet = opts.kinds && opts.kinds.length > 0 ? new Set(opts.kinds) : undefined;
1862
+ const relSet = opts.relationTypes && opts.relationTypes.length > 0 ? new Set(opts.relationTypes) : undefined;
1863
+ const roleSet = opts.roles && opts.roles.length > 0 ? new Set(opts.roles) : undefined;
1864
+ const edgeEnds = (e) => {
1865
+ if (e.kind === 'person-event')
1866
+ return { from: e.fromPersonId, to: e.toEventId };
1867
+ if (e.kind === 'person-entity')
1868
+ return { from: e.fromPersonId, to: e.toEntityId };
1869
+ if (e.kind === 'event-event')
1870
+ return { from: e.fromEventId, to: e.toEventId };
1871
+ if (e.kind === 'event-entity')
1872
+ return { from: e.fromEventId, to: e.toEntityId };
1873
+ if (e.kind === 'entity-entity')
1874
+ return { from: e.fromEntityId, to: e.toEntityId };
1875
+ return { from: e.fromPersonId, to: e.toPersonId };
1876
+ };
1877
+ const res = snapshot.edges.filter(e => {
1878
+ if (kindSet && !kindSet.has(e.kind))
1879
+ return false;
1880
+ if (e.lastReinforcedAt < cutoff)
1881
+ return false;
1882
+ const { from, to } = edgeEnds(e);
1883
+ if (opts.nodeId && from !== opts.nodeId && to !== opts.nodeId)
1884
+ return false;
1885
+ if (opts.fromId && from !== opts.fromId)
1886
+ return false;
1887
+ if (opts.toId && to !== opts.toId)
1888
+ return false;
1889
+ if (relSet) {
1890
+ if (e.kind === 'person-event' || e.kind === 'person-entity')
1891
+ return false;
1892
+ if (!relSet.has(e.relationType))
1893
+ return false;
1894
+ }
1895
+ if (roleSet) {
1896
+ if (e.kind !== 'person-event' && e.kind !== 'person-entity')
1897
+ return false;
1898
+ if (!roleSet.has(e.role))
1899
+ return false;
1900
+ }
1901
+ return true;
1902
+ });
1903
+ res.sort((a, b) => b.lastReinforcedAt - a.lastReinforcedAt);
1904
+ const limit = opts.limit && opts.limit > 0 ? opts.limit : 50;
1905
+ return res.slice(0, limit);
1906
+ }
1907
+ /**
1908
+ * 时间线:给定节点,返回与其相关的事件按时间倒序排列。
1909
+ * - 节点是 person → 返回该人参与的事件(按 personEvent.lastReinforcedAt 降序)
1910
+ * - 节点是 entity → 返回涉及该实体的事件(按 eventEntity.lastReinforcedAt 降序)
1911
+ * - 节点是 event → 返回该事件 + 由 event-event 边相连的相关事件
1912
+ * 返回每个事件附带触达它的边信息(用于追溯"为什么相关")。
1913
+ */
1914
+ async getTimeline(opts) {
1915
+ const snapshot = await this.store.loadAll();
1916
+ const eventById = new Map(snapshot.events.map(e => [e.id, e]));
1917
+ const cutoff = opts.days && opts.days > 0 ? Date.now() - opts.days * 86400_000 : 0;
1918
+ const limit = opts.limit && opts.limit > 0 ? opts.limit : 30;
1919
+ const collected = [];
1920
+ const seen = new Set();
1921
+ for (const e of snapshot.edges) {
1922
+ let evId;
1923
+ if (e.kind === 'person-event' && e.fromPersonId === opts.nodeId)
1924
+ evId = e.toEventId;
1925
+ else if (e.kind === 'event-entity' && e.toEntityId === opts.nodeId)
1926
+ evId = e.fromEventId;
1927
+ else if (e.kind === 'event-event' && e.fromEventId === opts.nodeId)
1928
+ evId = e.toEventId;
1929
+ else if (e.kind === 'event-event' && !e.directed && e.toEventId === opts.nodeId)
1930
+ evId = e.fromEventId;
1931
+ if (!evId)
1932
+ continue;
1933
+ if (seen.has(evId))
1934
+ continue;
1935
+ const ev = eventById.get(evId);
1936
+ if (!ev)
1937
+ continue;
1938
+ if (ev.lastReinforcedAt < cutoff)
1939
+ continue;
1940
+ seen.add(evId);
1941
+ collected.push({ event: ev, viaEdge: e });
1942
+ }
1943
+ collected.sort((a, b) => b.event.lastReinforcedAt - a.event.lastReinforcedAt);
1944
+ return collected.slice(0, limit);
1945
+ }
1946
+ // 1) 别名候选发现:人物 displayName 与 实体 name/aliases 的高相似对,给出候选
1947
+ // (不自动合并,只输出报告供用户决定;若 confidence 极高且开启 autoLink,则建 is-alias-of 边)
1948
+ // 2) PersonEventEdge 去重:按现行 addPersonEventEdge 吸收规则重排(修旧账)
1949
+ // 3) 报告:返回结构化结果,调用方按需展示
1950
+ //
1951
+ // 注:曾有「自动 part-of」步骤(实体名是事件标题子串时自动建边),已移除。
1952
+ // 原因:consolidate 在初始 snapshot 上运行,无法感知本轮 event 合并后的 rewire 结果,
1953
+ // 导致别名事件的 about 边 rewire 到 canonical 后与 consolidate 新建的 part-of 并存,
1954
+ // 形成同一 (event,entity) 对同时存在两种边类型的脏数据。
1955
+ // part-of 边应由 extractor LLM 在提取阶段负责建立。
1956
+ // ────────────────────────────────────────────────────────────────
1957
+ async consolidate(opts = {}) {
1958
+ // ─── (0) 伪 person 自动清理:与 extractor 落库守卫共用同一谓词。
1959
+ // 仅在 opts.ctx 传入且 `getPlatformNames(ctx)` 非空时启用 platform-whitelist
1960
+ // 分支;否则只兜底过滤 userId 通用占位(self/me/bot/assistant)。
1961
+ // 先做此步,再 loadAll,避免后续 alias / 层级推断把 fake person 牵连进去。
1962
+ let fakePersonsDeleted = 0;
1963
+ let fakePersonEdgesDeleted = 0;
1964
+ {
1965
+ const preSnap = await this.store.loadAll();
1966
+ const knownPlatforms = opts.ctx ? getKnownPlatformsLower(opts.ctx) : new Set();
1967
+ const fakes = preSnap.persons.filter(p => isPlaceholderSelfPersonId(p.platform, p.userId, knownPlatforms));
1968
+ for (const p of fakes) {
1969
+ const r = await this.store.deletePersonCascade(p.platform, p.userId);
1970
+ fakePersonsDeleted++;
1971
+ fakePersonEdgesDeleted += r.deletedEdges;
1972
+ }
1973
+ if (fakes.length > 0 && opts.llm?.ctx?.logger) {
1974
+ opts.llm.ctx.logger.info(`[user-relation] consolidate 清理伪 person ${fakes.length} 个 / 级联边 ${fakePersonEdgesDeleted} 条`);
1975
+ }
1976
+ }
1977
+ const snapshot = await this.store.loadAll();
1978
+ const aliasCandidates = [];
1979
+ let aliasEdgesCreated = 0;
1980
+ const partOfEdgesCreated = 0;
1981
+ let eventEdgesNormalized = 0;
1982
+ let entityHierarchyCandidates = 0;
1983
+ let entityHierarchyEdgesCreated = 0;
1984
+ let llmVerified = 0;
1985
+ let llmRejected = 0;
1986
+ /** consolidate 命中持久化 negativeCache 而省下的 LLM 调用次数。 */
1987
+ let llmRejectCacheHits = 0;
1988
+ let summariesRewritten = 0;
1989
+ // 解析可选 LLM 模型(A: 别名核验;B: 合并后摘要重写)
1990
+ const llmModel = opts.llm ? resolveConsolidateModel(opts.llm.ctx, { modelRef: opts.llm.modelRef }) : undefined;
1991
+ const llmDisableThinking = opts.llm?.disableThinking ?? true;
1992
+ /** 待合并实体 id → 经过 LLM 确认(或未启用 LLM 时直接 true)的列表 */
1993
+ const mergedCanonicals = new Set();
1994
+ // ─── (1) 别名候选:实体之间 name/aliases 完全相同(不同 id)→ 高置信
1995
+ const entitiesByNormName = new Map();
1996
+ for (const e of snapshot.entities) {
1997
+ const all = [e.name, ...(e.aliases ?? [])].map(normalizeName).filter(Boolean);
1998
+ for (const n of all) {
1999
+ if (!entitiesByNormName.has(n))
2000
+ entitiesByNormName.set(n, []);
2001
+ entitiesByNormName.get(n).push(e);
2002
+ }
2003
+ }
2004
+ const reportedEntityPairs = new Set();
2005
+ for (const [norm, list] of entitiesByNormName.entries()) {
2006
+ if (list.length < 2)
2007
+ continue;
2008
+ // 同 norm 多个实体 → 两两为候选
2009
+ for (let i = 0; i < list.length; i++) {
2010
+ for (let j = i + 1; j < list.length; j++) {
2011
+ const a = list[i];
2012
+ const b = list[j];
2013
+ if (a.id === b.id)
2014
+ continue;
2015
+ const k = a.id < b.id ? `${a.id}|${b.id}` : `${b.id}|${a.id}`;
2016
+ if (reportedEntityPairs.has(k))
2017
+ continue;
2018
+ reportedEntityPairs.add(k);
2019
+ aliasCandidates.push({
2020
+ aId: a.id,
2021
+ bId: b.id,
2022
+ aKind: 'entity',
2023
+ bKind: 'entity',
2024
+ reason: `name/aliases 完全等价于 "${norm}"`,
2025
+ });
2026
+ if (opts.autoLink) {
2027
+ // (A) LLM 语义核验:仅当传入了 llm 才执行;未启用则按算法直通
2028
+ let shouldMerge = true;
2029
+ // hierarchy 守门:若 a/b 之间已存在**有证据**的 part-of/contains 边
2030
+ // → 视为已知层级,禁止 alias 合并。
2031
+ // 注意:旧版无视 evidence 数,但 LLM 抽取阶段会产出大量 evidence=0 的幻觉层级边,
2032
+ // 把这道安全网污染了(如 APEX ↔ 《Apex英雄》被锁住无法合并)。
2033
+ // 改为只信任 evidence≥1 的层级边:幻觉边不再阻塞合并,真实层级仍守门。
2034
+ const knownHierarchyEdge = snapshot.edges.some(e => e.kind === 'entity-entity' &&
2035
+ (e.relationType === 'part-of' || e.relationType === 'contains') &&
2036
+ (e.evidence?.length ?? 0) >= 1 &&
2037
+ ((e.fromEntityId === a.id && e.toEntityId === b.id) ||
2038
+ (e.fromEntityId === b.id && e.toEntityId === a.id)));
2039
+ if (knownHierarchyEdge) {
2040
+ shouldMerge = false;
2041
+ if (opts.llm?.ctx.logger) {
2042
+ opts.llm.ctx.logger.info(`[user-relation] consolidate 跳过严格等价合并 ${a.id} ↔ ${b.id}:已存在 part-of/contains 边(evidence≥1)`);
2043
+ }
2044
+ else if (this.ctx?.logger) {
2045
+ this.ctx.logger.info(`[user-relation] consolidate 跳过严格等价合并 ${a.id} ↔ ${b.id}:已存在 part-of/contains 边(evidence≥1)`);
2046
+ }
2047
+ }
2048
+ if (shouldMerge && llmModel && opts.llm) {
2049
+ // negativeCache:双方 evidence 数都未变 → 跳过 LLM,复用上次否决结论
2050
+ // (evidence.length 在新关系/新提及时才增长,与衰减回写解耦,是稳定的"无新关系"信号)
2051
+ const [smaller, larger] = a.id < b.id ? [a, b] : [b, a];
2052
+ const sCount = smaller.evidence?.length ?? 0;
2053
+ const lCount = larger.evidence?.length ?? 0;
2054
+ const cached = await this.store.getMergeReject(a.id, b.id);
2055
+ if (cached && cached.aEvidenceCount === sCount && cached.bEvidenceCount === lCount) {
2056
+ llmRejectCacheHits++;
2057
+ shouldMerge = false;
2058
+ if (opts.llm.ctx.logger) {
2059
+ opts.llm.ctx.logger.debug(`[user-relation] consolidate 命中 mergeReject 缓存 ${a.id} ↔ ${b.id}(${cached.decidedBy}):${cached.reason}`);
2060
+ }
2061
+ }
2062
+ else {
2063
+ const v = await verifyAliasPair(opts.llm.ctx, llmModel, a, b, llmDisableThinking);
2064
+ if (v.isSame) {
2065
+ llmVerified++;
2066
+ if (opts.llm.ctx.logger) {
2067
+ opts.llm.ctx.logger.info(`[user-relation] consolidate LLM 同意合并 ${a.id} ↔ ${b.id}: ${v.reason}`);
2068
+ }
2069
+ // 之前否决但本次同意 → 清掉缓存(节点已演化)
2070
+ if (cached)
2071
+ await this.store.deleteMergeReject(a.id, b.id);
2072
+ }
2073
+ else {
2074
+ llmRejected++;
2075
+ shouldMerge = false;
2076
+ if (v.hierarchy) {
2077
+ const { parentId, childId } = v.hierarchy;
2078
+ const partOfBuilt = await this._upsertPartOfEdgeIfAbsent(snapshot.edges, childId, parentId, `consolidate LLM 判定 hierarchy:${v.reason}`);
2079
+ if (opts.llm.ctx.logger) {
2080
+ opts.llm.ctx.logger.info(`[user-relation] consolidate LLM 判定 hierarchy ${childId} part-of ${parentId}: ${v.reason}` +
2081
+ `${partOfBuilt ? '(已新建 part-of 边)' : '(part-of 边已存在)'}`);
2082
+ }
2083
+ }
2084
+ else if (opts.llm.ctx.logger) {
2085
+ opts.llm.ctx.logger.info(`[user-relation] consolidate LLM 否决合并 ${a.id} ↔ ${b.id}: ${v.reason}`);
2086
+ }
2087
+ // 落 negativeCache,避免下次 maintain 重复送 LLM(hierarchy 与 different 都阻断合并)
2088
+ await this.store.saveMergeReject({
2089
+ aId: smaller.id,
2090
+ bId: larger.id,
2091
+ aReinforcedAt: smaller.lastReinforcedAt ?? 0,
2092
+ bReinforcedAt: larger.lastReinforcedAt ?? 0,
2093
+ aEvidenceCount: sCount,
2094
+ bEvidenceCount: lCount,
2095
+ reason: v.hierarchy ? `hierarchy: ${v.reason}` : v.reason,
2096
+ decidedAt: Date.now(),
2097
+ decidedBy: 'strict-equiv',
2098
+ kind: 'entity',
2099
+ });
2100
+ }
2101
+ }
2102
+ }
2103
+ if (!shouldMerge)
2104
+ continue;
2105
+ const exists = snapshot.edges.some(e => e.kind === 'entity-entity' &&
2106
+ e.relationType === 'is-alias-of' &&
2107
+ ((e.fromEntityId === a.id && e.toEntityId === b.id) ||
2108
+ (e.fromEntityId === b.id && e.toEntityId === a.id)));
2109
+ if (!exists) {
2110
+ const now = Date.now();
2111
+ await this.store.upsertEdge({
2112
+ id: globalThis.crypto.randomUUID(),
2113
+ kind: 'entity-entity',
2114
+ fromEntityId: a.id,
2115
+ toEntityId: b.id,
2116
+ relationType: 'is-alias-of',
2117
+ directed: true,
2118
+ weight: 0.8,
2119
+ description: 'consolidate 自动识别:名称/别名等价',
2120
+ firstSeenAt: now,
2121
+ lastReinforcedAt: now,
2122
+ evidence: [],
2123
+ });
2124
+ aliasEdgesCreated++;
2125
+ }
2126
+ // 无条件触发真合并:即便 alias 边已存在(如老版本"路由+壳子"残留的
2127
+ // 半合并状态),也要把 alias 节点真正合并掉,确保收敛。mergeAlias 幂等:
2128
+ // alias 节点已不存在 → 直接 no-op;还在 → 正常合并。
2129
+ const mergeResult = await this.mergeAlias({ aliasId: a.id, canonicalId: b.id, kind: 'entity' });
2130
+ mergedCanonicals.add(mergeResult.effectiveCanonicalId);
2131
+ if (mergeResult.aliasDeleted && this.ctx?.logger) {
2132
+ this.ctx.logger.info(`[user-relation] consolidate strict-equiv 真合并:${mergeResult.effectiveAliasId} → ${mergeResult.effectiveCanonicalId}`);
2133
+ }
2134
+ }
2135
+ }
2136
+ }
2137
+ }
2138
+ // ─── (1.5) 别名候选「宽召回」:仅当启用 LLM 时执行
2139
+ // 目的:让 LLM 看到「绝航」vs「绝航号」、「绝航」vs「Project Juehang」等
2140
+ // normalize 后不严格相等、但语义上可能同一对象的候选对。
2141
+ // 召回路径(同 entityKind 内):
2142
+ // (a) name 子串包含(短名 ⊂ 长名,且短名长度 ≥ 2)
2143
+ // (b) 一方的某个 alias 与另一方的 name 归一相等
2144
+ //
2145
+ // ── 决策范式 (2026-05 P1):批量决策 → 并查集分簇 → 一次合并 ──
2146
+ // 1) 召回所有 pair 候选
2147
+ // 2) 对每个候选跑 verifyAliasPair(只记结果,不立即合并)
2148
+ // 3) 把所有 LLM 判 yes 的候选丢进并查集 (union-find),
2149
+ // 自然处理传递闭包:A↔B yes & B↔C yes ⇒ {A,B,C} 一簇
2150
+ // 4) 每个 size≥2 的簇内按 mergeScore 选 canonical
2151
+ // (mergeScore = 0.5·weightSum + 0.3·edgeCount + 0.2·evidenceCount,
2152
+ // 不含 recency;与 compositeScore 解耦,专为"挑代表"语义),
2153
+ // 把其它成员逐个 mergeAlias 到 canonical
2154
+ // 动机:旧逻辑是"判一对合一对",snapshot 不刷新会出现悬空合并 / 漏传递闭包。
2155
+ // 本范式让决策与应用分离,所有 LLM 判定基于同一份 snapshot,行为可预测。
2156
+ // 未启用 LLM 时跳过本段(保持原算法的零误合并保证)。
2157
+ if (opts.autoLink && llmModel && opts.llm) {
2158
+ const entitiesByKind = new Map();
2159
+ for (const e of snapshot.entities) {
2160
+ const k = e.entityKind ?? 'topic';
2161
+ if (!entitiesByKind.has(k))
2162
+ entitiesByKind.set(k, []);
2163
+ entitiesByKind.get(k).push(e);
2164
+ }
2165
+ // ─── Entity embedding 召回支持(lazy embed + 持久化复用)─────────────
2166
+ // 设计与 event ensureEmbedding 完全对称:当 EntityNode.embeddingHash 与
2167
+ // computeEntityEmbeddingHash(name, summary, entityKind) 不一致或缺失时,
2168
+ // 调 embed 服务一次,写回 store + 同步本地副本。embedding 服务缺失则全部 skip。
2169
+ const embedding = this.ctx?.getService('embedding');
2170
+ const entityCosThreshold = opts.entityCosThreshold ?? 0.86;
2171
+ const embedCache = new Map();
2172
+ const ensureEntityEmbedding = async (en) => {
2173
+ if (!embedding)
2174
+ return null;
2175
+ if (embedCache.has(en.id))
2176
+ return embedCache.get(en.id) ?? null;
2177
+ const expectedHash = computeEntityEmbeddingHash(en.name, en.summary, en.entityKind);
2178
+ if (en.embeddingHash === expectedHash && Array.isArray(en.embeddingVector) && en.embeddingVector.length > 0) {
2179
+ embedCache.set(en.id, en.embeddingVector);
2180
+ return en.embeddingVector;
2181
+ }
2182
+ const text = `${(en.name || '').trim()}\n${(en.summary || '').trim()}`.trim();
2183
+ if (!text) {
2184
+ embedCache.set(en.id, null);
2185
+ return null;
2186
+ }
2187
+ try {
2188
+ const vec = await embedding.embed(text);
2189
+ if (!Array.isArray(vec) || vec.length === 0) {
2190
+ embedCache.set(en.id, null);
2191
+ return null;
2192
+ }
2193
+ await this.store.upsertEntity({ ...en, embeddingVector: vec, embeddingHash: expectedHash });
2194
+ en.embeddingVector = vec;
2195
+ en.embeddingHash = expectedHash;
2196
+ embedCache.set(en.id, vec);
2197
+ return vec;
2198
+ }
2199
+ catch (err) {
2200
+ opts.llm?.ctx?.logger?.warn(`[user-relation] consolidate entity embed 失败 ${en.id} (${en.name.slice(0, 20)}): ${err instanceof Error ? err.message : String(err)}`);
2201
+ embedCache.set(en.id, null);
2202
+ return null;
2203
+ }
2204
+ };
2205
+ const candidates = [];
2206
+ // 轻量 Jaccard:对 normalize(name) 与 normalize(aliases) 合并的字符串集合做交并比。
2207
+ // 用于给 verifyAliasPair 提供"名字层文本相似度"信号,不引入额外开销。
2208
+ const computeEntityJaccard = (a, b) => {
2209
+ const tokensA = new Set();
2210
+ const tokensB = new Set();
2211
+ const an = normalizeName(a.name);
2212
+ const bn = normalizeName(b.name);
2213
+ if (an)
2214
+ tokensA.add(an);
2215
+ if (bn)
2216
+ tokensB.add(bn);
2217
+ for (const x of a.aliases ?? []) {
2218
+ const n = normalizeName(x);
2219
+ if (n)
2220
+ tokensA.add(n);
2221
+ }
2222
+ for (const x of b.aliases ?? []) {
2223
+ const n = normalizeName(x);
2224
+ if (n)
2225
+ tokensB.add(n);
2226
+ }
2227
+ if (tokensA.size === 0 || tokensB.size === 0)
2228
+ return 0;
2229
+ let inter = 0;
2230
+ for (const t of tokensA)
2231
+ if (tokensB.has(t))
2232
+ inter++;
2233
+ const union = tokensA.size + tokensB.size - inter;
2234
+ return union > 0 ? inter / union : 0;
2235
+ };
2236
+ for (const list of entitiesByKind.values()) {
2237
+ for (let i = 0; i < list.length; i++) {
2238
+ for (let j = i + 1; j < list.length; j++) {
2239
+ const a = list[i];
2240
+ const b = list[j];
2241
+ const k = a.id < b.id ? `${a.id}|${b.id}` : `${b.id}|${a.id}`;
2242
+ if (reportedEntityPairs.has(k))
2243
+ continue;
2244
+ const an = normalizeName(a.name);
2245
+ const bn = normalizeName(b.name);
2246
+ if (!an || !bn)
2247
+ continue;
2248
+ const aAliasNorms = (a.aliases ?? []).map(normalizeName).filter(Boolean);
2249
+ const bAliasNorms = (b.aliases ?? []).map(normalizeName).filter(Boolean);
2250
+ // (a) 子串包含
2251
+ const minSubstrLen = 2;
2252
+ const substring = (an.length >= minSubstrLen && bn.includes(an)) || (bn.length >= minSubstrLen && an.includes(bn));
2253
+ // (b) 别名/名互覆盖
2254
+ const aliasCover = aAliasNorms.includes(bn) || bAliasNorms.includes(an) || aAliasNorms.some(x => bAliasNorms.includes(x));
2255
+ // (c) 文本召回未命中时,尝试 embedding 召回:name+summary cos >= 阈值
2256
+ // 仅在 embedding 服务可用时计算;任一端 embed 失败则跳过本路径。
2257
+ let embedHit = false;
2258
+ let embedCos = null;
2259
+ if (!substring && !aliasCover && embedding) {
2260
+ const va = await ensureEntityEmbedding(a);
2261
+ const vb = await ensureEntityEmbedding(b);
2262
+ if (va && vb && va.length === vb.length) {
2263
+ embedCos = cosineSimilarity(va, vb);
2264
+ if (embedCos >= entityCosThreshold)
2265
+ embedHit = true;
2266
+ }
2267
+ }
2268
+ if (!substring && !aliasCover && !embedHit)
2269
+ continue;
2270
+ reportedEntityPairs.add(k);
2271
+ const reason = substring
2272
+ ? `名称子串包含:${a.name} ↔ ${b.name}`
2273
+ : aliasCover
2274
+ ? `别名/名互覆盖:${a.name} ↔ ${b.name}`
2275
+ : `embedding 相似(cos=${(embedCos ?? 0).toFixed(2)}):${a.name} ↔ ${b.name}`;
2276
+ aliasCandidates.push({ aId: a.id, bId: b.id, aKind: 'entity', bKind: 'entity', reason });
2277
+ candidates.push({
2278
+ a,
2279
+ b,
2280
+ reason,
2281
+ pairKey: k,
2282
+ cosineScore: embedCos,
2283
+ jaccardScore: computeEntityJaccard(a, b),
2284
+ });
2285
+ }
2286
+ }
2287
+ }
2288
+ // ─── 跨 entityKind 宽召回(substring + embedding)────────────────────────────────
2289
+ // 注:跨 kind 同名(精确相等)的情况已由 step (1) entitiesByNormName 覆盖并加入
2290
+ // reportedEntityPairs,本段只补 step (1) 之外的"名字不完全等价"盲区。
2291
+ // 补充上方"跨 kind 同名"段的盲区:名字不完全相等但子串包含或 embedding 相似的跨 kind 对。
2292
+ // 典型场景:extractor 把「绝航」抽为 thing,把「绝航号行动」抽为 topic → 子串命中。
2293
+ // 阈值比同 kind 更保守(substring ≥ 3,cos ≥ 0.90 vs 0.86),最终仍需 LLM 终判。
2294
+ // embedCache 与同 kind 段共享(Map 引用),已算过的向量直接复用,不重复调 embedding 服务。
2295
+ {
2296
+ const crossKindCosThreshold = 0.9;
2297
+ const allEntities = snapshot.entities;
2298
+ for (let i = 0; i < allEntities.length; i++) {
2299
+ for (let j = i + 1; j < allEntities.length; j++) {
2300
+ const a = allEntities[i];
2301
+ const b = allEntities[j];
2302
+ if ((a.entityKind ?? 'topic') === (b.entityKind ?? 'topic'))
2303
+ continue; // 同 kind 已被上方循环处理
2304
+ const k = a.id < b.id ? `${a.id}|${b.id}` : `${b.id}|${a.id}`;
2305
+ if (reportedEntityPairs.has(k))
2306
+ continue; // 精确同名段已覆盖
2307
+ const an = normalizeName(a.name);
2308
+ const bn = normalizeName(b.name);
2309
+ if (!an || !bn)
2310
+ continue;
2311
+ // 子串包含(保守:最短串 ≥ 3,比同 kind 的 ≥ 2 更严格)
2312
+ const substring = (an.length >= 3 && bn.includes(an)) || (bn.length >= 3 && an.includes(bn));
2313
+ // embedding 召回(fallback;阈值更高)
2314
+ let embedHit = false;
2315
+ let embedCos = null;
2316
+ if (!substring && embedding) {
2317
+ const va = await ensureEntityEmbedding(a);
2318
+ const vb = await ensureEntityEmbedding(b);
2319
+ if (va && vb && va.length === vb.length) {
2320
+ embedCos = cosineSimilarity(va, vb);
2321
+ embedHit = embedCos >= crossKindCosThreshold;
2322
+ }
2323
+ }
2324
+ if (!substring && !embedHit)
2325
+ continue;
2326
+ reportedEntityPairs.add(k);
2327
+ const ak = a.entityKind ?? 'topic';
2328
+ const bk = b.entityKind ?? 'topic';
2329
+ const reason = substring
2330
+ ? `跨 kind 子串包含(${ak}↔${bk}):${a.name} ↔ ${b.name}`
2331
+ : `跨 kind embedding 相似(${ak}↔${bk},cos=${(embedCos ?? 0).toFixed(2)}):${a.name} ↔ ${b.name}`;
2332
+ aliasCandidates.push({ aId: a.id, bId: b.id, aKind: 'entity', bKind: 'entity', reason });
2333
+ candidates.push({
2334
+ a,
2335
+ b,
2336
+ reason,
2337
+ pairKey: k,
2338
+ cosineScore: embedCos,
2339
+ jaccardScore: computeEntityJaccard(a, b),
2340
+ });
2341
+ }
2342
+ }
2343
+ }
2344
+ // 给候选里出现过的每个 entity 算一次 compositeScore(snapshot 已固定,避免重复扫边)
2345
+ // F2:缓存完整 score 对象(含 compositeScore + 邻居剖面),用于喂给 verifyAliasPair 的上下文。
2346
+ const scoreCache = new Map();
2347
+ const getScoreInfo = (id) => {
2348
+ if (scoreCache.has(id))
2349
+ return scoreCache.get(id) ?? null;
2350
+ const s = this._computeSingleNodeScore(id, snapshot);
2351
+ const v = s
2352
+ ? {
2353
+ compositeScore: s.compositeScore,
2354
+ neighbor: this._computeNeighborProfile(id, snapshot, 5),
2355
+ }
2356
+ : null;
2357
+ scoreCache.set(id, v);
2358
+ return v;
2359
+ };
2360
+ const scoreOf = (id) => getScoreInfo(id)?.compositeScore ?? 0;
2361
+ // F3 排序:按 max(scoreA, scoreB) 倒序——优先把"至少一端重要"的候选送给 LLM;
2362
+ // 平局时按 pairKey 字典序,保证可复现。
2363
+ candidates.sort((x, y) => {
2364
+ const sx = Math.max(scoreOf(x.a.id), scoreOf(x.b.id));
2365
+ const sy = Math.max(scoreOf(y.a.id), scoreOf(y.b.id));
2366
+ if (sx !== sy)
2367
+ return sy - sx;
2368
+ return x.pairKey < y.pairKey ? -1 : 1;
2369
+ });
2370
+ // F3 阈值跳过:双方都很 edge(compositeScore < threshold)→ 不调 LLM。
2371
+ // 默认 threshold=0.2 与 scoreToTier 的 edge 边界一致;设 0 则全部送 LLM。
2372
+ const skipLowScore = opts.skipLowScorePairs !== false;
2373
+ const lowScoreThreshold = opts.lowScoreThreshold ?? 0.2;
2374
+ const yesPairs = [];
2375
+ let lowScoreSkipped = 0;
2376
+ for (const cand of candidates) {
2377
+ const { a, b, reason } = cand;
2378
+ const sA = scoreOf(a.id);
2379
+ const sB = scoreOf(b.id);
2380
+ if (skipLowScore && lowScoreThreshold > 0 && sA < lowScoreThreshold && sB < lowScoreThreshold) {
2381
+ lowScoreSkipped++;
2382
+ if (opts.llm.ctx.logger) {
2383
+ opts.llm.ctx.logger.debug(`[user-relation] consolidate 跳过低权候选 ${a.id}(${sA.toFixed(2)}) ↔ ${b.id}(${sB.toFixed(2)}):双方都低于 ${lowScoreThreshold}`);
2384
+ }
2385
+ continue;
2386
+ }
2387
+ // hierarchy 守门(同严格等价段):仅信任 evidence≥1 的真实层级边,幻觉边不再阻塞。
2388
+ const knownHierarchy = snapshot.edges.some(e => e.kind === 'entity-entity' &&
2389
+ (e.relationType === 'part-of' || e.relationType === 'contains') &&
2390
+ (e.evidence?.length ?? 0) >= 1 &&
2391
+ ((e.fromEntityId === a.id && e.toEntityId === b.id) || (e.fromEntityId === b.id && e.toEntityId === a.id)));
2392
+ if (knownHierarchy) {
2393
+ if (opts.llm.ctx.logger) {
2394
+ opts.llm.ctx.logger.info(`[user-relation] consolidate 跳过宽召回候选 ${a.id} ↔ ${b.id}:已存在 part-of/contains 边(evidence≥1,层级关系优先)`);
2395
+ }
2396
+ continue;
2397
+ }
2398
+ // negativeCache:双方 evidence 数都未变 → 跳过 LLM,复用上次否决结论
2399
+ // (evidence.length 在新关系/新提及时才增长,与 evictByQuota 的衰减回写解耦)
2400
+ const [smaller, larger] = a.id < b.id ? [a, b] : [b, a];
2401
+ const sCount = smaller.evidence?.length ?? 0;
2402
+ const lCount = larger.evidence?.length ?? 0;
2403
+ const cached = await this.store.getMergeReject(a.id, b.id);
2404
+ if (cached && cached.aEvidenceCount === sCount && cached.bEvidenceCount === lCount) {
2405
+ llmRejectCacheHits++;
2406
+ if (opts.llm.ctx.logger) {
2407
+ opts.llm.ctx.logger.debug(`[user-relation] consolidate 命中 mergeReject 缓存(宽召回)${a.id} ↔ ${b.id}:${cached.reason}`);
2408
+ }
2409
+ continue;
2410
+ }
2411
+ const v = await verifyAliasPair(opts.llm.ctx, llmModel, a, b, llmDisableThinking, {
2412
+ aEvidenceQuotes: (a.evidence ?? [])
2413
+ .slice(-3)
2414
+ .map(ev => (ev.quote ?? '').trim())
2415
+ .filter(Boolean),
2416
+ bEvidenceQuotes: (b.evidence ?? [])
2417
+ .slice(-3)
2418
+ .map(ev => (ev.quote ?? '').trim())
2419
+ .filter(Boolean),
2420
+ aNeighbors: getScoreInfo(a.id)?.neighbor,
2421
+ bNeighbors: getScoreInfo(b.id)?.neighbor,
2422
+ scores: {
2423
+ ...(cand.cosineScore !== null ? { cosineScore: cand.cosineScore } : {}),
2424
+ jaccardScore: cand.jaccardScore,
2425
+ },
2426
+ });
2427
+ if (!v.isSame) {
2428
+ llmRejected++;
2429
+ // hierarchy 三态:LLM 判定为 part-of 关系 → **不合并**,改建 entity-entity[part-of] 边
2430
+ // 避免母概念(如「三角洲行动」)被并入子概念(如「三角洲行动·绝密航天」)。
2431
+ if (v.hierarchy) {
2432
+ const { parentId, childId } = v.hierarchy;
2433
+ const partOfBuilt = await this._upsertPartOfEdgeIfAbsent(snapshot.edges, childId, parentId, `consolidate LLM 判定 hierarchy:${v.reason}`);
2434
+ if (opts.llm.ctx.logger) {
2435
+ opts.llm.ctx.logger.info(`[user-relation] consolidate LLM 判定 hierarchy(宽召回)${childId} part-of ${parentId}: ${v.reason}` +
2436
+ `${partOfBuilt ? '(已新建 part-of 边)' : '(part-of 边已存在)'}`);
2437
+ }
2438
+ }
2439
+ else if (opts.llm.ctx.logger) {
2440
+ opts.llm.ctx.logger.info(`[user-relation] consolidate LLM 否决合并(宽召回)${a.id} ↔ ${b.id}: ${v.reason}`);
2441
+ }
2442
+ // 落 negativeCache,下次扫描双方未变就跳过(hierarchy 与 different 都阻断合并,复用同一缓存)
2443
+ await this.store.saveMergeReject({
2444
+ aId: smaller.id,
2445
+ bId: larger.id,
2446
+ aReinforcedAt: smaller.lastReinforcedAt ?? 0,
2447
+ bReinforcedAt: larger.lastReinforcedAt ?? 0,
2448
+ aEvidenceCount: sCount,
2449
+ bEvidenceCount: lCount,
2450
+ reason: v.hierarchy ? `hierarchy: ${v.reason}` : v.reason,
2451
+ decidedAt: Date.now(),
2452
+ decidedBy: 'wide-recall',
2453
+ kind: 'entity',
2454
+ });
2455
+ continue;
2456
+ }
2457
+ llmVerified++;
2458
+ if (opts.llm.ctx.logger) {
2459
+ opts.llm.ctx.logger.info(`[user-relation] consolidate LLM 同意合并(宽召回)${a.id} ↔ ${b.id}: ${v.reason}`);
2460
+ }
2461
+ // 之前否决但本次同意 → 清掉旧缓存
2462
+ if (cached)
2463
+ await this.store.deleteMergeReject(a.id, b.id);
2464
+ yesPairs.push({ aId: a.id, bId: b.id, reason });
2465
+ }
2466
+ if (lowScoreSkipped > 0 && opts.llm.ctx.logger) {
2467
+ opts.llm.ctx.logger.info(`[user-relation] consolidate F3 低权阈值跳过 ${lowScoreSkipped} 个 pair(阈值 ${lowScoreThreshold})`);
2468
+ }
2469
+ // ─── 并查集分簇 + 簇内挑 canonical + 统一合并 ───
2470
+ if (yesPairs.length > 0) {
2471
+ // 节点边/权聚合:供 mergeScore 使用。一次性扫边表,避免 O(N·E)。
2472
+ const entityEdgeStats = computeEntityEdgeStats(snapshot.edges);
2473
+ const entityById = new Map(snapshot.entities.map(e => [e.id, e]));
2474
+ // 并查集分簇:自然处理传递闭包 (A↔B yes & B↔C yes ⇒ {A,B,C} 一簇)
2475
+ const clusters = clusterEntitiesByPairs(yesPairs);
2476
+ // 候选合并时优先 reason 字典——非 canonical 成员要找到与 canonical 之间的召回 reason 作为 is-alias-of 边的 description
2477
+ // 若簇大小 >2,可能某对没有直接召回 reason(靠传递闭包入簇),fallback:用簇内"语义同一对象(传递闭包)"。
2478
+ const reasonLookup = new Map();
2479
+ for (const p of yesPairs) {
2480
+ const k1 = `${p.aId}|${p.bId}`;
2481
+ const k2 = `${p.bId}|${p.aId}`;
2482
+ reasonLookup.set(k1, p.reason);
2483
+ reasonLookup.set(k2, p.reason);
2484
+ }
2485
+ for (const [, members] of clusters) {
2486
+ if (members.size < 2)
2487
+ continue;
2488
+ // 簇内 mergeScore 最高者当 canonical
2489
+ const canonicalId = pickCanonicalByMergeScore(members, entityById, entityEdgeStats);
2490
+ if (!canonicalId)
2491
+ continue;
2492
+ // 把其他成员逐个合并到 canonical
2493
+ for (const memberId of members) {
2494
+ if (memberId === canonicalId)
2495
+ continue;
2496
+ // 重新校验 canonical 仍存在(safety)
2497
+ const stillExists = snapshot.entities.some(e => e.id === canonicalId);
2498
+ if (!stillExists)
2499
+ break;
2500
+ const reason = reasonLookup.get(`${memberId}|${canonicalId}`) ??
2501
+ `consolidate 簇内传递闭包:${entityById.get(memberId)?.name ?? memberId} ↔ ${entityById.get(canonicalId)?.name ?? canonicalId}`;
2502
+ // is-alias-of 边可能已存在(之前轮已建过),先查
2503
+ const exists = snapshot.edges.some(e => e.kind === 'entity-entity' &&
2504
+ e.relationType === 'is-alias-of' &&
2505
+ ((e.fromEntityId === memberId && e.toEntityId === canonicalId) ||
2506
+ (e.fromEntityId === canonicalId && e.toEntityId === memberId)));
2507
+ if (!exists) {
2508
+ const now = Date.now();
2509
+ await this.store.upsertEdge({
2510
+ id: globalThis.crypto.randomUUID(),
2511
+ kind: 'entity-entity',
2512
+ fromEntityId: memberId,
2513
+ toEntityId: canonicalId,
2514
+ relationType: 'is-alias-of',
2515
+ directed: true,
2516
+ weight: 0.7,
2517
+ description: `consolidate LLM 确认:${reason}`,
2518
+ firstSeenAt: now,
2519
+ lastReinforcedAt: now,
2520
+ evidence: [],
2521
+ });
2522
+ aliasEdgesCreated++;
2523
+ }
2524
+ const mergeResult = await this.mergeAlias({
2525
+ aliasId: memberId,
2526
+ canonicalId,
2527
+ kind: 'entity',
2528
+ });
2529
+ mergedCanonicals.add(mergeResult.effectiveCanonicalId);
2530
+ if (mergeResult.aliasDeleted && this.ctx?.logger) {
2531
+ this.ctx.logger.info(`[user-relation] consolidate wide-recall 真合并:${mergeResult.effectiveAliasId} → ${mergeResult.effectiveCanonicalId}`);
2532
+ }
2533
+ }
2534
+ }
2535
+ }
2536
+ }
2537
+ // ─── (3) PersonEventEdge 旧账整理:对每对 (person,event) 跑一次吸收规则
2538
+ const pairs = new Map();
2539
+ for (const e of snapshot.edges) {
2540
+ if (e.kind !== 'person-event')
2541
+ continue;
2542
+ const k = `${e.fromPersonId}|${e.toEventId}`;
2543
+ if (!pairs.has(k))
2544
+ pairs.set(k, []);
2545
+ pairs.get(k).push(e);
2546
+ }
2547
+ for (const [, list] of pairs.entries()) {
2548
+ if (list.length < 2)
2549
+ continue;
2550
+ // 选 evidence 最多 / weight 最高的作为"代表",调用 addPersonEventEdge 触发合并
2551
+ const rep = list.reduce((a, b) => (b.evidence.length > a.evidence.length || b.weight > a.weight ? b : a));
2552
+ // 删掉所有现有,再用最高 rank 的 role 写回,触发吸收
2553
+ for (const e of list) {
2554
+ if (e.id !== rep.id)
2555
+ await this.store.deleteEdge(e.id);
2556
+ }
2557
+ // 触发一次 add(input.role 取 rep.role),让逻辑重整
2558
+ await this.addPersonEventEdge({
2559
+ fromPersonId: rep.fromPersonId,
2560
+ toEventId: rep.toEventId,
2561
+ role: rep.role,
2562
+ sentiment: rep.sentiment,
2563
+ weight: rep.weight,
2564
+ description: rep.description,
2565
+ evidence: rep.evidence,
2566
+ });
2567
+ eventEdgesNormalized++;
2568
+ }
2569
+ // ─── (3b) PersonEntityEdge 旧账整理:同一 (person,entity) 多条不同 role 行 → 留最强 role
2570
+ // 旧版插入逻辑可能未做 (from,to) 级别去重;此处按当前 addPersonEntityEdge 规则修旧账。
2571
+ const peEntityPairs = new Map();
2572
+ for (const e of snapshot.edges) {
2573
+ if (e.kind !== 'person-entity')
2574
+ continue;
2575
+ const k = `${e.fromPersonId}|${e.toEntityId}`;
2576
+ if (!peEntityPairs.has(k))
2577
+ peEntityPairs.set(k, []);
2578
+ peEntityPairs.get(k).push(e);
2579
+ }
2580
+ for (const [, list] of peEntityPairs.entries()) {
2581
+ if (list.length < 2)
2582
+ continue;
2583
+ // 选 evidence 最多 / weight 最高的作为"代表",调用 addPersonEntityEdge 触发"保留最强 role"逻辑
2584
+ const rep = list.reduce((a, b) => (b.evidence.length > a.evidence.length || b.weight > a.weight ? b : a));
2585
+ for (const e of list) {
2586
+ if (e.id !== rep.id)
2587
+ await this.store.deleteEdge(e.id);
2588
+ }
2589
+ await this.addPersonEntityEdge({
2590
+ fromPersonId: rep.fromPersonId,
2591
+ toEntityId: rep.toEntityId,
2592
+ role: rep.role,
2593
+ sentiment: rep.sentiment,
2594
+ weight: rep.weight,
2595
+ description: rep.description,
2596
+ evidence: rep.evidence,
2597
+ });
2598
+ // 进一步:吸收同对、weaker role 的"残留"——遍历 list 中除 rep 之外的 role,逐条 add 触发吸收
2599
+ for (const e of list) {
2600
+ if (e.id === rep.id)
2601
+ continue;
2602
+ await this.addPersonEntityEdge({
2603
+ fromPersonId: e.fromPersonId,
2604
+ toEntityId: e.toEntityId,
2605
+ role: e.role,
2606
+ sentiment: e.sentiment,
2607
+ weight: e.weight,
2608
+ description: e.description,
2609
+ evidence: e.evidence,
2610
+ });
2611
+ }
2612
+ eventEdgesNormalized++; // 共用计数(含人-实体折叠)
2613
+ }
2614
+ // ─── (3c) EventEntityEdge 旧账整理:同一 (event,entity) 仅保留最强关系
2615
+ // 语义强度排序:part-of > related > about
2616
+ // · part-of = "属于/承载该实体",是最强的结构性绑定
2617
+ // · related = "围绕/相关",中等
2618
+ // · about = "顺带提及",最弱
2619
+ // 规则:
2620
+ // · 同一 (event,entity) 下不应同时存在多种关系标注("属于"已经包含了"关于")
2621
+ // · 取强度最高的一条作为 keep,其它边的 evidence 合并进来后删除
2622
+ // · 若同强度有多条 → 取 weight 最大者
2623
+ // · weight 取所有被合并边的最大值(保留强化记录)
2624
+ const eePairs = new Map();
2625
+ for (const e of snapshot.edges) {
2626
+ if (e.kind !== 'event-entity')
2627
+ continue;
2628
+ const k = `${e.fromEventId}|${e.toEntityId}`;
2629
+ if (!eePairs.has(k))
2630
+ eePairs.set(k, []);
2631
+ eePairs.get(k).push(e);
2632
+ }
2633
+ const eeStrength = { 'part-of': 3, related: 2, about: 1 };
2634
+ for (const [, list] of eePairs.entries()) {
2635
+ if (list.length < 2)
2636
+ continue;
2637
+ // 选 keep:先按强度降序,强度相同按 weight 降序
2638
+ const sorted = [...list].sort((a, b) => {
2639
+ const sa = eeStrength[a.relationType] ?? 0;
2640
+ const sb = eeStrength[b.relationType] ?? 0;
2641
+ if (sa !== sb)
2642
+ return sb - sa;
2643
+ return (b.weight ?? 0) - (a.weight ?? 0);
2644
+ });
2645
+ const keep = sorted[0];
2646
+ const drop = sorted.slice(1);
2647
+ const seenMsgKey = new Set(keep.evidence.map(ev => `${ev.sessionId}|${[...(ev.messageIds ?? [])].sort().join(',')}`));
2648
+ const mergedEvidence = [...keep.evidence];
2649
+ let maxWeight = keep.weight ?? 0;
2650
+ for (const dup of drop) {
2651
+ for (const ev of dup.evidence ?? []) {
2652
+ const mk = `${ev.sessionId}|${[...(ev.messageIds ?? [])].sort().join(',')}`;
2653
+ if (!seenMsgKey.has(mk)) {
2654
+ seenMsgKey.add(mk);
2655
+ mergedEvidence.push(ev);
2656
+ }
2657
+ }
2658
+ if ((dup.weight ?? 0) > maxWeight)
2659
+ maxWeight = dup.weight ?? 0;
2660
+ await this.store.deleteEdge(dup.id);
2661
+ }
2662
+ if (mergedEvidence.length !== keep.evidence.length || maxWeight !== (keep.weight ?? 0)) {
2663
+ await this.store.upsertEdge({
2664
+ ...keep,
2665
+ evidence: trimEvidence(mergedEvidence),
2666
+ weight: maxWeight,
2667
+ lastReinforcedAt: Date.now(),
2668
+ });
2669
+ }
2670
+ eventEdgesNormalized++;
2671
+ }
2672
+ // (B) 合并后摘要重写:基于最新 snapshot 收集 canonical 的别名/相关事件/相关人物
2673
+ if (llmModel && opts.llm && mergedCanonicals.size > 0) {
2674
+ const after = await this.store.loadAll();
2675
+ const entityById = new Map(after.entities.map(e => [e.id, e]));
2676
+ const personById = new Map(after.persons.map(p => [p.id, p]));
2677
+ const eventById = new Map(after.events.map(e => [e.id, e]));
2678
+ for (const canonicalId of mergedCanonicals) {
2679
+ const ent = entityById.get(canonicalId);
2680
+ if (!ent)
2681
+ continue;
2682
+ // 收集别名(含 is-alias-of 关联实体的 name 与 aliases)
2683
+ const aliasSet = new Set(ent.aliases ?? []);
2684
+ for (const e of after.edges) {
2685
+ if (e.kind === 'entity-entity' && e.relationType === 'is-alias-of') {
2686
+ const other = e.fromEntityId === canonicalId
2687
+ ? entityById.get(e.toEntityId)
2688
+ : e.toEntityId === canonicalId
2689
+ ? entityById.get(e.fromEntityId)
2690
+ : undefined;
2691
+ if (other) {
2692
+ aliasSet.add(other.name);
2693
+ for (const al of other.aliases ?? [])
2694
+ aliasSet.add(al);
2695
+ }
2696
+ }
2697
+ }
2698
+ // 收集近期相关事件(通过 event-entity 边)
2699
+ const recentEvents = [];
2700
+ for (const e of after.edges) {
2701
+ if (e.kind === 'event-entity' && e.toEntityId === canonicalId) {
2702
+ const ev = eventById.get(e.fromEventId);
2703
+ if (ev)
2704
+ recentEvents.push({ title: ev.title, summary: ev.summary });
2705
+ }
2706
+ }
2707
+ recentEvents.sort((a, b) => (b.summary ? 1 : 0) - (a.summary ? 1 : 0));
2708
+ // 相关人物(通过 person-entity 边)
2709
+ const relatedPersons = [];
2710
+ for (const e of after.edges) {
2711
+ if (e.kind === 'person-entity' && e.toEntityId === canonicalId) {
2712
+ const p = personById.get(e.fromPersonId);
2713
+ if (p?.displayName)
2714
+ relatedPersons.push({ displayName: p.displayName });
2715
+ }
2716
+ }
2717
+ const newSummary = await rewriteEntitySummary(opts.llm.ctx, llmModel, ent, {
2718
+ aliases: [...aliasSet].filter(a => a && a !== ent.name).slice(0, 10),
2719
+ recentEvents: recentEvents.slice(0, 6),
2720
+ relatedPersons: relatedPersons.slice(0, 8),
2721
+ }, llmDisableThinking);
2722
+ if (newSummary && newSummary !== ent.summary) {
2723
+ await this.store.upsertEntity({ ...ent, summary: newSummary, lastReinforcedAt: Date.now() });
2724
+ summariesRewritten++;
2725
+ }
2726
+ }
2727
+ }
2728
+ // ─── (3d) 实体层级推断:名称包含关系 → 候选 entity-entity[part-of] 边
2729
+ // 规则:normName(A) 是 normName(B) 的真子串,A.length >= 3,B > A → A 是 B 的父实体候选。
2730
+ // 无 LLM 时(autoLink=true):仅当 B.name 以 A.name 精确开头,直接建边(高置信启发式)。
2731
+ // 有 LLM 时:批量发给 inferEntityHierarchy 核验后建边。
2732
+ // autoLink=false:收集候选但不建边。
2733
+ {
2734
+ const afterSnapshot = await this.store.loadAll();
2735
+ const hierarchyCandidates = [];
2736
+ const existingHierarchyKeys = new Set();
2737
+ for (const e of afterSnapshot.edges) {
2738
+ if (e.kind === 'entity-entity')
2739
+ existingHierarchyKeys.add(`${e.fromEntityId}>${e.toEntityId}`);
2740
+ }
2741
+ for (const parentEnt of afterSnapshot.entities) {
2742
+ const normParent = normalizeName(parentEnt.name);
2743
+ if (normParent.length < 3)
2744
+ continue; // 太短的名字不做父实体(防误判)
2745
+ for (const childEnt of afterSnapshot.entities) {
2746
+ if (parentEnt.id === childEnt.id)
2747
+ continue;
2748
+ const normChild = normalizeName(childEnt.name);
2749
+ if (normChild.length <= normParent.length)
2750
+ continue; // child 必须比 parent 更长
2751
+ if (!normChild.includes(normParent))
2752
+ continue;
2753
+ // 已有任意方向的 entity-entity 边则跳过(含 is-alias-of / part-of 等)
2754
+ if (existingHierarchyKeys.has(`${childEnt.id}>${parentEnt.id}`) ||
2755
+ existingHierarchyKeys.has(`${parentEnt.id}>${childEnt.id}`))
2756
+ continue;
2757
+ hierarchyCandidates.push({ parent: parentEnt, child: childEnt });
2758
+ }
2759
+ }
2760
+ entityHierarchyCandidates = hierarchyCandidates.length;
2761
+ if (hierarchyCandidates.length > 0) {
2762
+ const toCreate = [];
2763
+ if (llmModel && opts.llm) {
2764
+ // LLM 核验:批量确认
2765
+ const llmCtx = opts.llm.ctx;
2766
+ const results = await inferEntityHierarchy(llmCtx, llmModel, hierarchyCandidates, llmDisableThinking);
2767
+ for (const r of results) {
2768
+ if (r.confirmed)
2769
+ toCreate.push({ parentId: r.parentId, childId: r.childId });
2770
+ }
2771
+ }
2772
+ else if (opts.autoLink) {
2773
+ // 无 LLM + autoLink:保守启发式 — child.name 精确以 parent.name 开头(字符串级别)
2774
+ for (const c of hierarchyCandidates) {
2775
+ const normP = normalizeName(c.parent.name);
2776
+ const normC = normalizeName(c.child.name);
2777
+ if (normC.startsWith(normP))
2778
+ toCreate.push({ parentId: c.parent.id, childId: c.child.id });
2779
+ }
2780
+ }
2781
+ for (const { parentId, childId } of toCreate) {
2782
+ await this.addEntityEntityEdge({
2783
+ fromEntityId: childId,
2784
+ toEntityId: parentId,
2785
+ relationType: 'part-of',
2786
+ directed: true,
2787
+ weight: 0.7,
2788
+ evidence: [],
2789
+ });
2790
+ entityHierarchyEdgesCreated++;
2791
+ }
2792
+ }
2793
+ }
2794
+ // ─── (3e) 兄弟实体 → 共同父实体「侧向推断」(仅 LLM 启用时执行)
2795
+ // 场景:同 kind 下 ≥2 个实体名共享前缀 ≥3 字(如「三角洲行动刀皮」+「三角洲行动绝密航天」),
2796
+ // 但父实体「三角洲行动」尚未作为节点存在 → (3d) 找不到候选。
2797
+ // 流程:
2798
+ // 1) 同 kind 内按 name 排序,贪心聚类:相邻 LCP ≥3 字 → 同簇;
2799
+ // 2) 跳过已有任意 entity-entity[part-of] 出边的成员(不重复挂父);
2800
+ // 3) 跳过 LCP 已作为同 kind 实体存在的簇(让 (3d) 处理);
2801
+ // 4) 发给 LLM inferMissingParent 核验「父名是否有意义」;
2802
+ // 5) accept → 新建父实体(可用 LLM 修正名),并为每个兄弟建 entity-entity[part-of] 边。
2803
+ // 未启用 LLM → 不创建(避免误造实体节点),但记录候选数到 lateralParentCandidates。
2804
+ let lateralParentCandidates = 0;
2805
+ let lateralParentsCreated = 0;
2806
+ let lateralEdgesCreated = 0;
2807
+ if (opts.autoLink) {
2808
+ const minLcpLen = 3;
2809
+ const afterSnap = await this.store.loadAll();
2810
+ // 已经有 entity-entity[part-of] 出边的子实体 id 集合
2811
+ const hasParent = new Set();
2812
+ for (const e of afterSnap.edges) {
2813
+ if (e.kind === 'entity-entity' && e.relationType === 'part-of') {
2814
+ hasParent.add(e.fromEntityId);
2815
+ }
2816
+ }
2817
+ const byKind = new Map();
2818
+ for (const e of afterSnap.entities) {
2819
+ if (hasParent.has(e.id))
2820
+ continue;
2821
+ const k = e.entityKind ?? 'topic';
2822
+ if (!byKind.has(k))
2823
+ byKind.set(k, []);
2824
+ byKind.get(k).push(e);
2825
+ }
2826
+ const clusters = [];
2827
+ for (const [kind, list] of byKind.entries()) {
2828
+ if (list.length < 2)
2829
+ continue;
2830
+ const sorted = [...list].sort((a, b) => a.name.localeCompare(b.name));
2831
+ let cur = { lcp: '', members: [] };
2832
+ const flush = () => {
2833
+ if (cur.members.length >= 2 && cur.lcp.length >= minLcpLen) {
2834
+ clusters.push({ kind, cluster: { lcp: cur.lcp, members: cur.members } });
2835
+ }
2836
+ };
2837
+ for (const e of sorted) {
2838
+ if (cur.members.length === 0) {
2839
+ cur = { lcp: e.name, members: [e] };
2840
+ continue;
2841
+ }
2842
+ const newLcp = commonPrefix(cur.lcp, e.name);
2843
+ if (newLcp.length >= minLcpLen) {
2844
+ cur.members.push(e);
2845
+ cur.lcp = newLcp;
2846
+ }
2847
+ else {
2848
+ flush();
2849
+ cur = { lcp: e.name, members: [e] };
2850
+ }
2851
+ }
2852
+ flush();
2853
+ }
2854
+ lateralParentCandidates = clusters.length;
2855
+ if (llmModel && opts.llm) {
2856
+ for (const { kind, cluster } of clusters) {
2857
+ const entityKind = kind;
2858
+ // 父名若已作为同 kind 实体存在则跳过(交给 (3d))
2859
+ const existingParent = await this.findEntityByKindAndName(entityKind, cluster.lcp);
2860
+ if (existingParent)
2861
+ continue;
2862
+ const verdict = await inferMissingParent(opts.llm.ctx, llmModel, { parentName: cluster.lcp, kind, siblings: cluster.members }, llmDisableThinking);
2863
+ if (!verdict.accept) {
2864
+ if (opts.llm.ctx.logger) {
2865
+ opts.llm.ctx.logger.info(`[user-relation] consolidate LLM 否决侧向父实体「${cluster.lcp}」(${kind}): ${verdict.reason}`);
2866
+ }
2867
+ continue;
2868
+ }
2869
+ const finalName = verdict.suggestedName ?? cluster.lcp;
2870
+ // 再次按 finalName 检查(LLM 可能修正成已有名)
2871
+ const dup = await this.findEntityByKindAndName(entityKind, finalName);
2872
+ const parentEntity = dup ??
2873
+ (await this.createEntity({
2874
+ name: finalName,
2875
+ entityKind,
2876
+ evidence: [],
2877
+ summary: `consolidate 侧向推断:根据 ${cluster.members.length} 个子实体共同前缀建立。`,
2878
+ }));
2879
+ if (!dup)
2880
+ lateralParentsCreated++;
2881
+ for (const child of cluster.members) {
2882
+ if (child.id === parentEntity.id)
2883
+ continue;
2884
+ await this.addEntityEntityEdge({
2885
+ fromEntityId: child.id,
2886
+ toEntityId: parentEntity.id,
2887
+ relationType: 'part-of',
2888
+ directed: true,
2889
+ weight: 0.7,
2890
+ evidence: [],
2891
+ });
2892
+ lateralEdgesCreated++;
2893
+ }
2894
+ }
2895
+ }
2896
+ }
2897
+ // ─── (3) Event 重复合并 ─────────────────────────────────────────
2898
+ // 触发:autoLink && LLM 已配置(与 entity wide-recall 同口径)
2899
+ // 边界:sessionScope 相同 或 双方均 'global' —— 跨 scope 由 mergeAlias 硬护栏(L537)兜底
2900
+ // 召回:每个 scope 池内 O(N²) pair;任一阈值达成即进 LLM 评审(OR 关系):
2901
+ // 有 embedding 服务:fused = 0.7·cos + 0.3·struct ≥ fusedThreshold(文本为主、结构为辅)
2902
+ // OR jaccard(chars) ≥ 0.4 OR struct ≥ 0.5(无 embedding 或孤立事件的兜底)
2903
+ // 终判:verifyEventPair(必经 LLM;mergeReject 缓存命中跳过)
2904
+ // 合并:并查集 → pickCanonicalForEvents → mergeAlias(kind:'event')
2905
+ // 注:本段以前面 entity 合并后的最新 snapshot 为准(重新 loadAll)。
2906
+ let eventDuplicateCandidates = 0;
2907
+ let eventDuplicatesMerged = 0;
2908
+ if (opts.autoLink && llmModel && opts.llm) {
2909
+ const r = await this._consolidateEventDuplicates({
2910
+ llmModel,
2911
+ llmCtx: opts.llm.ctx,
2912
+ disableThinking: llmDisableThinking,
2913
+ embedding: this.ctx?.getService('embedding'),
2914
+ dryRun: false,
2915
+ });
2916
+ eventDuplicateCandidates = r.candidates.length;
2917
+ eventDuplicatesMerged = r.merged;
2918
+ llmVerified += r.llmVerified;
2919
+ llmRejected += r.llmRejected;
2920
+ llmRejectCacheHits += r.llmRejectCacheHits;
2921
+ }
2922
+ const consolidateResult = {
2923
+ aliasCandidates,
2924
+ aliasEdgesCreated,
2925
+ partOfEdgesCreated,
2926
+ eventEdgesNormalized,
2927
+ entityHierarchyCandidates,
2928
+ entityHierarchyEdgesCreated,
2929
+ lateralParentCandidates,
2930
+ lateralParentsCreated,
2931
+ lateralEdgesCreated,
2932
+ fakePersonsDeleted,
2933
+ fakePersonEdgesDeleted,
2934
+ eventDuplicateCandidates,
2935
+ eventDuplicatesMerged,
2936
+ ...(opts.llm ? { llmVerified, llmRejected, llmRejectCacheHits, summariesRewritten } : {}),
2937
+ };
2938
+ this._lastConsolidateAt = Date.now();
2939
+ this._lastConsolidateTrigger = opts.triggerSource ?? 'api';
2940
+ this._lastConsolidateResultSummary =
2941
+ `别名候选 ${aliasCandidates.length},建别名边 ${aliasEdgesCreated},part-of ${partOfEdgesCreated},` +
2942
+ `事件边整理 ${eventEdgesNormalized},实体层级候选 ${entityHierarchyCandidates},层级边 ${entityHierarchyEdgesCreated}` +
2943
+ `,侧向父候选 ${lateralParentCandidates},新建父 ${lateralParentsCreated},侧向边 ${lateralEdgesCreated}` +
2944
+ (fakePersonsDeleted > 0 ? `,伪 person 清理 ${fakePersonsDeleted}(级联边 ${fakePersonEdgesDeleted})` : '') +
2945
+ (eventDuplicateCandidates > 0
2946
+ ? `,event 重复候选 ${eventDuplicateCandidates}(合并 ${eventDuplicatesMerged})`
2947
+ : '') +
2948
+ (opts.llm
2949
+ ? `,LLM 通过 ${llmVerified} 否决 ${llmRejected}` +
2950
+ (llmRejectCacheHits > 0 ? `(缓存命中 ${llmRejectCacheHits})` : '') +
2951
+ ` 摘要重写 ${summariesRewritten}`
2952
+ : '');
2953
+ return consolidateResult;
2954
+ }
2955
+ // ============================================================
2956
+ // Event 重复合并:consolidate (3) 段提取出的可复用实现 + dry-run API
2957
+ // ============================================================
2958
+ /**
2959
+ * 内部入口:执行一次 event 重复检测;可选 dryRun 仅返回候选不合并。
2960
+ *
2961
+ * 召回融合公式(任一阈值达成即进 LLM,OR 关系):
2962
+ * - 有 embedding 时:fused = 0.7·cos + 0.3·struct ≥ fusedThreshold(默认 0.7);
2963
+ * 任一端 embedding 失败 → 该项不参与 fused,仅看 jaccard / struct
2964
+ * - 永远兜底:jaccard(chars) ≥ jaccardThreshold(默认 0.4) **或** struct ≥ structuralThreshold(默认 0.5)
2965
+ *
2966
+ * cos 与 struct 权重对换的原因:标题/语义高度相似但孤立(无共邻边)的事件对在旧公式
2967
+ * 0.3·cos+0.7·struct 下永远 fused≈0 → 阈值不达 → 不进 LLM。文本相似度才是 event 同一性
2968
+ * 的主要信号,结构作为加成。
2969
+ *
2970
+ * sessionScope 隔离:
2971
+ * - 同 scope 池内才比对(含「都 'global'」、「同 sessionId」、「都 undefined → 兜底当 'global'」);
2972
+ * - 跨 scope 永不比对,与 L537 mergeAlias 硬护栏一致
2973
+ *
2974
+ * Lazy embed:当 EventNode.embeddingHash !== computeEventEmbeddingHash(title, summary) 时,
2975
+ * 实时调 embedding.embed() 并写回(持久化),下次 consolidate 复用。
2976
+ */
2977
+ async _consolidateEventDuplicates(opts) {
2978
+ const fusedThreshold = opts.fusedThreshold ?? 0.7;
2979
+ const jaccardThreshold = opts.jaccardThreshold ?? 0.4;
2980
+ const structuralThreshold = opts.structuralThreshold ?? 0.5;
2981
+ const logger = opts.llmCtx?.logger ?? this.ctx?.logger;
2982
+ const snapshot = await this.store.loadAll();
2983
+ const events = snapshot.events;
2984
+ // 按 sessionScope 分组:undefined → 'global'(与硬护栏 L537 兼容)
2985
+ const scopeOf = (e) => e.sessionScope ?? 'global';
2986
+ // 跨 scope 同名预合并(不需 LLM):global hub event 与其他 pool 中 normalizeName 完全相同的
2987
+ // session-scoped event 直接合并(保留 global 为 canonical)。修复两个问题:
2988
+ // (1) LLM 在会话内外重复创建同名事件(会话版 + global hub 版并存)
2989
+ // (2) reinforceEvent 不支持 sessionScope 升级,造成"老存量"无法被后期 LLM 拼接为 hub
2990
+ // 仅在 非 dryRun 且 events 可能出现 cross-scope 同名时走这条路径。
2991
+ let crossScopeMerged = 0;
2992
+ if (!opts.dryRun) {
2993
+ const globalEvents = events.filter(e => scopeOf(e) === 'global');
2994
+ if (globalEvents.length > 0) {
2995
+ const globalByTitle = new Map();
2996
+ for (const g of globalEvents) {
2997
+ const norm = normalizeName(g.title);
2998
+ if (norm)
2999
+ globalByTitle.set(norm, g);
3000
+ }
3001
+ if (globalByTitle.size > 0) {
3002
+ for (const ev of events) {
3003
+ if (scopeOf(ev) === 'global')
3004
+ continue;
3005
+ const norm = normalizeName(ev.title);
3006
+ if (!norm)
3007
+ continue;
3008
+ const hub = globalByTitle.get(norm);
3009
+ if (!hub || hub.id === ev.id)
3010
+ continue;
3011
+ try {
3012
+ const r = await this.mergeAlias({
3013
+ aliasId: ev.id,
3014
+ canonicalId: hub.id,
3015
+ kind: 'event',
3016
+ noCanonicalCorrection: true, // 强制 global hub 为 canonical
3017
+ });
3018
+ if (r.aliasDeleted) {
3019
+ crossScopeMerged++;
3020
+ logger?.info(`[user-relation] consolidate cross-scope 同名合并:${ev.id}(scope=${scopeOf(ev)}) → ${hub.id}(global) "${hub.title}"`);
3021
+ }
3022
+ }
3023
+ catch (err) {
3024
+ logger?.warn(`[user-relation] consolidate cross-scope 合并失败 ${ev.id} → ${hub.id}: ${err instanceof Error ? err.message : String(err)}`);
3025
+ }
3026
+ }
3027
+ }
3028
+ }
3029
+ }
3030
+ // 若跨 scope 预合并动了图,重取完整 snapshot(含最新边表),保证后续 pool 内 LLM 评估
3031
+ // 和 scoreBetween 的结构分都基于最新状态。
3032
+ const workingSnapshot = crossScopeMerged > 0 ? await this.store.loadAll() : snapshot;
3033
+ const workingEvents = workingSnapshot.events;
3034
+ const pools = new Map();
3035
+ for (const ev of workingEvents) {
3036
+ const s = scopeOf(ev);
3037
+ if (!pools.has(s))
3038
+ pools.set(s, []);
3039
+ pools.get(s).push(ev);
3040
+ }
3041
+ // Lazy embed:把所有需要 embed 的 event 一次性扫出,按需算
3042
+ const ensureEmbedding = async (ev) => {
3043
+ if (!opts.embedding)
3044
+ return null;
3045
+ const expectedHash = computeEventEmbeddingHash(ev.title, ev.summary);
3046
+ if (ev.embeddingHash === expectedHash && Array.isArray(ev.embeddingVector) && ev.embeddingVector.length > 0) {
3047
+ return ev.embeddingVector;
3048
+ }
3049
+ // 需要 embed
3050
+ const text = `${(ev.title || '').trim()}\n${(ev.summary || '').trim()}`.trim();
3051
+ if (!text)
3052
+ return null;
3053
+ try {
3054
+ const vec = await opts.embedding.embed(text);
3055
+ if (!Array.isArray(vec) || vec.length === 0)
3056
+ return null;
3057
+ // 写回持久化
3058
+ await this.store.upsertEvent({ ...ev, embeddingVector: vec, embeddingHash: expectedHash });
3059
+ // 同步缓存到本地副本(snapshot 不会再 reload)
3060
+ ev.embeddingVector = vec;
3061
+ ev.embeddingHash = expectedHash;
3062
+ return vec;
3063
+ }
3064
+ catch (err) {
3065
+ logger?.warn(`[user-relation] consolidate event embed 失败 ${ev.id} (${ev.title.slice(0, 20)}): ${err instanceof Error ? err.message : String(err)}`);
3066
+ return null;
3067
+ }
3068
+ };
3069
+ // 预热:在进入 O(N²) 循环前,并发批量计算所有 event 的 embedding,
3070
+ // 避免在双重循环里串行发起 N 次 Ollama 调用(每次 0.5-2s → N=300 时阻塞数分钟)。
3071
+ // 注:必须用 workingEvents(cross-scope 预合并后的最新列表);若用顶部 snapshot.events
3072
+ // 会对已被 mergeAlias 删除的 alias event 调 upsertEvent,把死节点"复活"。
3073
+ if (opts.embedding) {
3074
+ const EMBED_CONCURRENCY = 8;
3075
+ const needEmbed = workingEvents.filter(ev => {
3076
+ const expectedHash = computeEventEmbeddingHash(ev.title, ev.summary);
3077
+ return !(ev.embeddingHash === expectedHash &&
3078
+ Array.isArray(ev.embeddingVector) &&
3079
+ ev.embeddingVector.length > 0);
3080
+ });
3081
+ if (needEmbed.length > 0) {
3082
+ logger?.info(`[user-relation] consolidate event 预热 embedding:${needEmbed.length} / ${workingEvents.length} 个事件需要重算`);
3083
+ for (let start = 0; start < needEmbed.length; start += EMBED_CONCURRENCY) {
3084
+ await Promise.all(needEmbed.slice(start, start + EMBED_CONCURRENCY).map(ev => ensureEmbedding(ev)));
3085
+ }
3086
+ }
3087
+ }
3088
+ const candidates = [];
3089
+ for (const [scope, list] of pools.entries()) {
3090
+ if (list.length < 2)
3091
+ continue;
3092
+ for (let i = 0; i < list.length; i++) {
3093
+ for (let j = i + 1; j < list.length; j++) {
3094
+ const a = list[i];
3095
+ const b = list[j];
3096
+ // 标题完全相同的硬合并候选不在本段处理(extractor 阶段就强化了),跳过避免重复
3097
+ // 但若 sessionScope 相同 + title 相同 + 仍是两个节点 → 通常是异常,让 wide-recall 接住
3098
+ // 这里允许 title 相等的也进 LLM
3099
+ const cosA = await ensureEmbedding(a);
3100
+ const cosB = await ensureEmbedding(b);
3101
+ let cosineScore = null;
3102
+ if (cosA && cosB && cosA.length === cosB.length) {
3103
+ cosineScore = cosineSimilarity(cosA, cosB);
3104
+ }
3105
+ const jaccardScore = eventPairJaccard(a, b);
3106
+ // 结构相似(Katz + AA):可能较慢,仅在文本相似度已经达到门槛时才算
3107
+ // 预过滤:cosineScore >= 0.5 或 jaccard >= 0.3 才值得算 struct
3108
+ const preTextual = (cosineScore ?? 0) >= 0.5 || jaccardScore >= 0.3;
3109
+ let structuralScore = 0;
3110
+ if (preTextual) {
3111
+ try {
3112
+ // 复用本函数顶部已加载的 snapshot,避免 scoreBetween 内部对每个 pair 重新
3113
+ // store.loadAll()——N=300 时这一步会从 ~10s 膨胀到 ~100s。
3114
+ const sb = await this.scoreBetween(a.id, b.id, { maxDepth: 3, topPaths: 1, _snapshot: workingSnapshot });
3115
+ structuralScore = sb.score;
3116
+ }
3117
+ catch {
3118
+ structuralScore = 0;
3119
+ }
3120
+ }
3121
+ let fusedScore = null;
3122
+ if (cosineScore !== null) {
3123
+ // 2026-05 修:把 cos 与 struct 权重对换为 0.7·cos + 0.3·struct。
3124
+ // 原 0.3·cos + 0.7·struct 让"标题/语义高度相似但孤立(无共邻边)"的事件对永远
3125
+ // fused≈0 → 默认阈值 0.7 永远不达 → 永远不进 LLM(如「缸中之脑技术的讨论」与
3126
+ // 「缸中之脑的讨论」)。文本语义本身就是 event 相似度更稳定的信号,结构相似只在
3127
+ // 已经连接到共同实体/人物的场景下才能加成;这里以文本为主、结构为辅。
3128
+ fusedScore = 0.7 * cosineScore + 0.3 * structuralScore;
3129
+ }
3130
+ // 候选判定:fused 或 jaccard 或 struct 任一达阈即可(OR 兜底)。
3131
+ // 此前 embedding 可用时完全忽略 jaccard,导致高 cos 高 jaccard 但 struct=0 的孤立
3132
+ // 事件对被丢弃;现把 jaccard/struct 兜底显式纳入候选判定,让 LLM 仍有机会评审。
3133
+ const hasEmbed = cosineScore !== null;
3134
+ const isCandidate = (hasEmbed && fusedScore >= fusedThreshold) ||
3135
+ jaccardScore >= jaccardThreshold ||
3136
+ structuralScore >= structuralThreshold;
3137
+ if (!isCandidate)
3138
+ continue;
3139
+ candidates.push({ a, b, sessionScope: scope, cosineScore, jaccardScore, structuralScore, fusedScore });
3140
+ }
3141
+ }
3142
+ }
3143
+ // 按融合分倒序 / jaccard 倒序,优先把高置信送 LLM
3144
+ candidates.sort((x, y) => {
3145
+ const sx = x.fusedScore ?? x.jaccardScore;
3146
+ const sy = y.fusedScore ?? y.jaccardScore;
3147
+ return sy - sx;
3148
+ });
3149
+ logger?.info(`[user-relation] consolidate event 候选生成完成:events=${events.length},candidates=${candidates.length}`);
3150
+ const report = [];
3151
+ let llmVerified = 0;
3152
+ let llmRejected = 0;
3153
+ let llmRejectCacheHits = 0;
3154
+ const yesPairs = [];
3155
+ for (const cand of candidates) {
3156
+ const { a, b } = cand;
3157
+ const reportEntry = {
3158
+ aId: a.id,
3159
+ bId: b.id,
3160
+ aTitle: a.title,
3161
+ bTitle: b.title,
3162
+ sessionScope: cand.sessionScope,
3163
+ cosineScore: cand.cosineScore,
3164
+ jaccardScore: cand.jaccardScore,
3165
+ structuralScore: cand.structuralScore,
3166
+ fusedScore: cand.fusedScore,
3167
+ };
3168
+ if (!opts.llmModel || !opts.llmCtx) {
3169
+ // dryRun 但没传 LLM —— 直接收集候选不判定
3170
+ report.push(reportEntry);
3171
+ continue;
3172
+ }
3173
+ const [smaller, larger] = a.id < b.id ? [a, b] : [b, a];
3174
+ const sCount = smaller.evidence?.length ?? 0;
3175
+ const lCount = larger.evidence?.length ?? 0;
3176
+ const cached = await this.store.getMergeReject(a.id, b.id);
3177
+ if (cached && cached.aEvidenceCount === sCount && cached.bEvidenceCount === lCount) {
3178
+ llmRejectCacheHits++;
3179
+ reportEntry.cacheHit = true;
3180
+ reportEntry.llmVerdict = { isSame: false, reason: `mergeReject 缓存:${cached.reason}` };
3181
+ logger?.debug(`[user-relation] consolidate event 命中 mergeReject 缓存 ${a.id}(${a.title.slice(0, 20)}) ↔ ${b.id}(${b.title.slice(0, 20)}):${cached.reason}`);
3182
+ report.push(reportEntry);
3183
+ continue;
3184
+ }
3185
+ const verdict = await verifyEventPair(opts.llmCtx, opts.llmModel, a, b, opts.disableThinking ?? true, {
3186
+ aEvidenceQuotes: (a.evidence ?? [])
3187
+ .slice(-3)
3188
+ .map(ev => (ev.quote ?? '').trim())
3189
+ .filter(Boolean),
3190
+ bEvidenceQuotes: (b.evidence ?? [])
3191
+ .slice(-3)
3192
+ .map(ev => (ev.quote ?? '').trim())
3193
+ .filter(Boolean),
3194
+ aNeighbors: this._computeNeighborProfile(a.id, workingSnapshot, 5),
3195
+ bNeighbors: this._computeNeighborProfile(b.id, workingSnapshot, 5),
3196
+ scores: {
3197
+ ...(cand.cosineScore !== null ? { cosineScore: cand.cosineScore } : {}),
3198
+ jaccardScore: cand.jaccardScore,
3199
+ structuralScore: cand.structuralScore,
3200
+ ...(cand.fusedScore !== null ? { fusedScore: cand.fusedScore } : {}),
3201
+ },
3202
+ });
3203
+ reportEntry.llmVerdict = verdict;
3204
+ report.push(reportEntry);
3205
+ if (!verdict.isSame) {
3206
+ llmRejected++;
3207
+ await this.store.saveMergeReject({
3208
+ aId: smaller.id,
3209
+ bId: larger.id,
3210
+ aReinforcedAt: smaller.lastReinforcedAt ?? 0,
3211
+ bReinforcedAt: larger.lastReinforcedAt ?? 0,
3212
+ aEvidenceCount: sCount,
3213
+ bEvidenceCount: lCount,
3214
+ reason: verdict.reason,
3215
+ decidedAt: Date.now(),
3216
+ decidedBy: 'wide-recall',
3217
+ kind: 'event',
3218
+ });
3219
+ logger?.info(`[user-relation] consolidate event LLM 否决 ${a.id}(${a.title.slice(0, 20)}) ↔ ${b.id}(${b.title.slice(0, 20)}): ${verdict.reason}`);
3220
+ continue;
3221
+ }
3222
+ llmVerified++;
3223
+ if (cached)
3224
+ await this.store.deleteMergeReject(a.id, b.id);
3225
+ const reason = cand.fusedScore !== null
3226
+ ? `fused ${cand.fusedScore.toFixed(2)} (cos ${cand.cosineScore?.toFixed(2)}, struct ${cand.structuralScore.toFixed(2)})`
3227
+ : `jaccard ${cand.jaccardScore.toFixed(2)} / struct ${cand.structuralScore.toFixed(2)}`;
3228
+ yesPairs.push({ aId: a.id, bId: b.id, reason });
3229
+ logger?.info(`[user-relation] consolidate event LLM 同意合并 ${a.id}(${a.title.slice(0, 20)}) ↔ ${b.id}(${b.title.slice(0, 20)}): ${verdict.reason}`);
3230
+ }
3231
+ // dryRun 不合并
3232
+ let merged = 0;
3233
+ if (!opts.dryRun && yesPairs.length > 0) {
3234
+ const eventEdgeStats = computeEventEdgeStats(snapshot.edges);
3235
+ const eventById = new Map(snapshot.events.map(e => [e.id, e]));
3236
+ const clusters = clusterEntitiesByPairs(yesPairs);
3237
+ for (const [, members] of clusters) {
3238
+ if (members.size < 2)
3239
+ continue;
3240
+ const canonicalId = pickCanonicalForEvents(members, eventById, eventEdgeStats);
3241
+ if (!canonicalId)
3242
+ continue;
3243
+ for (const memberId of members) {
3244
+ if (memberId === canonicalId)
3245
+ continue;
3246
+ // mergeAlias 内部有跨 sessionScope 硬护栏,重复保险
3247
+ try {
3248
+ const r = await this.mergeAlias({ aliasId: memberId, canonicalId, kind: 'event' });
3249
+ if (r.aliasDeleted) {
3250
+ merged++;
3251
+ logger?.info(`[user-relation] consolidate event 真合并:${r.effectiveAliasId} → ${r.effectiveCanonicalId}`);
3252
+ }
3253
+ }
3254
+ catch (err) {
3255
+ logger?.warn(`[user-relation] consolidate event 合并失败 ${memberId} → ${canonicalId}: ${err instanceof Error ? err.message : String(err)}`);
3256
+ }
3257
+ }
3258
+ }
3259
+ }
3260
+ return { candidates: report, llmVerified, llmRejected, llmRejectCacheHits, merged: merged + crossScopeMerged };
3261
+ }
3262
+ /**
3263
+ * 公开 API:dry-run 查找 event 重复(不执行任何合并)。
3264
+ * 给 `/relation event-duplicates` 命令 / webui 调用使用。
3265
+ *
3266
+ * 与 consolidate 行为口径完全一致:sessionScope 同池 + 加权融合阈值 + mergeReject 缓存复用 + LLM 终判。
3267
+ * 但不写图,仅返回候选 + LLM 判定。
3268
+ */
3269
+ async findEventDuplicates(opts = {}) {
3270
+ const llmModel = opts.llm ? resolveConsolidateModel(opts.llm.ctx, { modelRef: opts.llm.modelRef }) : undefined;
3271
+ const embedding = this.ctx?.getService('embedding');
3272
+ const r = await this._consolidateEventDuplicates({
3273
+ llmModel,
3274
+ llmCtx: opts.llm?.ctx,
3275
+ disableThinking: opts.llm?.disableThinking ?? true,
3276
+ embedding,
3277
+ dryRun: true,
3278
+ fusedThreshold: opts.fusedThreshold,
3279
+ jaccardThreshold: opts.jaccardThreshold,
3280
+ structuralThreshold: opts.structuralThreshold,
3281
+ });
3282
+ return {
3283
+ candidates: r.candidates,
3284
+ llmVerified: r.llmVerified,
3285
+ llmRejected: r.llmRejected,
3286
+ llmRejectCacheHits: r.llmRejectCacheHits,
3287
+ embeddingAvailable: !!embedding,
3288
+ };
3289
+ }
3290
+ // ============================================================
3291
+ // renameNode —— 仅允许 Event / Entity 改名
3292
+ // - Person.name = platform displayName,禁改(不在此暴露)
3293
+ // - 旧 name/title 自动进 aliases,引用层 0 风险(key = id,非 name)
3294
+ // - 同步追加 nameHistory 审计条目
3295
+ // ============================================================
3296
+ async renameNode(opts) {
3297
+ const newName = opts.newName.trim();
3298
+ if (!newName)
3299
+ throw new Error('renameNode: newName 不能为空');
3300
+ if (newName.length > 80)
3301
+ throw new Error('renameNode: newName 过长(>80)');
3302
+ const now = Date.now();
3303
+ const by = opts.by ?? 'manual';
3304
+ const reason = opts.reason?.trim().slice(0, 80);
3305
+ if (opts.kind === 'event') {
3306
+ const node = await this.store.getEvent(opts.id);
3307
+ if (!node)
3308
+ throw new Error(`renameNode: event ${opts.id} 不存在`);
3309
+ const from = node.title;
3310
+ if (from === newName)
3311
+ return { from, to: newName, aliasesAdded: false };
3312
+ const aliases = Array.from(new Set([...(node.aliases ?? []), from]));
3313
+ const audit = { from, to: newName, at: now, by, ...(reason ? { reason } : {}) };
3314
+ await this.store.upsertEvent({
3315
+ ...node,
3316
+ title: newName,
3317
+ aliases,
3318
+ lastReinforcedAt: now,
3319
+ nameHistory: [...(node.nameHistory ?? []), audit],
3320
+ });
3321
+ return { from, to: newName, aliasesAdded: true };
3322
+ }
3323
+ const node = await this.store.getEntity(opts.id);
3324
+ if (!node)
3325
+ throw new Error(`renameNode: entity ${opts.id} 不存在`);
3326
+ const from = node.name;
3327
+ if (from === newName)
3328
+ return { from, to: newName, aliasesAdded: false };
3329
+ const aliases = Array.from(new Set([...(node.aliases ?? []), from]));
3330
+ const audit = { from, to: newName, at: now, by, ...(reason ? { reason } : {}) };
3331
+ await this.store.upsertEntity({
3332
+ ...node,
3333
+ name: newName,
3334
+ aliases,
3335
+ lastReinforcedAt: now,
3336
+ nameHistory: [...(node.nameHistory ?? []), audit],
3337
+ });
3338
+ return { from, to: newName, aliasesAdded: true };
3339
+ }
3340
+ /**
3341
+ * 关系边修正:LLM 发现某条边过弱 / 过强 / 是幻觉时调用。
3342
+ *
3343
+ * 设计原则(小破坏性 + 高效修正):
3344
+ * - **阶梯保护**:weight 越高越难物理删除,避免误删强关系
3345
+ * - weight ≥ 0.5:只允许 weaken;想 remove 需先反复 weaken 到 < 0.5(或 force=true)
3346
+ * - 0.3 ≤ weight < 0.5:可 weaken 或 remove
3347
+ * - weight < 0.3:自由(含 strengthen 重建)
3348
+ * - **alias 边禁操作**:is-alias-of / alt-account-of 是结构性边,
3349
+ * 修改会破坏 mergeAlias 不变量。需取消别名请走未来的 splitAlias 工具。
3350
+ * - **必填 reason**(≤80 字),写入 weightHistory[] 留痕
3351
+ * - **物理删除清理干净**:deleteEdge 直接落盘,不留墓碑(避免脏数据)
3352
+ *
3353
+ * 不接受 multiplier > 1 的 weaken / multiplier < 1 的 strengthen
3354
+ * (语义错位会让 LLM 误用)。
3355
+ */
3356
+ async correctEdge(opts) {
3357
+ const reason = opts.reason?.trim().slice(0, 80);
3358
+ if (!reason)
3359
+ throw new Error('correctEdge: reason 必填,请说明修正理由');
3360
+ const edge = await this.store.getEdge(opts.edgeId);
3361
+ if (!edge)
3362
+ throw new Error(`correctEdge: edge ${opts.edgeId} 不存在`);
3363
+ // alias / alt-account 边禁操作
3364
+ if (edge.kind === 'person-person' || edge.kind === 'entity-entity') {
3365
+ const rt = edge.relationType;
3366
+ if (rt === 'is-alias-of' || rt === 'alt-account-of') {
3367
+ throw new Error(`correctEdge: 禁止操作 alias 边 (relationType=${rt})。alias 是结构性边,错绑请走未来的 splitAlias 流程,不要直接 weaken/remove。`);
3368
+ }
3369
+ }
3370
+ const by = opts.by ?? 'llm';
3371
+ const now = Date.now();
3372
+ const prevWeight = edge.weight;
3373
+ if (opts.action === 'remove') {
3374
+ // 阶梯保护
3375
+ if (!opts.force && prevWeight >= 0.5) {
3376
+ throw new Error(`correctEdge: edge.weight=${prevWeight.toFixed(2)} ≥ 0.5,禁止直接 remove。请先用 weaken 把权重降到 < 0.5(建议反复 weaken 直至 < 0.3 再 remove),或确认后传 force=true。`);
3377
+ }
3378
+ await this.store.deleteEdge(opts.edgeId);
3379
+ return { action: 'removed', edgeId: opts.edgeId, from: prevWeight, to: 0 };
3380
+ }
3381
+ if (opts.action === 'weaken') {
3382
+ const m = opts.multiplier ?? 0.5;
3383
+ if (m <= 0 || m >= 1) {
3384
+ throw new Error(`correctEdge: weaken multiplier 必须 ∈ (0, 1),收到 ${m}`);
3385
+ }
3386
+ const newWeight = Math.max(0.001, prevWeight * m);
3387
+ const audit = { from: prevWeight, to: newWeight, action: 'weaken', at: now, by, reason };
3388
+ const updated = {
3389
+ ...edge,
3390
+ weight: newWeight,
3391
+ lastReinforcedAt: now,
3392
+ weightHistory: [...(edge.weightHistory ?? []), audit],
3393
+ };
3394
+ await this.store.upsertEdge(updated);
3395
+ return { action: 'weakened', edgeId: opts.edgeId, from: prevWeight, to: newWeight, edge: updated };
3396
+ }
3397
+ // strengthen
3398
+ const m = opts.multiplier ?? 1.5;
3399
+ if (m <= 1 || m > 5) {
3400
+ throw new Error(`correctEdge: strengthen multiplier 必须 ∈ (1, 5],收到 ${m}`);
3401
+ }
3402
+ const newWeight = Math.min(1, prevWeight * m);
3403
+ const audit = { from: prevWeight, to: newWeight, action: 'strengthen', at: now, by, reason };
3404
+ const updated = {
3405
+ ...edge,
3406
+ weight: newWeight,
3407
+ lastReinforcedAt: now,
3408
+ weightHistory: [...(edge.weightHistory ?? []), audit],
3409
+ };
3410
+ await this.store.upsertEdge(updated);
3411
+ return { action: 'strengthened', edgeId: opts.edgeId, from: prevWeight, to: newWeight, edge: updated };
3412
+ }
3413
+ // ============================================================
3414
+ // Alias merging(真合并:rewire 边 + 合并 aliases + 删 alias 节点)
3415
+ // is-alias-of / alt-account-of 边写入后立即触发:
3416
+ // - 按启发式校正 canonical 方向(name 较长 / aliases 较多 / 总 evidence 较多)
3417
+ // - 将所有引用 aliasId 的其它边重写为指向 canonicalId
3418
+ // - 同 dedup key 冲突时合并 evidence/weight 并删除冗余
3419
+ // - 合并 alias 的 name + aliases 到 canonical.aliases(仅 entity/event;
3420
+ // PersonNode 无 aliases 字段,留作未来扩展点)
3421
+ // - 级联删除 alias 节点本身(含 alias↔canonical 的 is-alias-of 标记边,
3422
+ // 由 cascade 自动清掉)
3423
+ // 设计对齐:docs/plugins/user-relation.md §3 合并语义
3424
+ // ============================================================
3425
+ async mergeAlias(opts) {
3426
+ let aliasId = opts.aliasId;
3427
+ let canonicalId = opts.canonicalId;
3428
+ let swapped = false;
3429
+ if (aliasId === canonicalId) {
3430
+ return {
3431
+ effectiveCanonicalId: canonicalId,
3432
+ effectiveAliasId: aliasId,
3433
+ edgesRewritten: 0,
3434
+ edgesMerged: 0,
3435
+ edgesDeleted: 0,
3436
+ swapped,
3437
+ aliasDeleted: false,
3438
+ };
3439
+ }
3440
+ const snapshot = await this.store.loadAll();
3441
+ if (!opts.noCanonicalCorrection) {
3442
+ const corrected = chooseCanonicalDirection(snapshot, aliasId, canonicalId, opts.kind);
3443
+ if (corrected) {
3444
+ swapped = true;
3445
+ aliasId = corrected.alias;
3446
+ canonicalId = corrected.canonical;
3447
+ }
3448
+ }
3449
+ // 索引:未被 alias 引用的现有边按 dedupKey 入索引,用于冲突检测
3450
+ const byKey = new Map();
3451
+ for (const e of snapshot.edges) {
3452
+ if (edgeReferences(e, aliasId))
3453
+ continue;
3454
+ byKey.set(edgeDedupKey(e), e);
3455
+ }
3456
+ let edgesRewritten = 0;
3457
+ let edgesMerged = 0;
3458
+ let edgesDeleted = 0;
3459
+ for (const e of snapshot.edges) {
3460
+ if (!edgeReferences(e, aliasId))
3461
+ continue;
3462
+ // 保留 alias↔canonical 的 alias 标记边自身;必要时翻转方向到 alias→canonical
3463
+ if (isAliasMarkerEdge(e) && edgeInvolvesBoth(e, aliasId, canonicalId)) {
3464
+ if (!isAliasEdgeDirectionCorrect(e, aliasId, canonicalId)) {
3465
+ const flipped = flipDirectedEdge(e, aliasId, canonicalId);
3466
+ if (flipped)
3467
+ await this.store.upsertEdge(flipped);
3468
+ }
3469
+ continue;
3470
+ }
3471
+ const rewritten = rewriteEdgeIds(e, aliasId, canonicalId);
3472
+ // 自环禁止:合并后从 == 到 → 直接删除
3473
+ if (isEdgeSelfLoop(rewritten)) {
3474
+ await this.store.deleteEdge(e.id);
3475
+ edgesDeleted++;
3476
+ continue;
3477
+ }
3478
+ const newKey = edgeDedupKey(rewritten);
3479
+ const conflict = byKey.get(newKey);
3480
+ if (conflict && conflict.id !== rewritten.id) {
3481
+ const merged = mergeTwoEdges(conflict, rewritten);
3482
+ await this.store.upsertEdge(merged);
3483
+ await this.store.deleteEdge(e.id);
3484
+ byKey.set(newKey, merged);
3485
+ edgesMerged++;
3486
+ }
3487
+ else {
3488
+ await this.store.upsertEdge(rewritten);
3489
+ byKey.set(newKey, rewritten);
3490
+ edgesRewritten++;
3491
+ }
3492
+ }
3493
+ // 清理涉及 aliasId 的 mergeReject 缓存:alias id 即将被吸收,旧缓存对未来无意义
3494
+ await this.store.deleteMergeRejectsByNode(aliasId);
3495
+ // ─── 真合并:合并 aliases + 级联删除 alias 节点 ───
3496
+ // 说明:mergeAlias 自身只 rewire 边,不能依赖此时的 snapshot 看到 canonical 节点最新状态
3497
+ // (上面已对 store 做了若干 upsertEdge / deleteEdge)。重新 loadAll 以拿到最新 nodes。
3498
+ // Person 节点目前没有 aliases 字段(PersonNode 见 types.ts L45),只删壳子。
3499
+ // Entity/Event 节点把 alias.name(或 title) + alias.aliases 并入 canonical.aliases。
3500
+ let aliasDeleted = false;
3501
+ try {
3502
+ const fresh = await this.store.loadAll();
3503
+ if (opts.kind === 'entity') {
3504
+ const aliasNode = fresh.entities.find(e => e.id === aliasId);
3505
+ const canonicalNode = fresh.entities.find(e => e.id === canonicalId);
3506
+ if (aliasNode && canonicalNode) {
3507
+ const merged = Array.from(new Set([...(canonicalNode.aliases ?? []), aliasNode.name, ...(aliasNode.aliases ?? [])])).filter(s => !!s && s !== canonicalNode.name);
3508
+ if (merged.length !== (canonicalNode.aliases?.length ?? 0)) {
3509
+ await this.store.upsertEntity({ ...canonicalNode, aliases: merged });
3510
+ }
3511
+ await this.store.deleteEntityCascade(aliasId);
3512
+ aliasDeleted = true;
3513
+ }
3514
+ }
3515
+ else if (opts.kind === 'event') {
3516
+ const aliasNode = fresh.events.find(e => e.id === aliasId);
3517
+ const canonicalNode = fresh.events.find(e => e.id === canonicalId);
3518
+ if (aliasNode && canonicalNode) {
3519
+ const merged = Array.from(new Set([...(canonicalNode.aliases ?? []), aliasNode.title, ...(aliasNode.aliases ?? [])])).filter(s => !!s && s !== canonicalNode.title);
3520
+ if (merged.length !== (canonicalNode.aliases?.length ?? 0)) {
3521
+ await this.store.upsertEvent({ ...canonicalNode, aliases: merged });
3522
+ }
3523
+ await this.store.deleteEventCascade(aliasId);
3524
+ aliasDeleted = true;
3525
+ }
3526
+ }
3527
+ else {
3528
+ // person:复合 id `${platform}:${userId}`
3529
+ const idx = aliasId.indexOf(':');
3530
+ if (idx > 0) {
3531
+ const platform = aliasId.slice(0, idx);
3532
+ const userId = aliasId.slice(idx + 1);
3533
+ const aliasNode = fresh.persons.find(p => p.id === aliasId);
3534
+ if (aliasNode) {
3535
+ await this.store.deletePersonCascade(platform, userId);
3536
+ aliasDeleted = true;
3537
+ }
3538
+ }
3539
+ }
3540
+ }
3541
+ catch (err) {
3542
+ // 真合并失败不应阻塞调用链(边已经 rewire 成功,最差情况退化为旧的"路由+壳子"行为)
3543
+ // eslint-disable-next-line no-console
3544
+ console.warn(`[user-relation] mergeAlias: 删除 alias 壳子失败 alias=${aliasId} canonical=${canonicalId} kind=${opts.kind}`, err);
3545
+ }
3546
+ return {
3547
+ effectiveCanonicalId: canonicalId,
3548
+ effectiveAliasId: aliasId,
3549
+ edgesRewritten,
3550
+ edgesMerged,
3551
+ edgesDeleted,
3552
+ swapped,
3553
+ aliasDeleted,
3554
+ };
3555
+ }
3556
+ // ============================================================
3557
+ // Agent 写工具:deleteNode / deleteEdge / mergeNodes / changeEntityKind
3558
+ // 设计要点(与 correctEdge / renameNode 对称):
3559
+ // - Person 节点禁止 agent 物理删除(platform 身份只能由 user-profile 同步)
3560
+ // - 阶梯保护:weight ≥ 0.8 或 evidence ≥ 5 视为强节点 / 强边 → 拒绝
3561
+ // - alias 边(is-alias-of / alt-account-of)禁删(破坏身份合并)
3562
+ // - 全部 logger.warn 记审计(含 by / reason / 影响范围)
3563
+ // ============================================================
3564
+ /**
3565
+ * 物理删除 event / entity 节点(级联删边)。Person 节点禁用。
3566
+ * 保护门:weight ≥ 0.8 或 evidence.length ≥ 5 直接拒绝。
3567
+ */
3568
+ async deleteNode(opts) {
3569
+ const reason = opts.reason?.trim().slice(0, 120);
3570
+ if (!reason)
3571
+ throw new Error('deleteNode: reason 必填');
3572
+ const by = opts.by ?? 'manual';
3573
+ if (opts.kind === 'event') {
3574
+ const node = await this.store.getEvent(opts.id);
3575
+ if (!node)
3576
+ throw new Error(`deleteNode: event ${opts.id} 不存在`);
3577
+ this._assertNodeDeletable(node.weight ?? 0.5, node.evidence?.length ?? 0, node.title);
3578
+ const { deletedEdges } = await this.store.deleteEventCascade(opts.id);
3579
+ this._audit(`[user-relation][AUDIT] deleteNode event id=${opts.id} title="${node.title}" by=${by} reason="${reason}" edges=${deletedEdges}`);
3580
+ return { kind: 'event', id: opts.id, deletedEdges };
3581
+ }
3582
+ const node = await this.store.getEntity(opts.id);
3583
+ if (!node)
3584
+ throw new Error(`deleteNode: entity ${opts.id} 不存在`);
3585
+ this._assertNodeDeletable(node.weight ?? 0.5, node.evidence?.length ?? 0, node.name);
3586
+ const { deletedEdges } = await this.store.deleteEntityCascade(opts.id);
3587
+ this._audit(`[user-relation][AUDIT] deleteNode entity id=${opts.id} name="${node.name}" by=${by} reason="${reason}" edges=${deletedEdges}`);
3588
+ return { kind: 'entity', id: opts.id, deletedEdges };
3589
+ }
3590
+ _assertNodeDeletable(weight, evidenceCount, nameForErr) {
3591
+ if (weight >= 0.8) {
3592
+ throw new Error(`节点 "${nameForErr}" 权重 ${weight.toFixed(2)} ≥ 0.8(强节点保护)。请先通过 correctEdge 或多次 cleanup 自然淡化,或走 /relation cleanup 人工命令。`);
3593
+ }
3594
+ if (evidenceCount >= 5) {
3595
+ throw new Error(`节点 "${nameForErr}" evidence ${evidenceCount} ≥ 5(强节点保护)。证据充足的节点不允许 agent 直接删除。`);
3596
+ }
3597
+ }
3598
+ /**
3599
+ * consolidate hierarchy 守门用:若 child→parent 的 entity-entity[part-of] 边缺失则新建;
3600
+ * 已存在(任一方向:part-of 或反向 contains)则跳过,返回 false。
3601
+ * 不强化既有边、不做证据合并——hierarchy 守门只负责"不被错误地 alias-merge 掉",
3602
+ * 后续走正规 inferEntityHierarchy / extractor 写边路径补全/强化。
3603
+ */
3604
+ async _upsertPartOfEdgeIfAbsent(edgesSnapshot, childId, parentId, description) {
3605
+ const existing = edgesSnapshot.some(e => e.kind === 'entity-entity' &&
3606
+ (e.relationType === 'part-of' || e.relationType === 'contains') &&
3607
+ ((e.fromEntityId === childId && e.toEntityId === parentId) ||
3608
+ (e.fromEntityId === parentId && e.toEntityId === childId)));
3609
+ if (existing)
3610
+ return false;
3611
+ const now = Date.now();
3612
+ await this.store.upsertEdge({
3613
+ id: globalThis.crypto.randomUUID(),
3614
+ kind: 'entity-entity',
3615
+ fromEntityId: childId,
3616
+ toEntityId: parentId,
3617
+ relationType: 'part-of',
3618
+ directed: true,
3619
+ weight: 0.6,
3620
+ description: description.slice(0, 80),
3621
+ firstSeenAt: now,
3622
+ lastReinforcedAt: now,
3623
+ evidence: [],
3624
+ });
3625
+ return true;
3626
+ }
3627
+ /**
3628
+ * 物理删除一条边(带保护门,供 agent 调用)。alias 边(is-alias-of / alt-account-of)禁删;
3629
+ * weight ≥ 0.8 或 evidence ≥ 5 拒绝(请先 correctEdge weaken)。
3630
+ *
3631
+ * 注:与旧 deleteEdge(edgeId)(无保护,供 consolidate 内部使用)区别开。
3632
+ */
3633
+ async deleteEdgeWithGuard(opts) {
3634
+ const reason = opts.reason?.trim().slice(0, 120);
3635
+ if (!reason)
3636
+ throw new Error('deleteEdgeWithGuard: reason 必填');
3637
+ const by = opts.by ?? 'manual';
3638
+ const edge = await this.store.getEdge(opts.edgeId);
3639
+ if (!edge)
3640
+ throw new Error(`deleteEdgeWithGuard: edge ${opts.edgeId} 不存在`);
3641
+ const rt = String(edge.relationType ?? '');
3642
+ if (rt === 'is-alias-of' || rt === 'alt-account-of') {
3643
+ throw new Error(`alias 边(${rt})禁止删除——会破坏身份合并。请走 /relation cleanup 人工命令。`);
3644
+ }
3645
+ if ((edge.weight ?? 0) >= 0.8) {
3646
+ throw new Error(`边权重 ${(edge.weight ?? 0).toFixed(2)} ≥ 0.8(强边保护)。请先用 correctEdge weaken 衰减。`);
3647
+ }
3648
+ if ((edge.evidence?.length ?? 0) >= 5) {
3649
+ throw new Error(`边 evidence ${edge.evidence?.length ?? 0} ≥ 5(强边保护)。证据充足的边不允许直接删除。`);
3650
+ }
3651
+ await this.store.deleteEdge(opts.edgeId);
3652
+ this._audit(`[user-relation][AUDIT] deleteEdge id=${opts.edgeId} kind=${edge.kind} relationType=${rt} weight=${edge.weight} by=${by} reason="${reason}"`);
3653
+ return { edgeId: opts.edgeId, kind: edge.kind, relationType: rt, weight: edge.weight ?? 0 };
3654
+ }
3655
+ /**
3656
+ * 物理合并:把 aliasIds 全部并入 canonicalId,并物理删除 aliasIds。
3657
+ * 仅支持 event / entity(person 合并请走 mergeAlias,保留 alias 标记边)。
3658
+ *
3659
+ * 内部分两步:
3660
+ * 1) 复用 mergeAlias 把每个 alias 的边改写到 canonical(保留同名 alias 标记边以便回溯);
3661
+ * 2) 物理删除 alias 节点本身(cascade 顺手清理残留的 alias 标记边)。
3662
+ */
3663
+ async mergeNodes(opts) {
3664
+ const reason = opts.reason?.trim().slice(0, 120);
3665
+ if (!reason)
3666
+ throw new Error('mergeNodes: reason 必填');
3667
+ const by = opts.by ?? 'manual';
3668
+ const aliasIds = Array.from(new Set(opts.aliasIds ?? [])).filter(id => id && id !== opts.canonicalId);
3669
+ if (aliasIds.length === 0)
3670
+ throw new Error('mergeNodes: aliasIds 至少 1 个且不可等于 canonicalId');
3671
+ // 校验 canonical 存在
3672
+ const canonical = opts.kind === 'event'
3673
+ ? await this.store.getEvent(opts.canonicalId)
3674
+ : await this.store.getEntity(opts.canonicalId);
3675
+ if (!canonical)
3676
+ throw new Error(`mergeNodes: canonical ${opts.kind} ${opts.canonicalId} 不存在`);
3677
+ let totalEdgesRewritten = 0;
3678
+ let totalEdgesMerged = 0;
3679
+ let totalEdgesDeleted = 0;
3680
+ const mergedAliasIds = [];
3681
+ for (const aliasId of aliasIds) {
3682
+ // alias 节点也要存在 & 与 canonical 同类
3683
+ const aliasNode = opts.kind === 'event' ? await this.store.getEvent(aliasId) : await this.store.getEntity(aliasId);
3684
+ if (!aliasNode) {
3685
+ this._audit(`[user-relation] mergeNodes 跳过不存在的 alias ${aliasId}`);
3686
+ continue;
3687
+ }
3688
+ // 不做删除保护(合并是「保留语义」而非「丢失」),但仍记审计
3689
+ const r = await this.mergeAlias({
3690
+ aliasId,
3691
+ canonicalId: opts.canonicalId,
3692
+ kind: opts.kind,
3693
+ noCanonicalCorrection: true,
3694
+ });
3695
+ totalEdgesRewritten += r.edgesRewritten;
3696
+ totalEdgesMerged += r.edgesMerged;
3697
+ totalEdgesDeleted += r.edgesDeleted;
3698
+ // 物理删除 alias 节点(级联清掉残留的 alias 标记边自身)
3699
+ if (opts.kind === 'event') {
3700
+ const { deletedEdges } = await this.store.deleteEventCascade(aliasId);
3701
+ totalEdgesDeleted += deletedEdges;
3702
+ }
3703
+ else {
3704
+ const { deletedEdges } = await this.store.deleteEntityCascade(aliasId);
3705
+ totalEdgesDeleted += deletedEdges;
3706
+ }
3707
+ mergedAliasIds.push(aliasId);
3708
+ }
3709
+ this._audit(`[user-relation][AUDIT] mergeNodes kind=${opts.kind} canonical=${opts.canonicalId} aliases=[${mergedAliasIds.join(',')}] by=${by} reason="${reason}" edges(rewritten=${totalEdgesRewritten} merged=${totalEdgesMerged} deleted=${totalEdgesDeleted})`);
3710
+ return {
3711
+ canonicalId: opts.canonicalId,
3712
+ mergedAliasIds,
3713
+ totalEdgesRewritten,
3714
+ totalEdgesMerged,
3715
+ totalEdgesDeleted,
3716
+ };
3717
+ }
3718
+ /**
3719
+ * 修改 entity 的 kind(topic/place/thing/work)。轻量操作,仅写入字段 + audit。
3720
+ * 不变更 id,所有引用边 0 风险。
3721
+ */
3722
+ async changeEntityKind(opts) {
3723
+ const reason = opts.reason?.trim().slice(0, 120);
3724
+ if (!reason)
3725
+ throw new Error('changeEntityKind: reason 必填');
3726
+ const by = opts.by ?? 'manual';
3727
+ const valid = ['topic', 'place', 'thing', 'work'];
3728
+ if (!valid.includes(opts.newKind)) {
3729
+ throw new Error(`changeEntityKind: newKind 必须是 ${valid.join('/')}`);
3730
+ }
3731
+ const node = await this.store.getEntity(opts.entityId);
3732
+ if (!node)
3733
+ throw new Error(`changeEntityKind: entity ${opts.entityId} 不存在`);
3734
+ const from = node.entityKind;
3735
+ if (from === opts.newKind)
3736
+ return { entityId: opts.entityId, from, to: opts.newKind };
3737
+ await this.store.upsertEntity({ ...node, entityKind: opts.newKind, lastReinforcedAt: Date.now() });
3738
+ this._audit(`[user-relation][AUDIT] changeEntityKind id=${opts.entityId} name="${node.name}" ${from}→${opts.newKind} by=${by} reason="${reason}"`);
3739
+ return { entityId: opts.entityId, from, to: opts.newKind };
3740
+ }
3741
+ /**
3742
+ * 从 entity 的 aliases[] 中剥离一个错误绑定的别名(轻量纠错)。
3743
+ *
3744
+ * 使用场景:consolidate 把一个不该并入 canonical 的别名错误合并了,
3745
+ * 导致 canonical 的 aliases 里出现了一个本不属于它的名字(如把母概念错并入子概念,
3746
+ * 母概念的名字残留为子概念的 alias)。本方法只把该名字从 aliases 中移除——
3747
+ * **不会**重建出当初被合并掉的 entity 节点(那个节点已被物理删除,证据已迁移)。
3748
+ * 之后 extractor 在新对话中再次看到该名字时,会自然地新建一个新 entity 节点。
3749
+ *
3750
+ * 不变更:name / 边 / evidence / nameHistory(不动 nameHistory:renameNode 才追写)。
3751
+ * 别名匹配按 trim 后字符串等价(不区分大小写不在此处处理;如有需要由调用方先归一)。
3752
+ */
3753
+ async splitAlias(opts) {
3754
+ const reason = opts.reason?.trim().slice(0, 120);
3755
+ if (!reason)
3756
+ throw new Error('splitAlias: reason 必填');
3757
+ const aliasName = opts.aliasName?.trim();
3758
+ if (!aliasName)
3759
+ throw new Error('splitAlias: aliasName 不能为空');
3760
+ const by = opts.by ?? 'manual';
3761
+ const node = await this.store.getEntity(opts.entityId);
3762
+ if (!node)
3763
+ throw new Error(`splitAlias: entity ${opts.entityId} 不存在`);
3764
+ if (aliasName === node.name.trim()) {
3765
+ throw new Error(`splitAlias: aliasName "${aliasName}" 与 entity.name 相同;如需改名请用 renameNode`);
3766
+ }
3767
+ const current = node.aliases ?? [];
3768
+ const target = current.find(a => a.trim() === aliasName);
3769
+ if (!target) {
3770
+ throw new Error(`splitAlias: aliasName "${aliasName}" 不在 entity "${node.name}" 的 aliases 中(现有:${current.join(', ') || '无'})`);
3771
+ }
3772
+ const remaining = current.filter(a => a !== target);
3773
+ await this.store.upsertEntity({ ...node, aliases: remaining, lastReinforcedAt: Date.now() });
3774
+ this._audit(`[user-relation][AUDIT] splitAlias id=${opts.entityId} name="${node.name}" removed="${target}" by=${by} reason="${reason}" remaining=${remaining.length}`);
3775
+ return { entityId: opts.entityId, removed: target, remainingAliases: remaining };
3776
+ }
3777
+ /**
3778
+ * 计算单个节点的「综合活跃度评分 + 排名 + 分级」,供 agent 快速判断节点份量。
3779
+ *
3780
+ * 返回字段语义:
3781
+ * - compositeScore: 0..1 综合分(pagerank 0.4 + edgeWeight 0.3 + recency 0.2 + degree 0.1)
3782
+ * - tier: 'core' | 'active' | 'normal' | 'edge',绝对分 + 同 kind 百分位双门槛分级
3783
+ * - rankInKind / rankInGlobal: 'k/N' 字符串,按 compositeScore 降序,1=最高
3784
+ * - percentileInKind / percentileInGlobal: 0..1,0.95=前 5%,越大越中心
3785
+ * - pagerankFresh: false=节点从未参与过 PR 计算(lastPageRankAt=0),pagerank=0 不代表"边缘"
3786
+ * - 其它字段:相关邻居计数 / 入边权 / pagerank 快照 / evidence 数 / 距上次强化天数
3787
+ *
3788
+ * 复杂度:O(N) 全图扫描,N=节点总数(几百到几千可接受;如果发现卡顿可加节点缓存)。
3789
+ */
3790
+ async computeNodeScore(nodeId) {
3791
+ const snap = await this.store.loadAll();
3792
+ const target = this._computeSingleNodeScore(nodeId, snap);
3793
+ if (!target)
3794
+ return null;
3795
+ // 全图排名:对每个节点算一次 compositeScore 并按 kind/全局排序。
3796
+ const allScores = [];
3797
+ for (const p of snap.persons) {
3798
+ const s = this._computeSingleNodeScore(p.id, snap);
3799
+ if (s)
3800
+ allScores.push({ id: p.id, kind: 'person', score: s.compositeScore });
3801
+ }
3802
+ for (const e of snap.events) {
3803
+ const s = this._computeSingleNodeScore(e.id, snap);
3804
+ if (s)
3805
+ allScores.push({ id: e.id, kind: 'event', score: s.compositeScore });
3806
+ }
3807
+ for (const e of snap.entities) {
3808
+ const s = this._computeSingleNodeScore(e.id, snap);
3809
+ if (s)
3810
+ allScores.push({ id: e.id, kind: 'entity', score: s.compositeScore });
3811
+ }
3812
+ const sameKind = allScores.filter(s => s.kind === target.kind).sort((a, b) => b.score - a.score);
3813
+ const global = [...allScores].sort((a, b) => b.score - a.score);
3814
+ const rankK = sameKind.findIndex(s => s.id === nodeId) + 1;
3815
+ const rankG = global.findIndex(s => s.id === nodeId) + 1;
3816
+ const percentileInKind = sameKind.length > 1 ? Number(((sameKind.length - rankK) / (sameKind.length - 1)).toFixed(4)) : 1;
3817
+ const percentileInGlobal = global.length > 1 ? Number(((global.length - rankG) / (global.length - 1)).toFixed(4)) : 1;
3818
+ const tier = scoreToTier(target.compositeScore, percentileInKind);
3819
+ return {
3820
+ ...target,
3821
+ tier,
3822
+ rankInKind: `${rankK}/${sameKind.length}`,
3823
+ rankInGlobal: `${rankG}/${global.length}`,
3824
+ percentileInKind,
3825
+ percentileInGlobal,
3826
+ };
3827
+ }
3828
+ /**
3829
+ * 内部:单节点综合分计算(不含排名)。抽出复用:computeNodeScore 与 actions graph_data。
3830
+ */
3831
+ _computeSingleNodeScore(nodeId, snap) {
3832
+ let kind = null;
3833
+ let name = '';
3834
+ let evidenceCount = 0;
3835
+ let lastReinforcedAt = 0;
3836
+ let pagerank = 0;
3837
+ let pagerankAt = 0;
3838
+ const person = snap.persons.find(p => p.id === nodeId);
3839
+ if (person) {
3840
+ kind = 'person';
3841
+ name = person.displayName ?? person.id;
3842
+ evidenceCount = 0;
3843
+ lastReinforcedAt = person.lastSeenAt ?? person.firstSeenAt ?? 0;
3844
+ pagerank = person.lastPageRank ?? 0;
3845
+ pagerankAt = person.lastPageRankAt ?? 0;
3846
+ }
3847
+ else {
3848
+ const event = snap.events.find(e => e.id === nodeId);
3849
+ if (event) {
3850
+ kind = 'event';
3851
+ name = event.title;
3852
+ evidenceCount = event.evidence?.length ?? 0;
3853
+ lastReinforcedAt = event.lastReinforcedAt;
3854
+ pagerank = event.lastPageRank ?? 0;
3855
+ pagerankAt = event.lastPageRankAt ?? 0;
3856
+ }
3857
+ else {
3858
+ const entity = snap.entities.find(e => e.id === nodeId);
3859
+ if (entity) {
3860
+ kind = 'entity';
3861
+ name = entity.name;
3862
+ evidenceCount = entity.evidence?.length ?? 0;
3863
+ lastReinforcedAt = entity.lastReinforcedAt;
3864
+ pagerank = entity.lastPageRank ?? 0;
3865
+ pagerankAt = entity.lastPageRankAt ?? 0;
3866
+ }
3867
+ }
3868
+ }
3869
+ if (!kind)
3870
+ return null;
3871
+ const peopleSet = new Set();
3872
+ const eventSet = new Set();
3873
+ const entitySet = new Set();
3874
+ let maxIncomingEdgeWeight = 0;
3875
+ let sumIncomingEdgeWeight = 0;
3876
+ let inEdgeCount = 0;
3877
+ for (const e of snap.edges) {
3878
+ const ends = getEdgeOtherEnd(e, nodeId);
3879
+ if (!ends)
3880
+ continue;
3881
+ const { otherId, otherKind, isIncoming } = ends;
3882
+ if (!otherId || otherId === nodeId)
3883
+ continue;
3884
+ if (otherKind === 'person')
3885
+ peopleSet.add(otherId);
3886
+ else if (otherKind === 'event')
3887
+ eventSet.add(otherId);
3888
+ else
3889
+ entitySet.add(otherId);
3890
+ if (isIncoming) {
3891
+ const w = e.weight ?? 0;
3892
+ if (w > maxIncomingEdgeWeight)
3893
+ maxIncomingEdgeWeight = w;
3894
+ sumIncomingEdgeWeight += w;
3895
+ inEdgeCount++;
3896
+ }
3897
+ }
3898
+ const avgIncomingEdgeWeight = inEdgeCount > 0 ? sumIncomingEdgeWeight / inEdgeCount : 0;
3899
+ const totalDegree = peopleSet.size + eventSet.size + entitySet.size;
3900
+ const daysSinceLastReinforced = lastReinforcedAt
3901
+ ? Math.max(0, (Date.now() - lastReinforcedAt) / 86400_000)
3902
+ : Number.POSITIVE_INFINITY;
3903
+ const prNorm = Math.min(1, pagerank * 10);
3904
+ const wNorm = Math.min(1, maxIncomingEdgeWeight);
3905
+ const recency = Number.isFinite(daysSinceLastReinforced) ? Math.exp(-daysSinceLastReinforced / 30) : 0;
3906
+ const degreeNorm = Math.min(1, totalDegree / 20);
3907
+ const compositeScore = Math.min(1, prNorm * 0.4 + wNorm * 0.3 + recency * 0.2 + degreeNorm * 0.1);
3908
+ return {
3909
+ nodeId,
3910
+ kind,
3911
+ name,
3912
+ relatedPeople: peopleSet.size,
3913
+ relatedEvents: eventSet.size,
3914
+ relatedEntities: entitySet.size,
3915
+ maxIncomingEdgeWeight: Number(maxIncomingEdgeWeight.toFixed(4)),
3916
+ avgIncomingEdgeWeight: Number(avgIncomingEdgeWeight.toFixed(4)),
3917
+ pagerank: Number(pagerank.toFixed(6)),
3918
+ pagerankFresh: pagerankAt > 0,
3919
+ evidenceCount,
3920
+ daysSinceLastReinforced: Number.isFinite(daysSinceLastReinforced)
3921
+ ? Number(daysSinceLastReinforced.toFixed(1))
3922
+ : -1,
3923
+ compositeScore: Number(compositeScore.toFixed(4)),
3924
+ };
3925
+ }
3926
+ /**
3927
+ * 计算节点的「邻居剖面」:返回每类邻居 (人/事件/实体) 的 **总数 + top-K {name, weight}**。
3928
+ * 用于 consolidate verifyAliasPair / verifyEventPair 给 LLM 提供更丰富的邻居证据。
3929
+ *
3930
+ * - weight 取节点对之间所有边的 weight 之和(同一对实体可能既是 mentioned 又是 enthusiast)
3931
+ * - 排序:weight 倒序;同权按 name 升序保证可复现
3932
+ * - 名字截断到 24 字以控制 prompt 体积
3933
+ * - 命中不存在的 otherId(被删/孤立)→ 跳过
3934
+ */
3935
+ _computeNeighborProfile(nodeId, snap, topK = 5) {
3936
+ const personMap = new Map(snap.persons.map(p => [p.id, p]));
3937
+ const eventMap = new Map(snap.events.map(e => [e.id, e]));
3938
+ const entityMap = new Map(snap.entities.map(e => [e.id, e]));
3939
+ const peopleAgg = new Map();
3940
+ const eventAgg = new Map();
3941
+ const entityAgg = new Map();
3942
+ for (const e of snap.edges) {
3943
+ const ends = getEdgeOtherEnd(e, nodeId);
3944
+ if (!ends)
3945
+ continue;
3946
+ const { otherId, otherKind } = ends;
3947
+ if (!otherId || otherId === nodeId)
3948
+ continue;
3949
+ const w = e.weight ?? 0;
3950
+ const bucket = otherKind === 'person' ? peopleAgg : otherKind === 'event' ? eventAgg : entityAgg;
3951
+ bucket.set(otherId, (bucket.get(otherId) ?? 0) + w);
3952
+ }
3953
+ const trim = (s) => (s.length > 24 ? `${s.slice(0, 23)}…` : s);
3954
+ const toTopK = (agg, nameOf) => {
3955
+ const arr = [];
3956
+ for (const [id, w] of agg.entries()) {
3957
+ const n = nameOf(id);
3958
+ if (!n)
3959
+ continue;
3960
+ arr.push({ name: trim(n), weight: Number(w.toFixed(2)) });
3961
+ }
3962
+ arr.sort((a, b) => (b.weight !== a.weight ? b.weight - a.weight : a.name.localeCompare(b.name)));
3963
+ return arr.slice(0, topK);
3964
+ };
3965
+ return {
3966
+ peopleCount: peopleAgg.size,
3967
+ eventCount: eventAgg.size,
3968
+ entityCount: entityAgg.size,
3969
+ topPeople: toTopK(peopleAgg, id => personMap.get(id)?.displayName ?? id),
3970
+ topEvents: toTopK(eventAgg, id => eventMap.get(id)?.title),
3971
+ topEntities: toTopK(entityAgg, id => entityMap.get(id)?.name),
3972
+ };
3973
+ }
3974
+ /**
3975
+ * 计算节点的「方向性出入度剖面」:返回 outByType / inByType / dominance / fanIdolHint。
3976
+ *
3977
+ * 仅统计 **有向的主体边**(person-person / event-event / entity-entity 且 directed=true)。
3978
+ * 桥型边(person-event / person-entity / event-entity)按设计天然双向,是"参与"不是"指代",
3979
+ * 不计入此剖面。
3980
+ *
3981
+ * 返回的 `outByType` 含义:节点作为 from 端发出的边("我主动指向谁"),按 relationType 分桶;
3982
+ * `inByType`:节点作为 to 端接收的边("谁指向我")。每个桶含 count / totalWeight / top-K 对端节点。
3983
+ *
3984
+ * `dominance` 启发式判断:
3985
+ * - outTotal - inTotal >= 2 且 outTotal/inTotal >= 1.5 → 'outgoing'(更偏向"主动方",如典型粉丝/学生)
3986
+ * - inTotal - outTotal >= 2 且 inTotal/outTotal >= 1.5 → 'incoming'(更偏向"被指方",如典型偶像/导师)
3987
+ * - 否则 'balanced'
3988
+ *
3989
+ * `fanIdolHint` 专门拎出 admirer 关系:fansCount = 入度 admirer(多少人 admire 我),
3990
+ * idolsCount = 出度 admirer(我 admire 多少人)。verdict 给出粗判。
3991
+ */
3992
+ async computeDirectionalDegree(nodeId, options = {}) {
3993
+ const topPerType = Math.max(1, Math.min(20, options.topPerType ?? 5));
3994
+ const snap = await this.store.loadAll();
3995
+ let kind = null;
3996
+ let name = '';
3997
+ const person = snap.persons.find(p => p.id === nodeId);
3998
+ if (person) {
3999
+ kind = 'person';
4000
+ name = person.displayName ?? person.id;
4001
+ }
4002
+ else {
4003
+ const ev = snap.events.find(e => e.id === nodeId);
4004
+ if (ev) {
4005
+ kind = 'event';
4006
+ name = ev.title;
4007
+ }
4008
+ else {
4009
+ const ent = snap.entities.find(e => e.id === nodeId);
4010
+ if (ent) {
4011
+ kind = 'entity';
4012
+ name = ent.name;
4013
+ }
4014
+ }
4015
+ }
4016
+ if (!kind)
4017
+ return null;
4018
+ const nodeKindOf = (id) => {
4019
+ if (id.includes(':'))
4020
+ return snap.persons.some(p => p.id === id) ? 'person' : null;
4021
+ if (snap.events.some(e => e.id === id))
4022
+ return 'event';
4023
+ if (snap.entities.some(e => e.id === id))
4024
+ return 'entity';
4025
+ return null;
4026
+ };
4027
+ const nodeNameOf = (id, k) => {
4028
+ if (k === 'person')
4029
+ return snap.persons.find(p => p.id === id)?.displayName ?? id;
4030
+ if (k === 'event')
4031
+ return snap.events.find(e => e.id === id)?.title ?? id;
4032
+ return snap.entities.find(e => e.id === id)?.name ?? id;
4033
+ };
4034
+ const outBuckets = new Map();
4035
+ const inBuckets = new Map();
4036
+ const add = (bucket, relType, otherId, weight) => {
4037
+ let b = bucket.get(relType);
4038
+ if (!b) {
4039
+ b = { count: 0, totalWeight: 0, items: [] };
4040
+ bucket.set(relType, b);
4041
+ }
4042
+ b.count += 1;
4043
+ b.totalWeight += weight;
4044
+ b.items.push({ otherId, weight });
4045
+ };
4046
+ for (const e of snap.edges) {
4047
+ // 仅有向的主体边
4048
+ if (e.kind === 'person-person' && e.directed) {
4049
+ if (e.fromPersonId === nodeId)
4050
+ add(outBuckets, e.relationType, e.toPersonId, e.weight ?? 0);
4051
+ else if (e.toPersonId === nodeId)
4052
+ add(inBuckets, e.relationType, e.fromPersonId, e.weight ?? 0);
4053
+ }
4054
+ else if (e.kind === 'event-event' && e.directed) {
4055
+ if (e.fromEventId === nodeId)
4056
+ add(outBuckets, e.relationType, e.toEventId, e.weight ?? 0);
4057
+ else if (e.toEventId === nodeId)
4058
+ add(inBuckets, e.relationType, e.fromEventId, e.weight ?? 0);
4059
+ }
4060
+ else if (e.kind === 'entity-entity' && e.directed) {
4061
+ if (e.fromEntityId === nodeId)
4062
+ add(outBuckets, e.relationType, e.toEntityId, e.weight ?? 0);
4063
+ else if (e.toEntityId === nodeId)
4064
+ add(inBuckets, e.relationType, e.fromEntityId, e.weight ?? 0);
4065
+ }
4066
+ }
4067
+ const serialize = (bucket) => {
4068
+ const out = {};
4069
+ for (const [relType, b] of bucket) {
4070
+ const sorted = b.items
4071
+ .slice()
4072
+ .sort((a, c) => c.weight - a.weight)
4073
+ .slice(0, topPerType);
4074
+ out[relType] = {
4075
+ count: b.count,
4076
+ totalWeight: Number(b.totalWeight.toFixed(4)),
4077
+ top: sorted
4078
+ .map(it => {
4079
+ const k = nodeKindOf(it.otherId);
4080
+ if (!k)
4081
+ return null;
4082
+ return {
4083
+ otherId: it.otherId,
4084
+ otherName: nodeNameOf(it.otherId, k),
4085
+ weight: Number(it.weight.toFixed(4)),
4086
+ kind: k,
4087
+ };
4088
+ })
4089
+ .filter((x) => x !== null),
4090
+ };
4091
+ }
4092
+ return out;
4093
+ };
4094
+ let outTotal = 0;
4095
+ let inTotal = 0;
4096
+ for (const b of outBuckets.values())
4097
+ outTotal += b.count;
4098
+ for (const b of inBuckets.values())
4099
+ inTotal += b.count;
4100
+ let dominance = 'balanced';
4101
+ if (outTotal - inTotal >= 2 && outTotal >= 1.5 * Math.max(1, inTotal))
4102
+ dominance = 'outgoing';
4103
+ else if (inTotal - outTotal >= 2 && inTotal >= 1.5 * Math.max(1, outTotal))
4104
+ dominance = 'incoming';
4105
+ const fansCount = inBuckets.get('admirer')?.count ?? 0;
4106
+ const idolsCount = outBuckets.get('admirer')?.count ?? 0;
4107
+ let verdict = 'none';
4108
+ if (fansCount === 0 && idolsCount === 0)
4109
+ verdict = 'none';
4110
+ else if (fansCount >= idolsCount + 2)
4111
+ verdict = 'idol-leaning';
4112
+ else if (idolsCount >= fansCount + 2)
4113
+ verdict = 'fan-leaning';
4114
+ else
4115
+ verdict = 'mutual';
4116
+ return {
4117
+ nodeId,
4118
+ kind,
4119
+ name,
4120
+ outTotal,
4121
+ inTotal,
4122
+ outByType: serialize(outBuckets),
4123
+ inByType: serialize(inBuckets),
4124
+ dominance,
4125
+ fanIdolHint: { fansCount, idolsCount, verdict },
4126
+ };
4127
+ }
4128
+ /**
4129
+ * 同社群活跃成员(Louvain 社群标签由 evictByQuota 写入;未跑过则返回空)。
4130
+ *
4131
+ * 用途:profile 注入 / agent 工具——「跟 X 同一个圈子的高活跃成员是谁」。
4132
+ *
4133
+ * - personId 必须是 `<platform>:<userId>` 完整 ID;
4134
+ * - 仅返回 person 类型的同社群成员(事件/实体也有 communityId 但用户视角无意义);
4135
+ * - 按 lastPageRank desc 排序,截断到 limit;
4136
+ * - 自己不会出现在结果里;
4137
+ * - 如果 personId 没有 communityId(节点太新或从未跑过 evict)→ communitySize=0、peers=[]。
4138
+ */
4139
+ async getCommunityPeers(personId, limit = 5) {
4140
+ const snap = await this.store.loadAll();
4141
+ const me = snap.persons.find(p => p.id === personId);
4142
+ if (!me?.communityId) {
4143
+ return { personId, communityId: me?.communityId ?? null, communitySize: 0, peers: [] };
4144
+ }
4145
+ const cid = me.communityId;
4146
+ const same = snap.persons.filter(p => p.communityId === cid && p.id !== personId);
4147
+ const ranked = same.slice().sort((a, b) => (b.lastPageRank ?? 0) - (a.lastPageRank ?? 0));
4148
+ const peers = ranked.slice(0, Math.max(1, Math.min(limit, 20))).map(p => ({
4149
+ id: p.id,
4150
+ displayName: p.displayName ?? p.id,
4151
+ pagerank: Number((p.lastPageRank ?? 0).toFixed(6)),
4152
+ communityId: cid,
4153
+ }));
4154
+ // communitySize 含自己
4155
+ return { personId, communityId: cid, communitySize: same.length + 1, peers };
4156
+ }
4157
+ /**
4158
+ * 两人是否同社群 + 各自社群信息(agent 工具 community_bridge 用)。
4159
+ *
4160
+ * 不算路径——find_path 已有同等能力,重复实现徒增维护。
4161
+ */
4162
+ async getCommunityBridge(personAId, personBId) {
4163
+ const snap = await this.store.loadAll();
4164
+ const pa = snap.persons.find(p => p.id === personAId);
4165
+ const pb = snap.persons.find(p => p.id === personBId);
4166
+ const sizeOf = (cid) => {
4167
+ if (!cid)
4168
+ return 0;
4169
+ return snap.persons.filter(p => p.communityId === cid).length;
4170
+ };
4171
+ return {
4172
+ a: {
4173
+ id: personAId,
4174
+ displayName: pa?.displayName ?? personAId,
4175
+ communityId: pa?.communityId ?? null,
4176
+ communitySize: sizeOf(pa?.communityId),
4177
+ },
4178
+ b: {
4179
+ id: personBId,
4180
+ displayName: pb?.displayName ?? personBId,
4181
+ communityId: pb?.communityId ?? null,
4182
+ communitySize: sizeOf(pb?.communityId),
4183
+ },
4184
+ sameCommunity: !!pa?.communityId && pa.communityId === pb?.communityId,
4185
+ };
4186
+ }
4187
+ /**
4188
+ * 全图社群概览:按 community 分组,每组列 top 成员/话题/事件,再算 modularity Q 和"桥梁人"。
4189
+ *
4190
+ * 设计要点:
4191
+ * - `algorithm` 不传默认实时跑一遍 Louvain;传 'leiden' 跑 Leiden-lite;传 'slpa' 跑 SLPA(原生重叠)。
4192
+ * **不读节点上的 communityId 缓存**,保证每次调用结果与当前快照严格一致(即使 evictByQuota 还没跑)。
4193
+ * - `sessionScope` 是**后过滤**:先在全图上跑社群算法(保证社群划分准确),再只统计 evidence 含该 scope
4194
+ * 的节点,避免把"跨群关系"切断。
4195
+ * - 每个节点的**分组归属**始终按"主社群"(memberships[0].id,即 SLPA 下 weight 最高的 label);
4196
+ * 保持 topMembers/Topics/Events 语义清晰,避免一个节点在多社群重复出现稀释 LLM 注意力。
4197
+ * - `topN`:每个社群展示的成员/话题/事件条数。默认动态:log2 自适应。传 0 = 不限。
4198
+ * - `bridges`:跨社群联系最广的 top-K person。`crossCommunityDegree` = 邻居中不在该 person 自身**任一**社群
4199
+ * 隶属里的"外社群"个数(SLPA 下自然把跨群人物的多归属考虑进去);`communityWeights` 给出按"外社群"分组的
4200
+ * 邻居边权累计(边越重 / 邻居越多 → weight 越大),供 LLM 判断该桥梁人物的跨群强度分布。
4201
+ *
4202
+ * Q(modularity)值粗判:Q > 0.3 = 圈子分明;0.1 ~ 0.3 = 一般;< 0.1 = 接近随机划分。
4203
+ * SLPA 下 Q 用"主社群"作为硬划分近似计算,仅作参考——重叠社区没有标准 modularity 定义。
4204
+ */
4205
+ async getCommunityOverview(opts) {
4206
+ const snap = await this.store.loadAll();
4207
+ const alg = opts?.algorithm ?? 'louvain';
4208
+ const resolutionMode = opts?.resolution === 'auto' ? 'auto' : opts?.resolution !== undefined ? 'explicit' : 'default';
4209
+ const resolution = opts?.resolution === 'auto' ? computeAdaptiveResolution(snap) : opts?.resolution;
4210
+ const effectiveResolution = alg === 'slpa' ? null : (resolution ?? 1.0);
4211
+ // 统一到 Map<nodeId, CommunityMembership[]>(按 weight 降序,memberships[0] = 主社群)
4212
+ let memberships;
4213
+ if (alg === 'slpa') {
4214
+ memberships = computeSlpa(snap);
4215
+ }
4216
+ else {
4217
+ const com = alg === 'leiden' ? computeLeiden(snap, { resolution }) : computeLouvain(snap, { resolution });
4218
+ memberships = new Map();
4219
+ for (const [id, cid] of com)
4220
+ memberships.set(id, [{ id: cid, weight: 1 }]);
4221
+ }
4222
+ // 主社群映射(用于分组与 modularity 近似计算)
4223
+ const primary = new Map();
4224
+ for (const [id, list] of memberships) {
4225
+ if (list.length > 0)
4226
+ primary.set(id, list[0].id);
4227
+ }
4228
+ const modularity = computeModularity(snap, primary);
4229
+ // topN 解析(保持原逻辑)
4230
+ const rawTopN = opts?.topN;
4231
+ const useAdaptive = rawTopN === undefined;
4232
+ const fixedCap = rawTopN === 0
4233
+ ? Number.MAX_SAFE_INTEGER
4234
+ : rawTopN !== undefined
4235
+ ? Math.max(1, Math.min(rawTopN, 2000))
4236
+ : Number.MAX_SAFE_INTEGER;
4237
+ const adaptiveTopN = (size) => Math.max(3, Math.ceil(Math.log2(Math.max(size, 1) + 1)));
4238
+ // sessionScope 后过滤
4239
+ const scope = opts?.sessionScope;
4240
+ const inScopeEvent = (ev) => {
4241
+ if (!scope)
4242
+ return true;
4243
+ return ev.sessionScope === scope;
4244
+ };
4245
+ const inScopeEntity = (en) => {
4246
+ if (!scope)
4247
+ return true;
4248
+ return en.evidence.some(e => e.sessionId === scope);
4249
+ };
4250
+ const eventsInScope = snap.events.filter(inScopeEvent);
4251
+ const entitiesInScope = snap.entities.filter(inScopeEntity);
4252
+ let personsInScope;
4253
+ if (!scope) {
4254
+ personsInScope = snap.persons;
4255
+ }
4256
+ else {
4257
+ const scopeEventIds = new Set(eventsInScope.map(e => e.id));
4258
+ const scopePersonIds = new Set();
4259
+ for (const e of snap.edges) {
4260
+ if (e.kind === 'person-event' && scopeEventIds.has(e.toEventId)) {
4261
+ scopePersonIds.add(e.fromPersonId);
4262
+ }
4263
+ }
4264
+ personsInScope = snap.persons.filter(p => scopePersonIds.has(p.id));
4265
+ }
4266
+ const inScopePerson = (p) => {
4267
+ if (!scope)
4268
+ return true;
4269
+ return personsInScope.includes(p);
4270
+ };
4271
+ // 按 community 分组(用 primary 主社群,避免一节点重复列在多社群)
4272
+ const personsByCom = new Map();
4273
+ const eventsByCom = new Map();
4274
+ const entitiesByCom = new Map();
4275
+ for (const p of personsInScope) {
4276
+ const c = primary.get(p.id);
4277
+ if (!c)
4278
+ continue;
4279
+ if (!personsByCom.has(c))
4280
+ personsByCom.set(c, []);
4281
+ personsByCom.get(c).push(p);
4282
+ }
4283
+ for (const ev of eventsInScope) {
4284
+ const c = primary.get(ev.id);
4285
+ if (!c)
4286
+ continue;
4287
+ if (!eventsByCom.has(c))
4288
+ eventsByCom.set(c, []);
4289
+ eventsByCom.get(c).push(ev);
4290
+ }
4291
+ for (const en of entitiesInScope) {
4292
+ const c = primary.get(en.id);
4293
+ if (!c)
4294
+ continue;
4295
+ if (!entitiesByCom.has(c))
4296
+ entitiesByCom.set(c, []);
4297
+ entitiesByCom.get(c).push(en);
4298
+ }
4299
+ const allCommunityIds = Array.from(new Set([...personsByCom.keys(), ...eventsByCom.keys(), ...entitiesByCom.keys()]));
4300
+ allCommunityIds.sort((a, b) => (personsByCom.get(b)?.length ?? 0) - (personsByCom.get(a)?.length ?? 0));
4301
+ const communities = allCommunityIds.map(cid => {
4302
+ const members = (personsByCom.get(cid) ?? [])
4303
+ .slice()
4304
+ .sort((a, b) => (b.lastPageRank ?? 0) - (a.lastPageRank ?? 0));
4305
+ const topics = (entitiesByCom.get(cid) ?? [])
4306
+ .slice()
4307
+ .sort((a, b) => (b.lastPageRank ?? 0) - (a.lastPageRank ?? 0));
4308
+ const events = (eventsByCom.get(cid) ?? []).slice().sort((a, b) => (b.weight ?? 0) - (a.weight ?? 0));
4309
+ const comTotal = members.length + topics.length + events.length;
4310
+ const perComTopN = useAdaptive ? adaptiveTopN(comTotal) : fixedCap;
4311
+ return {
4312
+ communityId: cid,
4313
+ size: members.length,
4314
+ topMembers: members.slice(0, perComTopN).map(p => ({
4315
+ id: p.id,
4316
+ displayName: p.displayName ?? p.id,
4317
+ pagerank: Number((p.lastPageRank ?? 0).toFixed(6)),
4318
+ })),
4319
+ topTopics: topics.slice(0, perComTopN).map(en => ({
4320
+ id: en.id,
4321
+ name: en.name,
4322
+ pagerank: Number((en.lastPageRank ?? 0).toFixed(6)),
4323
+ })),
4324
+ topEvents: events.slice(0, perComTopN).map(ev => ({
4325
+ id: ev.id,
4326
+ title: ev.title ?? ev.id,
4327
+ weight: Number((ev.weight ?? 0).toFixed(4)),
4328
+ ...(ev.sessionScope ? { sessionScope: ev.sessionScope } : {}),
4329
+ })),
4330
+ };
4331
+ });
4332
+ const personNbrs = new Map();
4333
+ const ensureNbrs = (id) => {
4334
+ let m = personNbrs.get(id);
4335
+ if (!m) {
4336
+ m = new Map();
4337
+ personNbrs.set(id, m);
4338
+ }
4339
+ return m;
4340
+ };
4341
+ const addNbr = (a, b, w) => {
4342
+ if (a === b)
4343
+ return;
4344
+ const ma = ensureNbrs(a);
4345
+ ma.set(b, (ma.get(b) ?? 0) + w);
4346
+ const mb = ensureNbrs(b);
4347
+ mb.set(a, (mb.get(a) ?? 0) + w);
4348
+ };
4349
+ // 直连 person-person
4350
+ for (const e of snap.edges) {
4351
+ if (e.kind === 'person-person') {
4352
+ addNbr(e.fromPersonId, e.toPersonId, Math.max(e.weight ?? 0.5, 0.05));
4353
+ }
4354
+ }
4355
+ // 通过 event 二跳:同 event 下任意两人配对
4356
+ const personsByEvent = new Map();
4357
+ for (const e of snap.edges) {
4358
+ if (e.kind === 'person-event') {
4359
+ const arr = personsByEvent.get(e.toEventId) ?? [];
4360
+ arr.push({ id: e.fromPersonId, w: Math.max(e.weight ?? 0.5, 0.05) });
4361
+ personsByEvent.set(e.toEventId, arr);
4362
+ }
4363
+ }
4364
+ for (const arr of personsByEvent.values()) {
4365
+ for (let i = 0; i < arr.length; i++) {
4366
+ for (let j = i + 1; j < arr.length; j++) {
4367
+ // 二跳边权取两端 min(更保守,避免热点事件把边权放大)
4368
+ addNbr(arr[i].id, arr[j].id, Math.min(arr[i].w, arr[j].w));
4369
+ }
4370
+ }
4371
+ }
4372
+ // 通过 entity 二跳:同 entity 下任意两人配对
4373
+ const personsByEntity = new Map();
4374
+ for (const e of snap.edges) {
4375
+ if (e.kind === 'person-entity') {
4376
+ const arr = personsByEntity.get(e.toEntityId) ?? [];
4377
+ arr.push({ id: e.fromPersonId, w: Math.max(e.weight ?? 0.5, 0.05) });
4378
+ personsByEntity.set(e.toEntityId, arr);
4379
+ }
4380
+ }
4381
+ for (const arr of personsByEntity.values()) {
4382
+ for (let i = 0; i < arr.length; i++) {
4383
+ for (let j = i + 1; j < arr.length; j++) {
4384
+ addNbr(arr[i].id, arr[j].id, Math.min(arr[i].w, arr[j].w));
4385
+ }
4386
+ }
4387
+ }
4388
+ const personById = new Map(snap.persons.map(p => [p.id, p]));
4389
+ const bridgeArr = [];
4390
+ for (const [pid, nbrs] of personNbrs) {
4391
+ const p = personById.get(pid);
4392
+ if (!p)
4393
+ continue;
4394
+ if (scope && !inScopePerson(p))
4395
+ continue;
4396
+ const myList = memberships.get(pid) ?? [];
4397
+ if (myList.length === 0)
4398
+ continue;
4399
+ const myCom = new Set(myList.map(m => m.id));
4400
+ // 累计邻居外社群的加权得分
4401
+ const weightByCom = new Map();
4402
+ for (const [nbId, edgeW] of nbrs) {
4403
+ const nbList = memberships.get(nbId);
4404
+ if (!nbList || nbList.length === 0)
4405
+ continue;
4406
+ for (const m of nbList) {
4407
+ if (myCom.has(m.id))
4408
+ continue; // 跳过自身社群
4409
+ weightByCom.set(m.id, (weightByCom.get(m.id) ?? 0) + edgeW * m.weight);
4410
+ }
4411
+ }
4412
+ if (weightByCom.size === 0)
4413
+ continue;
4414
+ const communityWeights = [...weightByCom.entries()]
4415
+ .map(([communityId, weight]) => ({ communityId, weight: Number(weight.toFixed(4)) }))
4416
+ .sort((a, b) => b.weight - a.weight);
4417
+ bridgeArr.push({
4418
+ id: pid,
4419
+ displayName: p.displayName ?? pid,
4420
+ communityId: myList[0].id,
4421
+ communityMemberships: myList,
4422
+ crossCommunityDegree: communityWeights.length,
4423
+ communityWeights,
4424
+ });
4425
+ }
4426
+ // 按"跨群总权重"降序排(不再只看 degree 数量,权重更能反映强度)
4427
+ bridgeArr.sort((a, b) => {
4428
+ const sa = a.communityWeights.reduce((s, w) => s + w.weight, 0);
4429
+ const sb = b.communityWeights.reduce((s, w) => s + w.weight, 0);
4430
+ return sb - sa;
4431
+ });
4432
+ const bridgesTopN = useAdaptive
4433
+ ? Math.max(3, Math.ceil(Math.log2(Math.max(personsInScope.length, 1) + 1)))
4434
+ : fixedCap;
4435
+ return {
4436
+ algorithm: alg,
4437
+ effectiveResolution,
4438
+ resolutionMode,
4439
+ numCommunities: allCommunityIds.length,
4440
+ modularity: Number(modularity.toFixed(4)),
4441
+ totalPersonsInScope: personsInScope.length,
4442
+ totalEventsInScope: eventsInScope.length,
4443
+ totalEntitiesInScope: entitiesInScope.length,
4444
+ communities,
4445
+ bridges: bridgeArr.slice(0, bridgesTopN),
4446
+ };
4447
+ }
4448
+ }
4449
+ /**
4450
+ * compositeScore + 同 kind 百分位双门槛分级。绝对分给"够亮"的小图节点保底,
4451
+ * 百分位给"大图但绝对分都低"的相对核心节点保底。
4452
+ */
4453
+ export function scoreToTier(score, percentile) {
4454
+ if (score >= 0.6 || percentile >= 0.9)
4455
+ return 'core';
4456
+ if (score >= 0.4 || percentile >= 0.7)
4457
+ return 'active';
4458
+ if (score >= 0.2 || percentile >= 0.4)
4459
+ return 'normal';
4460
+ return 'edge';
4461
+ }
4462
+ //# sourceMappingURL=service.js.map