@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,1356 @@
1
+ import { LLMCapabilities, resolveLLMModel } from '@aalis/plugin-llm-api';
2
+ import { WellKnownKinds } from '@aalis/plugin-message-api';
3
+ import { getPlatformNames } from '@aalis/plugin-platform-api';
4
+ import { parseLLMJsonObject } from '@aalis/util-json-repair';
5
+ import { RecommendedEntityEntityRelationTypes, RecommendedEventEntityRelationTypes, RecommendedEventEventRelationTypes, RecommendedPersonRelationTypes, } from './types.js';
6
+ import { normalizeName } from './utils.js';
7
+ /**
8
+ * `ExtractorConfig` 的单一默认值真源。
9
+ *
10
+ * 用途:
11
+ * - 测试构造 extractor 时直接 spread,避免每次新增字段都要在多份 fixture 里手补默认值;
12
+ * - `index.ts` apply() 的 `numCfg(config.x, DEFAULT)` 默认值也应优先从此处取(人工同步即可,因为 apply 路径有 string→number 解析需求)。
13
+ *
14
+ * **约束**:必须包含 `ExtractorConfig` 所有必填字段。`satisfies` 在编译期保证遗漏即报错——
15
+ * 给 `ExtractorConfig` 加新必填字段时,TS 会直接拒绝编译,倒逼此处同步,根治"测试 / 运行时
16
+ * 默认值漂移"问题。
17
+ *
18
+ * 可选字段(`?:`)默认留空(运行时按需启用)。
19
+ */
20
+ export const EXTRACTOR_CONFIG_DEFAULTS = {
21
+ triggerEveryNMessages: 20,
22
+ readWindowSize: 30,
23
+ mode: 'incremental',
24
+ allNewMaxMessages: 200,
25
+ candidateEventDays: 7,
26
+ candidateEventLimit: 20,
27
+ senderNeighborhoodEdgeLimit: 8,
28
+ disableThinking: true,
29
+ strictSelfAssertion: true,
30
+ evictionEnabled: true,
31
+ maxPersons: 1500,
32
+ maxEvents: 2500,
33
+ maxEntities: 1500,
34
+ maxEdges: 10000,
35
+ pagerankDamping: 0.85,
36
+ pagerankIterations: 20,
37
+ pagerankEpsilon: 1e-4,
38
+ evictHysteresisPct: 0.2,
39
+ evictTargetPct: 0.8,
40
+ weightDecayHalfLifeDays: 180,
41
+ weightDecayFloor: 0.3,
42
+ communityAlgorithm: 'louvain',
43
+ consolidateAfterEviction: true,
44
+ consolidateLLMDisableThinking: true,
45
+ consolidateAutoLink: false,
46
+ consolidateSkipLowScorePairs: true,
47
+ consolidateLowScoreThreshold: 0.2,
48
+ debug: false,
49
+ };
50
+ const VALID_ROLES = ['initiator', 'participant', 'witness', 'target', 'reporter'];
51
+ const VALID_SENTIMENTS = ['positive', 'negative', 'neutral', 'mixed'];
52
+ const VALID_CATEGORIES = ['discussion', 'conflict', 'collaboration', 'incident', 'milestone', 'other'];
53
+ const VALID_ENTITY_KINDS = ['topic', 'place', 'thing', 'work'];
54
+ const VALID_PERSON_ENTITY_ROLES = [
55
+ 'enthusiast',
56
+ 'participant',
57
+ 'owner',
58
+ 'creator',
59
+ 'critic',
60
+ 'visitor',
61
+ 'mentioned',
62
+ ];
63
+ /**
64
+ * 占位/伪 person 守卫:用于过滤 LLM 抽出的「不真实」person id(典型如
65
+ * `aalis:aalis` / `mia:mia` / `discord:xxx`——历史 prompt/渲染遗留下来的自指占位)。
66
+ *
67
+ * 设计原则(**persona-agnostic**):
68
+ * - 「真实平台」 = `getPlatformNames(ctx)` 返回的、当前运行时实际注册了 adapter
69
+ * 的平台集合。persona 改名、配置改名都不影响判定。
70
+ * - 通用 placeholder userId(self/me/bot/assistant)作为兜底,捕获
71
+ * `onebot:self` 这类「平台合法、id 是自指占位」的情形。**不再硬编码 persona
72
+ * 专属词**(aalis / 本机器人 / 机器人 / Mia / ...)。
73
+ *
74
+ * 判定规则(任一命中即视为占位):
75
+ * 1) platform / userId 为空;
76
+ * 2) 当 ctx 提供的 `knownPlatforms` 非空时,`platform` 小写不在白名单中;
77
+ * (为空时跳过该检查——保护「无 adapter 已注册」的测试 / 空环境场景,避免
78
+ * 误删旧数据)
79
+ * 3) `userId` 小写命中通用占位词 `{self, me, bot, assistant}`。
80
+ *
81
+ * 一致性:extractor.applyExtraction、`/relation cleanup fake-self` 命令、
82
+ * `RelationService.consolidate` 都共用本函数,保证三处口径完全一致。
83
+ */
84
+ const GENERIC_PLACEHOLDER_USERIDS = new Set(['self', 'me', 'bot', 'assistant']);
85
+ /** 取当前运行时已注册的平台名集合(小写)。无 adapter 时返回空集——调用方应
86
+ * 在传入 isPlaceholderSelfPersonId 时把「空集」视为 permissive。 */
87
+ export function getKnownPlatformsLower(ctx) {
88
+ try {
89
+ return new Set(getPlatformNames(ctx).map(p => p.toLowerCase()));
90
+ }
91
+ catch {
92
+ return new Set();
93
+ }
94
+ }
95
+ export function isPlaceholderSelfPersonId(platform, userId, knownPlatforms) {
96
+ if (!platform || !userId)
97
+ return true;
98
+ if (knownPlatforms && knownPlatforms.size > 0 && !knownPlatforms.has(platform.toLowerCase())) {
99
+ return true;
100
+ }
101
+ if (GENERIC_PLACEHOLDER_USERIDS.has(userId.toLowerCase()))
102
+ return true;
103
+ return false;
104
+ }
105
+ export class RelationExtractor {
106
+ ctx;
107
+ service;
108
+ cfg;
109
+ counts = new Map();
110
+ inFlight = new Set();
111
+ disposeListener;
112
+ constructor(ctx, service, cfg) {
113
+ this.ctx = ctx;
114
+ this.service = service;
115
+ this.cfg = cfg;
116
+ }
117
+ start() {
118
+ const handler = (...args) => {
119
+ const data = args[0];
120
+ if (!data?.sessionId)
121
+ return;
122
+ const n = (this.counts.get(data.sessionId) ?? 0) + 1;
123
+ this.counts.set(data.sessionId, n);
124
+ if (this.cfg.triggerEveryNMessages <= 0)
125
+ return;
126
+ if (n % this.cfg.triggerEveryNMessages !== 0)
127
+ return;
128
+ void this.extractSession(data.sessionId).catch(err => this.ctx.logger.debug(`[user-relation] 提取异常 session=${data.sessionId}: ${stringifyErr(err)}`));
129
+ };
130
+ this.ctx.on('inbound:message:archived', handler);
131
+ this.disposeListener = () => {
132
+ // ctx.on 在 dispose 时已自动清理,这里仅做幂等占位
133
+ };
134
+ }
135
+ stop() {
136
+ this.disposeListener?.();
137
+ this.counts.clear();
138
+ this.inFlight.clear();
139
+ }
140
+ /** 手动触发某 session 的提取(用于 page-action 的"立即提取"按钮) */
141
+ async triggerNow(sessionId, opts) {
142
+ if (this.inFlight.has(sessionId))
143
+ return { status: 'skipped', reason: 'in-flight' };
144
+ try {
145
+ await this.extractSession(sessionId, opts?.readScope);
146
+ return { status: 'ok' };
147
+ }
148
+ catch (err) {
149
+ return { status: 'error', reason: stringifyErr(err) };
150
+ }
151
+ }
152
+ async extractSession(sessionId, readScopeOverride) {
153
+ if (this.inFlight.has(sessionId))
154
+ return;
155
+ this.inFlight.add(sessionId);
156
+ try {
157
+ const memory = this.ctx.getService('memory');
158
+ if (!memory?.getHistory) {
159
+ if (this.cfg.debug)
160
+ this.ctx.logger.debug('[user-relation] memory.getHistory 不可用,跳过');
161
+ return;
162
+ }
163
+ const limit = this.cfg.mode === 'all-new' ? this.cfg.allNewMaxMessages : this.cfg.readWindowSize;
164
+ const readScope = readScopeOverride ?? this.cfg.readScope ?? 'same-session';
165
+ // history: Message[] 数组(用于 LLM prompt 渲染 + validMessageIds 校验);
166
+ // messageIdToSessionId: messageId -> 来源 sessionId(跨会话模式下,evidence.sessionId 据此回写真实来源)
167
+ // crossSession=true 时渲染层会自动给每条消息加 [sid] 前缀帮助 LLM 区分来源
168
+ let history;
169
+ let messageIdToSessionId;
170
+ const crossSession = readScope !== 'same-session';
171
+ if (!crossSession) {
172
+ const raw = (await memory.getHistory(sessionId, limit)).filter(m => m.kind !== WellKnownKinds.CrossSessionDelegation);
173
+ history = raw;
174
+ messageIdToSessionId = new Map();
175
+ for (const m of raw) {
176
+ const meta = m.metadata ?? {};
177
+ if (meta.messageId)
178
+ messageIdToSessionId.set(meta.messageId, sessionId);
179
+ }
180
+ }
181
+ else {
182
+ if (!memory.getRecentMessagesAcrossSessions) {
183
+ if (this.cfg.debug)
184
+ this.ctx.logger.debug(`[user-relation] readScope=${readScope} 但 memory 后端不支持 getRecentMessagesAcrossSessions,降级到 same-session`);
185
+ history = (await memory.getHistory(sessionId, limit)).filter(m => m.kind !== WellKnownKinds.CrossSessionDelegation);
186
+ messageIdToSessionId = new Map();
187
+ for (const m of history) {
188
+ const meta = m.metadata ?? {};
189
+ if (meta.messageId)
190
+ messageIdToSessionId.set(meta.messageId, sessionId);
191
+ }
192
+ }
193
+ else {
194
+ // 推断当前 sessionId 的 platform:从首条带 platform 的 message 推;否则不限平台
195
+ const peek = await memory.getHistory(sessionId, 3).catch(() => []);
196
+ const currentPlatform = inferPlatform(peek);
197
+ const maxAge = Math.max(0, this.cfg.crossSessionMaxAgeMinutes ?? 60);
198
+ const sinceTs = maxAge > 0 ? Date.now() - maxAge * 60_000 : undefined;
199
+ const records = await memory.getRecentMessagesAcrossSessions({
200
+ limit,
201
+ sinceTs,
202
+ platform: readScope === 'same-platform' ? currentPlatform : undefined,
203
+ roles: ['user', 'assistant', 'notice'],
204
+ excludeKinds: [WellKnownKinds.CrossSessionDelegation],
205
+ });
206
+ messageIdToSessionId = new Map();
207
+ // 把 sessionId 注入到 message.metadata.__extractorSessionId(运行时临时字段,仅用于渲染/反查)
208
+ history = records.map(r => {
209
+ const meta = r.message.metadata ?? {};
210
+ if (typeof meta.messageId === 'string')
211
+ messageIdToSessionId.set(meta.messageId, r.sessionId);
212
+ return {
213
+ ...r.message,
214
+ metadata: { ...meta, __extractorSessionId: r.sessionId },
215
+ };
216
+ });
217
+ }
218
+ }
219
+ const userMsgs = history.filter(m => m.role === 'user' && hasMessageId(m));
220
+ if (userMsgs.length === 0) {
221
+ if (this.cfg.debug)
222
+ this.ctx.logger.debug(`[user-relation] ${sessionId} 窗口内无可提取消息`);
223
+ return;
224
+ }
225
+ const modelEntry = resolveLLMModel(this.ctx, this.cfg.extractionModel, [LLMCapabilities.Chat]);
226
+ if (!modelEntry) {
227
+ if (this.cfg.debug)
228
+ this.ctx.logger.debug('[user-relation] 未找到可用 LLM,跳过提取');
229
+ return;
230
+ }
231
+ const platform = inferPlatform(userMsgs);
232
+ const { candidateEvents, candidateEntities } = await this.pickCandidates(userMsgs);
233
+ const senderNeighbors = await this.pickSenderNeighbors(userMsgs);
234
+ const promptMessages = buildExtractionPrompt(history, userMsgs, candidateEvents, candidateEntities, senderNeighbors, { crossSession, currentSessionId: sessionId });
235
+ const raw = await callLLM(modelEntry.instance, promptMessages, this.cfg.disableThinking);
236
+ let result = parseExtraction(raw);
237
+ if (result.kind === 'parse-error') {
238
+ // util-json-repair 已尝试剥 fence + 修裸引号 + 补括号;仍失败 → 多半是
239
+ // 模型彻底跑题(写了纯文本/markdown 段落)。给模型一次明确反馈再来一次,
240
+ // 避免一窗对话因为一次输出失败而完全丢失关系信号。
241
+ this.ctx.logger.warn(`[user-relation] LLM 输出无法解析为 JSON(model=${modelEntry.contextId}),尝试重试一次。原文前 200 字:${raw.slice(0, 200)}`);
242
+ const retryMessages = [
243
+ ...promptMessages,
244
+ { role: 'assistant', content: raw },
245
+ {
246
+ role: 'user',
247
+ content: '你上一条输出无法被 JSON.parse(很可能是包了 markdown 代码块、夹杂解释文字、或被截断)。' +
248
+ '请只输出**一个**合法的 JSON 对象,第一个字符必须是 `{`、最后一个字符必须是 `}`,' +
249
+ '禁止 ```json 围栏、禁止任何解释、禁止 markdown。如果实在没有可提取的内容,' +
250
+ '就输出 `{"persons":[],"events":[],"entities":[],"personEventEdges":[],"personEntityEdges":[],"personPersonEdges":[],"eventEventEdges":[],"eventEntityEdges":[],"entityEntityEdges":[]}`。',
251
+ },
252
+ ];
253
+ const rawRetry = await callLLM(modelEntry.instance, retryMessages, this.cfg.disableThinking);
254
+ result = parseExtraction(rawRetry);
255
+ if (result.kind === 'parse-error') {
256
+ this.ctx.logger.warn(`[user-relation] LLM 重试后仍无法解析 JSON(model=${modelEntry.contextId}),放弃本批次。重试原文前 200 字:${rawRetry.slice(0, 200)}`);
257
+ return;
258
+ }
259
+ this.ctx.logger.debug(`[user-relation] LLM 重试后解析成功(model=${modelEntry.contextId})`);
260
+ }
261
+ if (result.kind === 'empty') {
262
+ if (this.cfg.debug) {
263
+ this.ctx.logger.debug(`[user-relation] ${sessionId} LLM 明确表示本批次无可提取`);
264
+ }
265
+ return;
266
+ }
267
+ await this.applyExtraction(result.value, { sessionId, platform, history, messageIdToSessionId, crossSession });
268
+ }
269
+ finally {
270
+ this.inFlight.delete(sessionId);
271
+ }
272
+ }
273
+ /**
274
+ * 拉取候选事件 / 实体清单(缩短 LLM 上下文,避免重复创建)。
275
+ *
276
+ * **事件**:近 N 天活跃 → 按 lastReinforcedAt desc 取前 `candidateEventLimit`。
277
+ *
278
+ * **实体**:在事件策略基础上再叠加一层"名字命中"召回,解决"窗口里又提了某个老实体(已过 7 天活跃窗口)
279
+ * 而 LLM 看不到候选 → 重新建一份同名 entity 导致重复"的问题:
280
+ * 1. 近 N 天活跃 entity(与原行为一致,按 lastReinforcedAt desc,取 `candidateEventLimit * 2`)
281
+ * 2. 在当前消息窗口文本里以子串方式出现 normalize 后 name 或 aliases 的全库 entity(O(n) 扫描,
282
+ * n ≤ maxEntities ≈ 250,可忽略)。只对 normalize 长度 ≥2 的名字/别名做匹配,避免单字撞名。
283
+ * 两路按 id 去重后总长度截断到 `candidateEventLimit * 4`,避免 token 爆掉。
284
+ * 注:名字命中路径**带 id 一起塞**,保证 LLM 能直接填 `existingEntityId` 复用。
285
+ */
286
+ async pickCandidates(userMsgs = []) {
287
+ const snap = await this.service.loadAll();
288
+ const cutoff = Date.now() - this.cfg.candidateEventDays * 86_400_000;
289
+ const candidateEvents = snap.events
290
+ .filter(e => e.lastReinforcedAt >= cutoff)
291
+ .sort((a, b) => b.lastReinforcedAt - a.lastReinforcedAt)
292
+ .slice(0, this.cfg.candidateEventLimit);
293
+ const recentEntities = snap.entities
294
+ .filter(e => e.lastReinforcedAt >= cutoff)
295
+ .sort((a, b) => b.lastReinforcedAt - a.lastReinforcedAt)
296
+ .slice(0, this.cfg.candidateEventLimit * 2);
297
+ // 名字命中召回:把当前窗口里所有 user 消息文本拼成一段 normalize 后的"窗口文本",
298
+ // 再对全库 entity 做 substring 命中(防止"老实体超过 7 天活跃窗口"导致 LLM 看不到 → 重复创建)。
299
+ const recentIds = new Set(recentEntities.map(e => e.id));
300
+ const windowText = userMsgs
301
+ .map(m => (typeof m.content === 'string' ? m.content : ''))
302
+ .filter(Boolean)
303
+ .map(t => normalizeName(t))
304
+ .join('\n');
305
+ const nameMatched = [];
306
+ if (windowText) {
307
+ for (const e of snap.entities) {
308
+ if (recentIds.has(e.id))
309
+ continue;
310
+ const candidates = [e.name, ...(e.aliases ?? [])].map(s => normalizeName(s)).filter(s => s.length >= 2); // 单字撞名风险太大,跳过
311
+ if (candidates.some(k => windowText.includes(k))) {
312
+ nameMatched.push(e);
313
+ }
314
+ }
315
+ // 命中项按 lastReinforcedAt desc 排序,让"近期被强化过"的老节点优先进入候选
316
+ nameMatched.sort((a, b) => b.lastReinforcedAt - a.lastReinforcedAt);
317
+ }
318
+ const merged = [...recentEntities, ...nameMatched].slice(0, this.cfg.candidateEventLimit * 4);
319
+ return { candidateEvents, candidateEntities: merged };
320
+ }
321
+ /**
322
+ * 对窗口内每个已知发言人,拿其 1 跳邻居子图(按 weight 降序,截断到 N 条)。
323
+ * 目的:让 LLM 在「加强已有 vs 新建」判断时手里有真证据,避免反复创建同一人 / 同一兴趣的重复节点。
324
+ * 若 senderNeighborhoodEdgeLimit=0 或某 sender 在图中尚未存在,则跳过该 sender。
325
+ */
326
+ async pickSenderNeighbors(userMsgs) {
327
+ const limit = this.cfg.senderNeighborhoodEdgeLimit;
328
+ if (!limit || limit <= 0)
329
+ return [];
330
+ const senders = new Map();
331
+ for (const m of userMsgs) {
332
+ const meta = m.metadata ?? {};
333
+ if (!meta.userId || !meta.platform)
334
+ continue;
335
+ const key = `${meta.platform}:${meta.userId}`;
336
+ if (!senders.has(key)) {
337
+ senders.set(key, { platform: meta.platform, userId: meta.userId, nickname: meta.nickname });
338
+ }
339
+ }
340
+ if (senders.size === 0)
341
+ return [];
342
+ const snapshot = await this.service.loadAll();
343
+ const personById = new Map(snapshot.persons.map(p => [p.id, p]));
344
+ const eventById = new Map(snapshot.events.map(e => [e.id, e]));
345
+ const entityById = new Map(snapshot.entities.map(e => [e.id, e]));
346
+ const out = [];
347
+ for (const [key, s] of senders) {
348
+ if (!personById.has(key))
349
+ continue; // 新人 — 无邻居可注入
350
+ const edges = snapshot.edges.filter(e => {
351
+ if (e.kind === 'person-event')
352
+ return e.fromPersonId === key;
353
+ if (e.kind === 'person-entity')
354
+ return e.fromPersonId === key;
355
+ if (e.kind === 'person-person')
356
+ return e.fromPersonId === key || e.toPersonId === key;
357
+ return false;
358
+ });
359
+ if (edges.length === 0)
360
+ continue;
361
+ // 按 weight 降序,取 top N
362
+ const top = [...edges].sort((a, b) => (b.weight ?? 0) - (a.weight ?? 0)).slice(0, limit);
363
+ out.push({
364
+ personId: key,
365
+ platform: s.platform,
366
+ userId: s.userId,
367
+ nickname: s.nickname,
368
+ edges: top,
369
+ eventById,
370
+ entityById,
371
+ personById,
372
+ });
373
+ }
374
+ return out;
375
+ }
376
+ /** 把 LLM 输出落到关系图中 */
377
+ async applyExtraction(parsed, ctxInfo) {
378
+ const validMessageIds = new Set();
379
+ const contentBySid = new Map();
380
+ const senderBySid = new Map(); // messageId -> "platform:userId"
381
+ for (const m of ctxInfo.history) {
382
+ const meta = m.metadata ?? {};
383
+ const sid = meta.messageId;
384
+ if (sid) {
385
+ validMessageIds.add(sid);
386
+ contentBySid.set(sid, typeof m.content === 'string' ? m.content : JSON.stringify(m.content));
387
+ if (m.role === 'user' && meta.userId) {
388
+ senderBySid.set(sid, `${meta.platform ?? ctxInfo.platform}:${meta.userId}`);
389
+ }
390
+ }
391
+ }
392
+ const strict = this.cfg.strictSelfAssertion;
393
+ /** 严格自证校验:evidence.messageIds 中是否有至少一条的 sender == fromPersonId */
394
+ const isSelfAsserted = (fromPersonId, ev) => {
395
+ if (!ev)
396
+ return false;
397
+ for (const mid of ev.messageIds) {
398
+ if (senderBySid.get(mid) === fromPersonId)
399
+ return true;
400
+ }
401
+ return false;
402
+ };
403
+ const debugSkip = (label, reason) => {
404
+ if (this.cfg.debug)
405
+ this.ctx.logger.debug(`[user-relation] 严格自证丢弃 ${label}: ${reason}`);
406
+ };
407
+ const now = Date.now();
408
+ // ── 伪 person 守卫:persona-agnostic 平台白名单 + 通用占位 userId 兜底。
409
+ // 详细规则见 `isPlaceholderSelfPersonId` 的 jsdoc。这里在每次 applyExtraction
410
+ // 入口快照一次 `knownPlatforms`,避免内层多次 ctx.getAllServices。
411
+ const knownPlatforms = getKnownPlatformsLower(this.ctx);
412
+ const isPlaceholderSelfId = (platform, userId) => isPlaceholderSelfPersonId(platform, userId, knownPlatforms);
413
+ // 聚合 dropself:LLM 一次输出常含 N 个占位 person + N 条占位边,逐条 debug 会刷屏。
414
+ // 改为按类型计数 + 最多 2 个样本,整体一行 warn(首次提醒用户改 prompt/换模型)+ debug 列详情。
415
+ const dropCounts = {};
416
+ const dropSelf = (label, pid) => {
417
+ let bucket = dropCounts[label];
418
+ if (!bucket) {
419
+ bucket = { count: 0, samples: [] };
420
+ dropCounts[label] = bucket;
421
+ }
422
+ bucket.count++;
423
+ if (bucket.samples.length < 2)
424
+ bucket.samples.push(pid);
425
+ };
426
+ if (parsed.persons?.length) {
427
+ parsed.persons = parsed.persons.filter(p => {
428
+ if (isPlaceholderSelfId(p.platform, p.userId)) {
429
+ dropSelf('person', `${p.platform}:${p.userId}`);
430
+ return false;
431
+ }
432
+ return true;
433
+ });
434
+ }
435
+ if (parsed.personEventEdges?.length) {
436
+ parsed.personEventEdges = parsed.personEventEdges.filter(pe => {
437
+ if (isPlaceholderSelfId(pe.personPlatform, pe.personUserId)) {
438
+ dropSelf('person-event', `${pe.personPlatform}:${pe.personUserId}`);
439
+ return false;
440
+ }
441
+ return true;
442
+ });
443
+ }
444
+ if (parsed.personEntityEdges?.length) {
445
+ parsed.personEntityEdges = parsed.personEntityEdges.filter(pe => {
446
+ if (isPlaceholderSelfId(pe.personPlatform, pe.personUserId)) {
447
+ dropSelf('person-entity', `${pe.personPlatform}:${pe.personUserId}`);
448
+ return false;
449
+ }
450
+ return true;
451
+ });
452
+ }
453
+ if (parsed.personPersonEdges?.length) {
454
+ parsed.personPersonEdges = parsed.personPersonEdges.filter(pp => {
455
+ if (isPlaceholderSelfId(pp.fromPlatform, pp.fromUserId) || isPlaceholderSelfId(pp.toPlatform, pp.toUserId)) {
456
+ dropSelf('person-person', `${pp.fromPlatform}:${pp.fromUserId} ↔ ${pp.toPlatform}:${pp.toUserId}`);
457
+ return false;
458
+ }
459
+ return true;
460
+ });
461
+ }
462
+ const dropEntries = Object.entries(dropCounts);
463
+ if (dropEntries.length > 0) {
464
+ const total = dropEntries.reduce((s, [, v]) => s + v.count, 0);
465
+ const summary = dropEntries.map(([k, v]) => `${k}×${v.count}`).join(' + ');
466
+ this.ctx.logger.debug(`[user-relation] LLM 输出含 ${total} 个 self/占位字段,已丢弃 (${summary})。` +
467
+ `典型示例: ${dropEntries
468
+ .flatMap(([k, v]) => v.samples.map(s => `${k}=${s}`))
469
+ .slice(0, 3)
470
+ .join(', ')}`);
471
+ }
472
+ const mkEvidence = (raw) => {
473
+ if (!raw)
474
+ return null;
475
+ const ids = (raw.messageIds ?? []).filter(id => validMessageIds.has(id));
476
+ if (ids.length === 0)
477
+ return null;
478
+ // quote 必须能在至少一条窗口消息里找到子串(去空白对齐),否则视为幻觉
479
+ const quote = raw.quote?.trim();
480
+ if (quote) {
481
+ const normalizedQuote = quote.replace(/\s+/g, '');
482
+ const ok = ids.some(id => (contentBySid.get(id) ?? '').replace(/\s+/g, '').includes(normalizedQuote));
483
+ if (!ok)
484
+ return null;
485
+ }
486
+ return {
487
+ sessionId: ctxInfo.messageIdToSessionId?.get(ids[0]) ?? ctxInfo.sessionId,
488
+ messageIds: ids,
489
+ quote,
490
+ extractedAt: now,
491
+ };
492
+ };
493
+ // ── 反孤儿守卫:先扫一遍 LLM payload,收集被任意边引用的 person id / event refKey / entity refKey。
494
+ // 未被任何边引用的"裸节点"不予落库——既节省存储,也避免 evictByQuota 周期性回收噪声。
495
+ // existingEventId/existingEntityId 视为已在库的强化操作,豁免(即便本轮没新增边也合理)。
496
+ // Person 没有 refKey 体系,按 `${platform}:${userId}` 作集合 key;person-person 边的双向端点都计入。
497
+ const referencedPersonIds = new Set();
498
+ const referencedEventRefKeys = new Set();
499
+ const referencedEntityRefKeys = new Set();
500
+ for (const pe of parsed.personEventEdges ?? []) {
501
+ if (pe.eventRefKey)
502
+ referencedEventRefKeys.add(pe.eventRefKey);
503
+ if (pe.personPlatform && pe.personUserId) {
504
+ referencedPersonIds.add(`${pe.personPlatform}:${pe.personUserId}`);
505
+ }
506
+ }
507
+ for (const ee of parsed.eventEventEdges ?? []) {
508
+ if (ee.fromEventRefKey)
509
+ referencedEventRefKeys.add(ee.fromEventRefKey);
510
+ if (ee.toEventRefKey)
511
+ referencedEventRefKeys.add(ee.toEventRefKey);
512
+ }
513
+ for (const ee of parsed.eventEntityEdges ?? []) {
514
+ if (ee.eventRefKey)
515
+ referencedEventRefKeys.add(ee.eventRefKey);
516
+ if (ee.entityRefKey)
517
+ referencedEntityRefKeys.add(ee.entityRefKey);
518
+ }
519
+ for (const pe of parsed.personEntityEdges ?? []) {
520
+ if (pe.entityRefKey)
521
+ referencedEntityRefKeys.add(pe.entityRefKey);
522
+ if (pe.personPlatform && pe.personUserId) {
523
+ referencedPersonIds.add(`${pe.personPlatform}:${pe.personUserId}`);
524
+ }
525
+ }
526
+ for (const ee of parsed.entityEntityEdges ?? []) {
527
+ if (ee.fromEntityRefKey)
528
+ referencedEntityRefKeys.add(ee.fromEntityRefKey);
529
+ if (ee.toEntityRefKey)
530
+ referencedEntityRefKeys.add(ee.toEntityRefKey);
531
+ }
532
+ for (const pp of parsed.personPersonEdges ?? []) {
533
+ if (pp.fromPlatform && pp.fromUserId) {
534
+ referencedPersonIds.add(`${pp.fromPlatform}:${pp.fromUserId}`);
535
+ }
536
+ if (pp.toPlatform && pp.toUserId) {
537
+ referencedPersonIds.add(`${pp.toPlatform}:${pp.toUserId}`);
538
+ }
539
+ }
540
+ // 1) persons:只 observe 被边引用的;未被引用的旁观者跳过,避免孤儿人永久积累
541
+ for (const p of parsed.persons ?? []) {
542
+ if (!p.platform || !p.userId)
543
+ continue;
544
+ const pid = `${p.platform}:${p.userId}`;
545
+ if (!referencedPersonIds.has(pid)) {
546
+ if (this.cfg.debug) {
547
+ this.ctx.logger.debug(`[user-relation] 跳过孤立人物 "${p.displayName ?? pid}"(${pid} 无任何边引用)`);
548
+ }
549
+ continue;
550
+ }
551
+ await this.service.observePerson(p.platform, p.userId, p.displayName);
552
+ }
553
+ // 2) events: refKey → real eventId
554
+ const refToEventId = new Map();
555
+ for (const e of parsed.events ?? []) {
556
+ if (!e.refKey || !e.title)
557
+ continue;
558
+ // 反孤儿:本轮没有任何边引用该 refKey 且不是已存在节点的强化 → 跳过
559
+ if (!e.existingEventId && !referencedEventRefKeys.has(e.refKey)) {
560
+ if (this.cfg.debug) {
561
+ this.ctx.logger.debug(`[user-relation] 跳过孤立事件 "${e.title}"(refKey=${e.refKey} 无任何边引用)`);
562
+ }
563
+ continue;
564
+ }
565
+ const ev = mkEvidence(e.evidence);
566
+ const category = VALID_CATEGORIES.includes(e.category)
567
+ ? e.category
568
+ : undefined;
569
+ // scope 防御:'global' = LLM 主动声明跨会话事件。但 LLM 经常在单会话场景下
570
+ // **误标** scope=global,导致同一话题既建了 sessionScope=sid 的版本、又建了 global 版本。
571
+ // 两道闸:
572
+ // (1) 若本轮提取窗口本身就不是跨会话(crossSession=false)→ 任何 global 都视为误标,剥离
573
+ // (2) 即便跨会话窗口,若 evidence 的 sessionId 实际只指向当前 ctxInfo.sessionId
574
+ // (没有任何跨 sid 信号),也强制剥离
575
+ // 真正合法的 global 应该有 evidence 来源跨 ≥2 个 sid 才说得通。
576
+ let sessionScope;
577
+ if (e.scope === 'global') {
578
+ const evSid = ev?.sessionId;
579
+ const reallyCross = ctxInfo.crossSession === true && evSid !== undefined && evSid !== ctxInfo.sessionId;
580
+ if (reallyCross) {
581
+ sessionScope = 'global';
582
+ }
583
+ else {
584
+ sessionScope = ctxInfo.sessionId;
585
+ this.ctx.logger.debug(`[user-relation] 剥离 event "${e.title}" 的 scope=global 标签 ` +
586
+ `(crossSession=${ctxInfo.crossSession === true}, evSid=${evSid ?? '?'}, current=${ctxInfo.sessionId})`);
587
+ }
588
+ }
589
+ else {
590
+ sessionScope = ctxInfo.sessionId;
591
+ }
592
+ let eventId;
593
+ if (e.existingEventId) {
594
+ const reinforced = await this.service.reinforceEvent(e.existingEventId, {
595
+ title: e.title,
596
+ summary: e.summary,
597
+ category,
598
+ evidence: ev ? [ev] : [],
599
+ });
600
+ if (reinforced)
601
+ eventId = reinforced.id;
602
+ }
603
+ if (!eventId) {
604
+ const created = await this.service.createEvent({
605
+ title: e.title,
606
+ summary: e.summary,
607
+ category,
608
+ sessionScope,
609
+ evidence: ev ? [ev] : [],
610
+ });
611
+ eventId = created.id;
612
+ }
613
+ refToEventId.set(e.refKey, eventId);
614
+ }
615
+ // 2b) entities: refKey → real entityId
616
+ const refToEntityId = new Map();
617
+ for (const e of parsed.entities ?? []) {
618
+ if (!e.refKey || !e.name)
619
+ continue;
620
+ // 反孤儿:本轮没有任何边引用该 refKey 且不是已存在节点的强化 → 跳过
621
+ if (!e.existingEntityId && !referencedEntityRefKeys.has(e.refKey)) {
622
+ if (this.cfg.debug) {
623
+ this.ctx.logger.debug(`[user-relation] 跳过孤立实体 "${e.name}"(refKey=${e.refKey} 无任何边引用)`);
624
+ }
625
+ continue;
626
+ }
627
+ const ev = mkEvidence(e.evidence);
628
+ const entityKind = VALID_ENTITY_KINDS.includes(e.entityKind)
629
+ ? e.entityKind
630
+ : 'topic';
631
+ let entityId;
632
+ // 优先尊重 LLM 指明的 existingEntityId;service.createEntity 内部已按 (kind,name) 强制去重
633
+ if (e.existingEntityId) {
634
+ const reinforced = await this.service.reinforceEntity(e.existingEntityId, {
635
+ name: e.name,
636
+ aliases: e.aliases,
637
+ summary: e.summary,
638
+ entityKind,
639
+ evidence: ev ? [ev] : [],
640
+ });
641
+ if (reinforced)
642
+ entityId = reinforced.id;
643
+ }
644
+ if (!entityId) {
645
+ // createEntity 自身做 (kind, name) 去重;不再在 extractor 层重复
646
+ const created = await this.service.createEntity({
647
+ name: e.name,
648
+ aliases: e.aliases,
649
+ summary: e.summary,
650
+ entityKind,
651
+ evidence: ev ? [ev] : [],
652
+ });
653
+ entityId = created.id;
654
+ }
655
+ refToEntityId.set(e.refKey, entityId);
656
+ }
657
+ // 3) person-event edges
658
+ for (const pe of parsed.personEventEdges ?? []) {
659
+ const eventId = refToEventId.get(pe.eventRefKey);
660
+ if (!eventId)
661
+ continue;
662
+ if (!pe.personPlatform || !pe.personUserId)
663
+ continue;
664
+ const role = VALID_ROLES.includes(pe.role) ? pe.role : 'participant';
665
+ const sentiment = VALID_SENTIMENTS.includes(pe.sentiment) ? pe.sentiment : undefined;
666
+ const ev = mkEvidence(pe.evidence);
667
+ const fromPersonId = `${pe.personPlatform}:${pe.personUserId}`;
668
+ // person-event 不再强制自证:evidence 能从原文佐证该人参与即可,避免跟贴型参与者被误删。
669
+ await this.service.addPersonEventEdge({
670
+ fromPersonId,
671
+ toEventId: eventId,
672
+ role,
673
+ sentiment,
674
+ description: pe.description,
675
+ evidence: ev ? [ev] : [],
676
+ });
677
+ }
678
+ // 3b) person-entity edges
679
+ for (const pe of parsed.personEntityEdges ?? []) {
680
+ const entityId = refToEntityId.get(pe.entityRefKey);
681
+ if (!entityId)
682
+ continue;
683
+ if (!pe.personPlatform || !pe.personUserId)
684
+ continue;
685
+ const role = VALID_PERSON_ENTITY_ROLES.includes(pe.role)
686
+ ? pe.role
687
+ : 'mentioned';
688
+ const sentiment = VALID_SENTIMENTS.includes(pe.sentiment) ? pe.sentiment : undefined;
689
+ const ev = mkEvidence(pe.evidence);
690
+ const fromPersonId = `${pe.personPlatform}:${pe.personUserId}`;
691
+ // person-entity 不再强制自证;偏好类关系已在提示词中引导交给 user-profile。
692
+ await this.service.addPersonEntityEdge({
693
+ fromPersonId,
694
+ toEntityId: entityId,
695
+ role,
696
+ sentiment,
697
+ description: pe.description,
698
+ evidence: ev ? [ev] : [],
699
+ });
700
+ }
701
+ // 4) person-person edges
702
+ // 构建 personId → displayName 索引,便于严格自证丢弃日志输出可读姓名
703
+ const personIdToName = new Map();
704
+ for (const p of parsed.persons ?? []) {
705
+ if (p.platform && p.userId)
706
+ personIdToName.set(`${p.platform}:${p.userId}`, p.displayName ?? '');
707
+ }
708
+ for (const pp of parsed.personPersonEdges ?? []) {
709
+ if (!pp.fromPlatform || !pp.fromUserId || !pp.toPlatform || !pp.toUserId || !pp.relationType)
710
+ continue;
711
+ if (pp.fromPlatform === pp.toPlatform && pp.fromUserId === pp.toUserId)
712
+ continue; // 自环
713
+ const ev = mkEvidence(pp.evidence);
714
+ const fromPersonId = `${pp.fromPlatform}:${pp.fromUserId}`;
715
+ const toPersonId = `${pp.toPlatform}:${pp.toUserId}`;
716
+ if (strict && !isSelfAsserted(fromPersonId, ev)) {
717
+ // 升级到 info 级 + 结构化上下文:from/to 姓名、关系类型、evidence 来源
718
+ const fromName = personIdToName.get(fromPersonId) || '?';
719
+ const toName = personIdToName.get(toPersonId) || '?';
720
+ const evSummary = ev
721
+ ? ev.messageIds
722
+ .slice(0, 2)
723
+ .map(mid => `${mid.slice(0, 8)}(sender=${senderBySid.get(mid) ?? 'unknown'})`)
724
+ .join(',') + (ev.messageIds.length > 2 ? `,+${ev.messageIds.length - 2}` : '')
725
+ : '无 evidence';
726
+ this.ctx.logger.info(`[user-relation] 严格自证丢弃 person-person ${fromName}(${fromPersonId})→${toName}(${toPersonId}) ` +
727
+ `${pp.relationType}${pp.directed === false ? ' (undirected)' : ''}: ` +
728
+ `evidence 中无 from 方发言(evidence=${evSummary})`);
729
+ continue;
730
+ }
731
+ try {
732
+ await this.service.addPersonPersonEdge({
733
+ fromPersonId,
734
+ toPersonId,
735
+ relationType: pp.relationType,
736
+ directed: pp.directed,
737
+ hierarchy: pp.hierarchy,
738
+ description: pp.description,
739
+ evidence: ev ? [ev] : [],
740
+ });
741
+ }
742
+ catch (err) {
743
+ // to 不存在为 PersonNode → 防孤儿报错,这里静默跳过
744
+ debugSkip('person-person', stringifyErr(err));
745
+ }
746
+ }
747
+ // 5) event-event edges
748
+ for (const ee of parsed.eventEventEdges ?? []) {
749
+ const fromId = refToEventId.get(ee.fromEventRefKey);
750
+ const toId = refToEventId.get(ee.toEventRefKey);
751
+ if (!fromId || !toId || fromId === toId)
752
+ continue;
753
+ if (!ee.relationType)
754
+ continue;
755
+ const ev = mkEvidence(ee.evidence);
756
+ await this.service.addEventEventEdge({
757
+ fromEventId: fromId,
758
+ toEventId: toId,
759
+ relationType: ee.relationType,
760
+ directed: ee.directed,
761
+ description: ee.description,
762
+ evidence: ev ? [ev] : [],
763
+ });
764
+ }
765
+ // 5b) event-entity edges
766
+ for (const ee of parsed.eventEntityEdges ?? []) {
767
+ const eventId = refToEventId.get(ee.eventRefKey);
768
+ const entityId = refToEntityId.get(ee.entityRefKey);
769
+ if (!eventId || !entityId || !ee.relationType)
770
+ continue;
771
+ const ev = mkEvidence(ee.evidence);
772
+ await this.service.addEventEntityEdge({
773
+ fromEventId: eventId,
774
+ toEntityId: entityId,
775
+ relationType: ee.relationType,
776
+ description: ee.description,
777
+ evidence: ev ? [ev] : [],
778
+ });
779
+ }
780
+ // 5c) entity-entity edges
781
+ for (const ee of parsed.entityEntityEdges ?? []) {
782
+ const fromId = refToEntityId.get(ee.fromEntityRefKey);
783
+ const toId = refToEntityId.get(ee.toEntityRefKey);
784
+ if (!fromId || !toId || fromId === toId || !ee.relationType)
785
+ continue;
786
+ const ev = mkEvidence(ee.evidence);
787
+ try {
788
+ await this.service.addEntityEntityEdge({
789
+ fromEntityId: fromId,
790
+ toEntityId: toId,
791
+ relationType: ee.relationType,
792
+ directed: ee.directed,
793
+ description: ee.description,
794
+ evidence: ev ? [ev] : [],
795
+ });
796
+ }
797
+ catch (err) {
798
+ debugSkip('entity-entity', stringifyErr(err));
799
+ }
800
+ }
801
+ // 提取完成后打一条 info 级日志。这里用「解析出的数量」作为近似,
802
+ // 零变动时不打(避免闹日志)。
803
+ const total = (parsed.persons?.length ?? 0) +
804
+ (parsed.events?.length ?? 0) +
805
+ (parsed.entities?.length ?? 0) +
806
+ (parsed.personEventEdges?.length ?? 0) +
807
+ (parsed.personEntityEdges?.length ?? 0) +
808
+ (parsed.personPersonEdges?.length ?? 0) +
809
+ (parsed.eventEventEdges?.length ?? 0) +
810
+ (parsed.eventEntityEdges?.length ?? 0) +
811
+ (parsed.entityEntityEdges?.length ?? 0);
812
+ if (total > 0) {
813
+ this.ctx.logger.info(`[user-relation] 关系图已更新 (session=${ctxInfo.sessionId}): persons=${parsed.persons?.length ?? 0}, events=${parsed.events?.length ?? 0}, entities=${parsed.entities?.length ?? 0}, edges=${(parsed.personEventEdges?.length ?? 0) + (parsed.personEntityEdges?.length ?? 0) + (parsed.personPersonEdges?.length ?? 0) + (parsed.eventEventEdges?.length ?? 0) + (parsed.eventEntityEdges?.length ?? 0) + (parsed.entityEntityEdges?.length ?? 0)}`);
814
+ }
815
+ else if (this.cfg.debug) {
816
+ this.ctx.logger.debug(`[user-relation] ${ctxInfo.sessionId} 提取完成,本批次无变动`);
817
+ }
818
+ // 写后顺手老化(模仿 profile 风格,不开独立调度器)。
819
+ // 孤儿清理与配额无关——总是顺手扫一遍。注意:evictByQuota 内部已自带孤儿清理,
820
+ // 配了配额时不要重复调用 pruneOrphans。
821
+ const hasQuota = this.cfg.evictionEnabled &&
822
+ (this.cfg.maxPersons > 0 || this.cfg.maxEvents > 0 || this.cfg.maxEntities > 0 || this.cfg.maxEdges > 0);
823
+ if (this.cfg.evictionEnabled && !hasQuota) {
824
+ try {
825
+ const orphans = await this.service.pruneOrphans();
826
+ if (this.cfg.debug &&
827
+ (orphans.deletedPersons || orphans.deletedEvents || orphans.deletedEntities || orphans.deletedDanglingEdges)) {
828
+ this.ctx.logger.debug(`[user-relation] 自动孤儿清理: persons=${orphans.deletedPersons} events=${orphans.deletedEvents} entities=${orphans.deletedEntities} dangling_edges=${orphans.deletedDanglingEdges}`);
829
+ }
830
+ }
831
+ catch (err) {
832
+ if (this.cfg.debug)
833
+ this.ctx.logger.debug(`[user-relation] 孤儿清理失败: ${stringifyErr(err)}`);
834
+ }
835
+ }
836
+ if (hasQuota) {
837
+ // 节流:仅在「下一次 evictByQuota 真的会删东西」时才走 consolidate → evict 双步。
838
+ // - isOverQuota 与 evictByQuota 共用滞回公式(count >= ceil(cap·(1+hysteresisPct))),
839
+ // 不会和 evict 的内部判定漂移。
840
+ // - 顺序与 /relation maintain 一致:先 consolidate(合并别名/层级/事件去重)→ 再 evict
841
+ // (在去重后的图上跑 PageRank 淘汰),使 PageRank 入出度更完整、决策更稳。
842
+ // - 配额未超时直接跳过,避免每次提取都跑 PageRank / consolidate。
843
+ let overQuota = false;
844
+ try {
845
+ overQuota = await this.service.isOverQuota({
846
+ maxPersons: this.cfg.maxPersons,
847
+ maxEvents: this.cfg.maxEvents,
848
+ maxEntities: this.cfg.maxEntities,
849
+ maxEdges: this.cfg.maxEdges,
850
+ hysteresisPct: this.cfg.evictHysteresisPct,
851
+ });
852
+ }
853
+ catch (err) {
854
+ if (this.cfg.debug)
855
+ this.ctx.logger.debug(`[user-relation] isOverQuota 检查失败: ${stringifyErr(err)}`);
856
+ }
857
+ if (overQuota) {
858
+ if (this.cfg.consolidateAfterEviction) {
859
+ try {
860
+ const cr = await this.service.consolidate({
861
+ autoLink: this.cfg.consolidateAutoLink,
862
+ triggerSource: 'eviction',
863
+ ctx: this.ctx,
864
+ skipLowScorePairs: this.cfg.consolidateSkipLowScorePairs,
865
+ lowScoreThreshold: this.cfg.consolidateLowScoreThreshold,
866
+ ...(this.cfg.consolidateLLMModelRef
867
+ ? {
868
+ llm: {
869
+ ctx: this.ctx,
870
+ modelRef: this.cfg.consolidateLLMModelRef,
871
+ disableThinking: this.cfg.consolidateLLMDisableThinking,
872
+ },
873
+ }
874
+ : {}),
875
+ });
876
+ if (this.cfg.debug) {
877
+ this.ctx.logger.debug(`[user-relation] 淘汰前 consolidate 完成: 事件边整理=${cr.eventEdgesNormalized} 层级候选=${cr.entityHierarchyCandidates} 层级边=${cr.entityHierarchyEdgesCreated}`);
878
+ }
879
+ }
880
+ catch (err) {
881
+ this.ctx.logger.warn(`[user-relation] 淘汰前 consolidate 失败: ${err.message}`);
882
+ }
883
+ }
884
+ try {
885
+ const evicted = await this.service.evictByQuota({
886
+ maxPersons: this.cfg.maxPersons,
887
+ maxEvents: this.cfg.maxEvents,
888
+ maxEntities: this.cfg.maxEntities,
889
+ maxEdges: this.cfg.maxEdges,
890
+ pagerankDamping: this.cfg.pagerankDamping,
891
+ pagerankIterations: this.cfg.pagerankIterations,
892
+ pagerankEpsilon: this.cfg.pagerankEpsilon,
893
+ hysteresisPct: this.cfg.evictHysteresisPct,
894
+ targetPct: this.cfg.evictTargetPct,
895
+ decay: {
896
+ halfLifeDays: this.cfg.weightDecayHalfLifeDays,
897
+ floor: this.cfg.weightDecayFloor,
898
+ },
899
+ communityAlgorithm: this.cfg.communityAlgorithm,
900
+ });
901
+ if (this.cfg.debug &&
902
+ (evicted.deletedPersons || evicted.deletedEvents || evicted.deletedEntities || evicted.deletedEdges)) {
903
+ this.ctx.logger.debug(`[user-relation] 自动老化: 删除 persons=${evicted.deletedPersons} events=${evicted.deletedEvents} entities=${evicted.deletedEntities} edges=${evicted.deletedEdges}`);
904
+ }
905
+ }
906
+ catch (err) {
907
+ this.ctx.logger.warn(`[user-relation] 自动老化失败: ${err.message}`);
908
+ }
909
+ }
910
+ }
911
+ }
912
+ }
913
+ // ───── helpers ─────
914
+ function hasMessageId(m) {
915
+ return typeof m.metadata?.messageId === 'string';
916
+ }
917
+ function inferPlatform(msgs) {
918
+ for (const m of msgs) {
919
+ const p = m.metadata?.platform;
920
+ if (p)
921
+ return p;
922
+ }
923
+ return '';
924
+ }
925
+ function stringifyErr(err) {
926
+ return err instanceof Error ? err.message : String(err);
927
+ }
928
+ /** 渲染窗口内每条消息为 LLM 可读行:`[mid] (sender) content` */
929
+ function renderHistoryForLLM(history, opts) {
930
+ const lines = [];
931
+ for (const m of history) {
932
+ if (m.role !== 'user' && m.role !== 'assistant' && m.role !== 'notice')
933
+ continue;
934
+ const meta = m.metadata ?? {};
935
+ let sender;
936
+ if (m.role === 'assistant') {
937
+ sender = meta.userId ? `${meta.nickname ?? 'Aalis'}(${meta.userId})` : 'Aalis(本机器人)';
938
+ }
939
+ else if (m.role === 'notice') {
940
+ sender = '系统通知';
941
+ }
942
+ else {
943
+ sender = `${meta.nickname ?? '匿名'}(${meta.userId ?? '?'})`;
944
+ }
945
+ const mid = meta.messageId ?? '-';
946
+ const content = typeof m.content === 'string' ? m.content : JSON.stringify(m.content);
947
+ // 跨会话模式下,[sid] 前缀帮 LLM 区分同名事件来自哪个群/会话
948
+ const sidPrefix = opts?.crossSession && meta.__extractorSessionId ? `[sid:${meta.__extractorSessionId}] ` : '';
949
+ lines.push(`${sidPrefix}[${mid}] (${sender}) ${content.replace(/\n+/g, ' ').slice(0, 400)}`);
950
+ }
951
+ return lines.join('\n');
952
+ }
953
+ /**
954
+ * 渲染候选实体清单。在普通列表前会**先**列出"同 normalize name 但 kind 不同"的冲突组
955
+ * (⚠ 区块),强制提醒 LLM:要么从冲突候选里挑一项填 existingEntityId 复用、要么
956
+ * 加限定词避免新建出第三份重名异 kind 节点。
957
+ *
958
+ * 解决"同名实体跨 kind 重复"问题(如 topic "三角洲行动" vs work "三角洲行动")。
959
+ */
960
+ function renderEntityCandidates(candidateEntities) {
961
+ if (candidateEntities.length === 0)
962
+ return '(无)';
963
+ const fmt = (e) => `- id=${e.id} kind=${e.entityKind} name=${e.name}${e.aliases?.length ? ` aka=${e.aliases.join('|')}` : ''}`;
964
+ // 按 normalize(name) 分组,找出 kind 数 >1 的冲突组
965
+ const byKey = new Map();
966
+ for (const e of candidateEntities) {
967
+ const k = normalizeName(e.name);
968
+ if (!k)
969
+ continue;
970
+ if (!byKey.has(k))
971
+ byKey.set(k, []);
972
+ byKey.get(k)?.push(e);
973
+ }
974
+ const collisions = [];
975
+ for (const group of byKey.values()) {
976
+ const kinds = new Set(group.map(e => e.entityKind));
977
+ if (kinds.size > 1)
978
+ collisions.push(group);
979
+ }
980
+ if (collisions.length === 0) {
981
+ return candidateEntities.map(fmt).join('\n');
982
+ }
983
+ const collisionIds = new Set(collisions.flat().map(e => e.id));
984
+ const sections = [];
985
+ sections.push('⚠ 同名跨 kind 冲突候选(务必从下方组里挑一项填 existingEntityId 复用,禁止再创建同 normalize-name 的新实体;如新对象与下面任一都不同,请加限定词让 name 明显有别):');
986
+ for (const group of collisions) {
987
+ for (const e of group)
988
+ sections.push(fmt(e));
989
+ sections.push(''); // 空行分隔不同冲突组
990
+ }
991
+ const rest = candidateEntities.filter(e => !collisionIds.has(e.id));
992
+ if (rest.length > 0) {
993
+ sections.push('-- 其余候选 --');
994
+ for (const e of rest)
995
+ sections.push(fmt(e));
996
+ }
997
+ return sections.join('\n');
998
+ }
999
+ function buildExtractionPrompt(history, userMsgs, candidateEvents, candidateEntities, senderNeighbors, opts) {
1000
+ const rendered = renderHistoryForLLM(history, opts);
1001
+ const ownSid = opts?.currentSessionId;
1002
+ // candidate event 暴露 sessionScope 标签,便于 LLM 决策"reinforce 本会话 / 复用 global hub / 新建 hub":
1003
+ // - scope=global → 显式跨会话 hub event,可被任何 session 复用强化
1004
+ // - scope=other:<sid 简写> → 其他 session 的 current 事件,**禁止**直接 reinforce(不同 sessionScope 不能合并),
1005
+ // 但可在跨会话提取模式下输出 eventEventEdge part-of 把它挂到一个新建/已有的 global hub 下
1006
+ // - scope 与当前 session 相同 → 省略标签(默认即"自家事件")
1007
+ const scopeTag = (e) => {
1008
+ const s = e.sessionScope;
1009
+ if (!s || s === ownSid)
1010
+ return '';
1011
+ if (s === 'global')
1012
+ return ' scope=global';
1013
+ return ` scope=other:${s.slice(0, 12)}`;
1014
+ };
1015
+ const evtList = candidateEvents.length === 0
1016
+ ? '(无)'
1017
+ : candidateEvents.map(e => `- id=${e.id} title=${e.title}${scopeTag(e)}`).join('\n');
1018
+ const entList = renderEntityCandidates(candidateEntities);
1019
+ const senderList = collectSenderList(userMsgs);
1020
+ const neighborBlock = renderSenderNeighbors(senderNeighbors);
1021
+ const system = {
1022
+ role: 'system',
1023
+ content: [
1024
+ '你是 Aalis 的「社会关系神经」。你的任务是把对话窗口里的事实层信号,沉淀成可被 Aalis 长期回忆、可被遍历串联的关系图。',
1025
+ '',
1026
+ '## 你与 plugin-user-profile 的分工(务必内化)',
1027
+ '- **plugin-user-profile(内在画像)**:单人的内在属性 —— 喜好 / 性格 / 技能 / 状态 / 经历 / 单点偏好声明(「我喜欢猫」「我会日语」「我讨厌排队」)。这类信息**不属于关系图**。',
1028
+ '- **plugin-user-relation(你自己 / 社会图谱)**:**多个主体之间的可观察连接** —— 谁参与了什么、谁与什么对象有结构性关联、谁与谁互称什么、哪些事件围绕同一对象展开。',
1029
+ '- 一句判断:**「这条信息能帮 Aalis 把人或事件串起来吗?」** 能 → 写图;只描述某个人 → 留给 user-profile。',
1030
+ '',
1031
+ '## ⭐ Hub-first 抽取流程(核心方法论,按顺序执行)',
1032
+ '关系图的价值在于「实体作为枢纽(hub)让多个事件、多个人围绕它形成可遍历的网络」。一个孤立的事件 + 一个没挂任何边的实体 = Aalis 失忆。请按以下顺序思考:',
1033
+ '',
1034
+ '**第 1 步:扫描所有具名对象 → 抽成 Entity**',
1035
+ ' 在整个窗口里找出所有反复出现 / 多人提及 / 可被长期关联的具名对象:',
1036
+ ' · 作品类(游戏 / 影视 / 书籍 / 番剧 / 漫画 / 关卡 / mod / 副本名)→ entityKind=work',
1037
+ ' · 地点类(城市 / 店 / 场馆 / 副本场景)→ entityKind=place',
1038
+ ' · 物品类(设备 / 商品 / 道具 / 装备)→ entityKind=thing',
1039
+ ' · 话题类(社会议题 / 梗 / 概念 / 项目)→ entityKind=topic',
1040
+ ' **即便它只在事件标题里以名词出现,也必须单独抽成 entity**,不要让它"溶解"在事件标题里。',
1041
+ ' **主动填 aliases**:若窗口里同一对象出现多种叫法(中文名/英文名/简称/缩写/俗称/书名号包裹与否),把它们全部放进 aliases 数组——这是后端别名合并的关键输入。例:`name="绝航"` `aliases=["Project Juehang","JH","《绝航》"]`。',
1042
+ '',
1043
+ '**第 2 步:建 Event 时强制做 part-of 挂载**',
1044
+ ' 若 event.title 中含有任何第 1 步抽出的 entity 名(或其同义词),**必须同时输出一条 eventEntityEdges relationType="part-of"** 把事件挂在 entity 下。',
1045
+ ' 反面:「打《绝航》」「讨论《绝航》」两个事件如果都不挂 part-of → 绝航这个 entity 被切碎、两个事件成为孤岛、参与/讨论的人无法通过绝航相互发现 → **这是关系图最严重的失效**。',
1046
+ ' **part-of 与 about 互斥**:对同一对 (event, entity) 只输出**一条** event-entity 边。若事件围绕该对象展开(讨论/打/玩/合作),优先用 `part-of`;只有当事件只是顺带"提到"而非围绕它时才用 `about`。**不要同时输出两条**(part-of + about),后端会判为重复并合并。',
1047
+ '',
1048
+ '**第 3 步:积极建立 person-person 边(A 视角的一面之词即可,单向 directed=true)**',
1049
+ ' 当两人 A、B 都指向同一 entity(如都玩绝航、都在某店打卡)→ 直接输出 A→entity 和 B→entity 两条 personEntityEdge 即可,图层会自动呈现 A↔entity↔B 的二跳连接。',
1050
+ ' 在此之上,**只要发话人 A 亲口说出他与他人的关系定位,就积极抽 A→对方 的单向边**(B 是否在场 / 是否回应 / 是否同意都不影响),不要因"对方没背书"而吞掉这种 hub-grade 信号:',
1051
+ ' · 正向身份:「B 是我朋友 / 兄弟 / CP / 老婆 / 男友 / 师傅 / 同事 / 同学 / 队友」→ A→B directed=true,relationType 取对应词。',
1052
+ ' · 负向 / 紧张:「B 跟我闹翻 / B 拉黑我 / 我讨厌 B / B 是我前任」→ A→B directed=true,relationType=hostile / antagonist / rival / ex 等。',
1053
+ ' · 仰慕 / 单向情感:「我在追 B / B 是我偶像 / B 让我崇拜」→ A→B directed=true relationType=admirer 等。',
1054
+ ' · 层级(顺手填 hierarchy):「B 是我老板 / 老师 / 师傅 / 前辈」→ hierarchy=superior;「B 是我徒弟 / 学生 / 下属」→ subordinate。',
1055
+ ' · **关键**:A 提及第三方关系(如 "我听说 B 和 C 在一起了" / "B 跟 C 同班")——这是 A 的转述,不要直接输出 B→C 边(会因严格自证丢弃);可输出 A→B 一条 friend/colleague 表达 A 与 B 的认识关系(如果 A 与 B 本身确有关系陈述),否则跳过该信号。',
1056
+ ' · 一句话规则:**只要发话人自己说"我和 X 是某关系"就建边,单向、不需双方确认**。',
1057
+ ' · **依然要避免的幻觉**:',
1058
+ ' - 因为"共同兴趣 / 共同参与同一事件"就脑补 friend → 错。共享兴趣只走 entity 二跳,**不要**伪造身份性 person-person 边。',
1059
+ ' - 因为聊天中互相 @ 一两次 / 简单回复就脑补 friend → 错。要走下面的 familiar 行为观察通道,**且阈值很高**。',
1060
+ '',
1061
+ '**第 3 步补:familiar 弱关系(行为观察通道,慎用,weight 会比身份关系低)**',
1062
+ ' 当 A 与 B 在窗口内**有大量直接互动**——必须同时满足:',
1063
+ ' (1) 同一窗口内 A、B 直接对话 ≥ 3 轮且**两人都主动说过**话(不是一方喊一方沉默);',
1064
+ ' (2) 互相直呼对方昵称 / @ / 引用回复对方消息至少 2 次;',
1065
+ ' (3) 不是命令式 / 工具性互动(不是「@bot 帮我查」「@admin 申请进群」这种)。',
1066
+ ' 满足时可输出**一条** A→B relationType="familiar" directed=true(A 是互动主导/先发起方)。evidence 必须列举至少 2 条 A 自己发的、能体现互动的消息(严格自证仍生效)。',
1067
+ ' **familiar 的目的**:捕捉「这两人是常一起说话的熟人」这种**纯行为观察信号**,不预设关系性质。',
1068
+ ' **familiar 的反面清单**(任一命中就不要建):',
1069
+ ' · 仅旁观式同框(都在群里但没对话) → 不建;',
1070
+ ' · 单方喊话无回应 → 不建;',
1071
+ ' · 与机器人 / Aalis 自己的互动 → 不建;',
1072
+ ' · 已经有更强的关系边(friend / cp / colleague / mentor…)→ 不再加 familiar,避免冗余。',
1073
+ '',
1074
+ '## 关于「Aalis 自己」(机器人本体)',
1075
+ '- 窗口里 assistant 消息会被渲染为 `(nickname(userId))` 同用户消息一样的格式,其中 userId 是 **Aalis 在该平台上真实的 selfId**(如 `(Aalis(10000))`)。这时你**可以**把它当成一个普通 person 抽出来(用真实 personPlatform / personUserId),让 Aalis 与人的互动也能进入关系图。',
1076
+ '- **绝不要凭空生成占位符**:当 assistant 行渲染成 `Aalis(本机器人)`(CJK 全角括号 = 元数据缺失)时,**禁止**给它任何 person 字段;也**禁止**自己编 `platform="aalis"` 或 `userId ∈ {aalis, self, me, bot, assistant, 本机器人}` 这种伪 id —— 后端会一律剔除。这一轮就当 Aalis 没出现,跳过。',
1077
+ '- **绝对禁止使用 `undefined` / `unknown` / `null` / `none` / `n/a` / 空字符串作为 platform 或 userId 的值**(无论是字符串字面量还是 JSON null)。如果你不知道某个 person 的确切 platform/userId,**就不要把这个人写进 persons 数组、也不要在任何边里引用 ta**。宁可漏抽一个人,也不要造伪 id(造了也会被后端丢弃,纯粹浪费 token)。',
1078
+ '- 这条规则比 hub-first 优先:宁可少抽,也不要造假 id。',
1079
+ '',
1080
+ '## 输出格式(严格 JSON)',
1081
+ '严格输出**单个 JSON 对象**(不要任何解释文字、不要 ```json 包裹),结构如下:',
1082
+ '{',
1083
+ ' "persons": [{ "platform": str, "userId": str, "displayName"?: str }],',
1084
+ ' "events": [{ "refKey": str, "existingEventId"?: str|null, "title": str(<=30字), "summary"?: str(<=80字), "category"?: "discussion"|"conflict"|"collaboration"|"incident"|"milestone"|"other", "scope"?: "global", "evidence": { "messageIds": str[], "quote": str } }],',
1085
+ ' "entities": [{ "refKey": str, "existingEntityId"?: str|null, "name": str(<=20字), "aliases"?: str[], "summary"?: str(<=80字), "entityKind": "topic"|"place"|"thing"|"work", "evidence": { "messageIds": str[], "quote": str } }],',
1086
+ ' "personEventEdges": [{ "personPlatform": str, "personUserId": str, "eventRefKey": str, "role": "initiator"|"participant"|"witness"|"target"|"reporter", "sentiment"?: "positive"|"negative"|"neutral"|"mixed", "description"?: str(<=40字), "evidence": { "messageIds": str[], "quote": str } }],',
1087
+ ' "personEntityEdges": [{ "personPlatform": str, "personUserId": str, "entityRefKey": str, "role": "enthusiast"|"participant"|"owner"|"creator"|"critic"|"visitor"|"mentioned", "sentiment"?: "positive"|"negative"|"neutral"|"mixed", "description"?: str(<=40字), "evidence": { "messageIds": str[], "quote": str } }],',
1088
+ ' "personPersonEdges": [{ "fromPlatform": str, "fromUserId": str, "toPlatform": str, "toUserId": str, "relationType": str, "directed"?: bool, "hierarchy"?: "superior"|"peer"|"subordinate"|"unknown", "description"?: str(<=40字), "evidence": { "messageIds": str[], "quote": str } }],',
1089
+ ` "eventEventEdges": [{ "fromEventRefKey": str, "toEventRefKey": str, "relationType": str(推荐: ${RecommendedEventEventRelationTypes.join(' / ')}), "directed"?: bool, "description"?: str(<=40字), "evidence": { "messageIds": str[], "quote": str } }],`,
1090
+ ` "eventEntityEdges": [{ "eventRefKey": str, "entityRefKey": str, "relationType": str(推荐: ${RecommendedEventEntityRelationTypes.join(' / ')}), "description"?: str(<=40字), "evidence": { "messageIds": str[], "quote": str } }],`,
1091
+ ` "entityEntityEdges": [{ "fromEntityRefKey": str, "toEntityRefKey": str, "relationType": str(推荐: ${RecommendedEntityEntityRelationTypes.join(' / ')}), "directed"?: bool, "description"?: str(<=40字), "evidence": { "messageIds": str[], "quote": str } }]`,
1092
+ '}',
1093
+ '',
1094
+ '## 关键区别:Event vs Entity(务必正确使用)',
1095
+ '- **Event(事件)= 一次性发生的事**:必须有**明确的时间锚点**(昨晚 / 上周 / 刚才 / 某场 / 下周三…)和**可识别的动作或结果**(开黑、争吵、发布、签约、相遇、比赛…)。',
1096
+ '- **Entity(实体)= 持续存在的"东西"**:可被多人长期关联。例:游戏《三角洲》、电影《奥本海默》、北京、PS5、某个表情包、某个梗。',
1097
+ '- **当多人共享某个对象时,请把它建模为 Entity,让每个人各自通过 personEntityEdge 指向它**;不要把它写进事件标题里。',
1098
+ '',
1099
+ '## 事件提取(要积极但有据)',
1100
+ '- 优先记录:有**可识别动作**(开黑/争吵/比赛/发布/相遇/讨论某话题…)且**多人参与或多条消息支撑**的事。',
1101
+ '- 「X 和 Y 讨论 Z」「群里围绕 Z 聊了一阵」这类**多人对话事件**值得记 —— 只要 evidence.messageIds ≥ 2 条且至少 2 人发言,可以建。后端会按 weight 老化,不必过度自我审查。',
1102
+ '- 完全单条、零回应的随口提及不建;问候/客套/单字回应不建(见下方负面清单)。',
1103
+ '- **事件 category 选择指南(不要全部塞 discussion)**:',
1104
+ ' · `collaboration` —— 多人共同推进/参与/约定一件具体行为:开黑、组队、合奏、合写文档、约局、合作直播…(**优先级最高**,只要"多人协作做某事"就用这个)',
1105
+ ' · `conflict` —— 争吵 / 对立 / 拉黑 / 退群 / 翻脸 / 公开撕逼 / 互相 diss。',
1106
+ ' · `incident` —— 突发负面事件:bug / 事故 / 翻车 / 服务器崩 / 设备坏 / 被骗。',
1107
+ ' · `milestone` —— 标志性进展:发布、上线、签约、获奖、毕业、退役、达成成就、生日纪念日。',
1108
+ ' · `discussion` —— **兜底类别**:单纯围绕某话题"聊一聊、讨论、安利、吐槽、复盘",没有协作行为/冲突/事故/里程碑。**能选上面四个就不要用 discussion**。把所有事件都标 discussion 会让类别失去区分度。',
1109
+ ' · `other` —— 极少用,仅在以上五类都套不上时(如纯仪式性问候活动)才考虑。',
1110
+ '- **事件 scope 字段(决策极简版)**:',
1111
+ ' · **同时满足两条**才能填 `"global"`:(a) 当前是【跨会话模式】窗口(消息行首带 `[sid:xxx]` 前缀),**且** (b) 该事件 evidence 引用了**至少 2 个不同 sid** 的消息。',
1112
+ ' · **任意一条不满足都不要写 scope 字段**:单会话窗口(无 [sid] 前缀)一律不写;跨会话窗口里若 evidence 只来自一个 sid,也不写。',
1113
+ ' · 后端会自动给"省略 scope"的事件绑定当前 sessionId;填错 global 会被自动剥离并 audit log。',
1114
+ ' · 简单记法:**有 [sid:] 前缀的窗口 + evidence 跨 ≥2 个 sid → global;其余 → 留空**。',
1115
+ '- **严禁重复创建同名事件**:在你下笔写 `events` 前,**先逐条扫描"已有候选事件"清单**,若某个新事件的 title 与候选清单中任一项 normalize 后相同(去标点、空白、连接符后字符等价),**必须**用 `existingEventId` 复用旧节点,**绝不能**新建。复用规则:',
1116
+ ' · 候选项**无 scope 标签**(即当前 session 自家事件)→ 直接 `existingEventId=<旧 id>`,不写 scope。',
1117
+ ' · 候选项标 `scope=global`(hub)→ 直接 `existingEventId=<旧 id>`,**也不要写 scope=global**(hub 已是 global,复用即可)。',
1118
+ ' · 候选项标 `scope=other:xxx`(其他 session 的同名事件)→ **不要 reinforce**;按本轮真实场景决定:单会话窗口下建你自己的新事件即可(后端按 scope 隔离)。',
1119
+ '- **事件锚定原则(与 hub-first 配合)**:建一个 event 时先问自己——',
1120
+ ' · 「它围绕什么具名对象?」→ 有 → 必须按第 1/2 步抽 entity 并输出 part-of 边(首选路径)。',
1121
+ ' · 「它是纯人际事件?」(如 A 与 B 吵架/告白/和好/绝交/退群/相遇,无任何具名对象)→ 允许独立 event,但**必须配合至少一条 personEventEdge 把所有相关方挂上 + 一条 personPersonEdge 表达关系性质**(如 conflict/friend/hostile/reconciled)。否则该事件会沦为孤立浮岛。',
1122
+ ' · 「围绕对象和人际关系都没有?」→ 不要建 event。',
1123
+ '',
1124
+ '## ⚠️ 反孤儿节点(强制)',
1125
+ '- 每一个你创建的 person / event / entity,**必须至少被一条边引用**(personEvent / eventEvent / eventEntity 或 personEntity / entityEntity / personPerson)。',
1126
+ '- 不要写"光杆节点"——若你不打算给它任何边,就**直接从 persons / events / entities 数组里去掉**。提取器会丢弃这种孤立节点,等于白做。',
1127
+ '- persons 数组的作用是登记"这一轮你打算与之建立关系的人",**不是聊天窗口参与者花名册**。窗口里只是旁观的人若没有任何边引用,请不要列出。',
1128
+ '- 自检顺序:先列边 → 边中提到哪些 person id / refKey → 只把这些 person / refKey 写进 persons / events / entities。',
1129
+ '',
1130
+ '- **以下情况不要建 event**(负面清单):',
1131
+ ' · 纯言语声明(无行为支撑):「我喜欢 X / 我讨厌 Y / 我有 Z / 我会 W」——若 evidence 中**仅有声明性表态,没有行为事实**(参与时长 / 制作 / 购买 / 直播 / 规律互动…),则这是画像属性,由 plugin-user-profile 处理;**也不要建 event**。person-entity 边同理:单句声明不建,详见下方「person-entity 门槛」。',
1132
+ ' · 元对话/元请求:「帮我记一下…」「测试一下你的关系系统」「这是我」——对工具的指令不是世界中发生的事,**不要建 event**。',
1133
+ ' · 问候/客套/无信息内容:「在吗」「早」「哈哈」「ok」——什么都不建。',
1134
+ '- **event.title 命名原则(强制)**:',
1135
+ ' · **禁止**以「关于…的讨论/互动/调侃/分享/感叹/交流」等句型作 title —— 这类写法是话题标签,不是事件描述,让 consolidate 无法区分不同的事,并产生大量无效 LLM 调用。',
1136
+ ' · title 要回答三个问题:**发生了什么具体的事?** / **主要涉及哪些对象(人/物/作品)?** / **能和同类事件区分开吗?** 三问都能回答才算合格。',
1137
+ ' · 不需要套任何固定格式,用最直接的一句话描述这件事本身即可;加时间锚点(如"5月/周末/昨晚")可进一步区分同类事件。',
1138
+ ' · **category=discussion 是分类标签,不是 title 写法的许可**:即使 category=discussion,title 也要描述这场讨论里具体发生的事,而不是"有一场关于X的讨论"。',
1139
+ ' · title ≤30字,并尽量通过 eventEntityEdges part-of 把核心话题实体挂上,让事件可被检索。',
1140
+ '',
1141
+ '## 正确建模示例',
1142
+ '### 示例 1a:纯偏好声明(无行为证据)→ 留给 user-profile,本插件全空',
1143
+ "原话:'我喜欢打三角洲,Bob也喜欢'(Alice 单句声明,窗口内无其他行为记录)",
1144
+ '✅ 正确输出:persons: [], entities: [], personEntityEdges: [] ← 仅声明性表态,无行为事实;画像属性由 user-profile 处理。',
1145
+ '❌ 不要:role="enthusiast"(无行为证据)。若想保留态度信号但已有其他边,在那条边上加 sentiment=positive 即可。',
1146
+ '',
1147
+ '### 示例 1b:行为性热情 → 建 enthusiast 边(多人共同指向同实体,揭示社会连接)',
1148
+ "原话:'Alice 三角洲玩了两年还做了个 mod;Bob 每天晚上直播三角洲'",
1149
+ '✅ 正确输出:',
1150
+ ' entities: [{ refKey: "e1", name: "三角洲", entityKind: "work" }]',
1151
+ ' personEntityEdges: [{ Alice→e1 role=enthusiast sentiment=positive }, { Bob→e1 role=enthusiast sentiment=positive }]',
1152
+ ' — 有行为性证据(长期参与 + 创作/直播),且两人共同指向同实体,揭示潜在社会连接。',
1153
+ '❌ 不要:仅凭一句"喜欢"建 enthusiast;必须有行为事实支撑。',
1154
+ '',
1155
+ '### 示例 2:多人共同行为/讨论 → 建 event + part-of entity',
1156
+ "原话(多人多轮):A: '今晚一起打三角洲?' B: '行' A: '我开车' B: 'Bob 你来不?' Bob: '来'",
1157
+ '✅ 正确输出:',
1158
+ ' entities: [{ refKey: "e1", name: "三角洲", entityKind: "work" }]',
1159
+ ' events: [{ refKey: "ev1", title: "约局开黑《三角洲》", category: "collaboration" }]',
1160
+ ' personEventEdges: [{ A→ev1 role=initiator }, { B→ev1 role=participant }, { Bob→ev1 role=participant }]',
1161
+ ' eventEntityEdges: [{ ev1→e1 relationType="part-of" }]',
1162
+ '',
1163
+ '### 示例 3:单向 person-person 声明(允许不对等)',
1164
+ "原话:A: 'Bob 是我兄弟' (A 自己说;窗口里 Bob 没回应或没否认)",
1165
+ '✅ 正确输出:personPersonEdges: [{ A→Bob relationType="friend" directed=true }] ← 仅 A 的单向声明;不写 Bob→A。',
1166
+ '✅ 同理:A: "我跟 Bob 闹翻了" → personPersonEdges: [{ A→Bob relationType="hostile" directed=true }],允许负向且不对等。',
1167
+ '',
1168
+ '### ⭐ 示例 4:Hub-first(同一实体的不同事件必须共享同一 entity refKey)',
1169
+ '场景:一段窗口里 A、B 在「打绝航」;同窗口 C、D 在「讨论绝航的剧情」。这是 Aalis 最容易失忆的地方。',
1170
+ '❌ **致命错误**(把绝航溶解在事件标题里、不抽 entity / 不挂 part-of):',
1171
+ ' events: [{ refKey: "ev1", title: "打绝航" }, { refKey: "ev2", title: "讨论绝航" }]',
1172
+ ' entities: [] ← 错!绝航被切碎,A/B/C/D 无法通过它相互发现,Aalis 看图只能看到两个孤岛。',
1173
+ '✅ 正确:先抽 entity,再让事件挂上去:',
1174
+ ' entities: [{ refKey: "e1", name: "绝航", entityKind: "work" }]',
1175
+ ' events: [{ refKey: "ev1", title: "开黑《绝航》", category: "collaboration" }, { refKey: "ev2", title: "《绝航》剧情讨论", category: "discussion" }]',
1176
+ ' personEventEdges: [{ A→ev1 participant }, { B→ev1 participant }, { C→ev2 participant }, { D→ev2 participant }]',
1177
+ ' eventEntityEdges: [{ ev1→e1 part-of }, { ev2→e1 part-of }] ← **关键**:两个事件共享同一 e1,A/B 和 C/D 通过绝航形成二跳社会连接。',
1178
+ ' 注意:**不要**额外输出 personPersonEdges 把 A→C 串成 friend(共享兴趣 ≠ 关系,那是幻觉)。让图层自然呈现 A↔e1↔C 即可。',
1179
+ '',
1180
+ '## 其他规则(违反则该条目会被丢弃)',
1181
+ `- 每条 evidence.messageIds 必须从窗口里实际出现的 messageId 中选取,至少 1 个;`,
1182
+ `- 每条 evidence.quote 必须是 messageIds 中某条消息内容的原文子串(≤80 字);`,
1183
+ '- 一个 event/entity 可被多人共同关联(输出多条 edge 指向同一 refKey);',
1184
+ '- 一个人可同时参与多个 event/entity;',
1185
+ '- personPersonEdge **是单向声明**:只要发话人 A 自己亲口表达了对 B 的关系定位(朋友 / 敌人 / 师傅 / 暗恋 / 讨厌 / CP / 同事…),就可以输出 A→B 一条 directed=true 边,**无需 B 回应或背书**。负面关系(讨厌 / 拉黑 / 仇人)同样适用。',
1186
+ ' 不要把"参与同一事件"或"共享同一兴趣"误当作朋友关系——必须有明确的身份性陈述("是我朋友""跟我闹翻了""我老婆"…);',
1187
+ `- person-person relationType 优先使用:${RecommendedPersonRelationTypes.join(' / ')};确无合适词时可自创小写英文短词。`,
1188
+ '- **角色单选最强**:同一人对同一 event / entity 只输出**一条最强角色边**。语义包含关系:',
1189
+ ' · 人-事件:initiator > participant > target > reporter > witness(参与者已含旁观者,不要又写 participant 又写 witness);',
1190
+ ' · 人-实体:enthusiast > creator > owner > critic > participant > visitor > mentioned。',
1191
+ ' · 例外:若同一人对同一事件存在**真正不同性质的角色**(如既是 initiator 又是 target —— 自作自受 / 被自己引发的后果反噬),允许各自输出一条;后端会按规则保留可共存的角色。',
1192
+ '- **严格自证(仅 person-person)**:要写 personPersonEdge A→B 时,evidence.messageIds 必须包含至少一条 **A 自己发的消息**(表达对 B 的关系定位)。"A 说 B 是 C 的朋友" 不能写成 B→C friend(B 没自己说过);但可写 A→B/A→C 的相关边。person-event / person-entity 边不再强制自证,evidence 只需能从原文佐证该人参与即可。',
1193
+ '- **person-person 视为单向声明**:A→B 总是 directed=true,B 不背书也无妨;如要表达双向关系(互为朋友/互为敌人),必须 B 也在窗口里有相应陈述,各自输出一条 directed=true 边,不要用 directed=false。',
1194
+ '- **hierarchy 维度(与 directed 正交)**:当 from 的话语**明确**透露与 to 的高低 / 平级关系时,填 hierarchy 字段(`superior` / `peer` / `subordinate` / `unknown`)。语义统一为「from 视角下 to 处于什么位置」:',
1195
+ ' · "X 是我师傅 / 老板 / 老师 / 老前辈" → from=X 的说话人,to=X,hierarchy="superior"(对方更高);',
1196
+ ' · "X 是我徒弟 / 下属 / 小弟" → hierarchy="subordinate"(对方更低);',
1197
+ ' · "我跟 X 是同学 / 同事 / 朋友 / 兄弟" → hierarchy="peer";',
1198
+ ' · 不确定 / 不适用(如 cp、rival、antagonist 这类水平关系或纯情感) → 省略字段或填 "unknown"。',
1199
+ ' · **不要靠 relationType 文本去暗示层级**(不要写 "mentor-superior" 这种),把层级正交分离到 hierarchy 字段。',
1200
+ '- **person-entity 门槛**(记「结构性连接」,不记「态度声明」):',
1201
+ ' · `participant / owner / creator / visitor / mentioned` —— 行为性角色,有 evidence 支持即可建边;可附加 sentiment 字段表达态度方向。',
1202
+ ' · `enthusiast` —— 需要**深度行为性证据**(规律参与 / 制作内容 / 购买 / 直播 / 多人共同指向同一实体揭示社会连接);单句「我喜欢 X」**不够**,改用 participant + sentiment=positive。',
1203
+ ' · `critic` —— 需要**主动行为性批评**(写了评测 / 公开对抗 / 反复表达负面立场);单次「我不喜欢 / 我讨厌」**不够**,改用 mentioned + sentiment=negative。',
1204
+ ' · 若不确定是行为性还是纯声明,**省略该边**,交给 plugin-user-profile。',
1205
+ '- ⭐ **角色 / 关系升级(重要:让弱关系跟随新证据成长)**:邻居子图(== 候选人已有 1 跳邻居子图 ==)里的 `role=` / 关系类型代表**当前已有的快照**。如果本轮窗口里观察到**更强的信号**,请直接输出**更强的角色/关系**——后端会按 rank 比较自动用强 role 替换旧 role(同一 person-entity / person-event 同对只保留最强一条):',
1206
+ ' · 人-实体升级路径(按强度排序):`mentioned → visitor → participant → critic → owner → creator → enthusiast`。例:',
1207
+ ' - 邻居里有 `entity[e1] 绝航 (work) role=mentioned w=0.1`,本轮 A 又说「我玩绝航玩了两年,还做了个 mod」 → **直接输出** `personEntityEdges: [{ A→e1 role=enthusiast sentiment=positive existingEntityId=e1 }]`(用 existingEntityId 复用同一实体),后端会把 mentioned 升级为 enthusiast。',
1208
+ ' - 邻居里有 `role=visitor`,本轮 A 说「我又去了那家店,买了三件」 → 输出 `role=owner` 或 `participant`。',
1209
+ ' - 邻居里有 `role=participant`,本轮 A 持续做开发/创作内容 → 输出 `role=creator` 或 `enthusiast`。',
1210
+ ' · 人-事件升级路径:`witness / reporter → participant → target → initiator`。例:邻居 `role=witness`,本轮证据显示 A 实际是发起者 → 输出 `role=initiator`。',
1211
+ ' · 人-人升级(仅 `familiar` 被视为占位):邻居里 `person→B "familiar"`,本轮 A 说「B 是我老婆 / 兄弟 / 师傅 / 仇人」 → 输出对应的 friend/cp/mentor/hostile 等**真实关系边**(同时填 hierarchy)。后端会自动废除占位 familiar 边。其他 person-person 关系(friend/colleague/mentor 等)**不互相升级合并**——同一对人可同时是同事+朋友,让两条边并存。',
1212
+ ' · **判断依据**:必须有**本轮窗口内的新证据**支撑升级,不能凭空"我觉得应该更强"就升;evidence.messageIds 必须来自本轮新消息。',
1213
+ ' · **降级不允许**:本轮没有更强信号时,**不要**主动把已有的强 role 写成弱 role(如把 enthusiast 写成 mentioned);省略该边即可,后端会照常衰减。',
1214
+ '- **event-entity / entity-entity 边不要求严格自证**(无 fromPerson),但仍需 evidence + quote。仅在明显能从原文看出【事件关于/使用 某实体】、【实体 part-of/contains 实体】时才输出。',
1215
+ '- **description 字段是可选注释**(≤ 40 字中文/英文),只在 role / relationType 代号不足以表达语义时与以补充(例:「绝巴 part-of 三角洲」可加 description="三角洲的高难度关卡")。能靠 role/relationType 表达清楚的别冗余加。',
1216
+ '- **别名识别(is-alias-of)**:当窗口内同一句或紧邻句出现「A 又叫 / 也叫 / 别名 / 就是 B」「A 是 B 的小号 / 马甲」等等同表述时:',
1217
+ ' · 若 A、B 都是人物 → personPersonEdges 输出 relationType="is-alias-of"(或 "alt-account-of" 用于小号),directed=true(A 是 B 的别名/小号)。',
1218
+ ' · 若 A、B 都是实体 → entityEntityEdges 输出 relationType="is-alias-of",directed=true。',
1219
+ ' · **eventEventEdges 严禁使用 `is-alias-of`**:事件天然带 sessionScope,不同会话/群里同名事件("群A 聊三角洲" vs "群B 聊三角洲")是**不同事件**而非别名。若想表达"同一主题在多个群发生",请按上文"跨会话共享主题"规则建 `scope=global` hub event 并用 `part-of` 挂接。后端会拒绝跨 scope 的 event is-alias-of。',
1220
+ ' · 不要直接合并两个 entity / person 节点,让用户决定是否合并。',
1221
+ '- **part-of 强制挂载(hub-first 落地)**:若事件标题中含有任何具名对象(作品 / 游戏 / 地点 / 物品 / 话题 / 关卡 / mod / 比赛名…),**必须**:(1) 把它单独抽成 entity;(2) 同时输出一条 eventEntityEdges relationType="part-of" 把事件挂在该 entity 下。即便该 entity 在本窗口里只出现一次也要抽,因为它可能被未来的其他窗口复用,从而把"打X / 讨论X / 安利X / 吐槽X" 等围绕同一对象的事件串成一张可遍历的网。**漏挂 part-of = 制造孤岛 = Aalis 失忆的最大单一原因**。',
1222
+ '- **歧义实体必须带限定词(重要)**:后端按 (entityKind, name) 强制合并同名实体。对于跨作品/跨场景容易撞名的「通用词」——例如「月卡 / 年卡 / 会员 / 公会 / 副本 / 装备 / 皮肤 / boss / npc / 主线 / 支线」等——**name 字段必须带上限定的母实体名**,形如「洛克王国月卡」「原神月卡」「《三角洲》公会」,而不是裸的「月卡」。若上下文无法确定母实体,则宁可不建该实体(建 event 描述即可),避免错误合并到无关游戏。',
1223
+ ' · 同理:人物绰号/角色名若可能撞名(如多个作品的「林黛玉」),name 也要带作品限定。',
1224
+ ' · 真正全局唯一的专有名词(如「PS5」「北京」「奥本海默」)不需要加限定词。',
1225
+ '- **同名跨 kind 必须复用候选(关键反幻觉规则)**:候选实体清单顶部若出现「⚠ 同名跨 kind 冲突候选」区块,意味着库里已存在 normalize 后**同名但 kind 不同**的多份实体(如 topic="三角洲行动" 与 work="三角洲行动" 并存)。处理规则:',
1226
+ ' · 你**默认必须**从该冲突组里挑一项填 `existingEntityId` 复用旧节点,并继承其原有 kind(**不要**写新的 kind 试图"覆盖"——后端 reinforceEntity 已禁止改 kind,传入也会被忽略并留 audit 警告)。',
1227
+ ' · 若本轮证据确认该对象的真实 kind 与你想用的某项候选不符,**首选**:复用语义更准的那一项(如对象是游戏本体 → 选 work 那一份)。如果冲突组里**没有**真正语义对得上的项,请加限定词写新 name(例如「三角洲行动(梗)」),避免再造一份同 normalize-name 的"第三份重复"。',
1228
+ ' · **绝不能**忽略警告直接新建 `existingEntityId=null` 且 normalize(name) 与冲突项相同的新实体。',
1229
+ '- existingEventId / existingEntityId:若新条目与候选清单中某项实质相同,请填该 id(让旧节点被强化而非重复创建)。',
1230
+ '- 当窗口里没有可靠信号时,对应数组返回空 [] 即可,绝对不要编造。',
1231
+ '- **绝不输出裸 `null`、裸字符串或其他非对象 JSON**;完全无可提取时请输出 `{"persons":[],"events":[],"entities":[],"personEventEdges":[],"personEntityEdges":[],"personPersonEdges":[],"eventEventEdges":[],"eventEntityEdges":[],"entityEntityEdges":[]}`。',
1232
+ ].join('\n'),
1233
+ };
1234
+ const user = {
1235
+ role: 'user',
1236
+ content: [
1237
+ '== 窗口内已知参与者 ==',
1238
+ senderList,
1239
+ '',
1240
+ '== 已有候选事件(可被强化复用)==',
1241
+ evtList,
1242
+ '',
1243
+ '== 已有候选实体(可被强化复用)==',
1244
+ entList,
1245
+ '',
1246
+ '== 候选人已有 1 跳邻居子图(按权重降序;用于判断"加强已有 vs 新建")==',
1247
+ neighborBlock,
1248
+ '',
1249
+ '== 消息窗口(按时间升序,[mid] = 平台消息 ID)==',
1250
+ opts?.crossSession
1251
+ ? '【跨会话模式】下方消息聚合了多个会话(群聊/私聊/平台),每行行首 `[sid:xxx]` 标注来源会话 id。请把不同 sid 之间**默认视为彼此独立的语境**,除非证据明确表明同一对象/事件被跨会话讨论才把 event.scope 标为 `global`;person / entity 节点天然全局共享,可正常跨 sid 累计证据。\n' +
1252
+ '【跨会话 hub 建模规则】当本窗口出现 ≥2 个不同 sid 都在围绕同一抽象主题(如"工会战""周末聚餐计划""某游戏开黑")展开各自的讨论/约局/吐槽时:\n' +
1253
+ '- 为该共同主题建一个 `scope=global` 的 **hub event**(title 取主题本身,如"工会战"),并为每个 sid 各自建一个**缺省 scope**(即不写 scope 字段)的**子事件**(title 带 sid 语境,如"A群工会战集结(2025-05-20)")。\n' +
1254
+ '- 通过 `eventEventEdges` `relationType="part-of"` 把每个子事件挂到 hub event 下,directed=true(from=子, to=hub)。\n' +
1255
+ '- candidates 中标有 `scope=global` 的事件**可直接复用为 hub**(existingEventId 填它的 id);标有 `scope=other:xxx` 的事件**不要直接 reinforce**(不同 session 隔离),但可以输出 part-of 边把它和你新建的 hub 挂在一起。\n' +
1256
+ '- 当各 sid 只是恰好提到同一个具名对象但无共同事件主线时,**不要建 hub event**,按现有规则用 entity + personEntityEdge 关联即可。'
1257
+ : '',
1258
+ rendered,
1259
+ '',
1260
+ '请直接输出 JSON 对象。',
1261
+ ].join('\n'),
1262
+ };
1263
+ return [system, user];
1264
+ }
1265
+ function collectSenderList(userMsgs) {
1266
+ const seen = new Map();
1267
+ for (const m of userMsgs) {
1268
+ const meta = m.metadata ?? {};
1269
+ if (!meta.userId)
1270
+ continue;
1271
+ const key = `${meta.platform ?? ''}:${meta.userId}`;
1272
+ if (!seen.has(key))
1273
+ seen.set(key, { nickname: meta.nickname, platform: meta.platform });
1274
+ }
1275
+ if (seen.size === 0)
1276
+ return '(窗口内未出现可识别的入站用户)';
1277
+ return [...seen.entries()]
1278
+ .map(([k, v]) => `- platform=${v.platform ?? '?'} userId=${k.split(':')[1]} nickname=${v.nickname ?? ''}`)
1279
+ .join('\n');
1280
+ }
1281
+ /**
1282
+ * 把每个发言人的 1 跳邻居子图渲染成紧凑文本,给 LLM 看:
1283
+ * ## Alice (onebot:1234567)
1284
+ * event[eid] 开黑《三角洲》 role=participant w=2.3
1285
+ * entity[entid] 三角洲 (work) role=enthusiast w=4.1
1286
+ * person→Bob(onebot) "friend" w=1.0
1287
+ * 没邻居的发言人/新人不渲染。
1288
+ */
1289
+ function renderSenderNeighbors(neighbors) {
1290
+ if (!neighbors || neighbors.length === 0)
1291
+ return '(窗口内发言人均为新人,或邻居子图功能已关闭)';
1292
+ const blocks = [];
1293
+ for (const n of neighbors) {
1294
+ const lines = [];
1295
+ lines.push(`## ${n.nickname ?? '匿名'} (${n.platform}:${n.userId})`);
1296
+ for (const e of n.edges) {
1297
+ const w = (e.weight ?? 0).toFixed(1);
1298
+ if (e.kind === 'person-event') {
1299
+ const ev = n.eventById.get(e.toEventId);
1300
+ lines.push(` event[${e.toEventId}] ${ev?.title ?? '(已删)'} role=${e.role} w=${w}`);
1301
+ }
1302
+ else if (e.kind === 'person-entity') {
1303
+ const ent = n.entityById.get(e.toEntityId);
1304
+ lines.push(` entity[${e.toEntityId}] ${ent?.name ?? '(已删)'}${ent ? ` (${ent.entityKind})` : ''} role=${e.role} w=${w}`);
1305
+ }
1306
+ else if (e.kind === 'person-person') {
1307
+ const otherId = e.fromPersonId === n.personId ? e.toPersonId : e.fromPersonId;
1308
+ const other = n.personById.get(otherId);
1309
+ const dir = e.fromPersonId === n.personId ? '→' : '←';
1310
+ lines.push(` person${dir}${other?.displayName ?? otherId} "${e.relationType}" w=${w}`);
1311
+ }
1312
+ }
1313
+ blocks.push(lines.join('\n'));
1314
+ }
1315
+ return blocks.join('\n');
1316
+ }
1317
+ async function callLLM(model, messages, disableThinking) {
1318
+ const resp = await model.chat({
1319
+ messages,
1320
+ temperature: 0,
1321
+ ...(disableThinking ? { think: false } : {}),
1322
+ });
1323
+ return typeof resp.content === 'string' ? resp.content : JSON.stringify(resp.content);
1324
+ }
1325
+ /**
1326
+ * 从 LLM 文本中解析提取结果。区分三种情况:
1327
+ * - ok:成功解析出含内容的对象
1328
+ * - empty:解析成功但表达为空(LLM 主动指出没什么可提取,属于正常路径)
1329
+ * - parse-error:LLM 输出无法解析或不是对象(需 warn)
1330
+ */
1331
+ export function parseExtraction(text) {
1332
+ if (!text)
1333
+ return { kind: 'parse-error' };
1334
+ const trimmed = text.trim();
1335
+ if (!trimmed)
1336
+ return { kind: 'parse-error' };
1337
+ // 走共享 util:剥 ```json fence、配平 {}、修字符串内裸引号、补尾部 } / ] 等。
1338
+ const { parsed } = parseLLMJsonObject(trimmed);
1339
+ if (!parsed)
1340
+ return { kind: 'parse-error' };
1341
+ // 共享 util 已保证 parsed 是非空对象(非数组、非 null、非原始值)。
1342
+ const obj = parsed;
1343
+ const totalCount = (obj.persons?.length ?? 0) +
1344
+ (obj.events?.length ?? 0) +
1345
+ (obj.entities?.length ?? 0) +
1346
+ (obj.personEventEdges?.length ?? 0) +
1347
+ (obj.personEntityEdges?.length ?? 0) +
1348
+ (obj.personPersonEdges?.length ?? 0) +
1349
+ (obj.eventEventEdges?.length ?? 0) +
1350
+ (obj.eventEntityEdges?.length ?? 0) +
1351
+ (obj.entityEntityEdges?.length ?? 0);
1352
+ if (totalCount === 0)
1353
+ return { kind: 'empty' };
1354
+ return { kind: 'ok', value: obj };
1355
+ }
1356
+ //# sourceMappingURL=extractor.js.map