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
package/lib/ask.js
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// 智能关系检索:LLM编译检索计划(受限JSON) → 本地只读执行 → 关联发现 → 可选综述
|
|
4
|
+
// 设计约束:LLM永不触碰写通道;执行器仅调用白名单只读工具;runPlain可注入以便测试。
|
|
5
|
+
|
|
6
|
+
const db = require('./db');
|
|
7
|
+
const embeddings = require('./embeddings');
|
|
8
|
+
|
|
9
|
+
const STEP_LIMIT = 4;
|
|
10
|
+
const CYPHER_MAX_ROWS = 50;
|
|
11
|
+
const KEYWORD_TOPK = 10;
|
|
12
|
+
const EGO_MAX_NODES = 100;
|
|
13
|
+
const STEP_TIMEOUT_MS = 5000;
|
|
14
|
+
|
|
15
|
+
// ---------- 图谱目录(发给LLM的摘要,≤2KB) ----------
|
|
16
|
+
function buildDigest() {
|
|
17
|
+
const entities = db.listEntities();
|
|
18
|
+
const relations = db.listRelations();
|
|
19
|
+
const cat = (rows, key) => [...new Set(rows.map((r) => r[key]))].join('/');
|
|
20
|
+
const relNameCount = new Map();
|
|
21
|
+
const degree = new Map();
|
|
22
|
+
const touch = (id) => degree.set(id, (degree.get(id) || 0) + 1);
|
|
23
|
+
for (const r of relations) {
|
|
24
|
+
relNameCount.set(r.name, (relNameCount.get(r.name) || 0) + 1);
|
|
25
|
+
touch(r.source_id); touch(r.target_id);
|
|
26
|
+
}
|
|
27
|
+
const topRels = [...relNameCount.entries()].sort((a, b) => b[1] - a[1]).slice(0, 20)
|
|
28
|
+
.map(([n, c]) => `${n}(${c})`).join(', ');
|
|
29
|
+
const byId = new Map(entities.map((e) => [e.id, e]));
|
|
30
|
+
const highDegree = entities.filter((e) => (degree.get(e.id) || 0) >= 2)
|
|
31
|
+
.sort((a, b) => (degree.get(b.id) || 0) - (degree.get(a.id) || 0)).slice(0, 8);
|
|
32
|
+
const spread = entities.filter((_, i) => i % Math.max(1, Math.floor(entities.length / 8)) === 0).slice(0, 7);
|
|
33
|
+
const samples = [...new Map([...highDegree, ...spread].map((e) => [e.id, e])).values()].slice(0, 15)
|
|
34
|
+
.map((e) => `${e.name}(id${e.id})`).join(', ');
|
|
35
|
+
const digest = [
|
|
36
|
+
`实体大类: ${cat(entities, 'category')}`,
|
|
37
|
+
`关系大类: ${cat(relations, 'category')}`,
|
|
38
|
+
`高频关系名: ${topRels || '(暂无)'}`,
|
|
39
|
+
`样例实体: ${samples || '(暂无)'}`,
|
|
40
|
+
`规模: 实体${entities.length} 关系${relations.length}`,
|
|
41
|
+
].join('\n');
|
|
42
|
+
return { digest: digest.slice(0, 2048), stats: { entities: entities.length, relations: relations.length } };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------- 检索计划schema校验 ----------
|
|
46
|
+
const TOOLS = ['keyword', 'cypher', 'path', 'ego'];
|
|
47
|
+
function validatePlan(plan) {
|
|
48
|
+
const errors = [];
|
|
49
|
+
if (!plan || typeof plan !== 'object' || Array.isArray(plan)) return { ok: false, errors: ['计划必须是JSON对象'] };
|
|
50
|
+
if (!Array.isArray(plan.steps) || plan.steps.length === 0) return { ok: false, errors: ['steps必须为非空数组'] };
|
|
51
|
+
if (plan.steps.length > STEP_LIMIT) errors.push(`步骤数不得超过${STEP_LIMIT}`);
|
|
52
|
+
plan.steps.forEach((s, i) => {
|
|
53
|
+
if (!s || typeof s !== 'object') { errors.push(`步骤${i + 1}必须是对象`); return; }
|
|
54
|
+
if (!TOOLS.includes(s.tool)) { errors.push(`步骤${i + 1}工具"${s.tool}"不在白名单: ${TOOLS.join('/')}`); return; }
|
|
55
|
+
const need = (cond, msg) => { if (!cond) errors.push(`步骤${i + 1}: ${msg}`); };
|
|
56
|
+
if (s.tool === 'keyword') need(typeof s.q === 'string' && s.q.trim(), 'keyword需要非空q(字符串)');
|
|
57
|
+
if (s.tool === 'cypher') {
|
|
58
|
+
need(typeof s.query === 'string' && /^\s*MATCH/i.test(s.query) && /RETURN/i.test(s.query), 'cypher需要形如 MATCH (a)-[r]->(b) RETURN ... 的query');
|
|
59
|
+
}
|
|
60
|
+
if (s.tool === 'path') {
|
|
61
|
+
need(typeof s.from === 'string' && s.from.trim() && typeof s.to === 'string' && s.to.trim(), 'path需要非空from与to');
|
|
62
|
+
if (s.max !== undefined) need(Number.isInteger(s.max) && s.max >= 1 && s.max <= 12, 'path.max须为1-12整数');
|
|
63
|
+
}
|
|
64
|
+
if (s.tool === 'ego') {
|
|
65
|
+
need(typeof s.center === 'string' && s.center.trim(), 'ego需要非空center');
|
|
66
|
+
if (s.depth !== undefined) need(Number.isInteger(s.depth) && s.depth >= 1 && s.depth <= 6, 'ego.depth须为1-6整数');
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
return { ok: errors.length === 0, errors };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------- 从LLM文本中提取计划JSON ----------
|
|
73
|
+
function extractJson(text) {
|
|
74
|
+
let t = String(text || '').trim().replace(/```(?:json)?/gi, '');
|
|
75
|
+
const start = t.indexOf('{');
|
|
76
|
+
const end = t.lastIndexOf('}');
|
|
77
|
+
if (start === -1 || end <= start) throw new Error('输出中未找到JSON对象');
|
|
78
|
+
return JSON.parse(t.slice(start, end + 1));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------- 编译(失败携带错误重试1次) ----------
|
|
82
|
+
async function compilePlan(question, runPlain) {
|
|
83
|
+
const { digest } = buildDigest();
|
|
84
|
+
const rules = [
|
|
85
|
+
'你是知识图谱检索规划器。把用户问题编译为检索计划JSON,仅输出JSON对象,禁止任何解释或代码块外文本。',
|
|
86
|
+
'可用工具(只读): keyword{q:关键词} / cypher{query:MATCH (a)-[r:类型]->(b) WHERE a.name contains 词 RETURN a,r,b LIMIT n} / path{from:实体名或id,to:实体名或id,max:层数} / ego{center:实体名或id,depth:层数}',
|
|
87
|
+
`规则: steps数组1-${STEP_LIMIT}步;关系名必须来自"高频关系名"列表(或其近义词);引用实体优先用样例中出现的名称;不确定就拆成keyword步;输出形如 {"steps":[{"tool":"cypher","query":"..."}]}`,
|
|
88
|
+
].join('\n');
|
|
89
|
+
let lastErr = '';
|
|
90
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
91
|
+
const prompt = attempt === 0
|
|
92
|
+
? `${rules}\n\n【图谱目录】\n${digest}\n\n【用户问题】\n${question}`
|
|
93
|
+
: `${rules}\n\n【图谱目录】\n${digest}\n\n【用户问题】\n${question}\n\n【上次输出不合法】\n${lastErr}\n请修正后重新仅输出JSON。`;
|
|
94
|
+
const r = await runPlain(prompt, 45000);
|
|
95
|
+
if (!r.ok) { lastErr = r.error; continue; }
|
|
96
|
+
try {
|
|
97
|
+
const plan = extractJson(r.text);
|
|
98
|
+
const v = validatePlan(plan);
|
|
99
|
+
if (v.ok) return { plan, degraded: false };
|
|
100
|
+
lastErr = v.errors.join('; ');
|
|
101
|
+
} catch (e) {
|
|
102
|
+
lastErr = `JSON解析失败: ${e.message}`;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return { plan: null, degraded: true, error: lastErr || '编译两次失败' };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------- 单步执行(白名单只读 + 5s竞速超时) ----------
|
|
109
|
+
function clampCypher(query) {
|
|
110
|
+
let q = String(query).trim().replace(/\s+LIMIT\s+(\d+)\s*$/i, (_, n) => ` LIMIT ${Math.min(Number(n), CYPHER_MAX_ROWS)}`);
|
|
111
|
+
if (!/\s+LIMIT\s+\d+\s*$/i.test(q)) q += ` LIMIT ${CYPHER_MAX_ROWS}`;
|
|
112
|
+
return q;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function runStep(step) {
|
|
116
|
+
const t0 = Date.now();
|
|
117
|
+
const work = (async () => {
|
|
118
|
+
switch (step.tool) {
|
|
119
|
+
case 'keyword': {
|
|
120
|
+
const r = await embeddings.search(step.q.trim(), KEYWORD_TOPK);
|
|
121
|
+
const ents = r.results.map((x) => x.entity);
|
|
122
|
+
const rels = r.results.flatMap((x) => (x.hit_relations || []));
|
|
123
|
+
return { entities: ents, relations: rels };
|
|
124
|
+
}
|
|
125
|
+
case 'cypher': {
|
|
126
|
+
const rows = db.miniCypher(clampCypher(step.query));
|
|
127
|
+
const entities = [];
|
|
128
|
+
const relations = [];
|
|
129
|
+
const seenE = new Set();
|
|
130
|
+
const seenR = new Set();
|
|
131
|
+
for (const row of rows) {
|
|
132
|
+
for (const id of [row.source_id, row.target_id]) {
|
|
133
|
+
if (id && !seenE.has(id)) { seenE.add(id); const e = db.getEntity(id); if (e) entities.push(e); }
|
|
134
|
+
}
|
|
135
|
+
if (row.relation_id && !seenR.has(row.relation_id)) { seenR.add(row.relation_id); const rel = db.getRelation(row.relation_id); if (rel) relations.push(rel); }
|
|
136
|
+
}
|
|
137
|
+
return { entities, relations };
|
|
138
|
+
}
|
|
139
|
+
case 'path': {
|
|
140
|
+
const fromId = db.resolveKey(step.from);
|
|
141
|
+
const toId = db.resolveKey(step.to);
|
|
142
|
+
const r = db.findPath(fromId, toId, Number.isInteger(step.max) ? step.max : 6);
|
|
143
|
+
return r.found ? { entities: r.entities, relations: r.relations } : { entities: [], relations: [], note: '两实体间无连通路径' };
|
|
144
|
+
}
|
|
145
|
+
case 'ego': {
|
|
146
|
+
const centerId = db.resolveKey(step.center);
|
|
147
|
+
const g = db.egoSubgraph(centerId, Number.isInteger(step.depth) ? step.depth : null);
|
|
148
|
+
const keep = g.entities.slice(0, EGO_MAX_NODES).map((e) => e.id);
|
|
149
|
+
const keepSet = new Set(keep);
|
|
150
|
+
return { entities: g.entities.filter((e) => keepSet.has(e.id)), relations: g.relations.filter((r) => keepSet.has(r.source_id) && keepSet.has(r.target_id)) };
|
|
151
|
+
}
|
|
152
|
+
default:
|
|
153
|
+
throw new Error('未知工具');
|
|
154
|
+
}
|
|
155
|
+
})();
|
|
156
|
+
const timeout = new Promise((resolve) => setTimeout(() => resolve({ timeout: true }), STEP_TIMEOUT_MS));
|
|
157
|
+
try {
|
|
158
|
+
const r = await Promise.race([work, timeout]);
|
|
159
|
+
if (r && r.timeout) return { tool: step.tool, args: step, status: 'timeout', count: 0, ms: Date.now() - t0 };
|
|
160
|
+
return { tool: step.tool, args: step, status: 'ok', count: r.entities.length, ms: Date.now() - t0, note: r.note, entities: r.entities, relations: r.relations };
|
|
161
|
+
} catch (e) {
|
|
162
|
+
return { tool: step.tool, args: step, status: 'error', count: 0, ms: Date.now() - t0, error: e.message };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function executePlan(plan) {
|
|
167
|
+
const entities = new Map();
|
|
168
|
+
const relations = new Map();
|
|
169
|
+
const steps = [];
|
|
170
|
+
for (const step of plan.steps.slice(0, STEP_LIMIT)) {
|
|
171
|
+
const r = await runStep(step);
|
|
172
|
+
for (const e of r.entities || []) if (!entities.has(e.id)) entities.set(e.id, e);
|
|
173
|
+
for (const rel of r.relations || []) if (!relations.has(rel.id)) relations.set(rel.id, rel);
|
|
174
|
+
steps.push(r);
|
|
175
|
+
}
|
|
176
|
+
return { steps, entities: [...entities.values()], relations: [...relations.values()] };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---------- 图结构关联发现:公共邻居共现 + 桥接节点 ----------
|
|
180
|
+
function findCoNeighbors(entityIds, topN = 5) {
|
|
181
|
+
if (entityIds.length < 2) return { pairs: [], bridges: [] };
|
|
182
|
+
const idSet = new Set(entityIds);
|
|
183
|
+
const neighbors = new Map(); // entityId -> Set(邻居id)
|
|
184
|
+
const touch = (id) => { if (!neighbors.has(id)) neighbors.set(id, new Set()); };
|
|
185
|
+
for (const r of db.listRelations()) {
|
|
186
|
+
touch(r.source_id); touch(r.target_id);
|
|
187
|
+
neighbors.get(r.source_id).add(r.target_id);
|
|
188
|
+
neighbors.get(r.target_id).add(r.source_id);
|
|
189
|
+
}
|
|
190
|
+
const pairs = [];
|
|
191
|
+
for (let i = 0; i < entityIds.length; i++) {
|
|
192
|
+
for (let j = i + 1; j < entityIds.length; j++) {
|
|
193
|
+
const a = entityIds[i], b = entityIds[j];
|
|
194
|
+
const na = neighbors.get(a) || new Set();
|
|
195
|
+
const shared = [...(neighbors.get(b) || new Set())].filter((x) => na.has(x) && x !== a && x !== b);
|
|
196
|
+
if (shared.length > 0) pairs.push({ a, b, shared: shared.sort((x, y) => x - y) });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
pairs.sort((x, y) => y.shared.length - x.shared.length);
|
|
200
|
+
const bridgeCount = new Map();
|
|
201
|
+
for (const id of entityIds) {
|
|
202
|
+
for (const nb of neighbors.get(id) || []) {
|
|
203
|
+
if (idSet.has(nb)) continue;
|
|
204
|
+
bridgeCount.set(nb, (bridgeCount.get(nb) || 0) + 1);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const bridges = [...bridgeCount.entries()].filter(([, c]) => c >= 2)
|
|
208
|
+
.sort((x, y) => y[1] - x[1]).slice(0, topN)
|
|
209
|
+
.map(([id, links]) => { const e = db.getEntity(id); return e ? { id, name: e.name, links } : null; })
|
|
210
|
+
.filter(Boolean);
|
|
211
|
+
return { pairs: pairs.slice(0, topN), bridges };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---------- 综述 ----------
|
|
215
|
+
async function synthesize(question, merged, runPlain) {
|
|
216
|
+
const ents = merged.entities.slice(0, 30).map((e) => `${e.name}#${e.id}`).join('、');
|
|
217
|
+
const rels = merged.relations.slice(0, 40).map((r) => {
|
|
218
|
+
const s = merged.entities.find((e) => e.id === r.source_id);
|
|
219
|
+
const t = merged.entities.find((e) => e.id === r.target_id);
|
|
220
|
+
return `${s ? s.name : '#' + r.source_id}#id${r.source_id} —[${r.name}]→ ${t ? t.name : '#' + r.target_id}`;
|
|
221
|
+
}).join(';');
|
|
222
|
+
const prompt = [
|
|
223
|
+
'基于以下知识图谱检索结果,用简洁中文(不超过250字)回答用户问题。',
|
|
224
|
+
'规则: 提到的实体必须来自检索结果并以「名称#id」格式标注;禁止编造结果之外的实体或关系;结构化陈述,不加寒暄。',
|
|
225
|
+
`【用户问题】${question}`,
|
|
226
|
+
`【实体】${ents || '(无)'}`,
|
|
227
|
+
`【关系】${rels || '(无)'}`,
|
|
228
|
+
].join('\n');
|
|
229
|
+
const r = await runPlain(prompt, 60000);
|
|
230
|
+
if (!r.ok) return { synthesis: null, error: r.error };
|
|
231
|
+
return { synthesis: r.text.trim() };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ---------- 降级补充:问题文本中的实体名回扫(确定性,无LLM) ----------
|
|
235
|
+
function scanByName(question, cap = 10) {
|
|
236
|
+
const q = String(question || '');
|
|
237
|
+
const hits = db.listEntities().filter((e) => e.name && e.name.length >= 2 && q.includes(e.name)).slice(0, cap);
|
|
238
|
+
if (!hits.length) return { entities: [], relations: [] };
|
|
239
|
+
const ids = new Set(hits.map((e) => e.id));
|
|
240
|
+
const relations = db.listRelations().filter((r) => ids.has(r.source_id) || ids.has(r.target_id));
|
|
241
|
+
return { entities: hits, relations };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---------- 编排入口 ----------
|
|
245
|
+
async function ask(question, opts = {}) {
|
|
246
|
+
const q = String(question || '').trim();
|
|
247
|
+
if (!q) { const e = new Error('问题不能为空'); e.status = 400; throw e; }
|
|
248
|
+
if (q.length > 500) { const e = new Error('问题过长(上限500字)'); e.status = 400; throw e; }
|
|
249
|
+
const runPlain = opts.runPlain || require('./agent').runPlain;
|
|
250
|
+
const t0 = Date.now();
|
|
251
|
+
|
|
252
|
+
const c = await compilePlan(q, runPlain);
|
|
253
|
+
const compileMs = Date.now() - t0;
|
|
254
|
+
|
|
255
|
+
let merged;
|
|
256
|
+
let degraded = c.degraded;
|
|
257
|
+
if (c.plan) {
|
|
258
|
+
merged = await executePlan(c.plan);
|
|
259
|
+
} else {
|
|
260
|
+
merged = await executePlan({ steps: [{ tool: 'keyword', q }] });
|
|
261
|
+
merged.steps[0].note = '智能编译失败,已降级关键词检索';
|
|
262
|
+
const scan = scanByName(q); // 整句关键词可能匹配不到,回扫问题中出现的实体名
|
|
263
|
+
for (const e of scan.entities) if (!merged.entities.some((x) => x.id === e.id)) merged.entities.push(e);
|
|
264
|
+
for (const r of scan.relations) if (!merged.relations.some((x) => x.id === r.id)) merged.relations.push(r);
|
|
265
|
+
}
|
|
266
|
+
const executeMs = Date.now() - t0 - compileMs;
|
|
267
|
+
|
|
268
|
+
const co = findCoNeighbors(merged.entities.map((e) => e.id));
|
|
269
|
+
const result = {
|
|
270
|
+
ok: true,
|
|
271
|
+
degraded,
|
|
272
|
+
question: q, steps: merged.steps.map(({ entities, relations, ...rest }) => rest),
|
|
273
|
+
entities: merged.entities,
|
|
274
|
+
relations: merged.relations,
|
|
275
|
+
co_neighbors: co.pairs,
|
|
276
|
+
bridges: co.bridges,
|
|
277
|
+
timings: { compile_ms: compileMs, execute_ms: executeMs, total_ms: Date.now() - t0 },
|
|
278
|
+
};
|
|
279
|
+
if (degraded && c.error) result.compile_error = c.error;
|
|
280
|
+
|
|
281
|
+
if (opts.synthesis !== false && merged.entities.length > 0) {
|
|
282
|
+
const s = await synthesize(q, merged, runPlain);
|
|
283
|
+
result.synthesis = s.synthesis;
|
|
284
|
+
if (s.error) result.synth_error = s.error;
|
|
285
|
+
result.timings.total_ms = Date.now() - t0;
|
|
286
|
+
}
|
|
287
|
+
return result;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
module.exports = { buildDigest, validatePlan, compilePlan, executePlan, findCoNeighbors, synthesize, ask, extractJson, clampCypher };
|