pi-okf-memory 0.1.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/LICENSE +21 -0
- package/README.en.md +204 -0
- package/README.md +265 -0
- package/cordis.patch.yml +6 -0
- package/docs/graph-demo.png +0 -0
- package/lib/capture.js +53 -0
- package/lib/client.js +417 -0
- package/lib/client.js.map +1 -0
- package/lib/concept.js +264 -0
- package/lib/dedupe.js +149 -0
- package/lib/graph.js +95 -0
- package/lib/index.js +381 -0
- package/lib/learning.js +185 -0
- package/lib/memory.js +135 -0
- package/lib/recall.js +47 -0
- package/lib/store.js +217 -0
- package/package.json +89 -0
- package/src/client/index.tsx +232 -0
- package/src/pi/graph-html.ts +300 -0
- package/src/pi/index.ts +383 -0
- package/src/server/capture.ts +52 -0
- package/src/server/concept.ts +302 -0
- package/src/server/dedupe.ts +164 -0
- package/src/server/dsh-tools.d.ts +11 -0
- package/src/server/graph.ts +135 -0
- package/src/server/index.ts +376 -0
- package/src/server/learning.ts +219 -0
- package/src/server/memory.ts +157 -0
- package/src/server/recall.ts +58 -0
- package/src/server/store.ts +255 -0
package/lib/concept.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
//#region src/server/concept.ts
|
|
2
|
+
/**
|
|
3
|
+
* concept.ts — OKF v0.1 概念化:frontmatter 组装、正文模板、概念 ID 规范化、frontmatter 解析。
|
|
4
|
+
* 依据 OKF v0.1:frontmatter 唯一硬要求是 type;title/description 强烈建议;tags/timestamp 可选;扩展字段允许。
|
|
5
|
+
*/
|
|
6
|
+
/** 类型词表(起步版) */
|
|
7
|
+
const TYPE_VOCAB = [
|
|
8
|
+
"Fact",
|
|
9
|
+
"Preference",
|
|
10
|
+
"Decision",
|
|
11
|
+
"Method",
|
|
12
|
+
"Insight",
|
|
13
|
+
"Idea",
|
|
14
|
+
"Lesson",
|
|
15
|
+
"TechChoice"
|
|
16
|
+
];
|
|
17
|
+
/** 归一化并校验概念类型(大小写不敏感,必须属于 TYPE_VOCAB);非法时抛错 */
|
|
18
|
+
function normalizeType(type) {
|
|
19
|
+
const t = String(type || "").trim();
|
|
20
|
+
if (!t) throw new Error("OKF 概念 type 必填");
|
|
21
|
+
const hit = TYPE_VOCAB.find((v) => v.toLowerCase() === t.toLowerCase());
|
|
22
|
+
if (!hit) throw new Error(`非法 type:「${type}」;可选:${TYPE_VOCAB.join("/")}`);
|
|
23
|
+
return hit;
|
|
24
|
+
}
|
|
25
|
+
/** 规范化概念 ID:保留中文/字母数字,其余转连字符(Windows 安全字符集) */
|
|
26
|
+
function slugify(input) {
|
|
27
|
+
return String(input ?? "").trim().toLowerCase().replace(/[\s_]+/g, "-").replace(/[^\p{L}\p{N}\-]/gu, "").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
28
|
+
}
|
|
29
|
+
/** YAML 标量转义 */
|
|
30
|
+
function yamlScalar(v) {
|
|
31
|
+
if (typeof v === "string") {
|
|
32
|
+
if (/^[\p{L}\p{N}\s.,\-_/:()()%¥¥+*#@!?'"=<>\[\]{}|&^~`\\;]*$/u.test(v) && !/^[\s\-?:]/.test(v) && !v.includes(": ")) return v;
|
|
33
|
+
return JSON.stringify(v);
|
|
34
|
+
}
|
|
35
|
+
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
|
36
|
+
return JSON.stringify(v);
|
|
37
|
+
}
|
|
38
|
+
function yamlTags(tags) {
|
|
39
|
+
if (!Array.isArray(tags) || tags.length === 0) return "tags: []";
|
|
40
|
+
return `tags: [${tags.map((t) => {
|
|
41
|
+
const s = String(t);
|
|
42
|
+
return /[,()[\]{}"'#:]/.test(s) ? JSON.stringify(s) : yamlScalar(s);
|
|
43
|
+
}).join(", ")}]`;
|
|
44
|
+
}
|
|
45
|
+
/** 生成 frontmatter(固定顺序,稳定可比较) */
|
|
46
|
+
function buildFrontmatter(meta) {
|
|
47
|
+
const lines = ["---"];
|
|
48
|
+
const order = [
|
|
49
|
+
"type",
|
|
50
|
+
"title",
|
|
51
|
+
"description",
|
|
52
|
+
"resource",
|
|
53
|
+
"tags",
|
|
54
|
+
"timestamp",
|
|
55
|
+
"source"
|
|
56
|
+
];
|
|
57
|
+
for (const key of order) {
|
|
58
|
+
const v = meta[key];
|
|
59
|
+
if (v === void 0 || v === null || v === "") continue;
|
|
60
|
+
if (key === "tags") lines.push(yamlTags(v));
|
|
61
|
+
else lines.push(`${key}: ${yamlScalar(v)}`);
|
|
62
|
+
}
|
|
63
|
+
for (const [k, v] of Object.entries(meta)) {
|
|
64
|
+
if (order.includes(k)) continue;
|
|
65
|
+
lines.push(`${k}: ${yamlScalar(v)}`);
|
|
66
|
+
}
|
|
67
|
+
lines.push("---");
|
|
68
|
+
return lines.join("\n");
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* 生成概念文档。
|
|
72
|
+
*/
|
|
73
|
+
function buildConcept(meta, body) {
|
|
74
|
+
if (!String(meta.type || "").trim()) throw new Error("OKF 概念必须包含非空 type");
|
|
75
|
+
const fm = buildFrontmatter(meta);
|
|
76
|
+
const b = String(body || "").trim();
|
|
77
|
+
return b ? `${fm}\n\n${b}\n` : `${fm}\n`;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* 决策/结论三段式模板(继承用户约定:数据/分析/结论)。
|
|
81
|
+
*/
|
|
82
|
+
function buildDecisionBody(parts) {
|
|
83
|
+
const { data, analysis, conclusion } = parts || {};
|
|
84
|
+
const out = [];
|
|
85
|
+
if (data) out.push(`# 数据\n\n${data.trim()}`);
|
|
86
|
+
if (analysis) out.push(`# 分析\n\n${analysis.trim()}`);
|
|
87
|
+
if (conclusion) out.push(`# 结论\n\n${conclusion.trim()}`);
|
|
88
|
+
if (out.length === 0) throw new Error("Decision/Insight 正文需至少包含 conclusion");
|
|
89
|
+
return out.join("\n\n");
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* 技术选型正文模板(TechChoice):Options 候选表 + Active 当前使用。
|
|
93
|
+
*/
|
|
94
|
+
function buildTechChoiceBody(spec) {
|
|
95
|
+
const opts = spec.options || [];
|
|
96
|
+
if (opts.length === 0) throw new Error("TechChoice 至少需要一个候选");
|
|
97
|
+
const rows = opts.map((o) => `| ${yamlScalar(o.name)} | ${yamlScalar(o.desc || "")} | ${yamlScalar(o.config || "")} | ${yamlScalar(o.status || "candidate")} |`).join("\n");
|
|
98
|
+
const out = [];
|
|
99
|
+
out.push(`## Options\n\n| 候选 | 说明 | 配置要点 | 状态 |\n|---|---|---|---|\n${rows}`);
|
|
100
|
+
if (spec.active) out.push(`## Active\n\n- 当前使用:${spec.active}`);
|
|
101
|
+
if (spec.notes) out.push(`## 相关\n\n${spec.notes.trim()}`);
|
|
102
|
+
return out.join("\n\n");
|
|
103
|
+
}
|
|
104
|
+
/** 解析 frontmatter(容错:解析失败返回 {meta:null, body:原文};支持引号/多行块/flow 数组) */
|
|
105
|
+
function parseFrontmatter(md) {
|
|
106
|
+
const text = String(md || "");
|
|
107
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text);
|
|
108
|
+
if (!m) return {
|
|
109
|
+
meta: null,
|
|
110
|
+
body: text
|
|
111
|
+
};
|
|
112
|
+
const [, yaml, body] = m;
|
|
113
|
+
const meta = {};
|
|
114
|
+
const lines = yaml.split(/\r?\n/);
|
|
115
|
+
let i = 0;
|
|
116
|
+
while (i < lines.length) {
|
|
117
|
+
const line = lines[i];
|
|
118
|
+
const trimmed = line.trim();
|
|
119
|
+
if (!trimmed || trimmed.startsWith("#")) {
|
|
120
|
+
i++;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const idx = line.indexOf(":");
|
|
124
|
+
if (idx <= 0) {
|
|
125
|
+
i++;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const key = line.slice(0, idx).trim().replace(/^['"]|['"]$/g, "");
|
|
129
|
+
let val = line.slice(idx + 1).trim();
|
|
130
|
+
if (val === "|" || val === ">" || val === "|-" || val === ">-") {
|
|
131
|
+
const block = [];
|
|
132
|
+
i++;
|
|
133
|
+
while (i < lines.length && /^\s+/.test(lines[i])) {
|
|
134
|
+
block.push(lines[i].replace(/^[ \t]+/, ""));
|
|
135
|
+
i++;
|
|
136
|
+
}
|
|
137
|
+
meta[key] = block.join("\n");
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (val.startsWith("[") && val.endsWith("]")) {
|
|
141
|
+
meta[key] = splitFlowArray(val.slice(1, -1));
|
|
142
|
+
i++;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
meta[key] = unquoteScalar(val);
|
|
146
|
+
i++;
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
meta,
|
|
150
|
+
body: body || ""
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
/** flow 数组拆分:引号内的逗号不拆,去引号/转义(闭合引号消费但不进内容;反斜杠转义下一个字符) */
|
|
154
|
+
function splitFlowArray(s) {
|
|
155
|
+
const out = [];
|
|
156
|
+
let cur = "";
|
|
157
|
+
let q = null;
|
|
158
|
+
let esc = false;
|
|
159
|
+
for (const ch of s) if (esc) {
|
|
160
|
+
cur += ch;
|
|
161
|
+
esc = false;
|
|
162
|
+
} else if (ch === "\\") esc = true;
|
|
163
|
+
else if (q) {
|
|
164
|
+
if (ch === q) q = null;
|
|
165
|
+
else cur += ch;
|
|
166
|
+
} else if (ch === "\"" || ch === "'") q = ch;
|
|
167
|
+
else if (ch === ",") {
|
|
168
|
+
if (cur.trim()) out.push(unquoteScalar(cur));
|
|
169
|
+
cur = "";
|
|
170
|
+
} else cur += ch;
|
|
171
|
+
if (cur.trim()) out.push(unquoteScalar(cur));
|
|
172
|
+
return out.filter(Boolean);
|
|
173
|
+
}
|
|
174
|
+
/** 去掉标量两端匹配的引号并还原常见转义 */
|
|
175
|
+
function unquoteScalar(v) {
|
|
176
|
+
const s = String(v || "").trim();
|
|
177
|
+
if (s.startsWith("\"") && s.endsWith("\"") || s.startsWith("'") && s.endsWith("'")) return s.slice(1, -1).replace(/\\"/g, "\"").replace(/\\'/g, "'").replace(/\\n/g, "\n");
|
|
178
|
+
return s;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* OKF v0.1 符合性校验(三条硬要求 + 建议字段)。
|
|
182
|
+
*/
|
|
183
|
+
function validateConcept(md) {
|
|
184
|
+
const errors = [];
|
|
185
|
+
const warnings = [];
|
|
186
|
+
const { meta } = parseFrontmatter(md);
|
|
187
|
+
if (!meta) {
|
|
188
|
+
errors.push("缺少可解析的 YAML frontmatter");
|
|
189
|
+
return {
|
|
190
|
+
ok: false,
|
|
191
|
+
errors,
|
|
192
|
+
warnings
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
if (!String(meta.type || "").trim()) errors.push("type 字段为空");
|
|
196
|
+
if (!meta.title) warnings.push("缺 title(将用文件名推导)");
|
|
197
|
+
if (!meta.description) warnings.push("缺 description(索引/搜索靠它)");
|
|
198
|
+
if (!meta.timestamp) warnings.push("缺 timestamp");
|
|
199
|
+
return {
|
|
200
|
+
ok: errors.length === 0,
|
|
201
|
+
errors,
|
|
202
|
+
warnings
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* 按顶层小节合并两份概念正文(防止更新时无限追加 "## 补充(日期)"):
|
|
207
|
+
* - 相同小节标题(如 # 数据 / ## Options)→ 新内容覆盖旧小节,保留原位置
|
|
208
|
+
* - 新小节 → 追加到末尾
|
|
209
|
+
* - 无标题引言 → 仅当旧文没有引言时才补入
|
|
210
|
+
*/
|
|
211
|
+
function mergeConceptBodies(existing, incoming) {
|
|
212
|
+
const ex = splitSections(existing);
|
|
213
|
+
const inc = splitSections(incoming);
|
|
214
|
+
const byKey = new Map(ex.map((s) => [s.key, s]));
|
|
215
|
+
for (const s of inc) if (s.key === null) {
|
|
216
|
+
if (!byKey.has(null)) byKey.set(null, s);
|
|
217
|
+
} else byKey.set(s.key, s);
|
|
218
|
+
const out = [];
|
|
219
|
+
const used = /* @__PURE__ */ new Set();
|
|
220
|
+
for (const s of ex) {
|
|
221
|
+
const hit = byKey.get(s.key);
|
|
222
|
+
if (hit) {
|
|
223
|
+
out.push(hit);
|
|
224
|
+
used.add(s.key);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
for (const s of inc) if (!used.has(s.key)) {
|
|
228
|
+
out.push(s);
|
|
229
|
+
used.add(s.key);
|
|
230
|
+
}
|
|
231
|
+
return out.map(renderSection).filter(Boolean).join("\n\n");
|
|
232
|
+
}
|
|
233
|
+
/** 按 #/##/### 顶层标题切分成小节(含无标题引言小节 key=null) */
|
|
234
|
+
function splitSections(body) {
|
|
235
|
+
const sections = [];
|
|
236
|
+
let cur = null;
|
|
237
|
+
for (const line of String(body || "").split("\n")) {
|
|
238
|
+
const m = /^(#{1,3})\s+(.*)$/.exec(line);
|
|
239
|
+
if (m) {
|
|
240
|
+
cur = {
|
|
241
|
+
key: `${m[1]} ${m[2]}`.replace(/\s+/g, " ").trim(),
|
|
242
|
+
content: ""
|
|
243
|
+
};
|
|
244
|
+
sections.push(cur);
|
|
245
|
+
} else {
|
|
246
|
+
if (!cur) {
|
|
247
|
+
cur = {
|
|
248
|
+
key: null,
|
|
249
|
+
content: ""
|
|
250
|
+
};
|
|
251
|
+
sections.push(cur);
|
|
252
|
+
}
|
|
253
|
+
cur.content += line + "\n";
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return sections;
|
|
257
|
+
}
|
|
258
|
+
function renderSection(s) {
|
|
259
|
+
if (s.key === null) return s.content.trim();
|
|
260
|
+
const body = s.content.trim();
|
|
261
|
+
return body ? `${s.key}\n\n${body}` : s.key;
|
|
262
|
+
}
|
|
263
|
+
//#endregion
|
|
264
|
+
export { TYPE_VOCAB, buildConcept, buildDecisionBody, buildFrontmatter, buildTechChoiceBody, mergeConceptBodies, normalizeType, parseFrontmatter, slugify, validateConcept };
|
package/lib/dedupe.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { parseFrontmatter } from "./concept.js";
|
|
2
|
+
import { readConcept, scanBundle } from "./store.js";
|
|
3
|
+
import { promises } from "node:fs";
|
|
4
|
+
//#region src/server/dedupe.ts
|
|
5
|
+
/**
|
|
6
|
+
* dedupe.ts — 去重与互补决策(对应"互补而非复制"原则)。
|
|
7
|
+
* 写前必查:命中且相同 → skip;命中但互补 → merge 建议 + 交叉链接;未命中 → create。
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* 词项化:空白分词 + CJK 二元组。
|
|
11
|
+
* 纯子串匹配对中文短语(如「查询数据库」)几乎必然落空,而库中概念标题/描述往往写成
|
|
12
|
+
* 「查询鼎赞…数据…」。补重叠二元组后,「查询」「数据」等子片段也能命中,提升中文召回。
|
|
13
|
+
* 保留原始整串词项以维持精确短语加分。
|
|
14
|
+
*/
|
|
15
|
+
function tokenizeQuery(q) {
|
|
16
|
+
const terms = /* @__PURE__ */ new Set();
|
|
17
|
+
const raw = String(q || "").split(/\s+/).filter(Boolean);
|
|
18
|
+
for (const token of raw) {
|
|
19
|
+
terms.add(token);
|
|
20
|
+
if (/[\u4e00-\u9fff]/.test(token) && token.length >= 3) for (let i = 0; i < token.length - 1; i++) terms.add(token.slice(i, i + 2));
|
|
21
|
+
}
|
|
22
|
+
return [...terms];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* 全库检索:按关键词匹配 title/description/tags/type(正文做二级加分)。
|
|
26
|
+
*/
|
|
27
|
+
async function search(root, query, opts = {}) {
|
|
28
|
+
const { type, tags, limit = 20 } = opts;
|
|
29
|
+
const q = String(query || "").trim().toLowerCase();
|
|
30
|
+
const qTerms = tokenizeQuery(q);
|
|
31
|
+
const concepts = await scanBundle(root);
|
|
32
|
+
const hits = [];
|
|
33
|
+
for (const c of concepts) {
|
|
34
|
+
let text;
|
|
35
|
+
try {
|
|
36
|
+
text = await promises.readFile(c.filePath, "utf8");
|
|
37
|
+
} catch {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const { meta } = parseFrontmatter(text);
|
|
41
|
+
if (!meta || !meta.type) continue;
|
|
42
|
+
if (type && String(meta.type).toLowerCase() !== String(type).toLowerCase()) continue;
|
|
43
|
+
if (tags && tags.length > 0) {
|
|
44
|
+
const mt = Array.isArray(meta.tags) ? meta.tags.map(String) : [];
|
|
45
|
+
if (!tags.every((t) => mt.some((x) => x.toLowerCase().includes(String(t).toLowerCase())))) continue;
|
|
46
|
+
}
|
|
47
|
+
let score = 0;
|
|
48
|
+
if (qTerms.length > 0) {
|
|
49
|
+
const hay = [
|
|
50
|
+
meta.title,
|
|
51
|
+
meta.description,
|
|
52
|
+
Array.isArray(meta.tags) ? meta.tags.join(" ") : "",
|
|
53
|
+
meta.type
|
|
54
|
+
].filter(Boolean).join(" ").toLowerCase();
|
|
55
|
+
let matched = 0;
|
|
56
|
+
for (const t of qTerms) if (hay.includes(t)) matched++;
|
|
57
|
+
else if (text.toLowerCase().includes(t)) {
|
|
58
|
+
score += .3;
|
|
59
|
+
matched++;
|
|
60
|
+
}
|
|
61
|
+
if (matched === 0) continue;
|
|
62
|
+
score += matched / qTerms.length * 2;
|
|
63
|
+
if (hay.includes(q)) score += 3;
|
|
64
|
+
}
|
|
65
|
+
score += (Array.isArray(meta.tags) ? meta.tags.length : 0) * .1;
|
|
66
|
+
hits.push({
|
|
67
|
+
conceptId: c.conceptId,
|
|
68
|
+
title: meta.title || c.conceptId,
|
|
69
|
+
description: meta.description || "",
|
|
70
|
+
type: meta.type,
|
|
71
|
+
tags: Array.isArray(meta.tags) ? meta.tags : [],
|
|
72
|
+
score
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
hits.sort((a, b) => b.score - a.score);
|
|
76
|
+
return hits.slice(0, limit);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* 按标题找相似概念(去重主查:精确相等或互相包含)。
|
|
80
|
+
* 包含判定要求较短一方 ≥ MIN_CONTAIN_LEN(3 字符),否则「前端」这种短词
|
|
81
|
+
* 会误伤「前端方案」,挡住正常新建。
|
|
82
|
+
*/
|
|
83
|
+
const MIN_CONTAIN_LEN = 3;
|
|
84
|
+
async function findSimilarByTitle(root, title, type) {
|
|
85
|
+
const t = String(title || "").trim().toLowerCase();
|
|
86
|
+
if (!t) return [];
|
|
87
|
+
const concepts = await scanBundle(root);
|
|
88
|
+
const out = [];
|
|
89
|
+
for (const c of concepts) {
|
|
90
|
+
let text;
|
|
91
|
+
try {
|
|
92
|
+
text = await promises.readFile(c.filePath, "utf8");
|
|
93
|
+
} catch {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const { meta } = parseFrontmatter(text);
|
|
97
|
+
if (!meta || !meta.title) continue;
|
|
98
|
+
if (type && String(meta.type).toLowerCase() !== String(type).toLowerCase()) continue;
|
|
99
|
+
const ct = String(meta.title).trim().toLowerCase();
|
|
100
|
+
if (ct === t) out.push({
|
|
101
|
+
conceptId: c.conceptId,
|
|
102
|
+
title: meta.title,
|
|
103
|
+
type: meta.type,
|
|
104
|
+
similarity: 1
|
|
105
|
+
});
|
|
106
|
+
else if ((ct.includes(t) || t.includes(ct)) && Math.min(ct.length, t.length) >= MIN_CONTAIN_LEN) out.push({
|
|
107
|
+
conceptId: c.conceptId,
|
|
108
|
+
title: meta.title,
|
|
109
|
+
type: meta.type,
|
|
110
|
+
similarity: .6
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
return out.sort((a, b) => b.similarity - a.similarity);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* 去重决策。
|
|
117
|
+
*/
|
|
118
|
+
async function decide(root, { title, type, body }) {
|
|
119
|
+
const similar = await findSimilarByTitle(root, title, type);
|
|
120
|
+
if (similar.length > 0) {
|
|
121
|
+
const top = similar[0];
|
|
122
|
+
if (top.similarity >= 1) {
|
|
123
|
+
const existing = await readConcept(root, top.conceptId);
|
|
124
|
+
const bodyLen = String(body || "").trim().length;
|
|
125
|
+
const existingLen = String(existing.body || "").trim().length;
|
|
126
|
+
if (bodyLen > existingLen * .7) return {
|
|
127
|
+
action: "update",
|
|
128
|
+
conceptId: top.conceptId,
|
|
129
|
+
reason: `标题相同且新正文更完整(${bodyLen}字 vs 已有${existingLen}字),更新已有概念`
|
|
130
|
+
};
|
|
131
|
+
return {
|
|
132
|
+
action: "skip",
|
|
133
|
+
conceptId: top.conceptId,
|
|
134
|
+
reason: "标题相同的概念已存在,内容未明显增加,跳过写入"
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
action: "update",
|
|
139
|
+
conceptId: top.conceptId,
|
|
140
|
+
reason: `找到相近概念[${top.title}](similarity ${top.similarity}),建议互补合并或互建交叉链接`
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
action: "create",
|
|
145
|
+
reason: "未命中已有概念,新建"
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
//#endregion
|
|
149
|
+
export { decide, findSimilarByTitle, search };
|
package/lib/graph.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { parseFrontmatter } from "./concept.js";
|
|
2
|
+
import { scanBundle } from "./store.js";
|
|
3
|
+
import { loadMeta } from "./learning.js";
|
|
4
|
+
import { promises } from "node:fs";
|
|
5
|
+
//#region src/server/graph.ts
|
|
6
|
+
/**
|
|
7
|
+
* graph.ts — 记忆图谱数据提取:把 OKF 记忆库转成图谱 JSON(nodes/edges/timeline)。
|
|
8
|
+
* 供 okf_graph 工具/服务消费,契约与可视化前端一致,可被 dshfind 等复用。
|
|
9
|
+
* 纯业务逻辑,不依赖 dsh ctx,便于单测。
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* 提取记忆图谱数据。
|
|
13
|
+
*/
|
|
14
|
+
async function buildGraph(root, _opts = {}) {
|
|
15
|
+
const concepts = await scanBundle(root);
|
|
16
|
+
const weights = await loadMeta(root);
|
|
17
|
+
const meta = {
|
|
18
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
19
|
+
root,
|
|
20
|
+
totalConcepts: concepts.length
|
|
21
|
+
};
|
|
22
|
+
const nodes = [];
|
|
23
|
+
const byId = /* @__PURE__ */ new Map();
|
|
24
|
+
for (const c of concepts) {
|
|
25
|
+
let text;
|
|
26
|
+
try {
|
|
27
|
+
text = await readText(c.filePath);
|
|
28
|
+
} catch {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const { meta: fm } = parseFrontmatter(text);
|
|
32
|
+
if (!fm) continue;
|
|
33
|
+
const w = weights.entries[c.conceptId];
|
|
34
|
+
nodes.push({
|
|
35
|
+
id: c.conceptId,
|
|
36
|
+
title: fm.title || c.conceptId.replace(/^[^/]+\//, ""),
|
|
37
|
+
type: fm.type || "Other",
|
|
38
|
+
tags: Array.isArray(fm.tags) ? fm.tags : [],
|
|
39
|
+
description: fm.description || "",
|
|
40
|
+
weight: w ? +w.weight.toFixed(2) : 1,
|
|
41
|
+
state: w?.state || "active",
|
|
42
|
+
lastAccessed: w?.lastAccessed || null
|
|
43
|
+
});
|
|
44
|
+
byId.set(c.conceptId, c.conceptId);
|
|
45
|
+
}
|
|
46
|
+
const edges = [];
|
|
47
|
+
const seen = /* @__PURE__ */ new Set();
|
|
48
|
+
for (const c of concepts) {
|
|
49
|
+
let text;
|
|
50
|
+
try {
|
|
51
|
+
text = await readText(c.filePath);
|
|
52
|
+
} catch {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const { body } = parseFrontmatter(text);
|
|
56
|
+
const re = /\[([^\]]+)\]\(\/([^)]+\.md)\)/g;
|
|
57
|
+
let m;
|
|
58
|
+
while ((m = re.exec(body || "")) !== null) {
|
|
59
|
+
let targetId = m[2].replace(/^\/+/, "").replace(/\.md$/, "");
|
|
60
|
+
if (!byId.has(targetId)) {
|
|
61
|
+
const k = Object.keys(byId).find((x) => x.toLowerCase() === targetId.toLowerCase());
|
|
62
|
+
if (k) targetId = k;
|
|
63
|
+
}
|
|
64
|
+
if (targetId && targetId !== c.conceptId) {
|
|
65
|
+
const k = [c.conceptId, targetId].sort().join("||");
|
|
66
|
+
if (!seen.has(k)) {
|
|
67
|
+
seen.add(k);
|
|
68
|
+
edges.push({
|
|
69
|
+
source: c.conceptId,
|
|
70
|
+
target: targetId,
|
|
71
|
+
text: m[1]
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
meta,
|
|
79
|
+
nodes,
|
|
80
|
+
edges,
|
|
81
|
+
timeline: Object.entries(weights.entries || {}).map(([id, e]) => ({
|
|
82
|
+
id,
|
|
83
|
+
weight: +e.weight.toFixed(2),
|
|
84
|
+
state: e.state || "active",
|
|
85
|
+
lastAccessed: e.lastAccessed || null,
|
|
86
|
+
accessCount: e.accessCount || 0
|
|
87
|
+
}))
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/** 读文件 */
|
|
91
|
+
async function readText(filePath) {
|
|
92
|
+
return promises.readFile(filePath, "utf8");
|
|
93
|
+
}
|
|
94
|
+
//#endregion
|
|
95
|
+
export { buildGraph };
|