dsh-mastery-loop 0.0.0-stage → 0.2.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/CHANGELOG.md +49 -0
- package/LICENSE +21 -0
- package/README.md +81 -2
- package/cordis.patch.yml +10 -0
- package/lib/index.js +9 -0
- package/package.json +47 -4
- package/skills/mastery-loop/SKILL.md +201 -0
- package/skills/mastery-loop/exam-templates.md +102 -0
- package/skills/mastery-loop/scripts/_lib.mjs +408 -0
- package/skills/mastery-loop/scripts/aggregate.mjs +155 -0
- package/skills/mastery-loop/scripts/graph.mjs +448 -0
- package/skills/mastery-loop/scripts/ledger.mjs +223 -0
- package/skills/mastery-loop/scripts/rewrite-links.mjs +81 -0
- package/skills/mastery-loop/scripts/scan.mjs +276 -0
- package/skills/mastery-loop/scripts/validate.mjs +181 -0
- package/skills/mastery-loop/templates.md +255 -0
- package/skills/mastery-loop-setup/SKILL.md +161 -0
- package/skills/mastery-loop-setup/seeds/config.json +39 -0
- package/skills/mastery-loop-setup/seeds/error-taxonomy.json +4 -0
- package/skills/mastery-loop-setup/seeds/errors.jsonl +1 -0
- package/skills/mastery-loop-setup/seeds/graph.json +13 -0
- package/skills/mastery-loop-setup/seeds/ledger.jsonl +2 -0
- package/skills/mastery-loop-setup/seeds/moc.md +15 -0
- package/skills/mastery-loop-setup/seeds/note.md +33 -0
- package/skills/mastery-loop-setup/seeds/paper-format.json +15 -0
- package/skills/mastery-loop-setup/seeds/state.jsonl +2 -0
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// mastery-loop 关系图工具
|
|
3
|
+
//
|
|
4
|
+
// 用途:本脚本是关系图的读写器 —— 管 sys/graph.json(只存边;节点就是 kb/knowledge/ 里的笔记本身),
|
|
5
|
+
// 做六项校验,并把两个小节(## 前置知识 / ## 后置知识)渲染回笔记。
|
|
6
|
+
// 契约正文:../templates.md(第二、三、七节)
|
|
7
|
+
//
|
|
8
|
+
// 用法:
|
|
9
|
+
// node graph.mjs check <工作区根>
|
|
10
|
+
// node graph.mjs backlinks <工作区根> [--write]
|
|
11
|
+
// node graph.mjs sort <工作区根> --note <笔记名> [--folder <已注册 folder 名>] [--limit N]
|
|
12
|
+
//
|
|
13
|
+
// 硬要求:**永远不要崩溃** —— 缺文件、坏 JSON、目录不存在都只报错并以退出码结束。
|
|
14
|
+
|
|
15
|
+
import { readFileSync, writeFileSync, existsSync, statSync, readdirSync } from 'node:fs';
|
|
16
|
+
import { join, resolve, relative, basename, dirname, sep } from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
|
|
19
|
+
const MIYO_ENDPOINT = 'http://127.0.0.1:8742/v0/search/related';
|
|
20
|
+
const GRAPH_REL = join('sys', 'graph.json');
|
|
21
|
+
const NOTES_REL = join('kb', 'knowledge');
|
|
22
|
+
|
|
23
|
+
// ---------- 基础 ----------
|
|
24
|
+
|
|
25
|
+
function readIfPossible(path) {
|
|
26
|
+
try {
|
|
27
|
+
return readFileSync(path, 'utf8');
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 笔记名:去掉路径、去掉 .md、去掉 |别名、去掉 [[ ]] */
|
|
34
|
+
function noteName(raw) {
|
|
35
|
+
let s = String(raw || '').trim();
|
|
36
|
+
s = s.replace(/^\[\[/, '').replace(/\]\]$/, '');
|
|
37
|
+
const pipe = s.indexOf('|');
|
|
38
|
+
if (pipe !== -1) s = s.slice(0, pipe);
|
|
39
|
+
s = s.replace(/\.md$/i, '');
|
|
40
|
+
const parts = s.split(/[\\/]/);
|
|
41
|
+
return parts[parts.length - 1].trim();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function notesDir(root) {
|
|
45
|
+
const d = join(root, NOTES_REL);
|
|
46
|
+
return existsSync(d) && statSync(d).isDirectory() ? d : join(root, 'kb', 'knowledge');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** name -> 绝对路径 */
|
|
50
|
+
function collectNotes(root) {
|
|
51
|
+
const map = new Map();
|
|
52
|
+
const walk = (d, depth) => {
|
|
53
|
+
if (depth > 3) return;
|
|
54
|
+
let entries = [];
|
|
55
|
+
try {
|
|
56
|
+
entries = readdirSync(d, { withFileTypes: true });
|
|
57
|
+
} catch {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
for (const e of entries) {
|
|
61
|
+
if (e.name.startsWith('.')) continue;
|
|
62
|
+
const p = join(d, e.name);
|
|
63
|
+
if (e.isDirectory()) walk(p, depth + 1);
|
|
64
|
+
else if (/\.md$/i.test(e.name)) map.set(e.name.replace(/\.md$/i, ''), p);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
walk(notesDir(root), 0);
|
|
68
|
+
return map;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function loadGraph(root) {
|
|
72
|
+
const path = join(root, GRAPH_REL);
|
|
73
|
+
if (!existsSync(path)) return { ok: false, reason: `没有图文件:${path}`, path };
|
|
74
|
+
const text = readIfPossible(path);
|
|
75
|
+
if (text === null) return { ok: false, reason: `图文件读不了:${path}`, path };
|
|
76
|
+
let data;
|
|
77
|
+
try {
|
|
78
|
+
data = JSON.parse(text);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
return { ok: false, reason: `图文件不是合法 JSON:${err.message}`, path };
|
|
81
|
+
}
|
|
82
|
+
if (!data || typeof data !== 'object') return { ok: false, reason: `图文件不是对象:${path}`, path };
|
|
83
|
+
const edges = Array.isArray(data.edges) ? data.edges : [];
|
|
84
|
+
return { ok: true, path, data, edges };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------- 正文小节 ----------
|
|
88
|
+
|
|
89
|
+
function sectionRange(text, heading) {
|
|
90
|
+
const re = new RegExp('^##\\s*' + heading + '\\s*$', 'm');
|
|
91
|
+
const m = re.exec(text);
|
|
92
|
+
if (!m) return null;
|
|
93
|
+
const after = text.indexOf('\n', m.index);
|
|
94
|
+
if (after === -1) return { headStart: m.index, bodyStart: text.length, end: text.length };
|
|
95
|
+
const rest = text.slice(after + 1);
|
|
96
|
+
const nextRel = rest.search(/^##[ \t]/m);
|
|
97
|
+
const end = nextRel === -1 ? text.length : after + 1 + nextRel;
|
|
98
|
+
return { headStart: m.index, bodyStart: after + 1, end };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function sectionBullets(text, heading) {
|
|
102
|
+
const r = sectionRange(text, heading);
|
|
103
|
+
if (!r) return null;
|
|
104
|
+
const body = text.slice(r.bodyStart, r.end);
|
|
105
|
+
const names = [];
|
|
106
|
+
for (const line of body.split(/\r?\n/)) {
|
|
107
|
+
const mm = line.match(/^\s*[-*]\s+\[\[([^\]]+?)\]\]/);
|
|
108
|
+
if (mm) names.push(noteName(mm[1]));
|
|
109
|
+
}
|
|
110
|
+
return names;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function replaceSection(text, heading, bullets) {
|
|
114
|
+
const r = sectionRange(text, heading);
|
|
115
|
+
if (!r) return { text, found: false };
|
|
116
|
+
const before = text.slice(0, r.headStart);
|
|
117
|
+
const after = text.slice(r.end);
|
|
118
|
+
const body = bullets.length ? bullets.join('\n') : '_(暂无)_';
|
|
119
|
+
return { text: `${before}## ${heading}\n\n${body}\n\n${after}`, found: true };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---------- check ----------
|
|
123
|
+
|
|
124
|
+
export function check(root) {
|
|
125
|
+
const problems = [];
|
|
126
|
+
const warnings = [];
|
|
127
|
+
|
|
128
|
+
if (!existsSync(root) || !statSync(root).isDirectory()) {
|
|
129
|
+
console.log(`❌ 工作区根不存在:${root}`);
|
|
130
|
+
return 1;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const g = loadGraph(root);
|
|
134
|
+
if (!g.ok) {
|
|
135
|
+
console.log(`⚠️ ${g.reason}`);
|
|
136
|
+
console.log(' 没有图就没有关系可查 —— 契约见 templates.md 第三、七节。');
|
|
137
|
+
return 1;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const notes = collectNotes(root);
|
|
141
|
+
const hasNote = (name) => notes.has(name);
|
|
142
|
+
|
|
143
|
+
const fwd = new Map(); // from -> Set(to)
|
|
144
|
+
const bwd = new Map(); // to -> Set(from)
|
|
145
|
+
const addTo = (m, k, v) => {
|
|
146
|
+
if (!m.has(k)) m.set(k, new Set());
|
|
147
|
+
m.get(k).add(v);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
g.edges.forEach((e, i) => {
|
|
151
|
+
const at = `edges[${i}]`;
|
|
152
|
+
const from = noteName(e && e.from);
|
|
153
|
+
const to = noteName(e && e.to);
|
|
154
|
+
const reason = e && typeof e.reason === 'string' ? e.reason.trim() : '';
|
|
155
|
+
|
|
156
|
+
if (!from || !to) {
|
|
157
|
+
problems.push(`${at}:缺 from 或 to`);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (from === to) problems.push(`无自链 ✖ ${at}:${from} → ${to}`);
|
|
161
|
+
if (!hasNote(from)) problems.push(`无悬空 ✖ ${at}:找不到笔记「${from}」`);
|
|
162
|
+
if (!hasNote(to)) problems.push(`无悬空 ✖ ${at}:找不到笔记「${to}」`);
|
|
163
|
+
if (!reason) problems.push(`${at}:缺 reason(每条边必须 ≤30 字理由)`);
|
|
164
|
+
else if ([...reason].length > 30) problems.push(`${at}:reason ${[...reason].length} 字 > 30(${reason})`);
|
|
165
|
+
|
|
166
|
+
addTo(fwd, from, to);
|
|
167
|
+
addTo(bwd, to, from);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// 参与图的笔记:只见于图中的那些才强校验(图允许分批,图外的笔记不报缺小节)
|
|
171
|
+
const involved = new Set([...fwd.keys(), ...bwd.keys()]);
|
|
172
|
+
|
|
173
|
+
for (const [name, path] of notes) {
|
|
174
|
+
const text = readIfPossible(path);
|
|
175
|
+
if (text === null) {
|
|
176
|
+
warnings.push(`读不了:${path}`);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const pre = sectionBullets(text, '前置知识');
|
|
180
|
+
const post = sectionBullets(text, '后置知识');
|
|
181
|
+
|
|
182
|
+
if (involved.has(name)) {
|
|
183
|
+
if (pre === null) warnings.push(`「${name}」缺 \`## 前置知识\` 小节`);
|
|
184
|
+
if (post === null) warnings.push(`「${name}」缺 \`## 后置知识\` 小节`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
for (const [label, list] of [['前置知识', pre], ['后置知识', post]]) {
|
|
188
|
+
if (!list) continue;
|
|
189
|
+
const seen = new Set();
|
|
190
|
+
for (const n of list) {
|
|
191
|
+
if (seen.has(n)) problems.push(`同段无重复 ✖ 「${name}」\`## ${label}\` 里「${n}」出现两次`);
|
|
192
|
+
seen.add(n);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (pre && post) {
|
|
197
|
+
const both = [...new Set(pre.filter((n) => post.includes(n)))];
|
|
198
|
+
if (both.length) problems.push(`前后置互斥 ✖ 「${name}」同时出现在前置与后置:${both.join('、')}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (pre) {
|
|
202
|
+
for (const n of new Set(pre)) {
|
|
203
|
+
if (!(bwd.get(name) || new Set()).has(n)) {
|
|
204
|
+
problems.push(`双向自洽 ✖ 「${name}」的 \`## 前置知识\` 里有「${n}」,但图里没有这条边`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (post) {
|
|
209
|
+
for (const n of new Set(post)) {
|
|
210
|
+
if (!(fwd.get(name) || new Set()).has(n)) {
|
|
211
|
+
problems.push(`双向自洽 ✖ 「${name}」的 \`## 后置知识\` 里有「${n}」,但图里没有这条边`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// 图里有、正文里没有
|
|
218
|
+
for (const [from, tos] of fwd) {
|
|
219
|
+
for (const to of tos) {
|
|
220
|
+
const t1 = notes.has(from) ? readIfPossible(notes.get(from)) : null;
|
|
221
|
+
if (t1 !== null) {
|
|
222
|
+
const post = sectionBullets(t1, '后置知识') || [];
|
|
223
|
+
if (!post.includes(to)) problems.push(`双向自洽 ✖ 「${from}」的 \`## 后置知识\` 缺「${to}」(图里有这条边)`);
|
|
224
|
+
}
|
|
225
|
+
const t2 = notes.has(to) ? readIfPossible(notes.get(to)) : null;
|
|
226
|
+
if (t2 !== null) {
|
|
227
|
+
const pre = sectionBullets(t2, '前置知识') || [];
|
|
228
|
+
if (!pre.includes(from)) problems.push(`双向自洽 ✖ 「${to}」的 \`## 前置知识\` 缺「${from}」(图里有这条边)`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// 规则 6:同一篇的前置集合两两不可比(只收直接前置)
|
|
234
|
+
const reach = (start) => {
|
|
235
|
+
const seen = new Set([start]);
|
|
236
|
+
const stack = [...(fwd.get(start) || [])];
|
|
237
|
+
while (stack.length) {
|
|
238
|
+
const cur = stack.pop();
|
|
239
|
+
if (seen.has(cur)) continue;
|
|
240
|
+
seen.add(cur);
|
|
241
|
+
for (const t of fwd.get(cur) || []) if (!seen.has(t)) stack.push(t);
|
|
242
|
+
}
|
|
243
|
+
seen.delete(start);
|
|
244
|
+
return seen;
|
|
245
|
+
};
|
|
246
|
+
for (const name of involved) {
|
|
247
|
+
const path = notes.get(name);
|
|
248
|
+
if (!path) continue;
|
|
249
|
+
const list = sectionBullets(readIfPossible(path) || '', '前置知识');
|
|
250
|
+
if (!list) continue;
|
|
251
|
+
const uniq = [...new Set(list)];
|
|
252
|
+
for (let i = 0; i < uniq.length; i++) {
|
|
253
|
+
for (let j = i + 1; j < uniq.length; j++) {
|
|
254
|
+
const a = uniq[i];
|
|
255
|
+
const b = uniq[j];
|
|
256
|
+
if (reach(a).has(b)) problems.push(`前置两两不可比 ✖ 「${name}」的前置里「${a}」已经是「${b}」的前置 —— 只收直接前置`);
|
|
257
|
+
else if (reach(b).has(a)) problems.push(`前置两两不可比 ✖ 「${name}」的前置里「${b}」已经是「${a}」的前置 —— 只收直接前置`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (g.data['用途'] === undefined) warnings.push('graph.json 缺「用途」字段');
|
|
263
|
+
if (g.data['规则'] === undefined) warnings.push('graph.json 缺「规则」字段');
|
|
264
|
+
const cov = g.data.coverage;
|
|
265
|
+
if (cov && typeof cov === 'object') {
|
|
266
|
+
const done = Object.values(cov).flatMap((v) => (v && Array.isArray(v.done) ? v.done : []));
|
|
267
|
+
warnings.length; // no-op,保持结构清晰
|
|
268
|
+
if (!done.length) warnings.push('coverage 里还没有任何 done —— 图可能还没开始建');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
console.log(`图:${relative(process.cwd(), g.path) || g.path}`);
|
|
272
|
+
console.log(`边 ${g.edges.length} 条 · 知识点 ${notes.size} 篇 · 参与图的 ${involved.size} 篇`);
|
|
273
|
+
if (warnings.length) {
|
|
274
|
+
console.log('');
|
|
275
|
+
for (const w of warnings) console.log(`⚠️ ${w}`);
|
|
276
|
+
}
|
|
277
|
+
if (problems.length) {
|
|
278
|
+
console.log('');
|
|
279
|
+
for (const p of problems) console.log(`❌ ${p}`);
|
|
280
|
+
console.log('');
|
|
281
|
+
console.log(`未过:${problems.length} 个问题。`);
|
|
282
|
+
return 1;
|
|
283
|
+
}
|
|
284
|
+
console.log('');
|
|
285
|
+
console.log('✅ 六项校验全过:双向自洽 / 无自链 / 无悬空 / 同段无重复 / 前后置互斥 / 前置两两不可比。');
|
|
286
|
+
return 0;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ---------- backlinks ----------
|
|
290
|
+
|
|
291
|
+
function backlinks(root, write) {
|
|
292
|
+
if (!existsSync(root) || !statSync(root).isDirectory()) {
|
|
293
|
+
console.log(`❌ 工作区根不存在:${root}`);
|
|
294
|
+
return 2;
|
|
295
|
+
}
|
|
296
|
+
const g = loadGraph(root);
|
|
297
|
+
if (!g.ok) {
|
|
298
|
+
console.log(`❌ ${g.reason}`);
|
|
299
|
+
return 1;
|
|
300
|
+
}
|
|
301
|
+
const notes = collectNotes(root);
|
|
302
|
+
|
|
303
|
+
const preOf = new Map();
|
|
304
|
+
const postOf = new Map();
|
|
305
|
+
for (const e of g.edges) {
|
|
306
|
+
const from = noteName(e && e.from);
|
|
307
|
+
const to = noteName(e && e.to);
|
|
308
|
+
const reason = e && typeof e.reason === 'string' ? e.reason.trim() : '';
|
|
309
|
+
if (!from || !to) continue;
|
|
310
|
+
if (!postOf.has(from)) postOf.set(from, []);
|
|
311
|
+
postOf.get(from).push(to);
|
|
312
|
+
if (!preOf.has(to)) preOf.set(to, []);
|
|
313
|
+
preOf.get(to).push({ from, reason });
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const rel = (p) => relative(root, p).split(sep).join('/');
|
|
317
|
+
let changed = 0;
|
|
318
|
+
let touched = 0;
|
|
319
|
+
|
|
320
|
+
for (const [name, path] of notes) {
|
|
321
|
+
const pre = (preOf.get(name) || []).filter((x) => notes.has(x.from));
|
|
322
|
+
const post = [...new Set(postOf.get(name) || [])].filter((x) => notes.has(x));
|
|
323
|
+
if (!pre.length && !post.length) continue;
|
|
324
|
+
const text = readIfPossible(path);
|
|
325
|
+
if (text === null) continue;
|
|
326
|
+
const preBullets = pre.map((x) => {
|
|
327
|
+
const target = notes.get(x.from);
|
|
328
|
+
const link = target ? `[[${rel(target)}|${x.from}]]` : `[[${x.from}]]`;
|
|
329
|
+
return `- ${link}${x.reason ? ` —— ${x.reason}` : ''}`;
|
|
330
|
+
});
|
|
331
|
+
const postBullets = post.map((x) => {
|
|
332
|
+
const target = notes.get(x);
|
|
333
|
+
return target ? `- [[${rel(target)}|${x}]]` : `- [[${x}]]`;
|
|
334
|
+
});
|
|
335
|
+
const a = replaceSection(text, '前置知识', preBullets);
|
|
336
|
+
const b = replaceSection(a.text, '后置知识', postBullets);
|
|
337
|
+
if (!a.found && !b.found) continue;
|
|
338
|
+
touched++;
|
|
339
|
+
if (b.text !== text) {
|
|
340
|
+
changed++;
|
|
341
|
+
if (write) {
|
|
342
|
+
try {
|
|
343
|
+
writeFileSync(path, b.text, 'utf8');
|
|
344
|
+
} catch (err) {
|
|
345
|
+
console.log(`❌ 写不了 ${path}:${err.message}`);
|
|
346
|
+
return 1;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
console.log(`${write ? '已写回' : '预览(未写盘)'}:${touched} 篇有两段,${changed} 篇内容有变化。`);
|
|
353
|
+
if (!write && changed) console.log('加 --write 才会真正写盘(只动 `## 前置知识` / `## 后置知识` 两个小节)。');
|
|
354
|
+
return 0;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ---------- sort(可选:本机装了 Miyo 才用) ----------
|
|
358
|
+
|
|
359
|
+
async function miyoSort(root, noteArg, folderArg, limit) {
|
|
360
|
+
const note = noteName(noteArg);
|
|
361
|
+
const notes = collectNotes(root);
|
|
362
|
+
const path = notes.get(note);
|
|
363
|
+
if (!path) {
|
|
364
|
+
console.log(`❌ 找不到笔记「${note}」`);
|
|
365
|
+
return 2;
|
|
366
|
+
}
|
|
367
|
+
const folderName = folderArg || basename(dirname(root));
|
|
368
|
+
const relPath = relative(dirname(root), path).split(sep).join('\\');
|
|
369
|
+
const filePath = `${folderName}/${relPath}`;
|
|
370
|
+
|
|
371
|
+
let res;
|
|
372
|
+
try {
|
|
373
|
+
res = await fetch(MIYO_ENDPOINT, {
|
|
374
|
+
method: 'POST',
|
|
375
|
+
headers: { 'Content-Type': 'application/json' },
|
|
376
|
+
body: JSON.stringify({ file_path: filePath, limit }),
|
|
377
|
+
});
|
|
378
|
+
} catch (err) {
|
|
379
|
+
console.log(`ℹ️ 连不上 Miyo(${MIYO_ENDPOINT}):${err.message}`);
|
|
380
|
+
console.log(' 没装 Miyo 就跳过排序 —— 发布版不依赖它。');
|
|
381
|
+
return 0;
|
|
382
|
+
}
|
|
383
|
+
if (!res.ok) {
|
|
384
|
+
console.log(`ℹ️ Miyo 返回 ${res.status}:${(await res.text()).slice(0, 200)}`);
|
|
385
|
+
console.log(` 用的 file_path = ${filePath}`);
|
|
386
|
+
return 0;
|
|
387
|
+
}
|
|
388
|
+
const data = await res.json().catch(() => null);
|
|
389
|
+
const results = data && Array.isArray(data.results) ? data.results : [];
|
|
390
|
+
const score = new Map(results.map((r) => [noteName(r.path), r.score]));
|
|
391
|
+
|
|
392
|
+
console.log(`Miyo related:${filePath} → ${results.length} 条`);
|
|
393
|
+
console.log('');
|
|
394
|
+
const text = readIfPossible(path) || '';
|
|
395
|
+
for (const heading of ['前置知识', '后置知识']) {
|
|
396
|
+
const list = sectionBullets(text, heading) || [];
|
|
397
|
+
const ranked = [...list].sort((a, b) => (score.has(b) ? score.get(b) : -1) - (score.has(a) ? score.get(a) : -1));
|
|
398
|
+
console.log(`## ${heading}(按 Miyo 相关度排序,仅供参考;定序者是知识深度)`);
|
|
399
|
+
for (const n of ranked) {
|
|
400
|
+
const s = score.has(n) ? score.get(n).toFixed(3) : '—';
|
|
401
|
+
console.log(`- [[${n}]] ${s}`);
|
|
402
|
+
}
|
|
403
|
+
console.log('');
|
|
404
|
+
}
|
|
405
|
+
return 0;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ---------- main ----------
|
|
409
|
+
|
|
410
|
+
function main() {
|
|
411
|
+
const argv = process.argv.slice(2);
|
|
412
|
+
const cmd = argv[0];
|
|
413
|
+
const rest = argv.slice(1).filter((a) => !a.startsWith('--'));
|
|
414
|
+
const flags = new Set(argv.filter((a) => a.startsWith('--')).map((a) => a.split('=')[0]));
|
|
415
|
+
const opt = (name, dflt) => {
|
|
416
|
+
const hit = argv.find((a) => a.startsWith(name + '='));
|
|
417
|
+
if (hit) return hit.split('=').slice(1).join('=');
|
|
418
|
+
const i = argv.indexOf(name);
|
|
419
|
+
if (i !== -1 && argv[i + 1] && !argv[i + 1].startsWith('--')) return argv[i + 1];
|
|
420
|
+
return dflt;
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
if (!cmd || !['check', 'backlinks', 'sort'].includes(cmd)) {
|
|
424
|
+
console.log('用法:');
|
|
425
|
+
console.log(' node graph.mjs check <工作区根>');
|
|
426
|
+
console.log(' node graph.mjs backlinks <工作区根> [--write]');
|
|
427
|
+
console.log(' node graph.mjs sort <工作区根> --note <笔记名> [--folder <folder 名>] [--limit N]');
|
|
428
|
+
return 2;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const root = resolve(rest[0] || '.');
|
|
432
|
+
if (cmd === 'check') return check(root);
|
|
433
|
+
if (cmd === 'backlinks') return backlinks(root, flags.has('--write'));
|
|
434
|
+
const limit = Number(opt('--limit', '20')) || 20;
|
|
435
|
+
return miyoSort(root, opt('--note', ''), opt('--folder', ''), limit);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// 直接跑才执行;被 scan.mjs import 时不执行(只借它的 check 函数)
|
|
439
|
+
const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
440
|
+
if (invokedDirectly) {
|
|
441
|
+
try {
|
|
442
|
+
const code = await Promise.resolve(main());
|
|
443
|
+
process.exit(typeof code === 'number' ? code : 0);
|
|
444
|
+
} catch (err) {
|
|
445
|
+
console.log('❌ graph.mjs 自身出错:' + (err && err.message ? err.message : String(err)));
|
|
446
|
+
process.exit(1);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// 账本脚本
|
|
3
|
+
//
|
|
4
|
+
// 用途:本脚本管 sys/ledger.jsonl(复习账本,真值)—— 算达标、按 60/30/10 划范围清单、把批改结果写回。
|
|
5
|
+
// 契约正文:../templates.md(第十一节)
|
|
6
|
+
//
|
|
7
|
+
// 用法:
|
|
8
|
+
// node ledger.mjs stats <工作区根> [--scope 深度学习/L3] [--target 0.9]
|
|
9
|
+
// node ledger.mjs pick <工作区根> [--scope …] [--target 0.9] [--n 20]
|
|
10
|
+
// node ledger.mjs write <工作区根> <updates.jsonl>
|
|
11
|
+
//
|
|
12
|
+
// write 的输入:一行一条 {"知识点":"梯度","做对":1,"总共出过":1,"是否学习过":true,"最后复习日期":"2026-10-15"}
|
|
13
|
+
// 其中 做对 / 总共出过 按**增量**累加;熟练程度自动 = 做对 / 总共出过。
|
|
14
|
+
|
|
15
|
+
import { existsSync, statSync } from 'node:fs';
|
|
16
|
+
import { join, resolve } from 'node:path';
|
|
17
|
+
import { SYS, NOTES, readJson, readJsonl, writeJsonl, collectNotes, readNote, readIfPossible } from './_lib.mjs';
|
|
18
|
+
|
|
19
|
+
function load(root) {
|
|
20
|
+
const cfg = readJson(join(root, SYS, 'config.json'));
|
|
21
|
+
const led = readJsonl(join(root, SYS, 'ledger.jsonl'));
|
|
22
|
+
if (!led.ok) return { err: led.reason };
|
|
23
|
+
const notes = new Map();
|
|
24
|
+
for (const [name, p] of collectNotes(root)) {
|
|
25
|
+
const n = readNote(root, name, p, cfg.ok ? cfg.data : null);
|
|
26
|
+
notes.set(name, n.分类);
|
|
27
|
+
}
|
|
28
|
+
return { cfg: cfg.ok ? cfg.data : null, header: led.header, rows: led.rows, classes: notes };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function inScope(classes, scopeParts) {
|
|
32
|
+
if (!scopeParts) return true;
|
|
33
|
+
const list = classes || [];
|
|
34
|
+
return list.some((parts) => {
|
|
35
|
+
if (parts.length < scopeParts.length) return false;
|
|
36
|
+
return scopeParts.every((s, i) => parts[i] === s);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function proficiency(row) {
|
|
41
|
+
const done = Number(row['总共出过'] || 0);
|
|
42
|
+
const right = Number(row['做对'] || 0);
|
|
43
|
+
if (!done) return null;
|
|
44
|
+
return Math.round((right / done) * 1000) / 1000;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function flag(row, target) {
|
|
48
|
+
const done = Number(row['总共出过'] || 0);
|
|
49
|
+
const p = proficiency(row);
|
|
50
|
+
if (done < 5) return '样本不足';
|
|
51
|
+
if (p !== null && p >= target) return '达标';
|
|
52
|
+
return '未达标';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function cmdStats(root, scopeParts, target) {
|
|
56
|
+
const L = load(root);
|
|
57
|
+
if (L.err) {
|
|
58
|
+
console.log(`❌ ${L.err}`);
|
|
59
|
+
return 1;
|
|
60
|
+
}
|
|
61
|
+
const rows = L.rows.filter((r) => inScope(L.classes.get(r['知识点']), scopeParts));
|
|
62
|
+
console.log(`范围:${scopeParts ? scopeParts.join('/') : '全库'} · 知识点 ${rows.length} 个 · 目标正确率 ${target}`);
|
|
63
|
+
console.log('');
|
|
64
|
+
let ok = 0;
|
|
65
|
+
let thin = 0;
|
|
66
|
+
let bad = 0;
|
|
67
|
+
for (const r of rows) {
|
|
68
|
+
const f = flag(r, target);
|
|
69
|
+
if (f === '达标') ok++;
|
|
70
|
+
else if (f === '样本不足') thin++;
|
|
71
|
+
else bad++;
|
|
72
|
+
const p = proficiency(r);
|
|
73
|
+
console.log(
|
|
74
|
+
`- ${r['知识点']} · 出过 ${r['总共出过'] ?? 0} · 做对 ${r['做对'] ?? 0} · 熟练程度 ${p === null ? 'NULL' : p} · ${f}` +
|
|
75
|
+
(r['最后复习日期'] ? ` · 最后 ${r['最后复习日期']}` : '')
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
console.log('');
|
|
79
|
+
console.log(`达标 ${ok} · 未达标 ${bad} · 样本不足 ${thin}`);
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function cmdPick(root, scopeParts, target, n) {
|
|
84
|
+
const L = load(root);
|
|
85
|
+
if (L.err) {
|
|
86
|
+
console.log(`❌ ${L.err}`);
|
|
87
|
+
return 1;
|
|
88
|
+
}
|
|
89
|
+
const rows = L.rows.filter((r) => inScope(L.classes.get(r['知识点']), scopeParts));
|
|
90
|
+
const weakest = [...rows].sort((a, b) => {
|
|
91
|
+
const pa = proficiency(a);
|
|
92
|
+
const pb = proficiency(b);
|
|
93
|
+
if (pa === null && pb === null) return Number(a['总共出过'] || 0) - Number(b['总共出过'] || 0);
|
|
94
|
+
if (pa === null) return -1;
|
|
95
|
+
if (pb === null) return 1;
|
|
96
|
+
return pa - pb;
|
|
97
|
+
});
|
|
98
|
+
const never = rows.filter((r) => r['是否学习过'] !== true);
|
|
99
|
+
const oldest = [...rows]
|
|
100
|
+
.filter((r) => r['最后复习日期'])
|
|
101
|
+
.sort((a, b) => String(a['最后复习日期']).localeCompare(String(b['最后复习日期'])));
|
|
102
|
+
|
|
103
|
+
const total = n || rows.length;
|
|
104
|
+
const nA = Math.round(total * 0.6);
|
|
105
|
+
const nB = Math.round(total * 0.3);
|
|
106
|
+
const nC = Math.max(0, total - nA - nB);
|
|
107
|
+
|
|
108
|
+
const chosen = [];
|
|
109
|
+
const seen = new Set();
|
|
110
|
+
const take = (list, k, label) => {
|
|
111
|
+
for (const r of list) {
|
|
112
|
+
if (chosen.length >= total) return;
|
|
113
|
+
if (k <= 0) return;
|
|
114
|
+
const key = r['知识点'];
|
|
115
|
+
if (seen.has(key)) continue;
|
|
116
|
+
seen.add(key);
|
|
117
|
+
chosen.push({ ...r, bucket: label });
|
|
118
|
+
k--;
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
take(weakest, nA, '60% 熟练度最低');
|
|
122
|
+
take(never, nB, '30% 没学过');
|
|
123
|
+
take(oldest.length ? oldest : weakest, nC, '10% 最久没碰');
|
|
124
|
+
take(weakest, total, '补齐');
|
|
125
|
+
|
|
126
|
+
console.log(`本次范围:${scopeParts ? scopeParts.join('/') : '全库'} · 目标正确率 ${target} · 题量 ${total}`);
|
|
127
|
+
console.log('');
|
|
128
|
+
let last = '';
|
|
129
|
+
for (const r of chosen) {
|
|
130
|
+
if (r.bucket !== last) {
|
|
131
|
+
console.log(`## ${r.bucket}`);
|
|
132
|
+
last = r.bucket;
|
|
133
|
+
}
|
|
134
|
+
const p = proficiency(r);
|
|
135
|
+
console.log(`- ${r['知识点']} (出过 ${r['总共出过'] ?? 0} · 做对 ${r['做对'] ?? 0} · 熟练程度 ${p === null ? 'NULL' : p})`);
|
|
136
|
+
}
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function cmdWrite(root, updatesPath) {
|
|
141
|
+
const L = load(root);
|
|
142
|
+
if (L.err) {
|
|
143
|
+
console.log(`❌ ${L.err}`);
|
|
144
|
+
return 1;
|
|
145
|
+
}
|
|
146
|
+
const text = readIfPossible(resolve(updatesPath));
|
|
147
|
+
if (text === null) {
|
|
148
|
+
console.log(`❌ 读不了更新文件:${updatesPath}`);
|
|
149
|
+
return 1;
|
|
150
|
+
}
|
|
151
|
+
const updates = text
|
|
152
|
+
.split(/\r?\n/)
|
|
153
|
+
.filter((l) => l.trim())
|
|
154
|
+
.map((l) => {
|
|
155
|
+
try {
|
|
156
|
+
return JSON.parse(l);
|
|
157
|
+
} catch {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
.filter(Boolean);
|
|
162
|
+
|
|
163
|
+
const byName = new Map(L.rows.map((r) => [r['知识点'], r]));
|
|
164
|
+
let changed = 0;
|
|
165
|
+
for (const u of updates) {
|
|
166
|
+
const row = byName.get(u['知识点']);
|
|
167
|
+
if (!row) {
|
|
168
|
+
console.log(`⚠️ 账本里没有「${u['知识点']}」(跳过)`);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (u['做对'] !== undefined) row['做对'] = Number(row['做对'] || 0) + Number(u['做对']);
|
|
172
|
+
if (u['总共出过'] !== undefined) row['总共出过'] = Number(row['总共出过'] || 0) + Number(u['总共出过']);
|
|
173
|
+
if (u['是否学习过'] !== undefined) row['是否学习过'] = u['是否学习过'] === true;
|
|
174
|
+
if (u['最后复习日期'] !== undefined) row['最后复习日期'] = u['最后复习日期'];
|
|
175
|
+
const p = proficiency(row);
|
|
176
|
+
row['熟练程度'] = p === null ? null : p;
|
|
177
|
+
changed++;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
writeJsonl(join(root, SYS, 'ledger.jsonl'), L.header || { kind: 'header', '用途': '本文件是复习账本:逐条记每个知识点的复习统计。' }, L.rows);
|
|
181
|
+
console.log(`已写回 ${changed} 行(共 ${L.rows.length} 行)。`);
|
|
182
|
+
return 0;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function main() {
|
|
186
|
+
const argv = process.argv.slice(2);
|
|
187
|
+
const cmd = argv[0];
|
|
188
|
+
const rest = argv.slice(1).filter((a) => !a.startsWith('--'));
|
|
189
|
+
const opt = (name, dflt) => {
|
|
190
|
+
const i = argv.indexOf(name);
|
|
191
|
+
return i !== -1 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : dflt;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
if (!['stats', 'pick', 'write'].includes(cmd)) {
|
|
195
|
+
console.log('用法:');
|
|
196
|
+
console.log(' node ledger.mjs stats <工作区根> [--scope 深度学习/L3] [--target 0.9]');
|
|
197
|
+
console.log(' node ledger.mjs pick <工作区根> [--scope …] [--target 0.9] [--n 20]');
|
|
198
|
+
console.log(' node ledger.mjs write <工作区根> <updates.jsonl>');
|
|
199
|
+
return 2;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const root = resolve(rest[0] || '.');
|
|
203
|
+
if (!existsSync(root) || !statSync(root).isDirectory()) {
|
|
204
|
+
console.log(`❌ 工作区根不存在:${root}`);
|
|
205
|
+
return 1;
|
|
206
|
+
}
|
|
207
|
+
const scope = opt('--scope', '')
|
|
208
|
+
.split('/')
|
|
209
|
+
.map((s) => s.trim())
|
|
210
|
+
.filter(Boolean);
|
|
211
|
+
const target = Number(opt('--target', '0.9')) || 0.9;
|
|
212
|
+
|
|
213
|
+
if (cmd === 'stats') return cmdStats(root, scope.length ? scope : null, target);
|
|
214
|
+
if (cmd === 'pick') return cmdPick(root, scope.length ? scope : null, target, Number(opt('--n', '0')) || 0);
|
|
215
|
+
return cmdWrite(root, rest[1]);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
process.exit(main());
|
|
220
|
+
} catch (err) {
|
|
221
|
+
console.log('❌ ledger.mjs 自身出错:' + (err && err.message ? err.message : String(err)));
|
|
222
|
+
process.exit(1);
|
|
223
|
+
}
|