local-knowledge-graph 1.8.1 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HELP.md +55 -3
- package/lib/db.js +39 -0
- package/lib/recommend.js +133 -0
- package/package.json +1 -1
- package/public/app.js +170 -13
- package/public/index.html +12 -3
- package/server.js +115 -1
package/HELP.md
CHANGED
|
@@ -61,10 +61,23 @@ kg
|
|
|
61
61
|
|
|
62
62
|
程序自身零内置密钥,所有需要 key 的能力都在界面上自由配置:
|
|
63
63
|
|
|
64
|
-
- **语义检索 / 智能提问综述**:检索页签"语义检索配置"
|
|
64
|
+
- **语义检索 / 智能提问综述**:检索页签"语义检索配置"卡片完成三步——
|
|
65
|
+
1. **API Base URL**:默认 `https://api.siliconflow.cn/v1`(硅基流动)。任何 OpenAI 兼容接口都可换:填其 Base URL 即可(如 `https://api.openai.com/v1`、`https://api.deepseek.com/v1` 及各类本地/私有化网关)
|
|
66
|
+
2. **API Key + 模型**:粘贴密钥并填模型名(硅基流动默认 `BAAI/bge-m3`;换服务方时选其支持的 embedding 模型,如 OpenAI `text-embedding-3-small`)
|
|
67
|
+
3. **测试连接**:保存前先点「测试连接」,用页面上当前填写的配置(未保存也生效)向服务方发一次真实请求,成功会显示模型名、向量维度与耗时;失败给出具体错误(密钥无效 / 地址不可达 / 模型名错误)
|
|
68
|
+
|
|
69
|
+
Key 与 Base URL 仅存本地数据目录的 settings.json,服务端永不回传真实值。不配置则自动退化为关键词检索,其余功能不受影响。改配置后需重新「构建全量向量」才能让新模型生效于语义检索。
|
|
65
70
|
- **OpenCode(AI 对话建图 / 智能提问编译)**:使用 opencode CLI 自身的认证(`opencode auth login`),应用不经手密钥。未安装或未认证时仅这两个功能降级。
|
|
66
71
|
- 更换数据目录(`kg --data <目录>`)会使用该目录下的独立配置,互不串扰。
|
|
67
72
|
|
|
73
|
+
## 关系编辑与关系图片
|
|
74
|
+
|
|
75
|
+
关系(边)与实体一样支持人工与 AI 两种编辑方式,并可绑定佐证图片:
|
|
76
|
+
|
|
77
|
+
- **人工编辑**:画布上点击关系边,右侧信息卡可修改——置信度三档(确证/推测/存疑)、来源引用(URL/文献+页码)、删除关系。所有修改实时写库并产生可撤销的操作记录。
|
|
78
|
+
- **AI 编辑**:外部 Agent 通过 MCP 的 `kg_apply_ops` 可对关系做增删改(支持 `confidence` 置信度、`evidence_ref` 来源引用、按名称引用端点实体);文档批量入图抽取出的关系同样带置信度与来源,经审核勾选后入库。AI 写入与人工修改互相实时可见。
|
|
79
|
+
- **关系图片**:选中关系后点「绑图片」上传本地图片(支持多选,jpg/png/gif/webp/bmp,单张 ≤10MB,自动生成缩略图)。图片在关系信息卡缩略图网格展示,点击进入灯箱翻看,灯箱内可删除。典型用途:史料扫描件、聊天记录截图、地图/图表、新闻照片等佐证材料。图片文件存于数据目录 `data/uploads/r<关系id>/`,删除关系时其图片一并清理。
|
|
80
|
+
|
|
68
81
|
## MCP 外部接入(外部 Agent 读写图谱)
|
|
69
82
|
|
|
70
83
|
在 **MCP 页签** 开启"MCP 服务"后,外部 Agent(Claude Desktop、Cursor、Cline、opencode 等)即可通过标准 MCP 协议对本图谱做完整增删改查:
|
|
@@ -75,16 +88,53 @@ kg
|
|
|
75
88
|
- **只读模式**:勾选后外部仅可查询;**令牌管理**:一键重新生成即刻吊销旧令牌
|
|
76
89
|
- **实时同步**:图谱被任一写入方(页面、API、MCP、外部进程)修改后,网页 3 秒内自动刷新
|
|
77
90
|
|
|
91
|
+
## 知识推理:现有功能与方法体系
|
|
92
|
+
|
|
93
|
+
### 本程序已有的推理功能
|
|
94
|
+
|
|
95
|
+
图页签勾选「显示推理关系」后,图谱会叠加展示**隐性关系**(带"推"标记与推导依据链,仅推理展示、数据库中无记录):
|
|
96
|
+
|
|
97
|
+
- **传递推理**:师从(A,B) ∧ 师从(B,C) ⇒ 师从(A,C);位于(A,B) ∧ 位于(B,C) ⇒ 位于(A,C) 等具备传递性的关系自动沿链推导
|
|
98
|
+
- **对称/逆推理**:师生、上下级等方向的自动取反向边
|
|
99
|
+
- **推理限定中心**:可只在某实体为中心的子图范围内推理,配合中心层级视图排查隐性关联
|
|
100
|
+
- **问答证据链**:智能提问的回答附带支撑路径,推理结论可解释、可回溯
|
|
101
|
+
|
|
102
|
+
### 知识推理的科学方法体系(由浅入深)
|
|
103
|
+
|
|
104
|
+
| 方法 | 原理 | 典型代表 | 适合场景 |
|
|
105
|
+
| ---- | ---- | ---- | ---- |
|
|
106
|
+
| 公理/本体推理 | 依据关系性质(传递、对称、逆、子类)做逻辑推导 | OWL/RDF-Schema 推理机 | 本程序已内置;一致性与常识性推导 |
|
|
107
|
+
| 规则推理 | 用户自定义 if-then 规则批量推导 | Datalog、SWRL、Prolog | 领域规则明确的图谱(师承同门、亲属称谓、企业控股) |
|
|
108
|
+
| 图算法/链路预测 | 基于图结构打分:共同邻居、路径相似度、图嵌入向量 | Adamic-Adar、TransE、Node2Vec | 预测"可能存在但未录入"的关系,做补全推荐 |
|
|
109
|
+
| 概率/统计推理 | 从历史边中统计规律,给出候选与置信度 | 路径排序 PRA、规则挖掘 AMIE | 大规模图谱的关系发现与排序 |
|
|
110
|
+
| LLM+图谱协同 | 把子图上下文交给大模型归纳,人审核入库 | GraphRAG、LLM 关系抽取 | 开放域、规则难写尽的场景;本程序文档入图即此思路 |
|
|
111
|
+
|
|
112
|
+
### 与本程序结合的路线
|
|
113
|
+
|
|
114
|
+
1. **已具备**:传递/对称/逆公理推理 + 问答证据链 + MCP 推理查询(外部 Agent 可调 `kg_inference`)
|
|
115
|
+
2. **已具备·共同邻居推荐**:检索页签"关系推荐"卡一键扫描全图——无直接边但拥有共同邻居的实体对自动列为候选(Adamic-Adar 打分,邻居越稀有越靠前);实体信息卡"推荐关系"按钮查看与该实体相关的候选。每条候选可点「AI 判断」让 LLM 给出关系名/大类/置信度/依据,确认后一键采纳入库(来源引用自动记为 AI 推荐依据,可撤销)
|
|
116
|
+
3. **已具备·AI 推荐关系**:即上条「AI 判断」——把两实体属性与共同关联交给大模型判断"是否存在关系、是什么关系、依据是什么",输出仅供决策,入库必须人工点击确认(AI 不直接写库)
|
|
117
|
+
4. **进阶·自定义规则**:设置页开放规则编辑(如 师从(A,B) ∧ 师从(B,C) ⇒ 同门(A,C)),与内置传递/对称推理共用"推"标记展示
|
|
118
|
+
|
|
119
|
+
### 应用场景与效益
|
|
120
|
+
|
|
121
|
+
- **教育与学习**:历史人物、生物分类、概念依赖图谱——学生顺着推理链发现隐性关联,"为什么他俩有瓜葛"有推导依据可看
|
|
122
|
+
- **情报与调研**:碎片信息建图后,推理把散点串成网络(共同雇主、共同事件),链路预测提示值得人工核查的候选线索
|
|
123
|
+
- **企业知识管理**:专家经验、项目关系、人岗技能沉淀为图谱;推理显性化隐性知识("做过 A 类项目且懂 B 技术的人"自动成组),降低对个别专家的依赖
|
|
124
|
+
- **可解释问答**:相比黑盒大模型直答,图谱推理每一步给出证据路径,答案可验证、可追溯
|
|
125
|
+
- **效益本质**:把已录入的显性关系"盘活"——同一份数据,推理可多挖掘出一批隐性关联与候选补全(实践中常见 20% 以上的新边发现),同时暴露矛盾数据(如传递链断裂)反哺数据质量
|
|
126
|
+
|
|
78
127
|
## 功能速览
|
|
79
128
|
|
|
80
129
|
- **AI 对话建图**:右侧输入自然语言指令(联网补全),或上传文档(md/txt/pdf/docx)批量抽取三元组
|
|
81
130
|
- **智能提问**:检索页签一句话找关系(LLM 编译检索计划,本地只读执行,可生成带引用的综述)
|
|
131
|
+
- **关系推荐**:共同邻居算法找"该连没连"的实体对,AI 判断后一键采纳入库(检索页签"关系推荐"卡 / 实体卡"推荐关系"按钮)
|
|
82
132
|
- **语义检索**:配置硅基流动 API Key 后支持语义+关键词混合检索(未配置时自动退化为关键词模式)
|
|
83
|
-
- **最短路径 / 中心子图 /
|
|
133
|
+
- **最短路径 / 中心子图 / 推理**:挖掘实体间关联链路、层级范围与隐性关系(方法体系见上文"知识推理"章节)
|
|
84
134
|
- **撤销**:Ctrl+Z 或按钮,可连续撤销
|
|
85
135
|
- **浅色主题**:视图设置 → 界面主题 → 浅色,整站配色与 3D 画布同步切换,选择本地记忆
|
|
86
136
|
- **保存点与回溯**:版本页签打保存点,随时回滚;每次保存点自动滚动备份到 `data/backups/`
|
|
87
|
-
-
|
|
137
|
+
- **图片绑定**:实体与关系信息卡上传本地图片(缩略图+灯箱,佐证材料留存)
|
|
88
138
|
- **导出**:RDF(Turtle)、整库 .db、单文件只读网页版
|
|
89
139
|
|
|
90
140
|
## 键盘快捷键
|
|
@@ -115,6 +165,8 @@ kg
|
|
|
115
165
|
|
|
116
166
|
## 版本历史摘要
|
|
117
167
|
|
|
168
|
+
- **v1.10.0**:关系推荐——共同邻居算法(Adamic-Adar 打分,排除已有直接边)全图/中心实体两种扫描;AI 判断候选关系(LLM 给出关系名/大类/置信度/依据,人工确认后入库,不直接写库);检索页签新增"关系推荐"卡,实体信息卡新增"推荐关系"按钮
|
|
169
|
+
- **v1.9.0**:检索配置增强(Base URL 页面可配 + 测试连接按钮,保存前即可验证密钥/地址/模型);关系图片绑定(信息卡上传/缩略图/灯箱,佐证材料与关系绑定,删关系连带清理);帮助文档全面增补(检索配置详解/关系编辑与图片/知识推理方法体系与应用场景)
|
|
118
170
|
- **v1.8.0**:MCP 外部接入——设置新增 MCP 页签(服务开关/只读模式/令牌管理/三类客户端一键复制配置);`/mcp` Streamable HTTP 端点(令牌鉴权+CORS,12 个工具含完整 CRUD);`kg --mcp` 本机 stdio 接入;图谱变更实时同步(SSE 推送+跨进程探测,页面 3 秒内自动刷新)
|
|
119
171
|
- **v1.7.0**:六项图谱能力升级——①实体别名(增删/全局唯一/搜索与消歧联动)②同名冲突检测(创建即提示,可合并可强制)③多路径查找(2-6跳,图上高亮,MCP kg_path)④相似实体推荐(语义+结构双模式)⑤关系置信度三档(确证/推测/存疑,样式区分可过滤)与来源引用标注 ⑥智能问答附证据路径链 ⑦文档批量入图(md/txt/pdf→LLM抽取→审核勾选→来源标记入库,支持跳过审核)
|
|
120
172
|
- **v1.6.1**:修复浅色主题切换导致画布空白的问题;标题栏按钮防挤压(窄屏可横滑);修复3D样式主题刷新后不恢复的问题;新增脚本异常提示
|
package/lib/db.js
CHANGED
|
@@ -78,6 +78,16 @@ function initSchema() {
|
|
|
78
78
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
79
79
|
);
|
|
80
80
|
CREATE INDEX IF NOT EXISTS idx_alias_entity ON aliases(entity_id);
|
|
81
|
+
CREATE TABLE IF NOT EXISTS relation_images (
|
|
82
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
83
|
+
relation_id INTEGER NOT NULL REFERENCES relations(id) ON DELETE CASCADE,
|
|
84
|
+
filename TEXT NOT NULL,
|
|
85
|
+
stored_path TEXT NOT NULL,
|
|
86
|
+
thumb_path TEXT NOT NULL DEFAULT '',
|
|
87
|
+
caption TEXT NOT NULL DEFAULT '',
|
|
88
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
89
|
+
);
|
|
90
|
+
CREATE INDEX IF NOT EXISTS idx_rimg_relation ON relation_images(relation_id);
|
|
81
91
|
`);
|
|
82
92
|
migrateEvidence();
|
|
83
93
|
// 旧库迁移:entity_embeddings 已拆分至独立 vectors.db,主库内残留表直接清除
|
|
@@ -614,6 +624,34 @@ function deleteEntityImage(id) {
|
|
|
614
624
|
return row;
|
|
615
625
|
}
|
|
616
626
|
|
|
627
|
+
// ---- 关系图片绑定(结构与实体图片一致;文件本体由 server 层存取于 data/uploads/)----
|
|
628
|
+
function addRelationImage(relationId, item) {
|
|
629
|
+
if (!getRelation(relationId)) { const e = new Error(`关系id=${relationId} 不存在`); e.status = 404; throw e; }
|
|
630
|
+
if (typeof item.filename !== 'string' || !item.filename.trim()) { const e = new Error('filename必须为非空字符串'); e.status = 400; throw e; }
|
|
631
|
+
if (typeof item.stored_path !== 'string' || !item.stored_path.trim()) { const e = new Error('stored_path必须为非空字符串'); e.status = 400; throw e; }
|
|
632
|
+
const caption = typeof item.caption === 'string' ? item.caption.trim().slice(0, 300) : '';
|
|
633
|
+
const thumbPath = typeof item.thumb_path === 'string' ? item.thumb_path.trim() : '';
|
|
634
|
+
const info = db.prepare('INSERT INTO relation_images (relation_id, filename, stored_path, caption, thumb_path) VALUES (?, ?, ?, ?, ?)')
|
|
635
|
+
.run(relationId, item.filename.trim().slice(0, 200), item.stored_path.trim(), caption, thumbPath);
|
|
636
|
+
logOp('ADD_RELATION_IMAGE', { relation_id: relationId, image_id: Number(info.lastInsertRowid), filename: item.filename }, '手工');
|
|
637
|
+
return getRelationImage(Number(info.lastInsertRowid));
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function getRelationImage(id) {
|
|
641
|
+
return db.prepare('SELECT * FROM relation_images WHERE id = ?').get(id);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function listRelationImages(relationId) {
|
|
645
|
+
return db.prepare('SELECT * FROM relation_images WHERE relation_id = ? ORDER BY id').all(relationId);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function deleteRelationImage(id) {
|
|
649
|
+
const row = getRelationImage(id);
|
|
650
|
+
if (!row) { const e = new Error(`关系图片绑定id=${id} 不存在`); e.status = 404; throw e; }
|
|
651
|
+
db.prepare('DELETE FROM relation_images WHERE id = ?').run(id);
|
|
652
|
+
return row;
|
|
653
|
+
}
|
|
654
|
+
|
|
617
655
|
// 剪除悬空图片行:.db 文件可迁移,uploads/ 资产不随库走,导入后行指向的文件可能不存在
|
|
618
656
|
function pruneMissingImages() {
|
|
619
657
|
const rows = db.prepare('SELECT id, stored_path FROM entity_images').all();
|
|
@@ -835,6 +873,7 @@ module.exports = {
|
|
|
835
873
|
addEntity, updateEntity, deleteEntity,
|
|
836
874
|
addRelation, updateRelation, deleteRelation,
|
|
837
875
|
addEntityImage, getEntityImage, listEntityImages, deleteEntityImage, imageCounts, pruneMissingImages,
|
|
876
|
+
addRelationImage, getRelationImage, listRelationImages, deleteRelationImage,
|
|
838
877
|
egoSubgraph, findPath, findPaths, resolveKey,
|
|
839
878
|
listAliases, addAlias, removeAlias, aliasMap, aliasRecords, findNameConflicts,
|
|
840
879
|
undoLast,
|
package/lib/recommend.js
ADDED
|
@@ -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
package/public/app.js
CHANGED
|
@@ -12,6 +12,7 @@ const state = {
|
|
|
12
12
|
inferredData: null, // /api/inference 缓存 {inferred, entities, ontology}
|
|
13
13
|
imageCounts: new Map(), // entityId -> 图片数量
|
|
14
14
|
entityImages: new Map(), // entityId -> [图片行]
|
|
15
|
+
relationImages: new Map(), // relationId -> [图片行]
|
|
15
16
|
meta: null, // /api/meta 缓存(图例与下拉框用)
|
|
16
17
|
aliases: {}, // entityId -> [别名](/api/graph 附带)
|
|
17
18
|
confFilter: '', // 关系置信度过滤:''=全部 | 确证 | 推测 | 存疑
|
|
@@ -588,9 +589,11 @@ function renderInfoCard() {
|
|
|
588
589
|
<button onclick="focusEgo(${e.id})">以此为中心</button>
|
|
589
590
|
<button onclick="askPath(${e.id}, '${escapeHtml(e.name).replace(/'/g, "\\'")}')">查路径</button>
|
|
590
591
|
<button onclick="loadSimilar(${e.id})">相似实体</button>
|
|
592
|
+
<button onclick="loadEntityRecs(${e.id})">推荐关系</button>
|
|
591
593
|
<button onclick="$('entity-img-input').click()">绑图片</button>
|
|
592
594
|
</div>
|
|
593
595
|
<div id="similar-box"></div>
|
|
596
|
+
<div id="rec-box"></div>
|
|
594
597
|
<div class="btns"><button onclick="editEntity(${e.id})">编辑</button><button class="danger" onclick="delEntity(${e.id})">删除</button></div>`;
|
|
595
598
|
card.style.display = 'block';
|
|
596
599
|
if (imgCount > 0 && !imgs) loadEntityImages(e.id);
|
|
@@ -623,6 +626,10 @@ function renderInfoCard() {
|
|
|
623
626
|
const r = state.relations.find((x) => x.id === rid);
|
|
624
627
|
if (!r) { card.style.display = 'none'; return; }
|
|
625
628
|
const s = state.entityMap.get(r.source_id), t = state.entityMap.get(r.target_id);
|
|
629
|
+
const rimgs = state.relationImages.get(rid);
|
|
630
|
+
const rimgHtml = `<div id="rimg-section"><div class="kv"><b>图片</b>:${rimgs ? (rimgs.length ? rimgs.length + ' 张' : '(无)') : '加载中…'}</div>` +
|
|
631
|
+
(rimgs && rimgs.length ? `<div id="img-grid">` + rimgs.map((im, i) => `<img src="${escapeHtml(im.thumb_url || im.url)}" title="${escapeHtml(im.caption || im.filename)}" onclick="openRelLightbox(${rid},${i})">`).join('') + `</div>` : '') +
|
|
632
|
+
`</div>`;
|
|
626
633
|
card.innerHTML = `
|
|
627
634
|
<h4>${escapeHtml(r.name)} <span class="tag" style="color:${RELATION_STYLE[r.category].css};border-color:${RELATION_STYLE[r.category].css}55">${r.category}关系</span> ${confBadge(r)}</h4>
|
|
628
635
|
<div class="kv"><b>${s ? escapeHtml(s.entity.name) : '?'}</b> --> <b>${t ? escapeHtml(t.entity.name) : '?'}</b></div>
|
|
@@ -632,8 +639,10 @@ function renderInfoCard() {
|
|
|
632
639
|
${['确证', '推测', '存疑'].map((c) => `<option value="${c}"${(r.confidence || '确证') === c ? ' selected' : ''}>${c}</option>`).join('')}
|
|
633
640
|
</select></div>
|
|
634
641
|
<div class="kv"><b>来源引用</b>:<input id="rel-sref" value="${escapeHtml(r.source_ref || '')}" placeholder="URL/文献+页码" style="width:150px"></div>
|
|
635
|
-
|
|
642
|
+
${rimgHtml}
|
|
643
|
+
<div class="btns"><button onclick="saveRelMeta(${r.id})">保存标注</button><button onclick="$('entity-img-input').click()">绑图片</button><button class="danger" onclick="delRelation(${r.id})">删除</button></div>`;
|
|
636
644
|
card.style.display = 'block';
|
|
645
|
+
if (!rimgs) loadRelationImages(rid);
|
|
637
646
|
}
|
|
638
647
|
}
|
|
639
648
|
function escapeHtml(s) {
|
|
@@ -740,10 +749,13 @@ document.body.appendChild(imgInput);
|
|
|
740
749
|
window.$ = $; // 信息卡内联onclick需访问$
|
|
741
750
|
|
|
742
751
|
imgInput.addEventListener('change', async () => {
|
|
743
|
-
const
|
|
752
|
+
const sel = state.selected;
|
|
753
|
+
const isRel = sel && sel.type === 'relation';
|
|
754
|
+
const id = sel && (sel.type === 'entity' || sel.type === 'relation') ? sel.id : null;
|
|
744
755
|
const files = [...imgInput.files];
|
|
745
756
|
imgInput.value = '';
|
|
746
757
|
if (!id || !files.length) return;
|
|
758
|
+
const postUrl = isRel ? `/api/relations/${id}/images` : `/api/entities/${id}/images`;
|
|
747
759
|
for (const f of files) {
|
|
748
760
|
try {
|
|
749
761
|
if (f.size > 10 * 1024 * 1024) throw new Error('超过10MB上限');
|
|
@@ -751,15 +763,20 @@ imgInput.addEventListener('change', async () => {
|
|
|
751
763
|
const thumb = await makeThumbB64(f);
|
|
752
764
|
let caption = '';
|
|
753
765
|
if (files.length === 1) caption = prompt('图片备注(可留空)', '') || '';
|
|
754
|
-
await api(
|
|
766
|
+
await api(postUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: f.name, content_b64: b64, caption, thumb_b64: thumb }) });
|
|
755
767
|
toast(`已绑定图片 ${f.name}`);
|
|
756
768
|
} catch (e) { toast(`图片 ${f.name} 绑定失败: ${e.message}`, true); }
|
|
757
769
|
}
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
770
|
+
if (isRel) {
|
|
771
|
+
const rows = await api(`/api/relations/${id}/images`);
|
|
772
|
+
state.relationImages.set(id, rows);
|
|
773
|
+
} else {
|
|
774
|
+
state.entityImages.delete(id);
|
|
775
|
+
const cnt = await api(`/api/entities/${id}/images`);
|
|
776
|
+
state.entityImages.set(id, cnt);
|
|
777
|
+
state.imageCounts.set(id, cnt.length);
|
|
778
|
+
await refreshAll(false);
|
|
779
|
+
}
|
|
763
780
|
if (state.selected && state.selected.id === id) renderInfoCard();
|
|
764
781
|
});
|
|
765
782
|
|
|
@@ -786,22 +803,39 @@ async function loadEntityImages(entityId) {
|
|
|
786
803
|
} catch (e) { toast(e.message, true); }
|
|
787
804
|
}
|
|
788
805
|
|
|
806
|
+
async function loadRelationImages(relationId) {
|
|
807
|
+
try {
|
|
808
|
+
const rows = await api(`/api/relations/${relationId}/images`);
|
|
809
|
+
state.relationImages.set(relationId, rows);
|
|
810
|
+
if (state.selected && state.selected.type === 'relation' && state.selected.id === relationId) renderInfoCard();
|
|
811
|
+
} catch (e) { toast(e.message, true); }
|
|
812
|
+
}
|
|
813
|
+
|
|
789
814
|
/* ---- 灯箱 ---- */
|
|
790
|
-
let lbState = null; // { list, idx }
|
|
815
|
+
let lbState = null; // { kind:'entity'|'relation', list, idx }
|
|
791
816
|
function openLightbox(entityId, idx) {
|
|
792
817
|
const list = state.entityImages.get(entityId) || [];
|
|
793
818
|
if (!list.length) return;
|
|
794
|
-
lbState = { list, idx };
|
|
819
|
+
lbState = { kind: 'entity', list, idx };
|
|
795
820
|
renderLightbox();
|
|
796
821
|
$('lightbox').classList.add('show');
|
|
797
822
|
}
|
|
798
823
|
window.openLightbox = openLightbox;
|
|
799
824
|
|
|
825
|
+
function openRelLightbox(relationId, idx) {
|
|
826
|
+
const list = state.relationImages.get(relationId) || [];
|
|
827
|
+
if (!list.length) return;
|
|
828
|
+
lbState = { kind: 'relation', list, idx };
|
|
829
|
+
renderLightbox();
|
|
830
|
+
$('lightbox').classList.add('show');
|
|
831
|
+
}
|
|
832
|
+
window.openRelLightbox = openRelLightbox;
|
|
833
|
+
|
|
800
834
|
function renderLightbox() {
|
|
801
835
|
if (!lbState) return;
|
|
802
836
|
const im = lbState.list[lbState.idx];
|
|
803
837
|
$('lb-img').src = im.url;
|
|
804
|
-
$('lb-cap').textContent = (im.caption || im.filename) +
|
|
838
|
+
$('lb-cap').textContent = (im.caption || im.filename) + `(${lbState.kind === 'relation' ? '关系' : '实体'}#${lbState.kind === 'relation' ? im.relation_id : im.entity_id})`;
|
|
805
839
|
$('lb-pos').textContent = `${lbState.idx + 1} / ${lbState.list.length}`;
|
|
806
840
|
}
|
|
807
841
|
|
|
@@ -822,6 +856,16 @@ async function delImage() {
|
|
|
822
856
|
const im = lbState.list[lbState.idx];
|
|
823
857
|
if (!confirm(`删除图片绑定「${im.filename}」?文件将从磁盘移除。`)) return;
|
|
824
858
|
try {
|
|
859
|
+
if (lbState.kind === 'relation') {
|
|
860
|
+
await api(`/api/relation-images/${im.id}`, { method: 'DELETE' });
|
|
861
|
+
const rid = im.relation_id;
|
|
862
|
+
lbState.list.splice(lbState.idx, 1);
|
|
863
|
+
state.relationImages.set(rid, lbState.list);
|
|
864
|
+
if (!lbState.list.length) closeLightbox(); else renderLightbox();
|
|
865
|
+
toast('图片绑定已删除');
|
|
866
|
+
if (state.selected && state.selected.type === 'relation' && state.selected.id === rid) renderInfoCard();
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
825
869
|
await api(`/api/images/${im.id}`, { method: 'DELETE' });
|
|
826
870
|
const eid = im.entity_id;
|
|
827
871
|
lbState.list.splice(lbState.idx, 1);
|
|
@@ -1042,10 +1086,100 @@ async function loadSimilar(id) {
|
|
|
1042
1086
|
}
|
|
1043
1087
|
window.loadSimilar = loadSimilar;
|
|
1044
1088
|
|
|
1089
|
+
/* ================= 关系推荐(共同邻居 + AI判断) ================= */
|
|
1090
|
+
const judgeCache = new Map(); // 'a|b' -> judge结果(本会话内复用,避免重复调LLM)
|
|
1091
|
+
|
|
1092
|
+
// 单条候选的展开渲染:AI判断按钮 → 判断结果 → 采纳入库
|
|
1093
|
+
function recRowHtml(rec, boxId) {
|
|
1094
|
+
const key = `${rec.source_id}|${rec.target_id}`;
|
|
1095
|
+
const id = `${boxId}-${key.replace(/\|/g, '_')}`;
|
|
1096
|
+
return `<div class="kv" style="padding:6px 0;border-top:1px dashed #2a3a55" id="row-${id}">
|
|
1097
|
+
<span style="cursor:pointer;color:#7fd1ff" onclick="focusEntity(${rec.source_id})">${escapeHtml(rec.source_name)}</span>
|
|
1098
|
+
<b style="color:#c792ea"> ⇄? </b>
|
|
1099
|
+
<span style="cursor:pointer;color:#7fd1ff" onclick="focusEntity(${rec.target_id})">${escapeHtml(rec.target_name)}</span>
|
|
1100
|
+
<span style="color:#8fa3c0;font-size:11px">共同邻居${rec.common_count}:${escapeHtml((rec.common_names || []).join('、'))} AA ${rec.score}</span>
|
|
1101
|
+
<div style="margin-top:4px"><button class="ghost" onclick="aiJudgeRec(${rec.source_id},${rec.target_id},'${id}')">AI 判断</button></div>
|
|
1102
|
+
<div id="jr-${id}"></div>
|
|
1103
|
+
</div>`;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
async function aiJudgeRec(sourceId, targetId, rowId) {
|
|
1107
|
+
const box = $('jr-' + rowId);
|
|
1108
|
+
if (!box) return;
|
|
1109
|
+
const key = `${sourceId}|${targetId}`;
|
|
1110
|
+
if (judgeCache.has(key)) { renderJudge(box, judgeCache.get(key), rowId); return; }
|
|
1111
|
+
box.innerHTML = '<div class="kv" style="color:#8fa3c0">AI 判断中…(约10-30秒)</div>';
|
|
1112
|
+
try {
|
|
1113
|
+
const j = await api('/api/recommend/ai', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ source_id: sourceId, target_id: targetId }) });
|
|
1114
|
+
judgeCache.set(key, j);
|
|
1115
|
+
renderJudge(box, j, rowId);
|
|
1116
|
+
} catch (e) { box.innerHTML = `<div class="kv" style="color:#e0a768">判断失败:${escapeHtml(e.message)}</div>`; }
|
|
1117
|
+
}
|
|
1118
|
+
window.aiJudgeRec = aiJudgeRec;
|
|
1119
|
+
|
|
1120
|
+
function renderJudge(box, j, rowId) {
|
|
1121
|
+
if (!j.has_relation) { box.innerHTML = '<div class="kv" style="color:#8b949e">AI 判断:无明显关系可录</div>'; return; }
|
|
1122
|
+
const confColor = { '确证': '#7ee787', '推测': '#e0a768', '存疑': '#8b949e' }[j.confidence] || '#8fa3c0';
|
|
1123
|
+
box.innerHTML = `<div class="kv" style="background:#1a2332;border-radius:6px;padding:6px">
|
|
1124
|
+
建议关系:<b style="color:#7ee787">${escapeHtml(j.name)}</b>
|
|
1125
|
+
<span class="tag" style="color:${confColor};border-color:${confColor}55">${j.category}·${j.confidence}</span>
|
|
1126
|
+
<div style="color:#8fa3c0;font-size:11px">依据:${escapeHtml(j.evidence || '(无)')}</div>
|
|
1127
|
+
<div style="margin-top:4px"><button class="primary" onclick="acceptRec(${j.source_id},${j.target_id},'${rowId}')">采纳入库</button></div>
|
|
1128
|
+
</div>`;
|
|
1129
|
+
}
|
|
1130
|
+
window.acceptRec = acceptRec;
|
|
1131
|
+
|
|
1132
|
+
async function acceptRec(sourceId, targetId, rowId) {
|
|
1133
|
+
const judge = judgeCache.get(`${sourceId}|${targetId}`);
|
|
1134
|
+
if (!judge || !judge.has_relation) { toast('判断结果已失效,请重新AI判断', true); return; }
|
|
1135
|
+
try {
|
|
1136
|
+
await api('/api/relations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({
|
|
1137
|
+
source_id: sourceId, target_id: targetId, name: judge.name, category: judge.category, confidence: judge.confidence,
|
|
1138
|
+
source_ref: 'AI推荐: ' + (judge.evidence || '共同邻居推荐'),
|
|
1139
|
+
}) });
|
|
1140
|
+
toast(`已录入关系「${judge.name}」`);
|
|
1141
|
+
const row = $('row-' + rowId);
|
|
1142
|
+
if (row) row.remove();
|
|
1143
|
+
} catch (e) { toast(e.message, true); }
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
function renderRecList(recs, boxId, emptyText) {
|
|
1147
|
+
const box = $(boxId);
|
|
1148
|
+
if (!box) return;
|
|
1149
|
+
box.innerHTML = recs.length
|
|
1150
|
+
? recs.map((r) => recRowHtml(r, boxId)).join('')
|
|
1151
|
+
: `<div class="kv">${emptyText}</div>`;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
async function scanRecs() {
|
|
1155
|
+
const btn = $('rc-scan');
|
|
1156
|
+
btn.disabled = true;
|
|
1157
|
+
$('rc-status').textContent = '扫描中…';
|
|
1158
|
+
try {
|
|
1159
|
+
const r = await api('/api/recommend?limit=20');
|
|
1160
|
+
$('rc-status').textContent = `${r.recommendations.length} 条候选`;
|
|
1161
|
+
renderRecList(r.recommendations, 'rc-list', '无可推荐候选(共同邻居≥2且无直接边的实体对)');
|
|
1162
|
+
} catch (e) { $('rc-status').textContent = e.message; }
|
|
1163
|
+
btn.disabled = false;
|
|
1164
|
+
}
|
|
1165
|
+
$('rc-scan').addEventListener('click', scanRecs);
|
|
1166
|
+
|
|
1167
|
+
async function loadEntityRecs(id) {
|
|
1168
|
+
const box = $('rec-box');
|
|
1169
|
+
if (!box) return;
|
|
1170
|
+
box.innerHTML = '<div class="kv">候选关系计算中…</div>';
|
|
1171
|
+
try {
|
|
1172
|
+
const r = await api(`/api/recommend?center=${id}&limit=10`);
|
|
1173
|
+
renderRecList(r.recommendations, 'rec-box', '暂无候选(该实体2跳内无共同邻居≥1的无边实体对)');
|
|
1174
|
+
} catch (e) { box.innerHTML = `<div class="kv" style="color:#e0a768">${escapeHtml(e.message)}</div>`; }
|
|
1175
|
+
}
|
|
1176
|
+
window.loadEntityRecs = loadEntityRecs;
|
|
1177
|
+
|
|
1045
1178
|
async function delRelation(id) {
|
|
1046
|
-
if (!confirm(`删除关系 #${id}
|
|
1179
|
+
if (!confirm(`删除关系 #${id}?其绑定的图片文件将一并移除。`)) return;
|
|
1047
1180
|
try {
|
|
1048
1181
|
await api(`/api/relations/${id}`, { method: 'DELETE' });
|
|
1182
|
+
state.relationImages.delete(id);
|
|
1049
1183
|
if (state.selected && state.selected.id === id) state.selected = null;
|
|
1050
1184
|
toast('关系已删除');
|
|
1051
1185
|
await refreshAll();
|
|
@@ -2020,6 +2154,7 @@ async function loadSearchStatus() {
|
|
|
2020
2154
|
const [st, cfg] = await Promise.all([api('/api/embeddings/status'), api('/api/embeddings/settings')]);
|
|
2021
2155
|
$('s-status').textContent = `已索引 ${st.indexed}/${st.total_entities}`;
|
|
2022
2156
|
if (!$('s-model').value) $('s-model').value = cfg.model;
|
|
2157
|
+
if (!$('s-baseurl').value) $('s-baseurl').value = cfg.base_url || 'https://api.siliconflow.cn/v1';
|
|
2023
2158
|
$('s-key').placeholder = cfg.api_key === '已配置' ? '已配置(输入新值可更换)' : '仅存本地 data/settings.json';
|
|
2024
2159
|
} catch (_) { /* 服务未就绪时静默 */ }
|
|
2025
2160
|
}
|
|
@@ -2110,16 +2245,38 @@ try {
|
|
|
2110
2245
|
} catch (_) { /* 浏览器不支持时依赖手动刷新 */ }
|
|
2111
2246
|
|
|
2112
2247
|
$('s-save').addEventListener('click', async () => {
|
|
2113
|
-
const patch = {
|
|
2248
|
+
const patch = {
|
|
2249
|
+
model: $('s-model').value.trim() || 'BAAI/bge-m3',
|
|
2250
|
+
base_url: $('s-baseurl').value.trim() || 'https://api.siliconflow.cn/v1',
|
|
2251
|
+
};
|
|
2114
2252
|
const key = $('s-key').value.trim();
|
|
2115
2253
|
if (key) patch.api_key = key;
|
|
2116
2254
|
try {
|
|
2117
2255
|
await api('/api/embeddings/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) });
|
|
2118
2256
|
$('s-key').value = '';
|
|
2257
|
+
$('s-status').textContent = '配置已保存';
|
|
2119
2258
|
loadSearchStatus();
|
|
2120
2259
|
} catch (e) { $('s-status').textContent = e.message; }
|
|
2121
2260
|
});
|
|
2122
2261
|
|
|
2262
|
+
// 测试连接:用页面上当前填写的配置(未保存的也生效)向服务方发一次真实请求
|
|
2263
|
+
$('s-test').addEventListener('click', async () => {
|
|
2264
|
+
const btn = $('s-test');
|
|
2265
|
+
btn.disabled = true;
|
|
2266
|
+
$('s-status').textContent = '测试中…';
|
|
2267
|
+
const patch = {
|
|
2268
|
+
model: $('s-model').value.trim() || 'BAAI/bge-m3',
|
|
2269
|
+
base_url: $('s-baseurl').value.trim() || 'https://api.siliconflow.cn/v1',
|
|
2270
|
+
};
|
|
2271
|
+
const key = $('s-key').value.trim();
|
|
2272
|
+
if (key) patch.api_key = key;
|
|
2273
|
+
try {
|
|
2274
|
+
const r = await api('/api/embeddings/test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) });
|
|
2275
|
+
$('s-status').textContent = `连接成功:${r.model} 维度${r.dim} 耗时${r.ms}ms`;
|
|
2276
|
+
} catch (e) { $('s-status').textContent = `连接失败:${e.message}`; }
|
|
2277
|
+
btn.disabled = false;
|
|
2278
|
+
});
|
|
2279
|
+
|
|
2123
2280
|
$('s-build').addEventListener('click', async () => {
|
|
2124
2281
|
const btn = $('s-build');
|
|
2125
2282
|
btn.disabled = true;
|
package/public/index.html
CHANGED
|
@@ -101,11 +101,14 @@
|
|
|
101
101
|
</div>
|
|
102
102
|
<div class="card">
|
|
103
103
|
<h3>语义检索配置(硅基流动默认)</h3>
|
|
104
|
+
<div class="row">
|
|
105
|
+
<div style="flex:1"><label>API Base URL</label><input id="s-baseurl" placeholder="https://api.siliconflow.cn/v1"></div>
|
|
106
|
+
</div>
|
|
104
107
|
<div class="row">
|
|
105
108
|
<div style="flex:1"><label>API Key</label><input id="s-key" type="password" placeholder="仅存本地 data/settings.json"></div>
|
|
106
109
|
<div style="width:110px"><label>模型</label><input id="s-model"></div>
|
|
107
110
|
</div>
|
|
108
|
-
<div class="row"><button class="primary" id="s-save">保存配置</button><button id="s-build">构建全量向量</button><span id="s-status" style="font-size:11px;color:#8fa3c0;align-self:center"></span></div>
|
|
111
|
+
<div class="row"><button class="primary" id="s-save">保存配置</button><button id="s-test">测试连接</button><button id="s-build">构建全量向量</button><span id="s-status" style="font-size:11px;color:#8fa3c0;align-self:center"></span></div>
|
|
109
112
|
</div>
|
|
110
113
|
<div class="card">
|
|
111
114
|
<h3>混合检索(语义+关键词 RRF 融合)</h3>
|
|
@@ -114,6 +117,11 @@
|
|
|
114
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>
|
|
115
118
|
<div id="s-results"></div>
|
|
116
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>
|
|
117
125
|
<div class="card">
|
|
118
126
|
<h3>关系查询(迷你Cypher,只读)</h3>
|
|
119
127
|
<textarea id="c-query" placeholder="MATCH (a)-[r:互动]->(b) WHERE a.name contains 郑和 RETURN a.name, r.name, b.name LIMIT 10"></textarea>
|
|
@@ -246,12 +254,13 @@
|
|
|
246
254
|
<h3>快速上手</h3>
|
|
247
255
|
<ul class="hp-list">
|
|
248
256
|
<li><b>AI 对话建图</b>:右侧输入自然语言(如"联网查一下珠穆朗玛峰的海拔并建入图谱"),或上传文档(md/txt/pdf/docx)批量抽取</li>
|
|
249
|
-
<li><b>检索页签</b
|
|
257
|
+
<li><b>检索页签</b>:智能提问(一句话找关系)、语义/关键词混合检索;语义配置卡可填 Base URL/Key/模型并一键测试连接</li>
|
|
258
|
+
<li><b>关系推荐</b>:共同邻居找"该连没连"的实体对(检索页签"关系推荐"卡 / 实体卡"推荐关系"按钮),AI 判断后一键采纳入库</li>
|
|
250
259
|
<li><b>最短路径</b>:搜索或列表选中实体后,用"路径"功能查两实体关联链路</li>
|
|
251
260
|
<li><b>撤销</b>:按钮或 Ctrl+Z,可连续撤销;删除实体仅恢复实体本身,关联关系可在日志页看到级联记录</li>
|
|
252
261
|
<li><b>推理</b>:开关叠加虚线显示的隐性关系(传递/对称/逆规则),点击可见推导依据</li>
|
|
253
262
|
<li><b>保存点与回溯</b>:版本页签打保存点,随时回滚到任一历史状态</li>
|
|
254
|
-
<li><b>图片绑定</b
|
|
263
|
+
<li><b>图片绑定</b>:实体与关系信息卡上传本地图片(缩略图+灯箱),佐证材料随图谱留存</li>
|
|
255
264
|
<li><b>导出</b>:RDF(Turtle)、整库 .db、单文件只读网页版</li>
|
|
256
265
|
</ul>
|
|
257
266
|
<h3>键盘快捷键</h3>
|
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');
|
|
@@ -317,11 +318,70 @@ api.delete('/images/:imgId', (req, res) => {
|
|
|
317
318
|
res.json({ deleted: row });
|
|
318
319
|
});
|
|
319
320
|
|
|
321
|
+
// ---------- 关系图片绑定(与实体图片同模式,目录 r<id>)----------
|
|
322
|
+
api.get('/relations/:id/images', (req, res) => {
|
|
323
|
+
const id = Number(req.params.id);
|
|
324
|
+
if (!db.getRelation(id)) return res.status(404).json({ error: `关系id=${id} 不存在` });
|
|
325
|
+
const rel = (p) => (p ? '/uploads/' + p.replace(/^uploads[\\/]/, '') : null);
|
|
326
|
+
res.json(db.listRelationImages(id).map((r) => ({ ...r, url: rel(r.stored_path), thumb_url: rel(r.thumb_path) || rel(r.stored_path) })));
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
api.post('/relations/:id/images', (req, res) => {
|
|
330
|
+
const id = Number(req.params.id);
|
|
331
|
+
const { filename, content_b64, caption, thumb_b64 } = req.body || {};
|
|
332
|
+
if (!filename || !content_b64) return res.status(400).json({ error: '必须提供文件名与内容(content_b64)' });
|
|
333
|
+
const ext = path.extname(String(filename)).toLowerCase();
|
|
334
|
+
if (!IMAGE_EXTS.includes(ext)) return res.status(400).json({ error: `仅支持图片格式: ${IMAGE_EXTS.join(' ')}` });
|
|
335
|
+
let buf;
|
|
336
|
+
try { buf = Buffer.from(content_b64, 'base64'); } catch (_) { return res.status(400).json({ error: 'content_b64不是合法的base64' }); }
|
|
337
|
+
if (!buf.length) return res.status(400).json({ error: '文件内容为空' });
|
|
338
|
+
if (buf.length > MAX_IMAGE_BYTES) return res.status(400).json({ error: '单张图片不得超过10MB' });
|
|
339
|
+
|
|
340
|
+
const dir = path.join(git.DATA_DIR, 'uploads', `r${id}`);
|
|
341
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
342
|
+
const safe = path.basename(String(filename)).replace(/[^\w.\-\u4e00-\u9fa5]/g, '_').slice(0, 80) || 'image';
|
|
343
|
+
const storedPath = path.join('uploads', `r${id}`, `${Date.now()}_${safe}`);
|
|
344
|
+
fs.writeFileSync(path.join(git.DATA_DIR, storedPath), buf);
|
|
345
|
+
let thumbPath = '';
|
|
346
|
+
if (thumb_b64) {
|
|
347
|
+
try {
|
|
348
|
+
const tbuf = Buffer.from(thumb_b64, 'base64');
|
|
349
|
+
if (tbuf.length > 0 && tbuf.length <= 2 * 1024 * 1024) {
|
|
350
|
+
thumbPath = path.join('uploads', `r${id}`, `${Date.now()}_thumb_${safe}`);
|
|
351
|
+
fs.writeFileSync(path.join(git.DATA_DIR, thumbPath), tbuf);
|
|
352
|
+
}
|
|
353
|
+
} catch (_) { thumbPath = ''; }
|
|
354
|
+
}
|
|
355
|
+
try {
|
|
356
|
+
const row = db.addRelationImage(id, { filename, stored_path: storedPath, caption, thumb_path: thumbPath });
|
|
357
|
+
const rel = (p) => (p ? '/uploads/' + p.replace(/^uploads[\\/]/, '') : null);
|
|
358
|
+
res.json({ ...row, url: rel(row.stored_path), thumb_url: rel(row.thumb_path) || rel(row.stored_path) });
|
|
359
|
+
} catch (e) {
|
|
360
|
+
cleanupImageFiles([storedPath, thumbPath]);
|
|
361
|
+
throw e;
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
api.delete('/relation-images/:imgId', (req, res) => {
|
|
366
|
+
const row = db.deleteRelationImage(Number(req.params.imgId));
|
|
367
|
+
cleanupImageFiles([row.stored_path, row.thumb_path].filter(Boolean));
|
|
368
|
+
res.json({ deleted: row });
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// 删除关系时连带清理其图片文件(表行由外键级联清除)
|
|
372
|
+
api.delete('/relations/:id', (req, res) => {
|
|
373
|
+
const id = Number(req.params.id);
|
|
374
|
+
const files = db.listRelationImages(id).flatMap((r) => [r.stored_path, r.thumb_path].filter(Boolean));
|
|
375
|
+
const out = db.deleteRelation(id, '手工');
|
|
376
|
+
cleanupImageFiles(files);
|
|
377
|
+
res.json(out);
|
|
378
|
+
});
|
|
379
|
+
|
|
320
380
|
// ---------- 关系 ----------
|
|
321
381
|
api.get('/relations', (req, res) => res.json(db.listRelations()));
|
|
322
382
|
api.post('/relations', (req, res) => res.json(db.addRelation(req.body, '手工')));
|
|
323
383
|
api.put('/relations/:id', (req, res) => res.json(db.updateRelation(Number(req.params.id), req.body, '手工')));
|
|
324
|
-
|
|
384
|
+
// DELETE /relations/:id 已在上方"关系图片"块定义(连带清理图片文件)
|
|
325
385
|
|
|
326
386
|
// ---------- 图谱 / 日志 ----------
|
|
327
387
|
api.get('/graph', (req, res) => res.json(db.getGraph()));
|
|
@@ -353,6 +413,45 @@ api.get('/inference', (req, res) => {
|
|
|
353
413
|
|
|
354
414
|
api.get('/ontology', (req, res) => res.json(inference.loadOntology()));
|
|
355
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
|
+
|
|
356
455
|
// ---------- 向量混合检索 ----------
|
|
357
456
|
api.get('/embeddings/status', (req, res) => res.json(embeddings.status()));
|
|
358
457
|
|
|
@@ -371,6 +470,21 @@ api.put('/embeddings/settings', (req, res) => {
|
|
|
371
470
|
} catch (e) { res.status(400).json({ error: e.message }); }
|
|
372
471
|
});
|
|
373
472
|
|
|
473
|
+
// 测试连接:用当前(或请求体内临时覆盖的)配置向服务方发一次真实embedding请求
|
|
474
|
+
api.post('/embeddings/test', async (req, res) => {
|
|
475
|
+
const overrides = req.body || {};
|
|
476
|
+
if (overrides.api_key === '已配置') delete overrides.api_key; // 占位=沿用已存密钥
|
|
477
|
+
const s = { ...embeddings.loadSettings(), ...overrides };
|
|
478
|
+
const started = Date.now();
|
|
479
|
+
try {
|
|
480
|
+
const vec = await embeddings.embed(['连接测试'], s);
|
|
481
|
+
const dim = Array.isArray(vec) && Array.isArray(vec[0]) ? vec[0].length : (vec && vec.length) || 0;
|
|
482
|
+
res.json({ ok: true, model: s.model, base_url: s.base_url, dim, ms: Date.now() - started });
|
|
483
|
+
} catch (e) {
|
|
484
|
+
res.status(400).json({ ok: false, error: e.message, ms: Date.now() - started });
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
|
|
374
488
|
api.post('/embeddings/build', async (req, res) => {
|
|
375
489
|
try {
|
|
376
490
|
const result = await embeddings.build();
|