local-knowledge-graph 1.5.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/.opencode/skill/kg-triples/SKILL.md +55 -0
- package/HELP.md +112 -0
- package/LICENSE +21 -0
- package/README.md +165 -0
- package/bin/cli.js +86 -0
- package/lib/agent.js +211 -0
- package/lib/ask.js +290 -0
- package/lib/db.js +613 -0
- package/lib/embeddings.js +166 -0
- package/lib/git.js +175 -0
- package/lib/importer.js +54 -0
- package/lib/inference.js +123 -0
- package/lib/paths.js +53 -0
- package/lib/rdf.js +64 -0
- package/lib/updater.js +144 -0
- package/lib/validator.js +146 -0
- package/lib/vectors.js +68 -0
- package/lib/viewer.js +33 -0
- package/lib/viewer_template.html +283 -0
- package/mcp/server.js +262 -0
- package/package.json +44 -0
- package/public/app.js +1761 -0
- package/public/index.html +238 -0
- package/public/style.css +374 -0
- package/public/vendor/OrbitControls.js +1045 -0
- package/public/vendor/three.min.js +6 -0
- package/server.js +625 -0
- package/tools/ego.js +122 -0
- package/tools/search.js +101 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// 向量混合检索:实体名称+属性文本 → embedding(默认硅基流动 BAAI/bge-m3),
|
|
4
|
+
// 与关键词检索做 RRF 融合排序。配置存于 data/settings.json(gitignored,密钥不入库)。
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const vectors = require('./vectors');
|
|
9
|
+
const db = require('./db');
|
|
10
|
+
|
|
11
|
+
const { DATA_DIR } = require('./paths');
|
|
12
|
+
const SETTINGS_PATH = path.join(DATA_DIR, 'settings.json');
|
|
13
|
+
|
|
14
|
+
const DEFAULTS = {
|
|
15
|
+
provider: 'siliconflow',
|
|
16
|
+
base_url: 'https://api.siliconflow.cn/v1',
|
|
17
|
+
model: 'BAAI/bge-m3',
|
|
18
|
+
dim: 1024,
|
|
19
|
+
api_key: '',
|
|
20
|
+
rrf_k: 60,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function loadSettings() {
|
|
24
|
+
try { return { ...DEFAULTS, ...JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf8')) }; }
|
|
25
|
+
catch (_) { return { ...DEFAULTS }; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function saveSettings(patch) {
|
|
29
|
+
const merged = { ...loadSettings(), ...(patch || {}) };
|
|
30
|
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
31
|
+
fs.writeFileSync(SETTINGS_PATH, JSON.stringify(merged, null, 2));
|
|
32
|
+
return merged;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function entityText(e) {
|
|
36
|
+
let attrs = '';
|
|
37
|
+
try {
|
|
38
|
+
const o = JSON.parse(e.attributes || '{}');
|
|
39
|
+
attrs = Object.entries(o).map(([k, v]) => `${k}:${v}`).join(';');
|
|
40
|
+
} catch (_) { /* 属性非法时忽略 */ }
|
|
41
|
+
return `${e.name}(${e.category})${attrs ? ' ' + attrs : ''}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function cosine(a, b) {
|
|
45
|
+
let dot = 0, na = 0, nb = 0;
|
|
46
|
+
for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }
|
|
47
|
+
const d = Math.sqrt(na) * Math.sqrt(nb);
|
|
48
|
+
return d === 0 ? 0 : dot / d;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function embed(texts, settings) {
|
|
52
|
+
const s = settings || loadSettings();
|
|
53
|
+
if (!s.api_key) throw new Error('未配置embedding API key,请打开"检索"页签填写');
|
|
54
|
+
const url = s.base_url.replace(/\/+$/, '') + '/embeddings';
|
|
55
|
+
const resp = await fetch(url, {
|
|
56
|
+
method: 'POST',
|
|
57
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${s.api_key}` },
|
|
58
|
+
body: JSON.stringify({ model: s.model, input: texts }),
|
|
59
|
+
});
|
|
60
|
+
if (!resp.ok) {
|
|
61
|
+
const body = await resp.text().catch(() => '');
|
|
62
|
+
throw new Error(`embedding接口错误 ${resp.status}: ${body.slice(0, 300)}`);
|
|
63
|
+
}
|
|
64
|
+
const data = await resp.json();
|
|
65
|
+
const vectors = data.data.map((d) => d.embedding);
|
|
66
|
+
if (vectors.length !== texts.length) throw new Error('embedding返回数量与请求不一致');
|
|
67
|
+
return vectors;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function status() {
|
|
71
|
+
const s = loadSettings();
|
|
72
|
+
return {
|
|
73
|
+
configured: Boolean(s.api_key),
|
|
74
|
+
provider: s.provider, model: s.model, dim: s.dim,
|
|
75
|
+
indexed: vectors.count(),
|
|
76
|
+
total_entities: db.listEntities().length,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 构建全部实体向量(增量:仅未入库或文本变化的实体),并清理已删实体的孤儿向量
|
|
81
|
+
async function build(progress) {
|
|
82
|
+
const s = loadSettings();
|
|
83
|
+
const ents = db.listEntities();
|
|
84
|
+
vectors.pruneOrphans(ents.map((e) => e.id));
|
|
85
|
+
const pending = ents.filter((e) => {
|
|
86
|
+
const row = vectors.get(e.id);
|
|
87
|
+
return !row || row.text !== entityText(e);
|
|
88
|
+
});
|
|
89
|
+
const done = ents.length - pending.length;
|
|
90
|
+
if (progress) progress(done, ents.length);
|
|
91
|
+
const BATCH = 32;
|
|
92
|
+
for (let i = 0; i < pending.length; i += BATCH) {
|
|
93
|
+
const chunk = pending.slice(i, i + BATCH);
|
|
94
|
+
const texts = chunk.map(entityText);
|
|
95
|
+
const vecs = await embed(texts, s);
|
|
96
|
+
chunk.forEach((e, k) => vectors.upsert(e.id, texts[k], vecs[k]));
|
|
97
|
+
if (progress) progress(done + Math.min(i + BATCH, pending.length), ents.length);
|
|
98
|
+
}
|
|
99
|
+
return { indexed: vectors.count(), total: ents.length };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 关键词检索:名称包含/属性包含,粗略打分(命中名称权重高)
|
|
103
|
+
function keywordSearch(graph, query) {
|
|
104
|
+
const q = query.trim().toLowerCase();
|
|
105
|
+
if (!q) return [];
|
|
106
|
+
const terms = q.split(/\s+/);
|
|
107
|
+
const scored = [];
|
|
108
|
+
for (const e of graph.entities) {
|
|
109
|
+
let score = 0;
|
|
110
|
+
const name = e.name.toLowerCase();
|
|
111
|
+
for (const t of terms) {
|
|
112
|
+
if (name === t) score += 10;
|
|
113
|
+
else if (name.includes(t)) score += 5;
|
|
114
|
+
else if (entityText(e).toLowerCase().includes(t)) score += 1;
|
|
115
|
+
}
|
|
116
|
+
if (score > 0) scored.push({ id: e.id, score });
|
|
117
|
+
}
|
|
118
|
+
return scored;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 混合检索:语义(余弦) + 关键词 RRF 融合,k=60
|
|
122
|
+
async function search(query, topK) {
|
|
123
|
+
const s = loadSettings();
|
|
124
|
+
const k = Number(s.rrf_k) || 60;
|
|
125
|
+
const limit = Math.max(1, Math.min(Number(topK) || 10, 50));
|
|
126
|
+
const graph = db.getGraph();
|
|
127
|
+
const byId = new Map(graph.entities.map((e) => [e.id, e]));
|
|
128
|
+
|
|
129
|
+
const kw = keywordSearch(graph, query);
|
|
130
|
+
|
|
131
|
+
let sem = [];
|
|
132
|
+
if (s.api_key && vectors.count() > 0) {
|
|
133
|
+
const [qvec] = await embed([query], s);
|
|
134
|
+
sem = vectors.all()
|
|
135
|
+
.map((row) => ({ id: row.entity_id, score: cosine(qvec, Float32Array.from(row.vector)) }))
|
|
136
|
+
.filter((x) => x.score > 0.15);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const rrf = new Map();
|
|
140
|
+
const addRanking = (arr) => {
|
|
141
|
+
arr.sort((a, b) => b.score - a.score).slice(0, limit * 3).forEach((x, rank) => {
|
|
142
|
+
rrf.set(x.id, (rrf.get(x.id) || 0) + 1 / (k + rank + 1));
|
|
143
|
+
});
|
|
144
|
+
};
|
|
145
|
+
addRanking(kw);
|
|
146
|
+
if (sem.length) addRanking(sem);
|
|
147
|
+
|
|
148
|
+
const results = [...rrf.entries()]
|
|
149
|
+
.sort((a, b) => b[1] - a[1])
|
|
150
|
+
.slice(0, limit)
|
|
151
|
+
.map(([id, rrfScore]) => {
|
|
152
|
+
const e = byId.get(id);
|
|
153
|
+
const semRow = sem.find((x) => x.id === id);
|
|
154
|
+
const kwRow = kw.find((x) => x.id === id);
|
|
155
|
+
return {
|
|
156
|
+
entity: e,
|
|
157
|
+
rrf_score: Number(rrfScore.toFixed(5)),
|
|
158
|
+
semantic_score: semRow ? Number(semRow.score.toFixed(4)) : null,
|
|
159
|
+
keyword_score: kwRow ? kwRow.score : null,
|
|
160
|
+
hit_relations: graph.relations.filter((r) => r.source_id === id || r.target_id === id).length,
|
|
161
|
+
};
|
|
162
|
+
});
|
|
163
|
+
return { results, mode: s.api_key && vectors.count() > 0 ? 'hybrid' : 'keyword_only' };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
module.exports = { loadSettings, saveSettings, status, build, search, embed, entityText };
|
package/lib/git.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { execFileSync } = require('child_process');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
|
|
8
|
+
const { DATA_DIR } = require('./paths');
|
|
9
|
+
const DB_FILE = 'kg.db';
|
|
10
|
+
const DB_PATH = path.join(DATA_DIR, DB_FILE);
|
|
11
|
+
const META_FILE = '.kg_meta.json'; // 记录最近一次保存点覆盖到的日志id(随版本一起入库,回滚后自动一致)
|
|
12
|
+
|
|
13
|
+
function git(args, opts = {}) {
|
|
14
|
+
return execFileSync('git', args, {
|
|
15
|
+
cwd: DATA_DIR,
|
|
16
|
+
encoding: 'utf8',
|
|
17
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
18
|
+
env: gitEnv(opts),
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// 读取二进制对象(如SQLite文件)必须用Buffer,避免UTF-8转换损坏数据
|
|
23
|
+
function gitBuffer(args) {
|
|
24
|
+
return execFileSync('git', args, {
|
|
25
|
+
cwd: DATA_DIR,
|
|
26
|
+
encoding: 'buffer',
|
|
27
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
28
|
+
env: gitEnv({}),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function gitEnv(opts) {
|
|
33
|
+
return {
|
|
34
|
+
...process.env,
|
|
35
|
+
GIT_AUTHOR_NAME: opts.name || 'KG-Local', GIT_AUTHOR_EMAIL: 'kg@localhost',
|
|
36
|
+
GIT_COMMITTER_NAME: opts.name || 'KG-Local', GIT_COMMITTER_EMAIL: 'kg@localhost',
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function ensureRepo() {
|
|
41
|
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
42
|
+
if (!fs.existsSync(path.join(DATA_DIR, '.git'))) {
|
|
43
|
+
git(['init', '-b', 'main']);
|
|
44
|
+
}
|
|
45
|
+
git(['config', 'user.name', 'KG-Local']);
|
|
46
|
+
git(['config', 'user.email', 'kg@localhost']);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function hasCommits() {
|
|
50
|
+
try { git(['rev-parse', '--verify', 'HEAD']); return true; } catch (_) { return false; }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readMeta() {
|
|
54
|
+
try { return JSON.parse(fs.readFileSync(path.join(DATA_DIR, META_FILE), 'utf8')); } catch (_) { return { last_saved_log: 0 }; }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function writeMeta(meta) {
|
|
58
|
+
fs.writeFileSync(path.join(DATA_DIR, META_FILE), JSON.stringify(meta, null, 2));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 打保存点:提交当前合规数据。git仅存差异对象,不冗余占用空间。
|
|
62
|
+
// 提交信息内嵌日志id区间,实现操作日志与Git提交记录双向绑定。
|
|
63
|
+
function savepoint(title, source = '系统') {
|
|
64
|
+
ensureRepo();
|
|
65
|
+
const meta = readMeta();
|
|
66
|
+
const currentMax = dbMaxLogId();
|
|
67
|
+
const from = meta.last_saved_log + 1;
|
|
68
|
+
const to = currentMax;
|
|
69
|
+
const finalTitle = (title && String(title).trim()) || `保存点 ${new Date().toLocaleString('zh-CN')}`;
|
|
70
|
+
// 无新增日志但库字节变化(如schema迁移/资产变更)时 from>to,区间收敛到最近日志
|
|
71
|
+
const rangeFrom = Math.min(from, to);
|
|
72
|
+
const msg = `${finalTitle}|来源:${source}|日志ID:${rangeFrom}-${to}`;
|
|
73
|
+
|
|
74
|
+
git(['add', '-A', '--', DB_FILE]);
|
|
75
|
+
const staged = git(['diff', '--cached', '--name-only']);
|
|
76
|
+
if (!staged.trim()) return { committed: false, message: '当前数据与最近保存点一致,无差异需要提交' };
|
|
77
|
+
// 元数据与数据在同一个提交内,回滚后自动一致
|
|
78
|
+
writeMeta({ last_saved_log: to });
|
|
79
|
+
git(['add', '-A', '--', META_FILE]);
|
|
80
|
+
git(['commit', '-m', msg]);
|
|
81
|
+
const hash = git(['rev-parse', '--short', 'HEAD']).trim();
|
|
82
|
+
backupCopy();
|
|
83
|
+
return { committed: true, hash, message: msg, log_range: [rangeFrom, to] };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// data/backups/ 自动滚动副本:距上次备份超过10分钟才复制,保留最近10份(目录损坏时仍有外部副本可救)
|
|
87
|
+
const BACKUP_KEEP = 10;
|
|
88
|
+
const BACKUP_MIN_INTERVAL_MS = 10 * 60 * 1000;
|
|
89
|
+
let lastBackupAt = 0;
|
|
90
|
+
|
|
91
|
+
function backupCopy(force = false) {
|
|
92
|
+
const backupDir = path.join(DATA_DIR, 'backups');
|
|
93
|
+
try {
|
|
94
|
+
const now = Date.now();
|
|
95
|
+
if (!force && now - lastBackupAt < BACKUP_MIN_INTERVAL_MS) return null;
|
|
96
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
97
|
+
const stamp = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 19);
|
|
98
|
+
fs.copyFileSync(DB_PATH, path.join(backupDir, `kg-${stamp}.db`));
|
|
99
|
+
lastBackupAt = now;
|
|
100
|
+
const files = fs.readdirSync(backupDir).filter((f) => /^kg-.*\.db$/.test(f)).sort();
|
|
101
|
+
for (const f of files.slice(0, Math.max(0, files.length - BACKUP_KEEP))) {
|
|
102
|
+
try { fs.unlinkSync(path.join(backupDir, f)); } catch (_) { /* 单个旧副本清理失败可容忍 */ }
|
|
103
|
+
}
|
|
104
|
+
return path.join(backupDir, `kg-${stamp}.db`);
|
|
105
|
+
} catch (_) { return null; }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function dbMaxLogId() {
|
|
109
|
+
try {
|
|
110
|
+
const database = require('./db');
|
|
111
|
+
return database.maxLogId();
|
|
112
|
+
} catch (_) { return readMeta().last_saved_log; }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 提交历史(含日志绑定信息)
|
|
116
|
+
function history(limit = 100) {
|
|
117
|
+
ensureRepo();
|
|
118
|
+
if (!hasCommits()) return [];
|
|
119
|
+
const raw = git(['log', '-n', String(limit), '--date=iso-local', '--pretty=format:%H%x01%h%x01%aI%x01%s%x01%an']).trim();
|
|
120
|
+
if (!raw) return [];
|
|
121
|
+
return raw.split('\n').map((line) => {
|
|
122
|
+
const [hash, short, date, subject, author] = line.split('\x01');
|
|
123
|
+
let title = subject, logRange = null, src = '';
|
|
124
|
+
const m = subject.match(/^(.*)|来源:(.*)|日志ID:(\d+)-(\d+)$/);
|
|
125
|
+
if (m) { title = m[1]; src = m[2]; logRange = [Number(m[3]), Number(m[4])]; }
|
|
126
|
+
return { hash, short, date, author, title, source: src, log_range: logRange };
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 回溯:切到任意保存点。先自动备份当前状态,再恢复目标版本并校验完整性。
|
|
131
|
+
function restore(hash) {
|
|
132
|
+
ensureRepo();
|
|
133
|
+
if (!hasCommits()) { const e = new Error('仓库中还没有任何保存点'); e.status = 400; throw e; }
|
|
134
|
+
try { git(['cat-file', '-e', `${hash}^{commit}`]); } catch (_) {
|
|
135
|
+
const e = new Error(`保存点 ${hash} 不存在`); e.status = 404; throw e;
|
|
136
|
+
}
|
|
137
|
+
let backup = null;
|
|
138
|
+
try { backup = savepoint(`回滚前自动备份 ${new Date().toLocaleString('zh-CN')}`, '系统'); } catch (_) { backup = null; }
|
|
139
|
+
|
|
140
|
+
// 先在临时文件上校验目标版本完整性,通过后才替换主库,确保任何情况下不损坏现有数据
|
|
141
|
+
const target = gitBuffer(['show', `${hash}:${DB_FILE}`]);
|
|
142
|
+
const tmp = path.join(os.tmpdir(), `kg_restore_${Date.now()}.db`);
|
|
143
|
+
fs.writeFileSync(tmp, target);
|
|
144
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
145
|
+
let probe;
|
|
146
|
+
try {
|
|
147
|
+
probe = new DatabaseSync(tmp);
|
|
148
|
+
const r = probe.prepare('PRAGMA integrity_check').get();
|
|
149
|
+
const v = Object.values(r)[0];
|
|
150
|
+
if (v !== 'ok') { const e = new Error(`目标保存点数据异常: ${v}`); e.status = 500; throw e; }
|
|
151
|
+
} finally {
|
|
152
|
+
try { if (probe) probe.close(); } catch (_) {}
|
|
153
|
+
}
|
|
154
|
+
fs.copyFileSync(tmp, path.join(DATA_DIR, DB_FILE));
|
|
155
|
+
fs.unlinkSync(tmp);
|
|
156
|
+
// 同步恢复日志绑定元数据,保证保存点区间与日志双向绑定始终一致
|
|
157
|
+
try {
|
|
158
|
+
const metaBuf = gitBuffer(['show', `${hash}:${META_FILE}`]);
|
|
159
|
+
fs.writeFileSync(path.join(DATA_DIR, META_FILE), metaBuf);
|
|
160
|
+
} catch (_) {
|
|
161
|
+
writeMeta({ last_saved_log: 0 });
|
|
162
|
+
}
|
|
163
|
+
const database = require('./db');
|
|
164
|
+
database.reopen();
|
|
165
|
+
const check = database.integrityCheck();
|
|
166
|
+
if (!check.ok) {
|
|
167
|
+
const e = new Error('恢复后的数据库完整性校验失败: ' + check.detail);
|
|
168
|
+
e.status = 500; throw e;
|
|
169
|
+
}
|
|
170
|
+
// 记录回溯操作(source=系统),保证日志与版本可互溯
|
|
171
|
+
const counts = database.counts();
|
|
172
|
+
return { restored: hash, backup: backup && backup.hash ? backup.hash : null, counts };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
module.exports = { ensureRepo, savepoint, history, restore, readMeta, writeMeta, backupCopy, DATA_DIR };
|
package/lib/importer.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// 整库导入校验:SQLite魔数 → 完整性 → 必需表 → 必需列 → 计数与日志水位。
|
|
4
|
+
// 独立成模块便于单元测试(构造任意残缺库验证拦截)。
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
const REQUIRED_TABLES = ['entities', 'relations', 'operation_logs'];
|
|
11
|
+
const REQUIRED_COLS = {
|
|
12
|
+
entities: ['id', 'name', 'category', 'attributes', 'source', 'created_at'],
|
|
13
|
+
relations: ['id', 'source_id', 'target_id', 'name', 'category', 'source', 'created_at'],
|
|
14
|
+
operation_logs: ['id', 'op_type', 'snapshot', 'source', 'created_at'],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function validateImportBuffer(buf) {
|
|
18
|
+
if (!buf || !buf.length) return { ok: false, error: '文件为空' };
|
|
19
|
+
if (buf.length > 200 * 1024 * 1024) return { ok: false, error: '文件超过200MB上限' };
|
|
20
|
+
if (!/^SQLite format 3\x00/.test(buf.toString('latin1', 0, 16))) return { ok: false, error: '该文件不是SQLite数据库' };
|
|
21
|
+
|
|
22
|
+
const tmp = path.join(os.tmpdir(), `kg_import_probe_${Date.now()}_${Math.random().toString(36).slice(2)}.db`);
|
|
23
|
+
fs.writeFileSync(tmp, buf);
|
|
24
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
25
|
+
let probe;
|
|
26
|
+
try {
|
|
27
|
+
probe = new DatabaseSync(tmp);
|
|
28
|
+
const v = Object.values(probe.prepare('PRAGMA integrity_check').get())[0];
|
|
29
|
+
if (v !== 'ok') return { ok: false, error: `数据库完整性校验失败: ${v}` };
|
|
30
|
+
const tables = probe.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((r) => r.name);
|
|
31
|
+
for (const t of REQUIRED_TABLES) {
|
|
32
|
+
if (!tables.includes(t)) return { ok: false, error: `缺少必需的数据表 ${t},这不是本系统的图谱文件` };
|
|
33
|
+
}
|
|
34
|
+
for (const [t, cols] of Object.entries(REQUIRED_COLS)) {
|
|
35
|
+
const actual = probe.prepare(`PRAGMA table_info(${t})`).all().map((c) => c.name);
|
|
36
|
+
const missing = cols.filter((c) => !actual.includes(c));
|
|
37
|
+
if (missing.length) return { ok: false, error: `表 ${t} 缺少必需字段: ${missing.join(', ')}` };
|
|
38
|
+
}
|
|
39
|
+
const importedMaxLog = probe.prepare('SELECT COALESCE(MAX(id),0) AS m FROM operation_logs').get().m;
|
|
40
|
+
const counts = {
|
|
41
|
+
entities: probe.prepare('SELECT COUNT(*) AS c FROM entities').get().c,
|
|
42
|
+
relations: probe.prepare('SELECT COUNT(*) AS c FROM relations').get().c,
|
|
43
|
+
logs: probe.prepare('SELECT COUNT(*) AS c FROM operation_logs').get().c,
|
|
44
|
+
};
|
|
45
|
+
return { ok: true, importedMaxLog, counts };
|
|
46
|
+
} catch (e) {
|
|
47
|
+
return { ok: false, error: '数据库读取失败: ' + e.message };
|
|
48
|
+
} finally {
|
|
49
|
+
try { if (probe) probe.close(); } catch (_) { /* 已关闭 */ }
|
|
50
|
+
try { fs.unlinkSync(tmp); } catch (_) { /* 临时探针文件清理失败可容忍 */ }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = { validateImportBuffer, REQUIRED_TABLES, REQUIRED_COLS };
|
package/lib/inference.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// OWL式推理引擎:基于本体属性特征(传递/对称/逆)从显式关系推导隐性关系。
|
|
4
|
+
// 推理结果为虚拟三元组(不入库),附带推导规则与关系id路径,供前端展示与API查询。
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
const DATA_DIR = path.join(__dirname, '..', 'data');
|
|
10
|
+
const ONTOLOGY_PATH = path.join(DATA_DIR, 'ontology.json');
|
|
11
|
+
|
|
12
|
+
const DEFAULT_ONTOLOGY = {
|
|
13
|
+
transitive: ['位于', '在', '包含', '包括', '属于', '下辖于', '隶属于', '统治', '管辖', '发源于', '流入'],
|
|
14
|
+
symmetric: ['挚友', '夫妻', '同学', '同事', '结为兄弟', '相邻', '同义词'],
|
|
15
|
+
inverse: [['父子', '子父'], ['包含', '属于'], ['创建', '创建者'], ['师从', '学生为']],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function loadOntology() {
|
|
19
|
+
try {
|
|
20
|
+
const o = JSON.parse(fs.readFileSync(ONTOLOGY_PATH, 'utf8'));
|
|
21
|
+
return normalizeOntology(o);
|
|
22
|
+
} catch (_) {
|
|
23
|
+
return normalizeOntology(DEFAULT_ONTOLOGY);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function normalizeOntology(o) {
|
|
28
|
+
const out = { transitive: [], symmetric: [], inverse: [] };
|
|
29
|
+
if (o && typeof o === 'object') {
|
|
30
|
+
if (Array.isArray(o.transitive)) out.transitive = o.transitive.filter((x) => typeof x === 'string' && x.trim()).map((x) => x.trim());
|
|
31
|
+
if (Array.isArray(o.symmetric)) out.symmetric = o.symmetric.filter((x) => typeof x === 'string' && x.trim()).map((x) => x.trim());
|
|
32
|
+
if (Array.isArray(o.inverse)) {
|
|
33
|
+
out.inverse = o.inverse
|
|
34
|
+
.filter((p) => Array.isArray(p) && p.length === 2 && p.every((x) => typeof x === 'string' && x.trim()))
|
|
35
|
+
.map((p) => [p[0].trim(), p[1].trim()]);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function saveOntology(o) {
|
|
42
|
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
43
|
+
fs.writeFileSync(ONTOLOGY_PATH, JSON.stringify(normalizeOntology(o), null, 2));
|
|
44
|
+
return loadOntology();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 推理主入口:graph = {entities, relations}
|
|
48
|
+
function computeInferred(graph, ontology) {
|
|
49
|
+
const ont = ontology || loadOntology();
|
|
50
|
+
const inferred = [];
|
|
51
|
+
const seen = new Set();
|
|
52
|
+
const explicitKeys = new Set(graph.relations.map((r) => `${r.source_id}|${r.target_id}|${r.name}`));
|
|
53
|
+
|
|
54
|
+
// R1 传递性(TransitiveProperty): A -n-> B, B -n-> C ⇒ A -n-> C(闭包,路径取最短)
|
|
55
|
+
for (const name of ont.transitive) {
|
|
56
|
+
const rels = graph.relations.filter((r) => r.name === name);
|
|
57
|
+
if (!rels.length) continue;
|
|
58
|
+
const adj = new Map();
|
|
59
|
+
for (const r of rels) {
|
|
60
|
+
if (!adj.has(r.source_id)) adj.set(r.source_id, []);
|
|
61
|
+
adj.get(r.source_id).push(r);
|
|
62
|
+
}
|
|
63
|
+
for (const [start, outEdges] of adj) {
|
|
64
|
+
const reached = new Map(); // nodeId -> path(rel[])
|
|
65
|
+
const queue = [];
|
|
66
|
+
for (const e of outEdges) queue.push({ node: e.target_id, path: [e] });
|
|
67
|
+
while (queue.length) {
|
|
68
|
+
const { node, path } = queue.shift();
|
|
69
|
+
if (reached.has(node) || node === start) continue;
|
|
70
|
+
reached.set(node, path);
|
|
71
|
+
for (const e of adj.get(node) || []) {
|
|
72
|
+
if (!reached.has(e.target_id)) queue.push({ node: e.target_id, path: [...path, e] });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
for (const [node, path] of reached) {
|
|
76
|
+
const key = `${start}|${node}|${name}`;
|
|
77
|
+
if (explicitKeys.has(key)) continue;
|
|
78
|
+
seen.add(key);
|
|
79
|
+
inferred.push({
|
|
80
|
+
source_id: start, target_id: node, name,
|
|
81
|
+
category: path[0].category,
|
|
82
|
+
rule: `传递性(${name})`,
|
|
83
|
+
via: path.map((e) => e.id),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// R2 对称性(SymmetricProperty): A -n-> B ⇒ B -n-> A
|
|
90
|
+
for (const name of ont.symmetric) {
|
|
91
|
+
for (const r of graph.relations.filter((x) => x.name === name)) {
|
|
92
|
+
if (r.source_id === r.target_id) continue;
|
|
93
|
+
const key = `${r.target_id}|${r.source_id}|${r.name}`;
|
|
94
|
+
if (explicitKeys.has(key) || seen.has(key)) continue;
|
|
95
|
+
seen.add(key);
|
|
96
|
+
inferred.push({
|
|
97
|
+
source_id: r.target_id, target_id: r.source_id, name: r.name,
|
|
98
|
+
category: r.category,
|
|
99
|
+
rule: `对称性(${r.name})`,
|
|
100
|
+
via: [r.id],
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// R3 逆关系(inverseOf): A -p-> B ⇒ B -q-> A(p,q为逆对)
|
|
106
|
+
for (const [p, q] of ont.inverse) {
|
|
107
|
+
for (const r of graph.relations.filter((x) => x.name === p)) {
|
|
108
|
+
const key = `${r.target_id}|${r.source_id}|${q}`;
|
|
109
|
+
if (explicitKeys.has(key) || seen.has(key)) continue;
|
|
110
|
+
seen.add(key);
|
|
111
|
+
inferred.push({
|
|
112
|
+
source_id: r.target_id, target_id: r.source_id, name: q,
|
|
113
|
+
category: r.category,
|
|
114
|
+
rule: `逆关系(${p} ↔ ${q})`,
|
|
115
|
+
via: [r.id],
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return inferred;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = { loadOntology, saveOntology, computeInferred };
|
package/lib/paths.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// 数据目录统一解析:环境变量 > 开发仓库(./data) > npm安装(~/.local-knowledge-graph)
|
|
4
|
+
// 全部数据(kg.db/vectors.db/settings.json/uploads/backups/保存点git仓库)都收敛在数据目录内,
|
|
5
|
+
// npm 升级只替换程序文件,数据天然不受影响。
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
|
|
11
|
+
const APP_ROOT = path.join(__dirname, '..');
|
|
12
|
+
const PKG_NAME = 'local-knowledge-graph';
|
|
13
|
+
|
|
14
|
+
function isDevRepo(root = APP_ROOT) {
|
|
15
|
+
return fs.existsSync(path.join(root, '.git'));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function resolveDataDir(opts = {}) {
|
|
19
|
+
const env = opts.env || process.env;
|
|
20
|
+
const home = opts.home || os.homedir();
|
|
21
|
+
const root = opts.root || APP_ROOT;
|
|
22
|
+
if (env.KG_DATA_DIR) return env.KG_DATA_DIR;
|
|
23
|
+
if (isDevRepo(root)) return path.join(root, 'data');
|
|
24
|
+
return path.join(home, '.local-knowledge-graph');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const DATA_DIR = resolveDataDir();
|
|
28
|
+
const DB_PATH = process.env.KG_DB_PATH || path.join(DATA_DIR, 'kg.db');
|
|
29
|
+
|
|
30
|
+
// npm 安装模式下,若包目录残留旧版数据(1.4.x 及之前存放在程序目录 data/),自动迁移到数据目录
|
|
31
|
+
function ensureLegacyMigration(log = console.log) {
|
|
32
|
+
if (process.env.KG_DATA_DIR || process.env.KG_DB_PATH) return;
|
|
33
|
+
if (isDevRepo()) return;
|
|
34
|
+
const legacy = path.join(APP_ROOT, 'data');
|
|
35
|
+
if (!fs.existsSync(path.join(legacy, 'kg.db'))) return;
|
|
36
|
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
37
|
+
const files = ['kg.db', 'vectors.db', 'settings.json', '.kg_meta.json', '.agent_session'];
|
|
38
|
+
for (const f of files) {
|
|
39
|
+
const src = path.join(legacy, f);
|
|
40
|
+
const dst = path.join(DATA_DIR, f);
|
|
41
|
+
if (fs.existsSync(src) && !fs.existsSync(dst)) fs.copyFileSync(src, dst);
|
|
42
|
+
}
|
|
43
|
+
for (const dir of ['uploads', 'backups']) {
|
|
44
|
+
const src = path.join(legacy, dir);
|
|
45
|
+
const dst = path.join(DATA_DIR, dir);
|
|
46
|
+
if (fs.existsSync(src) && !fs.existsSync(dst)) {
|
|
47
|
+
fs.cpSync(src, dst, { recursive: true });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
log(`[迁移] 检测到程序目录内的旧数据,已复制到 ${DATA_DIR}(原 data/ 目录保留未动)`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { APP_ROOT, DATA_DIR, DB_PATH, PKG_NAME, isDevRepo, resolveDataDir, ensureLegacyMigration };
|
package/lib/rdf.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// 标准RDF(Turtle)导出:所有字段严格映射为三元组,禁止冗余结构
|
|
4
|
+
const { RDF_TYPE_MAP, RDF_RELATION_TYPE_MAP } = require('./validator');
|
|
5
|
+
|
|
6
|
+
const HEADER = `@prefix kg: <http://monkeycode.local/kg/> .
|
|
7
|
+
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
|
8
|
+
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
|
|
9
|
+
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
|
|
10
|
+
`;
|
|
11
|
+
|
|
12
|
+
function esc(s) {
|
|
13
|
+
return String(s)
|
|
14
|
+
.replace(/\\/g, '\\\\')
|
|
15
|
+
.replace(/"/g, '\\"')
|
|
16
|
+
.replace(/\n/g, '\\n')
|
|
17
|
+
.replace(/\r/g, '\\r')
|
|
18
|
+
.replace(/\t/g, '\\t');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function lit(v) {
|
|
22
|
+
if (typeof v === 'number') return Number.isInteger(v) ? `"${v}"^^xsd:integer` : `"${v}"^^xsd:decimal`;
|
|
23
|
+
if (typeof v === 'boolean') return `"${v}"^^xsd:boolean`;
|
|
24
|
+
if (v === null || v === undefined) return `""^^rdf:nil`;
|
|
25
|
+
return `"${esc(v)}"`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function exportTurtle(graph) {
|
|
29
|
+
const parts = [HEADER];
|
|
30
|
+
|
|
31
|
+
for (const e of graph.entities) {
|
|
32
|
+
const cls = RDF_TYPE_MAP[e.category] || 'Entity';
|
|
33
|
+
const lines = [];
|
|
34
|
+
lines.push(`kg:e${e.id} rdf:type kg:${cls} ;`);
|
|
35
|
+
lines.push(` rdfs:label "${esc(e.name)}"@zh ;`);
|
|
36
|
+
lines.push(` kg:category "${esc(e.category)}" ;`);
|
|
37
|
+
lines.push(` kg:createdAt "${esc(e.created_at)}"^^xsd:dateTime ;`);
|
|
38
|
+
lines.push(` kg:source "${esc(e.source)}"`);
|
|
39
|
+
let attrs = {};
|
|
40
|
+
try { attrs = JSON.parse(e.attributes || '{}'); } catch (_) { attrs = {}; }
|
|
41
|
+
const chunks = [];
|
|
42
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
43
|
+
chunks.push(` kg:attribute [ kg:key ${lit(k)} ; kg:value ${lit(v)} ]`);
|
|
44
|
+
}
|
|
45
|
+
parts.push(chunks.length ? lines.join('\n') + ' ;\n' + chunks.join(' ;\n') + ' .\n' : lines.join('\n') + ' .\n');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const r of graph.relations) {
|
|
49
|
+
const cls = RDF_RELATION_TYPE_MAP[r.category] || 'Relation';
|
|
50
|
+
parts.push(
|
|
51
|
+
`kg:r${r.id} rdf:type kg:${cls} ;\n` +
|
|
52
|
+
` rdfs:label "${esc(r.name)}"@zh ;\n` +
|
|
53
|
+
` kg:category "${esc(r.category)}" ;\n` +
|
|
54
|
+
` kg:subject kg:e${r.source_id} ;\n` +
|
|
55
|
+
` kg:object kg:e${r.target_id} ;\n` +
|
|
56
|
+
` kg:createdAt "${esc(r.created_at)}"^^xsd:dateTime ;\n` +
|
|
57
|
+
` kg:source "${esc(r.source)}" .\n`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return parts.join('\n');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { exportTurtle };
|