local-knowledge-graph 1.9.0 → 1.10.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.
package/HELP.md CHANGED
@@ -112,8 +112,8 @@ kg
112
112
  ### 与本程序结合的路线
113
113
 
114
114
  1. **已具备**:传递/对称/逆公理推理 + 问答证据链 + MCP 推理查询(外部 Agent 可调 `kg_inference`)
115
- 2. **低成本扩展·共同邻居推荐**:A→B、B→C 有边则提示 A↔C 为候选关系,在实体信息卡列出"推荐关系",一键让 AI 验证或人工确认入库
116
- 3. **AI 推荐关系**:选取两个实体,把二者属性与邻域子图交给 LLM 判断"是否存在关系、是什么关系、依据是什么",经审核界面勾选入库(复用文档入图的审核管线)
115
+ 2. **已具备·共同邻居推荐**:检索页签"关系推荐"卡一键扫描全图——无直接边但拥有共同邻居的实体对自动列为候选(Adamic-Adar 打分,邻居越稀有越靠前);实体信息卡"推荐关系"按钮查看与该实体相关的候选。每条候选可点「AI 判断」让 LLM 给出关系名/大类/置信度/依据,确认后一键采纳入库(来源引用自动记为 AI 推荐依据,可撤销)
116
+ 3. **已具备·AI 推荐关系**:即上条「AI 判断」——把两实体属性与共同关联交给大模型判断"是否存在关系、是什么关系、依据是什么",输出仅供决策,入库必须人工点击确认(AI 不直接写库)
117
117
  4. **进阶·自定义规则**:设置页开放规则编辑(如 师从(A,B) ∧ 师从(B,C) ⇒ 同门(A,C)),与内置传递/对称推理共用"推"标记展示
118
118
 
119
119
  ### 应用场景与效益
@@ -128,6 +128,7 @@ kg
128
128
 
129
129
  - **AI 对话建图**:右侧输入自然语言指令(联网补全),或上传文档(md/txt/pdf/docx)批量抽取三元组
130
130
  - **智能提问**:检索页签一句话找关系(LLM 编译检索计划,本地只读执行,可生成带引用的综述)
131
+ - **关系推荐**:共同邻居算法找"该连没连"的实体对,AI 判断后一键采纳入库(检索页签"关系推荐"卡 / 实体卡"推荐关系"按钮)
131
132
  - **语义检索**:配置硅基流动 API Key 后支持语义+关键词混合检索(未配置时自动退化为关键词模式)
132
133
  - **最短路径 / 中心子图 / 推理**:挖掘实体间关联链路、层级范围与隐性关系(方法体系见上文"知识推理"章节)
133
134
  - **撤销**:Ctrl+Z 或按钮,可连续撤销
@@ -164,6 +165,7 @@ kg
164
165
 
165
166
  ## 版本历史摘要
166
167
 
168
+ - **v1.10.0**:关系推荐——共同邻居算法(Adamic-Adar 打分,排除已有直接边)全图/中心实体两种扫描;AI 判断候选关系(LLM 给出关系名/大类/置信度/依据,人工确认后入库,不直接写库);检索页签新增"关系推荐"卡,实体信息卡新增"推荐关系"按钮
167
169
  - **v1.9.0**:检索配置增强(Base URL 页面可配 + 测试连接按钮,保存前即可验证密钥/地址/模型);关系图片绑定(信息卡上传/缩略图/灯箱,佐证材料与关系绑定,删关系连带清理);帮助文档全面增补(检索配置详解/关系编辑与图片/知识推理方法体系与应用场景)
168
170
  - **v1.8.0**:MCP 外部接入——设置新增 MCP 页签(服务开关/只读模式/令牌管理/三类客户端一键复制配置);`/mcp` Streamable HTTP 端点(令牌鉴权+CORS,12 个工具含完整 CRUD);`kg --mcp` 本机 stdio 接入;图谱变更实时同步(SSE 推送+跨进程探测,页面 3 秒内自动刷新)
169
171
  - **v1.7.0**:六项图谱能力升级——①实体别名(增删/全局唯一/搜索与消歧联动)②同名冲突检测(创建即提示,可合并可强制)③多路径查找(2-6跳,图上高亮,MCP kg_path)④相似实体推荐(语义+结构双模式)⑤关系置信度三档(确证/推测/存疑,样式区分可过滤)与来源引用标注 ⑥智能问答附证据路径链 ⑦文档批量入图(md/txt/pdf→LLM抽取→审核勾选→来源标记入库,支持跳过审核)
@@ -0,0 +1,133 @@
1
+ 'use strict';
2
+
3
+ // 关系推荐:共同邻居图算法(Adamic-Adar打分)+ LLM候选关系判断
4
+ // 设计约束:推荐纯本地只读计算;LLM判断只输出建议JSON,入库须经前端确认(走人工接口,来源'手工')。
5
+
6
+ const REL_CATS = ['空间', '互动', '归属', '时间', '属性'];
7
+ const CONF_LEVELS = ['确证', '推测', '存疑'];
8
+ const MAX_LLM_TIMEOUT = 60000; // opencode CLI 实测冷启动+推理可达30-50s,与综述超时一致
9
+
10
+ // ---------- 共同邻居推荐 ----------
11
+ // 对每对无直接边的实体统计共同邻居并按 Adamic-Adar 打分(邻居越稀有分越高)。
12
+ // opts.centerId 限定只返回与该实体相关的候选;opts.minCommon 最小共同邻居数(默认2,center模式1);opts.limit 返回上限(默认20)。
13
+ function computeCoNeighborRecs(graph, opts = {}) {
14
+ const centerId = opts.centerId || null;
15
+ const limit = Math.min(Math.max(1, Number(opts.limit) || 20), 100);
16
+ const minCommon = Math.max(1, Number(opts.minCommon) || (centerId ? 1 : 2));
17
+
18
+ const byId = new Map(graph.entities.map((e) => [e.id, e]));
19
+ const neighbors = new Map(); // id -> Map(邻居id -> 经该邻居相连的关系数)
20
+ const touch = (id) => { if (!neighbors.has(id)) neighbors.set(id, new Map()); };
21
+ const direct = new Set(); // 'a|b' 双向规范化键
22
+ const dkey = (a, b) => (a < b ? `${a}|${b}` : `${b}|${a}`);
23
+ for (const r of graph.relations) {
24
+ if (!byId.has(r.source_id) || !byId.has(r.target_id)) continue;
25
+ touch(r.source_id); touch(r.target_id);
26
+ neighbors.get(r.source_id).set(r.target_id, (neighbors.get(r.source_id).get(r.target_id) || 0) + 1);
27
+ neighbors.get(r.target_id).set(r.source_id, (neighbors.get(r.target_id).get(r.source_id) || 0) + 1);
28
+ direct.add(dkey(r.source_id, r.target_id));
29
+ }
30
+
31
+ // 每节点收集2跳计数:O(V * deg^2),大图时节点按度截断保护
32
+ const DEG_CAP = 300;
33
+ const nodes = [...byId.keys()].filter((id) => neighbors.has(id));
34
+ const counter = new Map(); // 'a|b' -> Map(commonId -> true)
35
+ for (const a of nodes) {
36
+ const nbs = [...neighbors.get(a).keys()];
37
+ if (nbs.length > DEG_CAP) continue; // 度数过高的枢纽节点跳过全对展开,防爆炸
38
+ for (const m of nbs) {
39
+ const mn = neighbors.get(m);
40
+ if (!mn) continue;
41
+ for (const b of mn.keys()) {
42
+ if (b === a || neighbors.get(a).has(b)) continue; // 自身或已有直接边
43
+ const k = dkey(a, b);
44
+ if (!counter.has(k)) counter.set(k, new Map());
45
+ counter.get(k).set(m, true);
46
+ }
47
+ }
48
+ }
49
+
50
+ const nameOf = (id) => (byId.get(id) ? byId.get(id).name : `#${id}`);
51
+ const recs = [];
52
+ for (const [k, commons] of counter) {
53
+ if (commons.size < minCommon) continue;
54
+ const [a, b] = k.split('|').map(Number);
55
+ if (centerId && a !== centerId && b !== centerId) continue;
56
+ // Adamic-Adar:sum(1/log(deg(共同邻居)))
57
+ let score = 0;
58
+ for (const c of commons.keys()) {
59
+ const deg = (neighbors.get(c) || new Map()).size || 1;
60
+ score += 1 / Math.log(deg + 1);
61
+ }
62
+ recs.push({
63
+ source_id: a, target_id: b,
64
+ source_name: nameOf(a), target_name: nameOf(b),
65
+ common_count: commons.size,
66
+ common_names: [...commons.keys()].slice(0, 6).map(nameOf),
67
+ score: Number(score.toFixed(4)),
68
+ });
69
+ }
70
+ recs.sort((x, y) => y.score - x.score || y.common_count - x.common_count);
71
+ return recs.slice(0, limit);
72
+ }
73
+
74
+ // ---------- LLM 候选关系判断 ----------
75
+ function buildJudgePrompt(a, b, commonNames) {
76
+ const ent = (e) => {
77
+ let s = `- ${e.name}(${e.category})`;
78
+ let attrs = '';
79
+ try { attrs = JSON.stringify(JSON.parse(e.attributes || '{}')); } catch (_) { attrs = String(e.attributes || '{}'); }
80
+ if (attrs && attrs !== '{}') s += ` 属性:${attrs.slice(0, 300)}`;
81
+ return s;
82
+ };
83
+ return [
84
+ '你是知识图谱专家。判断以下两个实体之间是否存在值得录入图谱的明确关系(基于常识与给出的事实,不要编造)。',
85
+ `实体A: ${ent(a)}`,
86
+ `实体B: ${ent(b)}`,
87
+ commonNames && commonNames.length ? `它们在图谱中拥有共同关联: ${commonNames.join('、')}` : '',
88
+ '',
89
+ '只输出JSON(不要其他文字),格式:',
90
+ '{"has_relation": true/false, "name": "关系名(2-4字,如 师从/位于/效力于)", "category": "空间|互动|归属|时间|属性 之一", "confidence": "确证|推测|存疑 之一", "evidence": "一句话依据"}',
91
+ 'has_relation 为 false 时其余字段留空字符串。',
92
+ ].filter(Boolean).join('\n');
93
+ }
94
+
95
+ // 校验LLM输出;非法字段回退默认或拒绝
96
+ function validateJudge(j) {
97
+ if (!j || typeof j !== 'object') return { ok: false, error: '输出不是JSON对象' };
98
+ const out = {
99
+ has_relation: Boolean(j.has_relation),
100
+ name: String(j.name || '').trim().slice(0, 30),
101
+ category: REL_CATS.includes(j.category) ? j.category : '',
102
+ confidence: CONF_LEVELS.includes(j.confidence) ? j.confidence : '推测',
103
+ evidence: String(j.evidence || '').trim().slice(0, 300),
104
+ };
105
+ if (!out.has_relation) return { ok: true, judge: { has_relation: false } };
106
+ if (!out.name) return { ok: false, error: 'has_relation为true时关系名不能为空' };
107
+ if (!out.category) return { ok: false, error: `关系大类必须是: ${REL_CATS.join('/')}` };
108
+ return { ok: true, judge: out };
109
+ }
110
+
111
+ function extractJson(text) {
112
+ let t = String(text || '').trim().replace(/```(?:json)?/gi, '');
113
+ const start = t.indexOf('{');
114
+ const end = t.lastIndexOf('}');
115
+ if (start === -1 || end <= start) throw new Error('输出中未找到JSON对象');
116
+ return JSON.parse(t.slice(start, end + 1));
117
+ }
118
+
119
+ // runPlain 可注入(测试mock);graph 关系用于取两实体邻域摘要
120
+ async function aiJudgeRelation(payload, runPlain) {
121
+ const { a, b, common_names } = payload || {};
122
+ if (!a || !b) { const e = new Error('必须提供待判断的两个实体'); e.status = 400; throw e; }
123
+ const rp = runPlain || require('./agent').runPlain;
124
+ const r = await rp(buildJudgePrompt(a, b, common_names || []), MAX_LLM_TIMEOUT);
125
+ if (!r.ok) { const e = new Error(`LLM判断失败: ${r.error}`); e.status = 502; throw e; }
126
+ let raw;
127
+ try { raw = extractJson(r.text); } catch (e) { const err = new Error(`LLM输出无法解析: ${e.message}`); err.status = 502; throw err; }
128
+ const v = validateJudge(raw);
129
+ if (!v.ok) { const err = new Error(`LLM输出校验未通过: ${v.error}`); err.status = 502; throw err; }
130
+ return { ...v.judge, source_id: a.id, target_id: b.id, source_name: a.name, target_name: b.name };
131
+ }
132
+
133
+ module.exports = { computeCoNeighborRecs, buildJudgePrompt, validateJudge, aiJudgeRelation, REL_CATS, CONF_LEVELS };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "local-knowledge-graph",
3
- "version": "1.9.0",
3
+ "version": "1.10.1",
4
4
  "description": "本地知识图谱整合器:3D可视化 + AI对话建图 + 智能检索 + Git回溯 + RDF合规,全流程本地运行",
5
5
  "main": "server.js",
6
6
  "bin": {
package/public/app.js CHANGED
@@ -256,6 +256,83 @@ function makeLabelSprite(text, cssColor, fontSize) {
256
256
  return sprite;
257
257
  }
258
258
 
259
+ /* ---- 平行边弧形分离 ---- */
260
+ // 同对节点多条关系时线弯曲错开:offset 为弧的偏移强度(0=直线),标签置于各弧顶
261
+ const ARC_SEGMENTS = 16;
262
+ function arcOffsetVec(dir, arc) {
263
+ // 弧偏移方向:取与边垂直的平面,按边序号均匀转开角度;强度=边长*比例,保证不同长度边分离度一致
264
+ const up = Math.abs(dir.y) > 0.92 ? new THREE.Vector3(1, 0, 0) : new THREE.Vector3(0, 1, 0);
265
+ const u = new THREE.Vector3().crossVectors(dir, up).normalize();
266
+ const v = new THREE.Vector3().crossVectors(dir, u).normalize();
267
+ const ang = (arc.idx / arc.total) * Math.PI * 2 + 0.6; // 固定相位避免与常用方向重合
268
+ const strength = 0.14; // 弧顶偏移 = 边长 * strength
269
+ return u.multiplyScalar(Math.cos(ang)).add(v.multiplyScalar(Math.sin(ang))).multiplyScalar(dir.length() * strength);
270
+ }
271
+
272
+ // 曲线上参数 t∈[0,1] 的点:a→b 直线叠加抛物弧偏移(中点最大,两端为0)
273
+ function arcPoint(out, a, b, dir, off, t) {
274
+ const sag = 4 * t * (1 - t);
275
+ out.set(
276
+ a.x + dir.x * t + off.x * sag,
277
+ a.y + dir.y * t + off.y * sag,
278
+ a.z + dir.z * t + off.z * sag
279
+ );
280
+ return out;
281
+ }
282
+
283
+ function makeRelLine(pa, pb, arc, color, dashed, opacity, dashSize, gapSize) {
284
+ const pts = [];
285
+ if (arc) {
286
+ const dir = pb.clone().sub(pa);
287
+ const off = arcOffsetVec(dir, arc);
288
+ for (let i = 0; i <= ARC_SEGMENTS; i++) pts.push(arcPoint(new THREE.Vector3(), pa, pb, dir, off, i / ARC_SEGMENTS).clone());
289
+ } else {
290
+ pts.push(pa.clone(), pb.clone());
291
+ }
292
+ const geo = new THREE.BufferGeometry().setFromPoints(pts);
293
+ const mat = dashed
294
+ ? new THREE.LineDashedMaterial({ color, dashSize, gapSize, transparent: true, opacity })
295
+ : new THREE.LineBasicMaterial({ color, transparent: true, opacity });
296
+ const line = new THREE.Line(geo, mat);
297
+ if (dashed) line.computeLineDistances();
298
+ return line;
299
+ }
300
+
301
+ // 每帧根据节点最新位置刷新弧线几何
302
+ const _arcDir = new THREE.Vector3(), _arcOff = new THREE.Vector3(), _arcTmp = new THREE.Vector3();
303
+ function updateRelLine(l) {
304
+ if (!l.arc) {
305
+ const posAttr = l.line.geometry.attributes.position;
306
+ posAttr.setXYZ(0, l.a.pos.x, l.a.pos.y, l.a.pos.z);
307
+ posAttr.setXYZ(1, l.b.pos.x, l.b.pos.y, l.b.pos.z);
308
+ posAttr.needsUpdate = true;
309
+ if (l.dashed) l.line.computeLineDistances();
310
+ l.label.position.copy(l.a.pos).add(l.b.pos).multiplyScalar(0.5);
311
+ return;
312
+ }
313
+ _arcDir.subVectors(l.b.pos, l.a.pos);
314
+ _arcOff.copy(arcOffsetVec(_arcDir, l.arc));
315
+ const posAttr = l.line.geometry.attributes.position;
316
+ for (let i = 0; i <= ARC_SEGMENTS; i++) {
317
+ arcPoint(_arcTmp, l.a.pos, l.b.pos, _arcDir, _arcOff, i / ARC_SEGMENTS);
318
+ posAttr.setXYZ(i, _arcTmp.x, _arcTmp.y, _arcTmp.z);
319
+ }
320
+ posAttr.needsUpdate = true;
321
+ if (l.dashed) l.line.computeLineDistances();
322
+ // 标签置于弧顶(t=0.5)外移一点,跟随弧线弯曲
323
+ arcPoint(_arcTmp, l.a.pos, l.b.pos, _arcDir, _arcOff, 0.5);
324
+ const lift = _arcOff.clone().multiplyScalar(6);
325
+ l.label.position.copy(_arcTmp).add(lift);
326
+ }
327
+
328
+ function setRelLabelPos(lbl, pa, pb, arc) {
329
+ if (!arc) { lbl.position.copy(pa).add(pb).multiplyScalar(0.5); return; }
330
+ const dir = pb.clone().sub(pa);
331
+ const off = arcOffsetVec(dir, arc);
332
+ const p = arcPoint(new THREE.Vector3(), pa, pb, dir, off, 0.5);
333
+ lbl.position.copy(p).add(off.clone().multiplyScalar(6));
334
+ }
335
+
259
336
  function buildNodeMesh(category) {
260
337
  const st = ENTITY_STYLE[category] || { color: 0xaaaaaa };
261
338
  let mesh;
@@ -338,6 +415,21 @@ function rebuildGraph() {
338
415
  simNodes.push({ id: e.id, pos: mesh.position.clone(), vel: new THREE.Vector3(), mesh, label, radius: 10, sizeScale: ENTITY_STYLE[e.category] ? (ENTITY_STYLE[e.category].size || 1) : 1 });
339
416
  });
340
417
 
418
+ // 同节点对的平行边统一编号:线作弧形分离,标签各置弧顶,避免文字与线完全重叠
419
+ const pairCount = new Map(); // 'a|b'(无向) -> 同对节点边的总数
420
+ for (const r of rels) {
421
+ const k = r.source_id < r.target_id ? `${r.source_id}|${r.target_id}` : `${r.target_id}|${r.source_id}`;
422
+ pairCount.set(k, (pairCount.get(k) || 0) + 1);
423
+ }
424
+ const pairSeen = new Map();
425
+ const arcOf = (r) => {
426
+ const k = r.source_id < r.target_id ? `${r.source_id}|${r.target_id}` : `${r.target_id}|${r.source_id}`;
427
+ const n = pairCount.get(k) || 1;
428
+ const i = pairSeen.get(k) || 0;
429
+ pairSeen.set(k, i + 1);
430
+ return n > 1 ? { idx: i, total: n } : null;
431
+ };
432
+
341
433
  rels.forEach((r) => {
342
434
  const a = simNodes.find((n) => n.id === r.source_id);
343
435
  const b = simNodes.find((n) => n.id === r.target_id);
@@ -347,21 +439,16 @@ function rebuildGraph() {
347
439
  const baseOp = st.opacity === undefined ? 0.9 : st.opacity;
348
440
  const op = conf === '存疑' ? Math.min(baseOp, 0.35) : baseOp; // 存疑降不透明度
349
441
  const dashed = st.dashed || conf === '推测'; // 推测强制虚线
350
- const geo = new THREE.BufferGeometry().setFromPoints([a.pos, b.pos]);
351
- const mat = dashed
352
- ? new THREE.LineDashedMaterial({ color: st.color, dashSize: st.dashSize || 6, gapSize: st.gapSize || 4, transparent: true, opacity: op })
353
- : new THREE.LineBasicMaterial({ color: st.color, transparent: true, opacity: op });
354
- const line = new THREE.Line(geo, mat);
442
+ const arc = arcOf(r);
443
+ const line = makeRelLine(a.pos, b.pos, arc, st.color, dashed, op, st.dashSize || 6, st.gapSize || 4);
355
444
  line.userData.relationId = r.id;
356
445
  line.userData.baseOpacity = op;
357
446
  linkGroup.add(line);
358
- const mid = a.pos.clone().add(b.pos).multiplyScalar(0.5);
359
- // 线标注显示具体关系名(如"父子"),线型/颜色仍由大类规定
360
447
  const lbl = makeLabelSprite(r.name, st.css, 24);
361
448
  lbl.userData.text = r.name;
362
- lbl.position.copy(mid);
449
+ setRelLabelPos(lbl, a.pos, b.pos, arc);
363
450
  labelGroup.add(lbl);
364
- simLinks.push({ id: r.id, a, b, line, label: lbl, dashed, confidence: conf });
451
+ simLinks.push({ id: r.id, a, b, line, label: lbl, dashed, confidence: conf, arc });
365
452
  });
366
453
 
367
454
  // 推理关系叠加:虚化虚线 + "(推)"标注,负数id与库中显式关系区分;仅显示两端均在当前视图的边
@@ -372,16 +459,18 @@ function rebuildGraph() {
372
459
  const a = simNodes.find((n) => n.id === ir.source_id);
373
460
  const b = simNodes.find((n) => n.id === ir.target_id);
374
461
  if (!a || !b) return;
375
- const geo = new THREE.BufferGeometry().setFromPoints([a.pos, b.pos]);
376
- const mat = new THREE.LineDashedMaterial({ color: 0xc792ea, dashSize: 3, gapSize: 5, transparent: true, opacity: 0.35 });
377
- const line = new THREE.Line(geo, mat);
378
- line.computeLineDistances();
462
+ // 推理边并入平行边分组:同对节点的显式边与推理边互不错开编号,仅继续排号
463
+ const k = ir.source_id < ir.target_id ? `${ir.source_id}|${ir.target_id}` : `${ir.target_id}|${ir.source_id}`;
464
+ const n = (pairCount.get(k) || 0) + 1;
465
+ pairCount.set(k, n);
466
+ const arc = n > 1 ? { idx: n - 1, total: n } : null;
467
+ const line = makeRelLine(a.pos, b.pos, arc, 0xc792ea, true, 0.35, 3, 5);
379
468
  line.userData.relationId = -(idx + 1);
380
469
  linkGroup.add(line);
381
470
  const lbl = makeLabelSprite(ir.name + '(推)', '#c792ea', 22);
382
- lbl.position.copy(a.pos.clone().add(b.pos).multiplyScalar(0.5));
471
+ setRelLabelPos(lbl, a.pos, b.pos, arc);
383
472
  labelGroup.add(lbl);
384
- simLinks.push({ id: line.userData.relationId, a, b, line, label: lbl, dashed: true });
473
+ simLinks.push({ id: line.userData.relationId, a, b, line, label: lbl, dashed: true, arc });
385
474
  });
386
475
  }
387
476
 
@@ -429,12 +518,7 @@ function simStep() {
429
518
  }
430
519
  settleCount++;
431
520
  for (const l of simLinks) {
432
- const posAttr = l.line.geometry.attributes.position;
433
- posAttr.setXYZ(0, l.a.pos.x, l.a.pos.y, l.a.pos.z);
434
- posAttr.setXYZ(1, l.b.pos.x, l.b.pos.y, l.b.pos.z);
435
- posAttr.needsUpdate = true;
436
- if (l.dashed) l.line.computeLineDistances();
437
- l.label.position.copy(l.a.pos).add(l.b.pos).multiplyScalar(0.5);
521
+ updateRelLine(l);
438
522
  }
439
523
  for (const nd of simNodes) {
440
524
  nd.mesh.position.copy(nd.pos);
@@ -589,9 +673,11 @@ function renderInfoCard() {
589
673
  <button onclick="focusEgo(${e.id})">以此为中心</button>
590
674
  <button onclick="askPath(${e.id}, '${escapeHtml(e.name).replace(/'/g, "\\'")}')">查路径</button>
591
675
  <button onclick="loadSimilar(${e.id})">相似实体</button>
676
+ <button onclick="loadEntityRecs(${e.id})">推荐关系</button>
592
677
  <button onclick="$('entity-img-input').click()">绑图片</button>
593
678
  </div>
594
679
  <div id="similar-box"></div>
680
+ <div id="rec-box"></div>
595
681
  <div class="btns"><button onclick="editEntity(${e.id})">编辑</button><button class="danger" onclick="delEntity(${e.id})">删除</button></div>`;
596
682
  card.style.display = 'block';
597
683
  if (imgCount > 0 && !imgs) loadEntityImages(e.id);
@@ -1084,6 +1170,95 @@ async function loadSimilar(id) {
1084
1170
  }
1085
1171
  window.loadSimilar = loadSimilar;
1086
1172
 
1173
+ /* ================= 关系推荐(共同邻居 + AI判断) ================= */
1174
+ const judgeCache = new Map(); // 'a|b' -> judge结果(本会话内复用,避免重复调LLM)
1175
+
1176
+ // 单条候选的展开渲染:AI判断按钮 → 判断结果 → 采纳入库
1177
+ function recRowHtml(rec, boxId) {
1178
+ const key = `${rec.source_id}|${rec.target_id}`;
1179
+ const id = `${boxId}-${key.replace(/\|/g, '_')}`;
1180
+ return `<div class="kv" style="padding:6px 0;border-top:1px dashed #2a3a55" id="row-${id}">
1181
+ <span style="cursor:pointer;color:#7fd1ff" onclick="focusEntity(${rec.source_id})">${escapeHtml(rec.source_name)}</span>
1182
+ <b style="color:#c792ea"> ⇄? </b>
1183
+ <span style="cursor:pointer;color:#7fd1ff" onclick="focusEntity(${rec.target_id})">${escapeHtml(rec.target_name)}</span>
1184
+ <span style="color:#8fa3c0;font-size:11px">共同邻居${rec.common_count}:${escapeHtml((rec.common_names || []).join('、'))} AA ${rec.score}</span>
1185
+ <div style="margin-top:4px"><button class="ghost" onclick="aiJudgeRec(${rec.source_id},${rec.target_id},'${id}')">AI 判断</button></div>
1186
+ <div id="jr-${id}"></div>
1187
+ </div>`;
1188
+ }
1189
+
1190
+ async function aiJudgeRec(sourceId, targetId, rowId) {
1191
+ const box = $('jr-' + rowId);
1192
+ if (!box) return;
1193
+ const key = `${sourceId}|${targetId}`;
1194
+ if (judgeCache.has(key)) { renderJudge(box, judgeCache.get(key), rowId); return; }
1195
+ box.innerHTML = '<div class="kv" style="color:#8fa3c0">AI 判断中…(约10-30秒)</div>';
1196
+ try {
1197
+ const j = await api('/api/recommend/ai', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ source_id: sourceId, target_id: targetId }) });
1198
+ judgeCache.set(key, j);
1199
+ renderJudge(box, j, rowId);
1200
+ } catch (e) { box.innerHTML = `<div class="kv" style="color:#e0a768">判断失败:${escapeHtml(e.message)}</div>`; }
1201
+ }
1202
+ window.aiJudgeRec = aiJudgeRec;
1203
+
1204
+ function renderJudge(box, j, rowId) {
1205
+ if (!j.has_relation) { box.innerHTML = '<div class="kv" style="color:#8b949e">AI 判断:无明显关系可录</div>'; return; }
1206
+ const confColor = { '确证': '#7ee787', '推测': '#e0a768', '存疑': '#8b949e' }[j.confidence] || '#8fa3c0';
1207
+ box.innerHTML = `<div class="kv" style="background:#1a2332;border-radius:6px;padding:6px">
1208
+ 建议关系:<b style="color:#7ee787">${escapeHtml(j.name)}</b>
1209
+ <span class="tag" style="color:${confColor};border-color:${confColor}55">${j.category}·${j.confidence}</span>
1210
+ <div style="color:#8fa3c0;font-size:11px">依据:${escapeHtml(j.evidence || '(无)')}</div>
1211
+ <div style="margin-top:4px"><button class="primary" onclick="acceptRec(${j.source_id},${j.target_id},'${rowId}')">采纳入库</button></div>
1212
+ </div>`;
1213
+ }
1214
+ window.acceptRec = acceptRec;
1215
+
1216
+ async function acceptRec(sourceId, targetId, rowId) {
1217
+ const judge = judgeCache.get(`${sourceId}|${targetId}`);
1218
+ if (!judge || !judge.has_relation) { toast('判断结果已失效,请重新AI判断', true); return; }
1219
+ try {
1220
+ await api('/api/relations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({
1221
+ source_id: sourceId, target_id: targetId, name: judge.name, category: judge.category, confidence: judge.confidence,
1222
+ source_ref: 'AI推荐: ' + (judge.evidence || '共同邻居推荐'),
1223
+ }) });
1224
+ toast(`已录入关系「${judge.name}」`);
1225
+ const row = $('row-' + rowId);
1226
+ if (row) row.remove();
1227
+ } catch (e) { toast(e.message, true); }
1228
+ }
1229
+
1230
+ function renderRecList(recs, boxId, emptyText) {
1231
+ const box = $(boxId);
1232
+ if (!box) return;
1233
+ box.innerHTML = recs.length
1234
+ ? recs.map((r) => recRowHtml(r, boxId)).join('')
1235
+ : `<div class="kv">${emptyText}</div>`;
1236
+ }
1237
+
1238
+ async function scanRecs() {
1239
+ const btn = $('rc-scan');
1240
+ btn.disabled = true;
1241
+ $('rc-status').textContent = '扫描中…';
1242
+ try {
1243
+ const r = await api('/api/recommend?limit=20');
1244
+ $('rc-status').textContent = `${r.recommendations.length} 条候选`;
1245
+ renderRecList(r.recommendations, 'rc-list', '无可推荐候选(共同邻居≥2且无直接边的实体对)');
1246
+ } catch (e) { $('rc-status').textContent = e.message; }
1247
+ btn.disabled = false;
1248
+ }
1249
+ $('rc-scan').addEventListener('click', scanRecs);
1250
+
1251
+ async function loadEntityRecs(id) {
1252
+ const box = $('rec-box');
1253
+ if (!box) return;
1254
+ box.innerHTML = '<div class="kv">候选关系计算中…</div>';
1255
+ try {
1256
+ const r = await api(`/api/recommend?center=${id}&limit=10`);
1257
+ renderRecList(r.recommendations, 'rec-box', '暂无候选(该实体2跳内无共同邻居≥1的无边实体对)');
1258
+ } catch (e) { box.innerHTML = `<div class="kv" style="color:#e0a768">${escapeHtml(e.message)}</div>`; }
1259
+ }
1260
+ window.loadEntityRecs = loadEntityRecs;
1261
+
1087
1262
  async function delRelation(id) {
1088
1263
  if (!confirm(`删除关系 #${id}?其绑定的图片文件将一并移除。`)) return;
1089
1264
  try {
package/public/index.html CHANGED
@@ -117,6 +117,11 @@
117
117
  <div class="row"><button class="primary" id="s-run">检索</button><span id="s-mode" style="font-size:11px;color:#8fa3c0;align-self:center"></span></div>
118
118
  <div id="s-results"></div>
119
119
  </div>
120
+ <div class="card">
121
+ <h3>关系推荐(共同邻居 + AI 判断)</h3>
122
+ <div class="row"><button class="primary" id="rc-scan">扫描全图推荐</button><span id="rc-status" style="font-size:11px;color:#8fa3c0;align-self:center"></span></div>
123
+ <div id="rc-list"></div>
124
+ </div>
120
125
  <div class="card">
121
126
  <h3>关系查询(迷你Cypher,只读)</h3>
122
127
  <textarea id="c-query" placeholder="MATCH (a)-[r:互动]->(b) WHERE a.name contains 郑和 RETURN a.name, r.name, b.name LIMIT 10"></textarea>
@@ -250,6 +255,7 @@
250
255
  <ul class="hp-list">
251
256
  <li><b>AI 对话建图</b>:右侧输入自然语言(如"联网查一下珠穆朗玛峰的海拔并建入图谱"),或上传文档(md/txt/pdf/docx)批量抽取</li>
252
257
  <li><b>检索页签</b>:智能提问(一句话找关系)、语义/关键词混合检索;语义配置卡可填 Base URL/Key/模型并一键测试连接</li>
258
+ <li><b>关系推荐</b>:共同邻居找"该连没连"的实体对(检索页签"关系推荐"卡 / 实体卡"推荐关系"按钮),AI 判断后一键采纳入库</li>
253
259
  <li><b>最短路径</b>:搜索或列表选中实体后,用"路径"功能查两实体关联链路</li>
254
260
  <li><b>撤销</b>:按钮或 Ctrl+Z,可连续撤销;删除实体仅恢复实体本身,关联关系可在日志页看到级联记录</li>
255
261
  <li><b>推理</b>:开关叠加虚线显示的隐性关系(传递/对称/逆规则),点击可见推导依据</li>
package/server.js CHANGED
@@ -10,6 +10,7 @@ const rdf = require('./lib/rdf');
10
10
  const agent = require('./lib/agent');
11
11
  const V = require('./lib/validator');
12
12
  const inference = require('./lib/inference');
13
+ const recommend = require('./lib/recommend');
13
14
  const embeddings = require('./lib/embeddings');
14
15
  const askLib = require('./lib/ask');
15
16
  const updater = require('./lib/updater');
@@ -412,6 +413,45 @@ api.get('/inference', (req, res) => {
412
413
 
413
414
  api.get('/ontology', (req, res) => res.json(inference.loadOntology()));
414
415
 
416
+ // ---------- 关系推荐(共同邻居 + LLM判断) ----------
417
+ api.get('/recommend', (req, res) => {
418
+ const g = db.getGraph();
419
+ const center = String(req.query.center || '').trim();
420
+ const opts = { limit: Number(req.query.limit) || 20 };
421
+ if (center) {
422
+ let cid = null;
423
+ if (/^\d+$/.test(center)) cid = Number(center);
424
+ else {
425
+ const cands = g.entities.filter((e) => e.name === center);
426
+ if (cands.length === 1) cid = cands[0].id;
427
+ else if (cands.length > 1) return res.status(409).json({ error: `实体名"${center}"存在${cands.length}个候选,请改用id`, candidates: cands.map((h) => ({ id: h.id, name: h.name, category: h.category })) });
428
+ }
429
+ if (cid && !g.entities.some((e) => e.id === cid)) return res.status(404).json({ error: `实体"${center}"不存在` });
430
+ if (cid) opts.centerId = cid;
431
+ }
432
+ res.json({ recommendations: recommend.computeCoNeighborRecs(g, opts) });
433
+ });
434
+
435
+ // LLM判断候选关系:只给建议,入库由前端确认后走人工接口
436
+ api.post('/recommend/ai', async (req, res) => {
437
+ if (agentBusy) return res.status(429).json({ error: '已有AI任务执行中(问答、导入或智能检索),请稍后再试' });
438
+ const body = req.body || {};
439
+ const g = db.getGraph();
440
+ const byId = new Map(g.entities.map((e) => [e.id, e]));
441
+ const a = byId.get(Number(body.source_id));
442
+ const b = byId.get(Number(body.target_id));
443
+ if (!a || !b) return res.status(404).json({ error: '待判断的实体不存在' });
444
+ agentBusy = true;
445
+ try {
446
+ const judge = await recommend.aiJudgeRelation({ a, b, common_names: body.common_names });
447
+ res.json(judge);
448
+ } catch (e) {
449
+ res.status(e.status || 500).json({ error: e.message });
450
+ } finally {
451
+ agentBusy = false;
452
+ }
453
+ });
454
+
415
455
  // ---------- 向量混合检索 ----------
416
456
  api.get('/embeddings/status', (req, res) => res.json(embeddings.status()));
417
457