dsh-plugin-lookatstudy 0.8.1
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.md +79 -0
- package/cordis.patch.yml +5 -0
- package/lib/client.js +1214 -0
- package/lib/client.js.map +1 -0
- package/lib/code-parser-BOOk9IWV.mjs +133 -0
- package/lib/code-parser-BOOk9IWV.mjs.map +1 -0
- package/lib/index.d.mts +37 -0
- package/lib/index.d.mts.map +1 -0
- package/lib/index.mjs +4447 -0
- package/lib/index.mjs.map +1 -0
- package/lib/notebook-parser-ChbZBIKJ.mjs +104 -0
- package/lib/notebook-parser-ChbZBIKJ.mjs.map +1 -0
- package/package.json +72 -0
package/lib/index.mjs
ADDED
|
@@ -0,0 +1,4447 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join, relative, sep } from "node:path";
|
|
3
|
+
import z from "@deepseek-ai/schemastery";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
7
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
8
|
+
import https from "node:https";
|
|
9
|
+
//#region src/config.ts
|
|
10
|
+
/**
|
|
11
|
+
* Plugin configuration schema (Schemastery) for dsh-plugin-lookatstudy.
|
|
12
|
+
* @module dsh-plugin-lookatstudy/config
|
|
13
|
+
*/
|
|
14
|
+
/** Schemastery configuration validated at plugin load. */
|
|
15
|
+
const Config = z.object({
|
|
16
|
+
mode: z.union([
|
|
17
|
+
"direct",
|
|
18
|
+
"guide",
|
|
19
|
+
"practice"
|
|
20
|
+
]).default("guide"),
|
|
21
|
+
statePath: z.string().default("")
|
|
22
|
+
});
|
|
23
|
+
//#endregion
|
|
24
|
+
//#region src/markdown.ts
|
|
25
|
+
/**
|
|
26
|
+
* Server-side markdown → HTML for the study workbench's 讲解 view. Escapes
|
|
27
|
+
* every HTML character first, then renders a pragmatic GFM subset (headings,
|
|
28
|
+
* fenced code, inline code, bold/italic, links, lists, blockquotes, tables,
|
|
29
|
+
* hr, paragraphs). Lesson bodies are imported teaching material, so raw HTML
|
|
30
|
+
* never passes through.
|
|
31
|
+
* @module dsh-plugin-lookatstudy/markdown
|
|
32
|
+
*/
|
|
33
|
+
/** Escape all HTML-significant characters. */
|
|
34
|
+
function escapeHtml(text) {
|
|
35
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
36
|
+
}
|
|
37
|
+
/** Render inline markup (code, bold, italic, links) over escaped text. */
|
|
38
|
+
function inline(escaped) {
|
|
39
|
+
return escaped.replace(/`([^`]+)`/g, "<code>$1</code>").replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>").replace(/\*([^*]+)\*/g, "<em>$1</em>").replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, "<a href=\"$2\" target=\"_blank\" rel=\"noreferrer\">$1</a>");
|
|
40
|
+
}
|
|
41
|
+
/** True when the line opens a GFM table row (pipes with a delimiter row next). */
|
|
42
|
+
function isTableRow(line) {
|
|
43
|
+
return line.trim().startsWith("|") && line.trim().endsWith("|") && line.includes("|", 1);
|
|
44
|
+
}
|
|
45
|
+
function isDelimiterRow(line) {
|
|
46
|
+
const trimmed = line.trim();
|
|
47
|
+
if (!trimmed.startsWith("|") || !trimmed.endsWith("|")) return false;
|
|
48
|
+
const cells = trimmed.replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim());
|
|
49
|
+
return cells.length > 0 && cells.every((c) => /^:?-+:?$/.test(c));
|
|
50
|
+
}
|
|
51
|
+
/** Split a table row into trimmed cells (leading/trailing pipes removed). */
|
|
52
|
+
function rowCells(line) {
|
|
53
|
+
return line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim());
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Render markdown text to sanitized HTML.
|
|
57
|
+
* @param md - markdown source (lesson body).
|
|
58
|
+
* @returns HTML string safe to inject into the page.
|
|
59
|
+
*/
|
|
60
|
+
function renderMarkdown(md) {
|
|
61
|
+
const lines = escapeHtml(md).split(/\r?\n/);
|
|
62
|
+
const out = [];
|
|
63
|
+
let i = 0;
|
|
64
|
+
const flushParagraph = (buffer) => {
|
|
65
|
+
if (buffer.length > 0) out.push(`<p>${inline(buffer.join(" "))}</p>`);
|
|
66
|
+
};
|
|
67
|
+
let paragraph = [];
|
|
68
|
+
while (i < lines.length) {
|
|
69
|
+
const line = lines[i];
|
|
70
|
+
const fence = /^```(\w*)\s*$/.exec(line.trim());
|
|
71
|
+
if (fence) {
|
|
72
|
+
flushParagraph(paragraph);
|
|
73
|
+
paragraph = [];
|
|
74
|
+
const lang = fence[1] ?? "";
|
|
75
|
+
const code = [];
|
|
76
|
+
i++;
|
|
77
|
+
while (i < lines.length && lines[i].trim() !== "```") {
|
|
78
|
+
code.push(lines[i]);
|
|
79
|
+
i++;
|
|
80
|
+
}
|
|
81
|
+
i++;
|
|
82
|
+
out.push(`<pre><code${lang === "" ? "" : ` class="lang-${lang}"`}>${code.join("\n")}</code></pre>`);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
86
|
+
if (heading) {
|
|
87
|
+
flushParagraph(paragraph);
|
|
88
|
+
paragraph = [];
|
|
89
|
+
const level = heading[1].length;
|
|
90
|
+
out.push(`<h${level}>${inline(heading[2])}</h${level}>`);
|
|
91
|
+
i++;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
|
|
95
|
+
flushParagraph(paragraph);
|
|
96
|
+
paragraph = [];
|
|
97
|
+
out.push("<hr>");
|
|
98
|
+
i++;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (/^\s*>\s?/.test(line)) {
|
|
102
|
+
flushParagraph(paragraph);
|
|
103
|
+
paragraph = [];
|
|
104
|
+
const quote = [];
|
|
105
|
+
while (i < lines.length && /^\s*>\s?/.test(lines[i])) {
|
|
106
|
+
quote.push(lines[i].replace(/^\s*>\s?/, ""));
|
|
107
|
+
i++;
|
|
108
|
+
}
|
|
109
|
+
out.push(`<blockquote><p>${inline(quote.join(" "))}</p></blockquote>`);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (/^\s*[-*+]\s+/.test(line)) {
|
|
113
|
+
flushParagraph(paragraph);
|
|
114
|
+
paragraph = [];
|
|
115
|
+
const items = [];
|
|
116
|
+
while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i])) {
|
|
117
|
+
items.push(`<li>${inline(lines[i].replace(/^\s*[-*+]\s+/, ""))}</li>`);
|
|
118
|
+
i++;
|
|
119
|
+
}
|
|
120
|
+
out.push(`<ul>${items.join("")}</ul>`);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (/^\s*\d+\.\s+/.test(line)) {
|
|
124
|
+
flushParagraph(paragraph);
|
|
125
|
+
paragraph = [];
|
|
126
|
+
const items = [];
|
|
127
|
+
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
|
|
128
|
+
items.push(`<li>${inline(lines[i].replace(/^\s*\d+\.\s+/, ""))}</li>`);
|
|
129
|
+
i++;
|
|
130
|
+
}
|
|
131
|
+
out.push(`<ol>${items.join("")}</ol>`);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (isTableRow(line) && i + 1 < lines.length && isDelimiterRow(lines[i + 1])) {
|
|
135
|
+
flushParagraph(paragraph);
|
|
136
|
+
paragraph = [];
|
|
137
|
+
const headers = rowCells(line);
|
|
138
|
+
i += 2;
|
|
139
|
+
const rows = [];
|
|
140
|
+
while (i < lines.length && isTableRow(lines[i])) {
|
|
141
|
+
const cells = rowCells(lines[i]);
|
|
142
|
+
rows.push(`<tr>${cells.map((c) => `<td>${inline(c)}</td>`).join("")}</tr>`);
|
|
143
|
+
i++;
|
|
144
|
+
}
|
|
145
|
+
out.push(`<table><thead><tr>${headers.map((h) => `<th>${inline(h)}</th>`).join("")}</tr></thead><tbody>${rows.join("")}</tbody></table>`);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (line.trim() === "") {
|
|
149
|
+
flushParagraph(paragraph);
|
|
150
|
+
paragraph = [];
|
|
151
|
+
i++;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
paragraph.push(line.trim());
|
|
155
|
+
i++;
|
|
156
|
+
}
|
|
157
|
+
flushParagraph(paragraph);
|
|
158
|
+
return out.join("\n");
|
|
159
|
+
}
|
|
160
|
+
//#endregion
|
|
161
|
+
//#region src/vendor/sm2.ts
|
|
162
|
+
function computeSm2(prev, quality, now = /* @__PURE__ */ new Date()) {
|
|
163
|
+
let { easeFactor, intervalDays, repetitions } = prev;
|
|
164
|
+
if (quality < 3) {
|
|
165
|
+
repetitions = 0;
|
|
166
|
+
intervalDays = 1;
|
|
167
|
+
} else {
|
|
168
|
+
repetitions += 1;
|
|
169
|
+
if (repetitions === 1) intervalDays = 1;
|
|
170
|
+
else if (repetitions === 2) intervalDays = 6;
|
|
171
|
+
else intervalDays = Math.round(intervalDays * easeFactor);
|
|
172
|
+
}
|
|
173
|
+
const q = quality;
|
|
174
|
+
const delta = .1 - (5 - q) * (.08 + (5 - q) * .02);
|
|
175
|
+
easeFactor = Math.max(1.3, Math.min(3, easeFactor + delta));
|
|
176
|
+
const dueAt = new Date(now.getTime() + intervalDays * 24 * 60 * 60 * 1e3);
|
|
177
|
+
return {
|
|
178
|
+
easeFactor,
|
|
179
|
+
intervalDays,
|
|
180
|
+
repetitions,
|
|
181
|
+
dueAt: dueAt.toISOString()
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
//#endregion
|
|
185
|
+
//#region src/vendor/bkt.ts
|
|
186
|
+
/** 文献默认参数(ROADMAP R1:先验用文献默认,数据多后再调) */
|
|
187
|
+
const BKT_DEFAULTS = {
|
|
188
|
+
pInit: .5,
|
|
189
|
+
pTransit: .1,
|
|
190
|
+
pSlip: .1,
|
|
191
|
+
pGuess: .2
|
|
192
|
+
};
|
|
193
|
+
const clamp01 = (x) => Math.max(0, Math.min(1, x));
|
|
194
|
+
/**
|
|
195
|
+
* 单次观测后的掌握度更新。
|
|
196
|
+
*
|
|
197
|
+
* @param prev 更新前的 P(L)。null/undefined → 用 params.pInit
|
|
198
|
+
* @param correct 这次观测是否答对
|
|
199
|
+
* @param params BKT 四参数(默认文献值)
|
|
200
|
+
* @returns 新的 P(L),已 clamp 到 [0,1]
|
|
201
|
+
*/
|
|
202
|
+
function updateMastery(prev, correct, params = BKT_DEFAULTS) {
|
|
203
|
+
const pL = clamp01(prev ?? params.pInit);
|
|
204
|
+
const { pTransit, pSlip, pGuess } = params;
|
|
205
|
+
const pObsGivenL = correct ? 1 - pSlip : pSlip;
|
|
206
|
+
const pObsGivenNotL = correct ? pGuess : 1 - pGuess;
|
|
207
|
+
const pObs = pObsGivenL * pL + pObsGivenNotL * (1 - pL);
|
|
208
|
+
if (pObs === 0) return pL;
|
|
209
|
+
const pLGivenObs = pObsGivenL * pL / pObs;
|
|
210
|
+
return clamp01(pLGivenObs + pTransit * (1 - pLGivenObs));
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* 把 mastery 概率映射成 crown level(1-5)给 UI 用。
|
|
214
|
+
* < 0.3 → 1, <0.5 → 2, <0.7 → 3, <0.9 → 4, ≥0.9 → 5。null → 0。
|
|
215
|
+
*/
|
|
216
|
+
function masteryToCrown(mastery) {
|
|
217
|
+
if (mastery == null) return 0;
|
|
218
|
+
if (mastery < .3) return 1;
|
|
219
|
+
if (mastery < .5) return 2;
|
|
220
|
+
if (mastery < .7) return 3;
|
|
221
|
+
if (mastery < .9) return 4;
|
|
222
|
+
return 5;
|
|
223
|
+
}
|
|
224
|
+
//#endregion
|
|
225
|
+
//#region src/state.ts
|
|
226
|
+
/**
|
|
227
|
+
* Learning state: courses → sections → lessons with mastery-driven gating,
|
|
228
|
+
* per-concept (KC) BKT tracking aggregated as the weakest concept, SM-2
|
|
229
|
+
* spaced repetition, pending mastery proposals, friction log, learner
|
|
230
|
+
* memory, and Cornell-style notes. Persisted as one JSON file; every
|
|
231
|
+
* mutation is saved synchronously.
|
|
232
|
+
* @module dsh-plugin-lookatstudy/state
|
|
233
|
+
*/
|
|
234
|
+
const DAY_MS = 864e5;
|
|
235
|
+
/** Mastery at or above this graduates the lesson automatically (LookatStudy MASTERED_MASTERY_THRESHOLD). */
|
|
236
|
+
const MASTERED_THRESHOLD = .9;
|
|
237
|
+
/** Concepts below this mastery are flagged weak (LookatStudy kcContext). */
|
|
238
|
+
const WEAK_CONCEPT_THRESHOLD = .7;
|
|
239
|
+
const FRICTION_CAP = 10;
|
|
240
|
+
/** Fresh empty state for a first run. */
|
|
241
|
+
function emptyState() {
|
|
242
|
+
return {
|
|
243
|
+
version: 2,
|
|
244
|
+
courses: [],
|
|
245
|
+
mode: "guide",
|
|
246
|
+
focus: null,
|
|
247
|
+
memoryGlobal: null,
|
|
248
|
+
memoryPatterns: {},
|
|
249
|
+
proposals: [],
|
|
250
|
+
lessonSessions: {}
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Resolve the state-file location: explicit config path wins, otherwise
|
|
255
|
+
* `$DSH_HOME ?? ~/.dsh` under a plugin-named subdirectory.
|
|
256
|
+
* @param configured - Config `statePath` (empty means default).
|
|
257
|
+
* @returns absolute state-file path.
|
|
258
|
+
*/
|
|
259
|
+
function resolveStatePath(configured) {
|
|
260
|
+
if (configured !== "") return configured;
|
|
261
|
+
return join(process.env.DSH_HOME ?? join(homedir(), ".dsh"), "lookatstudy-plugin", "state.json");
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Load persisted state; a missing file yields empty state, a corrupt file fails loud.
|
|
265
|
+
* v1 → v2 migration: `completed` lessons become `mastered`, lessons gain `kind`
|
|
266
|
+
* (default `study`). Newer files than this code knows are rejected.
|
|
267
|
+
* @param path - state-file path.
|
|
268
|
+
* @returns the loaded state.
|
|
269
|
+
*/
|
|
270
|
+
function loadState(path) {
|
|
271
|
+
if (!existsSync(path)) return emptyState();
|
|
272
|
+
let parsed;
|
|
273
|
+
try {
|
|
274
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
275
|
+
} catch (error) {
|
|
276
|
+
throw new Error(`lookatstudy-plugin: state file is not valid JSON: ${path} (${String(error)})`);
|
|
277
|
+
}
|
|
278
|
+
if (typeof parsed !== "object" || parsed === null || !Array.isArray(parsed.courses)) throw new Error(`lookatstudy-plugin: state file has an unexpected shape: ${path}`);
|
|
279
|
+
const raw = parsed;
|
|
280
|
+
if (raw.version !== void 0 && raw.version > 2) throw new Error(`lookatstudy-plugin: state file version ${raw.version} is newer than this plugin supports: ${path}`);
|
|
281
|
+
const courses = raw.courses.map((course) => ({
|
|
282
|
+
...course,
|
|
283
|
+
sections: course.sections.map((section) => ({
|
|
284
|
+
...section,
|
|
285
|
+
lessons: section.lessons.map((lesson) => ({
|
|
286
|
+
...lesson,
|
|
287
|
+
kind: lesson.kind ?? "study",
|
|
288
|
+
status: lesson.status === "completed" ? "mastered" : lesson.status
|
|
289
|
+
}))
|
|
290
|
+
}))
|
|
291
|
+
}));
|
|
292
|
+
for (const course of courses) course.sections.forEach((section, si) => {
|
|
293
|
+
const hasExam = section.lessons.some((l) => l.kind === "exam");
|
|
294
|
+
const studyCount = section.lessons.filter((l) => l.kind === "study").length;
|
|
295
|
+
if (!hasExam && studyCount >= 2) section.lessons.push({
|
|
296
|
+
...freshLesson(`${section.title} · 章节测验`, `${section.anchor}#exam`, "", "exam"),
|
|
297
|
+
id: `${course.id}:${si}:${section.lessons.length}`,
|
|
298
|
+
status: "available"
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
return {
|
|
302
|
+
version: 2,
|
|
303
|
+
courses,
|
|
304
|
+
mode: raw.mode ?? "guide",
|
|
305
|
+
focus: raw.focus ?? null,
|
|
306
|
+
memoryGlobal: raw.memoryGlobal ?? null,
|
|
307
|
+
memoryPatterns: raw.memoryPatterns ?? {},
|
|
308
|
+
proposals: raw.proposals ?? [],
|
|
309
|
+
lessonSessions: raw.lessonSessions ?? {}
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Persist state atomically (write a sibling temp file, then rename).
|
|
314
|
+
* @param path - state-file path.
|
|
315
|
+
* @param state - state to persist.
|
|
316
|
+
*/
|
|
317
|
+
function saveState(path, state) {
|
|
318
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
319
|
+
const tmp = `${path}.tmp`;
|
|
320
|
+
writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
321
|
+
renameSync(tmp, path);
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Slugify a course title into an id prefix: lowercase alphanumerics joined by `-`.
|
|
325
|
+
* @param title - course title.
|
|
326
|
+
* @returns slug, at least `course`.
|
|
327
|
+
*/
|
|
328
|
+
function slugify(title) {
|
|
329
|
+
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
330
|
+
return slug === "" ? "course" : slug;
|
|
331
|
+
}
|
|
332
|
+
function freshLesson(title, anchor, body, kind = "study") {
|
|
333
|
+
return {
|
|
334
|
+
id: "",
|
|
335
|
+
title,
|
|
336
|
+
anchor,
|
|
337
|
+
body,
|
|
338
|
+
kind,
|
|
339
|
+
status: "locked",
|
|
340
|
+
concepts: null,
|
|
341
|
+
conceptMastery: null,
|
|
342
|
+
mastery: null,
|
|
343
|
+
attempts: 0,
|
|
344
|
+
correctCount: 0,
|
|
345
|
+
lastAnsweredAt: null,
|
|
346
|
+
completedAt: null,
|
|
347
|
+
sm2: null,
|
|
348
|
+
dueAt: null,
|
|
349
|
+
friction: [],
|
|
350
|
+
memory: null,
|
|
351
|
+
notes: []
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Import a parsed course. Idempotent: the id is the title slug, so importing
|
|
356
|
+
* the same source again returns the existing course unchanged (LookatStudy's
|
|
357
|
+
* pasted-markdown contract, applied to every source). Study lessons are gated
|
|
358
|
+
* (first available, rest locked); every study section with ≥2 lessons also
|
|
359
|
+
* gets a 章节测验 exam node (available in state, gated on sibling mastery in
|
|
360
|
+
* the UI — LookatStudy's rule).
|
|
361
|
+
* @param state - state to mutate.
|
|
362
|
+
* @param parsed - course tree from an importer.
|
|
363
|
+
* @param source - import origin.
|
|
364
|
+
* @param sourceRef - markdown/folder/repo reference for display.
|
|
365
|
+
* @returns the imported (or pre-existing) course.
|
|
366
|
+
*/
|
|
367
|
+
function importCourse(state, parsed, source, sourceRef) {
|
|
368
|
+
const id = slugify(parsed.title);
|
|
369
|
+
const existing = state.courses.find((c) => c.id === id);
|
|
370
|
+
if (existing) return existing;
|
|
371
|
+
const course = {
|
|
372
|
+
id,
|
|
373
|
+
title: parsed.title,
|
|
374
|
+
source,
|
|
375
|
+
sourceRef,
|
|
376
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
377
|
+
sections: parsed.sections.map((section) => {
|
|
378
|
+
const lessons = section.lessons.map((lesson) => freshLesson(lesson.title, lesson.anchor, lesson.body, lesson.world === "practice" ? "practice" : "study"));
|
|
379
|
+
if (section.world !== "practice" && lessons.filter((l) => l.kind === "study").length >= 2) lessons.push(freshLesson(`${section.title} · 章节测验`, `${section.anchor}#exam`, section.examBody ?? "", "exam"));
|
|
380
|
+
return {
|
|
381
|
+
title: section.title,
|
|
382
|
+
anchor: section.anchor,
|
|
383
|
+
lessons
|
|
384
|
+
};
|
|
385
|
+
})
|
|
386
|
+
};
|
|
387
|
+
let first = true;
|
|
388
|
+
for (let si = 0; si < course.sections.length; si++) {
|
|
389
|
+
const lessons = course.sections[si].lessons;
|
|
390
|
+
for (let li = 0; li < lessons.length; li++) {
|
|
391
|
+
const lesson = lessons[li];
|
|
392
|
+
lesson.id = `${id}:${si}:${li}`;
|
|
393
|
+
if (lesson.kind === "exam" || lesson.kind === "practice") lesson.status = "available";
|
|
394
|
+
else if (first) {
|
|
395
|
+
lesson.status = "available";
|
|
396
|
+
first = false;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
state.courses.push(course);
|
|
401
|
+
return course;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Drop a course from state; unknown ids fail loud.
|
|
405
|
+
* @param state - state to mutate.
|
|
406
|
+
* @param courseId - course to remove.
|
|
407
|
+
*/
|
|
408
|
+
function deleteCourse(state, courseId) {
|
|
409
|
+
const i = state.courses.findIndex((c) => c.id === courseId);
|
|
410
|
+
if (i < 0) throw new Error(`lookatstudy-plugin: unknown course id ${JSON.stringify(courseId)}`);
|
|
411
|
+
state.courses.splice(i, 1);
|
|
412
|
+
state.proposals = state.proposals.filter((p) => !p.lessonId.startsWith(`${courseId}:`));
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Locate a course; unknown ids fail loud.
|
|
416
|
+
* @param state - state to search.
|
|
417
|
+
* @param courseId - course id.
|
|
418
|
+
* @returns the course.
|
|
419
|
+
*/
|
|
420
|
+
function findCourse(state, courseId) {
|
|
421
|
+
const course = state.courses.find((c) => c.id === courseId);
|
|
422
|
+
if (!course) throw new Error(`lookatstudy-plugin: unknown course id ${JSON.stringify(courseId)}`);
|
|
423
|
+
return course;
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Locate a lesson by its hierarchical id; unknown ids fail loud.
|
|
427
|
+
* @param state - state to search.
|
|
428
|
+
* @param lessonId - lesson id (`courseId:sectionIndex:lessonIndex`).
|
|
429
|
+
* @returns course/section/lesson references.
|
|
430
|
+
*/
|
|
431
|
+
function findLesson(state, lessonId) {
|
|
432
|
+
const parts = lessonId.split(":");
|
|
433
|
+
const li = parts.pop();
|
|
434
|
+
const si = parts.pop();
|
|
435
|
+
const course = findCourse(state, parts.join(":"));
|
|
436
|
+
const sectionIndex = Number.parseInt(si ?? "", 10);
|
|
437
|
+
const lessonIndex = Number.parseInt(li ?? "", 10);
|
|
438
|
+
if (Number.isInteger(sectionIndex) && Number.isInteger(lessonIndex)) {
|
|
439
|
+
const section = course.sections[sectionIndex];
|
|
440
|
+
const lesson = section?.lessons[lessonIndex];
|
|
441
|
+
if (lesson && lesson.id === lessonId) return {
|
|
442
|
+
course,
|
|
443
|
+
section,
|
|
444
|
+
lesson
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
throw new Error(`lookatstudy-plugin: unknown lesson id ${JSON.stringify(lessonId)}`);
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Find the next STUDY lesson after the given one in flat course order
|
|
451
|
+
* (practice/exam nodes never gate the path).
|
|
452
|
+
* @param course - course to walk.
|
|
453
|
+
* @param lessonId - current lesson id.
|
|
454
|
+
* @returns the next study lesson, or null at the end of the path.
|
|
455
|
+
*/
|
|
456
|
+
function nextLesson(course, lessonId) {
|
|
457
|
+
const flat = course.sections.flatMap((s) => s.lessons);
|
|
458
|
+
const i = flat.findIndex((l) => l.id === lessonId);
|
|
459
|
+
for (let j = i + 1; j < flat.length; j++) if (flat[j].kind === "study") return flat[j];
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* LookatStudy's dual-track unlock, fired whenever the current lesson reaches
|
|
464
|
+
* mastery ≥0.5 (which includes the 0.5 seed from the first attempt): unlock
|
|
465
|
+
* (1) the next locked study lesson later in the same section AND (2) the
|
|
466
|
+
* first study lesson of the next section. Only `locked` nodes ever change;
|
|
467
|
+
* nothing re-locks.
|
|
468
|
+
* @param ref - the lesson that reached the threshold.
|
|
469
|
+
* @returns the lessons unlocked by this call.
|
|
470
|
+
*/
|
|
471
|
+
function unlockAfter(ref) {
|
|
472
|
+
const unlocked = [];
|
|
473
|
+
const lessons = ref.section.lessons;
|
|
474
|
+
const li = lessons.indexOf(ref.lesson);
|
|
475
|
+
for (let i = li + 1; i < lessons.length; i++) {
|
|
476
|
+
const next = lessons[i];
|
|
477
|
+
if (next.kind !== "study") continue;
|
|
478
|
+
if (next.status === "locked") {
|
|
479
|
+
next.status = "available";
|
|
480
|
+
unlocked.push({
|
|
481
|
+
id: next.id,
|
|
482
|
+
title: next.title
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
break;
|
|
486
|
+
}
|
|
487
|
+
const si = ref.course.sections.indexOf(ref.section);
|
|
488
|
+
const nextSection = ref.course.sections[si + 1];
|
|
489
|
+
if (nextSection !== void 0) {
|
|
490
|
+
const first = nextSection.lessons.find((l) => l.kind === "study");
|
|
491
|
+
if (first !== void 0 && first.status === "locked") {
|
|
492
|
+
first.status = "available";
|
|
493
|
+
unlocked.push({
|
|
494
|
+
id: first.id,
|
|
495
|
+
title: first.title
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
return unlocked;
|
|
500
|
+
}
|
|
501
|
+
/** Recompute lesson mastery as the weakest concept once KCs exist. */
|
|
502
|
+
function aggregateMastery(lesson) {
|
|
503
|
+
if (lesson.concepts === null || lesson.conceptMastery === null) return;
|
|
504
|
+
const values = lesson.concepts.map((_, i) => lesson.conceptMastery[i] ?? .5);
|
|
505
|
+
lesson.mastery = Math.min(...values);
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Graduate a lesson: mark mastered, seed its SM-2 schedule if absent (first
|
|
509
|
+
* review due tomorrow), and run the dual-track unlock.
|
|
510
|
+
*/
|
|
511
|
+
function graduate(lesson, course, now) {
|
|
512
|
+
if (lesson.status !== "mastered") {
|
|
513
|
+
lesson.status = "mastered";
|
|
514
|
+
lesson.completedAt = now.toISOString();
|
|
515
|
+
if (lesson.sm2 === null) {
|
|
516
|
+
lesson.sm2 = {
|
|
517
|
+
easeFactor: 2.5,
|
|
518
|
+
intervalDays: 1,
|
|
519
|
+
repetitions: 0
|
|
520
|
+
};
|
|
521
|
+
lesson.dueAt = new Date(now.getTime() + DAY_MS).toISOString();
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
const si = course.sections.findIndex((s) => s.lessons.includes(lesson));
|
|
525
|
+
return unlockAfter({
|
|
526
|
+
course,
|
|
527
|
+
section: course.sections[si],
|
|
528
|
+
lesson
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
/** Whether every study lesson of the course is mastered. */
|
|
532
|
+
function courseComplete(course) {
|
|
533
|
+
return course.sections.every((s) => s.lessons.every((l) => l.kind !== "study" || l.status === "mastered"));
|
|
534
|
+
}
|
|
535
|
+
/** Describe the path effects of a mastery change (early unlock, graduation). */
|
|
536
|
+
function applyProgression(ref, now) {
|
|
537
|
+
const before = ref.lesson.status;
|
|
538
|
+
const graduated = ref.lesson.mastery !== null && ref.lesson.mastery >= .9;
|
|
539
|
+
let unlocked = [];
|
|
540
|
+
if (graduated && before !== "mastered") unlocked = graduate(ref.lesson, ref.course, now);
|
|
541
|
+
else if (ref.lesson.mastery !== null && ref.lesson.mastery >= .5) unlocked = unlockAfter(ref);
|
|
542
|
+
return {
|
|
543
|
+
graduated: graduated && before !== "mastered",
|
|
544
|
+
unlocked,
|
|
545
|
+
nextDue: ref.lesson.dueAt,
|
|
546
|
+
courseComplete: courseComplete(ref.course)
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Open a lesson for study (LookatStudy markNodeAttempted): locked lessons
|
|
551
|
+
* fail loud; the first open of an `available` lesson marks it `in_progress`,
|
|
552
|
+
* seeds mastery at the BKT prior (0.5), and — because 0.5 already meets the
|
|
553
|
+
* unlock threshold — runs the dual-track unlock, so merely starting a lesson
|
|
554
|
+
* lights up the next ones.
|
|
555
|
+
* @param state - state to mutate.
|
|
556
|
+
* @param lessonId - lesson to open.
|
|
557
|
+
* @param now - current time.
|
|
558
|
+
* @returns the lesson ref, whether this open started it, and what unlocked.
|
|
559
|
+
*/
|
|
560
|
+
function attemptLesson(state, lessonId, now) {
|
|
561
|
+
const ref = findLesson(state, lessonId);
|
|
562
|
+
if (ref.lesson.status === "locked") throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} is locked; complete earlier lessons first`);
|
|
563
|
+
if (ref.lesson.status !== "available") return {
|
|
564
|
+
ref,
|
|
565
|
+
started: false,
|
|
566
|
+
unlocked: []
|
|
567
|
+
};
|
|
568
|
+
ref.lesson.status = "in_progress";
|
|
569
|
+
ref.lesson.lastAnsweredAt = now.toISOString();
|
|
570
|
+
if (ref.lesson.mastery === null) ref.lesson.mastery = .5;
|
|
571
|
+
return {
|
|
572
|
+
ref,
|
|
573
|
+
started: true,
|
|
574
|
+
unlocked: unlockAfter(ref)
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Record one graded answer against a lesson: attribute it to one knowledge
|
|
579
|
+
* component when named, update BKT (per-KC, aggregated as the weakest),
|
|
580
|
+
* nudge the SM-2 schedule when one exists, and apply mastery-driven
|
|
581
|
+
* progression (early unlock at 0.5, graduation at 0.9). Locked lessons fail
|
|
582
|
+
* loud — open the lesson first (study_lesson does).
|
|
583
|
+
* @param state - state to mutate.
|
|
584
|
+
* @param lessonId - lesson to update.
|
|
585
|
+
* @param correct - whether the learner answered correctly.
|
|
586
|
+
* @param concept - concept title the question tested, when attributable.
|
|
587
|
+
* @param now - current time.
|
|
588
|
+
* @returns mastery transition, KC attribution, and progression effects.
|
|
589
|
+
*/
|
|
590
|
+
function recordAnswer(state, lessonId, correct, concept, now) {
|
|
591
|
+
const ref = findLesson(state, lessonId);
|
|
592
|
+
if (ref.lesson.status === "locked") throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} is locked; open it with study_lesson first`);
|
|
593
|
+
if (ref.lesson.status === "available") ref.lesson.status = "in_progress";
|
|
594
|
+
const kcIndex = concept === void 0 ? void 0 : ref.lesson.concepts?.findIndex((c) => c.title === concept);
|
|
595
|
+
if (concept !== void 0 && (ref.lesson.concepts === null || kcIndex === void 0 || kcIndex < 0)) throw new Error(`lookatstudy-plugin: unknown concept ${JSON.stringify(concept)} on lesson ${JSON.stringify(lessonId)} — define concepts with study_define_concepts first`);
|
|
596
|
+
const prev = ref.lesson.mastery;
|
|
597
|
+
if (ref.lesson.concepts !== null && kcIndex !== void 0) {
|
|
598
|
+
const masteries = ref.lesson.conceptMastery ?? {};
|
|
599
|
+
masteries[kcIndex] = updateMastery(masteries[kcIndex], correct);
|
|
600
|
+
ref.lesson.conceptMastery = masteries;
|
|
601
|
+
aggregateMastery(ref.lesson);
|
|
602
|
+
} else if (ref.lesson.concepts !== null) {
|
|
603
|
+
const masteries = ref.lesson.conceptMastery ?? {};
|
|
604
|
+
ref.lesson.concepts.forEach((_, i) => {
|
|
605
|
+
masteries[i] = updateMastery(masteries[i], correct);
|
|
606
|
+
});
|
|
607
|
+
ref.lesson.conceptMastery = masteries;
|
|
608
|
+
aggregateMastery(ref.lesson);
|
|
609
|
+
} else ref.lesson.mastery = updateMastery(prev, correct);
|
|
610
|
+
ref.lesson.attempts += 1;
|
|
611
|
+
if (correct) ref.lesson.correctCount += 1;
|
|
612
|
+
ref.lesson.lastAnsweredAt = now.toISOString();
|
|
613
|
+
if (ref.lesson.sm2 !== null) {
|
|
614
|
+
const result = computeSm2(ref.lesson.sm2, correct ? 5 : 2, now);
|
|
615
|
+
ref.lesson.sm2 = {
|
|
616
|
+
easeFactor: result.easeFactor,
|
|
617
|
+
intervalDays: result.intervalDays,
|
|
618
|
+
repetitions: result.repetitions
|
|
619
|
+
};
|
|
620
|
+
ref.lesson.dueAt = result.dueAt;
|
|
621
|
+
}
|
|
622
|
+
const progression = applyProgression(ref, now);
|
|
623
|
+
return {
|
|
624
|
+
ref,
|
|
625
|
+
concept: kcIndex === void 0 ? null : {
|
|
626
|
+
title: concept,
|
|
627
|
+
mastery: ref.lesson.conceptMastery[kcIndex]
|
|
628
|
+
},
|
|
629
|
+
prevMastery: prev ?? 0,
|
|
630
|
+
newMastery: ref.lesson.mastery ?? 0,
|
|
631
|
+
crown: masteryToCrown(ref.lesson.mastery),
|
|
632
|
+
mastered: (ref.lesson.mastery ?? 0) >= MASTERED_THRESHOLD,
|
|
633
|
+
progression
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Complete a lesson explicitly (the manual path; mastery graduation is the
|
|
638
|
+
* automatic one). Locked lessons fail loud.
|
|
639
|
+
* @param state - state to mutate.
|
|
640
|
+
* @param lessonId - lesson to complete.
|
|
641
|
+
* @param now - current time.
|
|
642
|
+
* @returns completion result including the unlocked lessons.
|
|
643
|
+
*/
|
|
644
|
+
function completeLesson(state, lessonId, now) {
|
|
645
|
+
const ref = findLesson(state, lessonId);
|
|
646
|
+
if (ref.lesson.status === "locked") throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} is locked; complete earlier lessons first`);
|
|
647
|
+
return {
|
|
648
|
+
ref,
|
|
649
|
+
unlocked: graduate(ref.lesson, ref.course, now),
|
|
650
|
+
dueAt: ref.lesson.dueAt ?? new Date(now.getTime() + DAY_MS).toISOString(),
|
|
651
|
+
courseComplete: courseComplete(ref.course)
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Define (or replace) a lesson's knowledge components — the independently
|
|
656
|
+
* quizzable units per-KC mastery tracks. Existing per-KC mastery resets.
|
|
657
|
+
* @param state - state to mutate.
|
|
658
|
+
* @param lessonId - lesson to describe.
|
|
659
|
+
* @param concepts - 2–7 short concepts.
|
|
660
|
+
*/
|
|
661
|
+
function defineConcepts(state, lessonId, concepts) {
|
|
662
|
+
const ref = findLesson(state, lessonId);
|
|
663
|
+
if (concepts.length < 2 || concepts.length > 7) throw new Error(`lookatstudy-plugin: define 2–7 concepts (got ${concepts.length})`);
|
|
664
|
+
for (const def of concepts) if (def.title.trim() === "" || def.description.trim() === "") throw new Error("lookatstudy-plugin: every concept needs a non-empty title and description");
|
|
665
|
+
ref.lesson.concepts = concepts.map((c) => ({
|
|
666
|
+
title: c.title.trim(),
|
|
667
|
+
description: c.description.trim()
|
|
668
|
+
}));
|
|
669
|
+
ref.lesson.conceptMastery = {};
|
|
670
|
+
aggregateMastery(ref.lesson);
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Log one silent friction event (confusion / block / frustration).
|
|
674
|
+
* @param state - state to mutate.
|
|
675
|
+
* @param lessonId - lesson it happened on, when attributable.
|
|
676
|
+
* @param category - friction category.
|
|
677
|
+
* @param summary - optional one-line description.
|
|
678
|
+
* @param now - current time.
|
|
679
|
+
*/
|
|
680
|
+
function addFriction(state, lessonId, category, summary, now) {
|
|
681
|
+
const entry = {
|
|
682
|
+
category,
|
|
683
|
+
summary,
|
|
684
|
+
at: now.toISOString()
|
|
685
|
+
};
|
|
686
|
+
if (lessonId === null) return;
|
|
687
|
+
const ref = findLesson(state, lessonId);
|
|
688
|
+
ref.lesson.friction.push(entry);
|
|
689
|
+
if (ref.lesson.friction.length > FRICTION_CAP) ref.lesson.friction.splice(0, ref.lesson.friction.length - FRICTION_CAP);
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* Set a memory slot. The tutor merges mentally before writing (read the
|
|
693
|
+
* current slot, then send the merged 1–3 sentence text).
|
|
694
|
+
* @param state - state to mutate.
|
|
695
|
+
* @param category - which slot.
|
|
696
|
+
* @param lessonId - lesson for the `lesson` slot.
|
|
697
|
+
* @param content - merged slot content.
|
|
698
|
+
* @returns the previous content, for the tutor's merge flow.
|
|
699
|
+
*/
|
|
700
|
+
function setMemory(state, category, content, lessonId) {
|
|
701
|
+
if (category === "global") {
|
|
702
|
+
const prev = state.memoryGlobal;
|
|
703
|
+
state.memoryGlobal = content;
|
|
704
|
+
return prev;
|
|
705
|
+
}
|
|
706
|
+
if (category === "pattern") {
|
|
707
|
+
const course = findCourse(state, lessonId === void 0 ? "" : lessonId.slice(0, lessonId.lastIndexOf(":")));
|
|
708
|
+
const prev = state.memoryPatterns[course.id] ?? null;
|
|
709
|
+
state.memoryPatterns[course.id] = content;
|
|
710
|
+
return prev;
|
|
711
|
+
}
|
|
712
|
+
if (lessonId === void 0) throw new Error("lookatstudy-plugin: the lesson memory slot needs a lessonId");
|
|
713
|
+
const ref = findLesson(state, lessonId);
|
|
714
|
+
const prev = ref.lesson.memory;
|
|
715
|
+
ref.lesson.memory = content;
|
|
716
|
+
return prev;
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* Add one notebook entry to a lesson's Cornell zones.
|
|
720
|
+
* @param state - state to mutate.
|
|
721
|
+
* @param lessonId - lesson the note belongs to (required: notes anchor to material).
|
|
722
|
+
* @param zone - Cornell zone.
|
|
723
|
+
* @param title - short entry title.
|
|
724
|
+
* @param text - entry body (markdown for the understand zone).
|
|
725
|
+
* @param source - where the content came from.
|
|
726
|
+
* @param quote - verbatim source quote for record-zone notes.
|
|
727
|
+
* @param now - current time.
|
|
728
|
+
* @returns the created note.
|
|
729
|
+
*/
|
|
730
|
+
function addNote(state, lessonId, zone, title, text, source, quote, now) {
|
|
731
|
+
const ref = findLesson(state, lessonId);
|
|
732
|
+
const note = {
|
|
733
|
+
id: `${lessonId}:n${ref.lesson.notes.length}`,
|
|
734
|
+
zone,
|
|
735
|
+
title,
|
|
736
|
+
text,
|
|
737
|
+
source,
|
|
738
|
+
quote,
|
|
739
|
+
at: now.toISOString()
|
|
740
|
+
};
|
|
741
|
+
ref.lesson.notes.push(note);
|
|
742
|
+
return note;
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* Propose early mastery graduation for the learner to accept or reject in chat.
|
|
746
|
+
* @param state - state to mutate.
|
|
747
|
+
* @param lessonId - lesson judged mastered.
|
|
748
|
+
* @param rationale - why the tutor believes it is mastered.
|
|
749
|
+
* @param now - current time.
|
|
750
|
+
* @returns the pending proposal.
|
|
751
|
+
*/
|
|
752
|
+
function proposeMastery(state, lessonId, rationale, now) {
|
|
753
|
+
if (findLesson(state, lessonId).lesson.status === "locked") throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} is locked`);
|
|
754
|
+
const pending = state.proposals.find((p) => p.lessonId === lessonId && p.status === "pending");
|
|
755
|
+
if (pending) return pending;
|
|
756
|
+
const proposal = {
|
|
757
|
+
id: `prop-${randomBytes(3).toString("hex")}`,
|
|
758
|
+
lessonId,
|
|
759
|
+
rationale,
|
|
760
|
+
status: "pending",
|
|
761
|
+
createdAt: now.toISOString()
|
|
762
|
+
};
|
|
763
|
+
state.proposals.push(proposal);
|
|
764
|
+
return proposal;
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* Resolve a pending proposal: acceptance floors every concept (and the
|
|
768
|
+
* lesson) to 0.95 and graduates; rejection changes nothing.
|
|
769
|
+
* @param state - state to mutate.
|
|
770
|
+
* @param proposalId - proposal to resolve.
|
|
771
|
+
* @param accept - learner's decision.
|
|
772
|
+
* @param now - current time.
|
|
773
|
+
* @returns the resolved proposal.
|
|
774
|
+
*/
|
|
775
|
+
function resolveProposal(state, proposalId, accept, now) {
|
|
776
|
+
const proposal = state.proposals.find((p) => p.id === proposalId);
|
|
777
|
+
if (!proposal) throw new Error(`lookatstudy-plugin: unknown proposal id ${JSON.stringify(proposalId)}`);
|
|
778
|
+
if (proposal.status !== "pending") throw new Error(`lookatstudy-plugin: proposal ${JSON.stringify(proposalId)} is already ${proposal.status}`);
|
|
779
|
+
if (accept) {
|
|
780
|
+
const ref = findLesson(state, proposal.lessonId);
|
|
781
|
+
if (ref.lesson.concepts !== null && ref.lesson.conceptMastery !== null) {
|
|
782
|
+
for (let i = 0; i < ref.lesson.concepts.length; i++) ref.lesson.conceptMastery[i] = Math.max(ref.lesson.conceptMastery[i] ?? 0, .95);
|
|
783
|
+
aggregateMastery(ref.lesson);
|
|
784
|
+
} else ref.lesson.mastery = Math.max(ref.lesson.mastery ?? 0, .95);
|
|
785
|
+
graduate(ref.lesson, ref.course, now);
|
|
786
|
+
if (ref.lesson.sm2 !== null) {
|
|
787
|
+
const review = computeSm2(ref.lesson.sm2, 5, now);
|
|
788
|
+
ref.lesson.sm2 = {
|
|
789
|
+
easeFactor: review.easeFactor,
|
|
790
|
+
intervalDays: review.intervalDays,
|
|
791
|
+
repetitions: review.repetitions
|
|
792
|
+
};
|
|
793
|
+
ref.lesson.dueAt = review.dueAt;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
proposal.status = accept ? "applied" : "rejected";
|
|
797
|
+
return proposal;
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Record an SM-2 review grade and advance the schedule.
|
|
801
|
+
* @param state - state to mutate.
|
|
802
|
+
* @param lessonId - lesson being reviewed.
|
|
803
|
+
* @param quality - SM-2 quality grade 0–5.
|
|
804
|
+
* @param now - current time.
|
|
805
|
+
* @returns the advanced schedule.
|
|
806
|
+
*/
|
|
807
|
+
function recordReview(state, lessonId, quality, now) {
|
|
808
|
+
const ref = findLesson(state, lessonId);
|
|
809
|
+
if (!ref.lesson.sm2) throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} has no review schedule; complete it first`);
|
|
810
|
+
const result = computeSm2(ref.lesson.sm2, quality, now);
|
|
811
|
+
ref.lesson.sm2 = {
|
|
812
|
+
easeFactor: result.easeFactor,
|
|
813
|
+
intervalDays: result.intervalDays,
|
|
814
|
+
repetitions: result.repetitions
|
|
815
|
+
};
|
|
816
|
+
ref.lesson.dueAt = result.dueAt;
|
|
817
|
+
return {
|
|
818
|
+
ref,
|
|
819
|
+
intervalDays: result.intervalDays,
|
|
820
|
+
repetitions: result.repetitions,
|
|
821
|
+
easeFactor: result.easeFactor,
|
|
822
|
+
dueAt: result.dueAt
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* List mastered lessons whose SM-2 review is due, oldest first.
|
|
827
|
+
* @param state - state to scan.
|
|
828
|
+
* @param courseId - restrict to one course when provided.
|
|
829
|
+
* @param now - current time.
|
|
830
|
+
* @returns due items across the requested scope.
|
|
831
|
+
*/
|
|
832
|
+
function dueReviews(state, courseId, now) {
|
|
833
|
+
const courses = courseId ? [findCourse(state, courseId)] : state.courses;
|
|
834
|
+
const due = [];
|
|
835
|
+
for (const course of courses) for (const lesson of course.sections.flatMap((s) => s.lessons)) {
|
|
836
|
+
if (lesson.status !== "mastered" || lesson.dueAt === null) continue;
|
|
837
|
+
if (Date.parse(lesson.dueAt) > now.getTime()) continue;
|
|
838
|
+
due.push({
|
|
839
|
+
lessonId: lesson.id,
|
|
840
|
+
courseId: course.id,
|
|
841
|
+
courseTitle: course.title,
|
|
842
|
+
lessonTitle: lesson.title,
|
|
843
|
+
dueAt: lesson.dueAt,
|
|
844
|
+
overdueDays: Math.floor((now.getTime() - Date.parse(lesson.dueAt)) / DAY_MS)
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
due.sort((a, b) => Date.parse(a.dueAt) - Date.parse(b.dueAt));
|
|
848
|
+
return due;
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* Summarize every course: counts, average mastery, due reviews, and the
|
|
852
|
+
* current (first not-yet-mastered study) lesson.
|
|
853
|
+
* @param state - state to summarize.
|
|
854
|
+
* @param now - current time.
|
|
855
|
+
* @returns one summary per course, in import order.
|
|
856
|
+
*/
|
|
857
|
+
function courseSummaries(state, now) {
|
|
858
|
+
return state.courses.map((course) => {
|
|
859
|
+
const lessons = course.sections.flatMap((s) => s.lessons);
|
|
860
|
+
const answered = lessons.filter((l) => l.mastery !== null);
|
|
861
|
+
const due = dueReviews({
|
|
862
|
+
...emptyState(),
|
|
863
|
+
courses: [course]
|
|
864
|
+
}, course.id, now);
|
|
865
|
+
const current = lessons.find((l) => l.kind === "study" && l.status !== "mastered") ?? null;
|
|
866
|
+
const avg = answered.length === 0 ? null : answered.reduce((sum, l) => sum + (l.mastery ?? 0), 0) / answered.length;
|
|
867
|
+
return {
|
|
868
|
+
courseId: course.id,
|
|
869
|
+
title: course.title,
|
|
870
|
+
source: course.source,
|
|
871
|
+
createdAt: course.createdAt,
|
|
872
|
+
total: lessons.length,
|
|
873
|
+
mastered: lessons.filter((l) => l.status === "mastered").length,
|
|
874
|
+
available: lessons.filter((l) => l.status === "available").length,
|
|
875
|
+
avgMasteryPct: avg === null ? null : Math.round(avg * 100),
|
|
876
|
+
dueCount: due.length,
|
|
877
|
+
currentLessonId: current?.id ?? null
|
|
878
|
+
};
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
/**
|
|
882
|
+
* Teaching-strategy band for a mastery level (LookatStudy learner-model bands).
|
|
883
|
+
* @param mastery - lesson mastery, null before any answer.
|
|
884
|
+
* @returns the strategy instruction for the tutor.
|
|
885
|
+
*/
|
|
886
|
+
function strategyBand(mastery) {
|
|
887
|
+
if (mastery === null || mastery < .1) return "先建立直觉再讲细节:用类比引入概念,分步骤引导,不堆术语。";
|
|
888
|
+
if (mastery < .4) return "用提问检验理解,发现误解时立即纠正,多给实际例子。";
|
|
889
|
+
if (mastery < .7) return "深化理解:对比相似概念的区别,考察边界情况,可以出有迷惑性的问题。";
|
|
890
|
+
return "综合应用阶段:让学习者尝试用自己的话教回来(费曼技巧),考虑提议标记掌握。";
|
|
891
|
+
}
|
|
892
|
+
/**
|
|
893
|
+
* Project a lesson's concepts with mastery and weak flags.
|
|
894
|
+
* @param lesson - lesson to project.
|
|
895
|
+
* @returns concept views in definition order, or null before concepts exist.
|
|
896
|
+
*/
|
|
897
|
+
function conceptViews(lesson) {
|
|
898
|
+
if (lesson.concepts === null) return null;
|
|
899
|
+
return lesson.concepts.map((c, i) => {
|
|
900
|
+
const mastery = lesson.conceptMastery?.[i] ?? .5;
|
|
901
|
+
return {
|
|
902
|
+
title: c.title,
|
|
903
|
+
masteryPct: Math.round(mastery * 100),
|
|
904
|
+
weak: mastery < WEAK_CONCEPT_THRESHOLD,
|
|
905
|
+
tested: lesson.conceptMastery !== null && i in lesson.conceptMastery ? 1 : 0
|
|
906
|
+
};
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
/** The four consolidation starters attached to a lesson (LookatStudy templates). */
|
|
910
|
+
function starterPrompts(lessonTitle) {
|
|
911
|
+
return [
|
|
912
|
+
{
|
|
913
|
+
label: "🔬 深入这点",
|
|
914
|
+
message: `帮我深入讲讲「${lessonTitle}」刚才那个核心点——展开它的结构、细节和容易忽略的边界。`,
|
|
915
|
+
effect: "none"
|
|
916
|
+
},
|
|
917
|
+
{
|
|
918
|
+
label: "💡 举个例子",
|
|
919
|
+
message: `给我一个「${lessonTitle}」的实际例子或用法,让我更具体地理解。`,
|
|
920
|
+
effect: "none"
|
|
921
|
+
},
|
|
922
|
+
{
|
|
923
|
+
label: "📝 考考我",
|
|
924
|
+
message: `出一道关于「${lessonTitle}」的应用题考考我,看我是否真懂了——我答完请判断对错。`,
|
|
925
|
+
effect: "mastery"
|
|
926
|
+
},
|
|
927
|
+
{
|
|
928
|
+
label: "🤔 我没太懂",
|
|
929
|
+
message: `关于「${lessonTitle}」,我有地方不太懂,帮我理一理——先问我是哪里不清楚。`,
|
|
930
|
+
effect: "friction"
|
|
931
|
+
}
|
|
932
|
+
];
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Compose the learner snapshot for the focused lesson (or course-wide when
|
|
936
|
+
* no focus): strategy band, weak concepts, recent friction, memory slots,
|
|
937
|
+
* due count, pending proposal. The tutor persona's volatile tail.
|
|
938
|
+
* @param state - state to read.
|
|
939
|
+
* @param now - current time.
|
|
940
|
+
* @returns the snapshot value.
|
|
941
|
+
*/
|
|
942
|
+
function learnerSnapshot(state, now) {
|
|
943
|
+
let ref = state.focus === null ? null : tryFindLesson(state, state.focus.lessonId);
|
|
944
|
+
if (ref === null && state.courses.length > 0) {
|
|
945
|
+
const lessons = state.courses[0].sections.flatMap((s) => s.lessons);
|
|
946
|
+
const current = lessons.find((l) => l.kind === "study" && l.status === "in_progress") ?? lessons.find((l) => l.kind === "study" && l.status === "available") ?? null;
|
|
947
|
+
ref = current === null ? null : {
|
|
948
|
+
course: state.courses[0],
|
|
949
|
+
section: state.courses[0].sections.find((s) => s.lessons.includes(current)),
|
|
950
|
+
lesson: current
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
return {
|
|
954
|
+
focus: ref === null ? null : {
|
|
955
|
+
lessonId: ref.lesson.id,
|
|
956
|
+
courseTitle: ref.course.title,
|
|
957
|
+
lessonTitle: ref.lesson.title,
|
|
958
|
+
masteryPct: ref.lesson.mastery === null ? null : Math.round(ref.lesson.mastery * 100),
|
|
959
|
+
status: ref.lesson.status
|
|
960
|
+
},
|
|
961
|
+
strategy: ref === null ? null : strategyBand(ref.lesson.mastery),
|
|
962
|
+
concepts: ref === null ? null : conceptViews(ref.lesson),
|
|
963
|
+
friction: ref === null ? [] : ref.lesson.friction.slice(-5),
|
|
964
|
+
memoryGlobal: state.memoryGlobal,
|
|
965
|
+
memoryLesson: ref?.lesson.memory ?? null,
|
|
966
|
+
memoryPattern: ref === null ? null : state.memoryPatterns[ref.course.id] ?? null,
|
|
967
|
+
dueCount: dueReviews(state, void 0, now).length,
|
|
968
|
+
pendingProposal: state.proposals.find((p) => p.status === "pending") ?? null
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
/** findLesson that returns null instead of throwing (snapshot focus may be stale). */
|
|
972
|
+
function tryFindLesson(state, lessonId) {
|
|
973
|
+
try {
|
|
974
|
+
return findLesson(state, lessonId);
|
|
975
|
+
} catch {
|
|
976
|
+
return null;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
//#endregion
|
|
980
|
+
//#region src/dashboard.ts
|
|
981
|
+
/**
|
|
982
|
+
* The study tab's HTTP API under `/lookatstudy/api/*`: the polled state feed
|
|
983
|
+
* and the tab's write actions (focus, mode, lesson-session binding, course
|
|
984
|
+
* deletion, study-workspace path), reading the same live plugin state the
|
|
985
|
+
* tutor tools write. The v0.3 standalone workbench page and its reverse
|
|
986
|
+
* message channel were removed once the in-client study tab superseded them.
|
|
987
|
+
* @module dsh-plugin-lookatstudy/dashboard
|
|
988
|
+
*/
|
|
989
|
+
/**
|
|
990
|
+
* Assemble the whole workbench state (pure read; the lesson HTML is rendered
|
|
991
|
+
* server-side from the sanitized markdown pipeline).
|
|
992
|
+
* @param state - live learning state.
|
|
993
|
+
* @param now - current time.
|
|
994
|
+
* @returns the page's data contract.
|
|
995
|
+
*/
|
|
996
|
+
function workbenchState(state, now) {
|
|
997
|
+
const focusId = state.focus?.lessonId ?? null;
|
|
998
|
+
const dueIds = new Set(dueReviews(state, void 0, now).map((d) => d.lessonId));
|
|
999
|
+
const courses = state.courses.map((course) => {
|
|
1000
|
+
const lessons = course.sections.flatMap((s) => s.lessons);
|
|
1001
|
+
const answered = lessons.filter((l) => l.mastery !== null);
|
|
1002
|
+
return {
|
|
1003
|
+
courseId: course.id,
|
|
1004
|
+
title: course.title,
|
|
1005
|
+
mastered: lessons.filter((l) => l.status === "mastered").length,
|
|
1006
|
+
total: lessons.length,
|
|
1007
|
+
avgMasteryPct: answered.length === 0 ? null : Math.round(answered.reduce((sum, l) => sum + (l.mastery ?? 0), 0) / answered.length * 100),
|
|
1008
|
+
sections: course.sections.map((section, index) => ({
|
|
1009
|
+
title: section.title,
|
|
1010
|
+
index,
|
|
1011
|
+
lessons: section.lessons.map((lesson) => ({
|
|
1012
|
+
id: lesson.id,
|
|
1013
|
+
title: lesson.title,
|
|
1014
|
+
kind: lesson.kind,
|
|
1015
|
+
status: lesson.status,
|
|
1016
|
+
masteryPct: lesson.mastery === null ? null : Math.round(lesson.mastery * 100),
|
|
1017
|
+
weakConcepts: (conceptViews(lesson) ?? []).filter((c) => c.weak).length,
|
|
1018
|
+
frictionCount: lesson.friction.length,
|
|
1019
|
+
due: dueIds.has(lesson.id),
|
|
1020
|
+
focus: lesson.id === focusId
|
|
1021
|
+
}))
|
|
1022
|
+
}))
|
|
1023
|
+
};
|
|
1024
|
+
});
|
|
1025
|
+
let lesson = null;
|
|
1026
|
+
if (focusId !== null) try {
|
|
1027
|
+
const ref = findLesson(state, focusId);
|
|
1028
|
+
lesson = {
|
|
1029
|
+
lessonId: ref.lesson.id,
|
|
1030
|
+
courseTitle: ref.course.title,
|
|
1031
|
+
sectionTitle: ref.section.title,
|
|
1032
|
+
title: ref.lesson.title,
|
|
1033
|
+
status: ref.lesson.status,
|
|
1034
|
+
masteryPct: ref.lesson.mastery === null ? null : Math.round(ref.lesson.mastery * 100),
|
|
1035
|
+
strategy: strategyBand(ref.lesson.mastery),
|
|
1036
|
+
concepts: conceptViews(ref.lesson) ?? [],
|
|
1037
|
+
starters: starterPrompts(ref.lesson.title).map((s) => ({
|
|
1038
|
+
label: s.label,
|
|
1039
|
+
message: s.message
|
|
1040
|
+
})),
|
|
1041
|
+
notes: ref.lesson.notes.map((n) => ({
|
|
1042
|
+
id: n.id,
|
|
1043
|
+
zone: n.zone,
|
|
1044
|
+
title: n.title,
|
|
1045
|
+
text: n.text,
|
|
1046
|
+
source: n.source,
|
|
1047
|
+
quote: n.quote
|
|
1048
|
+
})),
|
|
1049
|
+
html: renderMarkdown(ref.lesson.body)
|
|
1050
|
+
};
|
|
1051
|
+
} catch {
|
|
1052
|
+
lesson = null;
|
|
1053
|
+
}
|
|
1054
|
+
const due = dueReviews(state, void 0, now);
|
|
1055
|
+
return {
|
|
1056
|
+
mode: state.mode,
|
|
1057
|
+
courses,
|
|
1058
|
+
focusLessonId: focusId,
|
|
1059
|
+
lesson,
|
|
1060
|
+
dueCount: due.length,
|
|
1061
|
+
due: due.map((d) => ({
|
|
1062
|
+
lessonId: d.lessonId,
|
|
1063
|
+
lessonTitle: d.lessonTitle,
|
|
1064
|
+
courseTitle: d.courseTitle,
|
|
1065
|
+
overdueDays: d.overdueDays
|
|
1066
|
+
})),
|
|
1067
|
+
pendingProposals: state.proposals.filter((p) => p.status === "pending").map((p) => {
|
|
1068
|
+
try {
|
|
1069
|
+
return {
|
|
1070
|
+
id: p.id,
|
|
1071
|
+
lessonTitle: findLesson(state, p.lessonId).lesson.title,
|
|
1072
|
+
rationale: p.rationale
|
|
1073
|
+
};
|
|
1074
|
+
} catch {
|
|
1075
|
+
return {
|
|
1076
|
+
id: p.id,
|
|
1077
|
+
lessonTitle: p.lessonId,
|
|
1078
|
+
rationale: p.rationale
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1081
|
+
}),
|
|
1082
|
+
memory: (() => {
|
|
1083
|
+
const snap = learnerSnapshot(state, now);
|
|
1084
|
+
return {
|
|
1085
|
+
global: snap.memoryGlobal,
|
|
1086
|
+
lesson: snap.memoryLesson,
|
|
1087
|
+
pattern: snap.memoryPattern
|
|
1088
|
+
};
|
|
1089
|
+
})(),
|
|
1090
|
+
lessonSessions: state.lessonSessions
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" };
|
|
1094
|
+
function sendJson(res, status, value) {
|
|
1095
|
+
res.writeHead(status, JSON_HEADERS).end(JSON.stringify(value));
|
|
1096
|
+
}
|
|
1097
|
+
/**
|
|
1098
|
+
* Read one JSON body, answering 400 on malformed or oversized input so the
|
|
1099
|
+
* handler never throws into the HTTP layer.
|
|
1100
|
+
* @returns the parsed value, or undefined when the response is already sent.
|
|
1101
|
+
*/
|
|
1102
|
+
async function readJsonBodySafe(req, res) {
|
|
1103
|
+
try {
|
|
1104
|
+
return await readJsonBody(req);
|
|
1105
|
+
} catch (error) {
|
|
1106
|
+
sendJson(res, 400, {
|
|
1107
|
+
ok: false,
|
|
1108
|
+
error: error instanceof Error ? error.message : "bad request"
|
|
1109
|
+
});
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
/** Read one JSON request body with a hard 64 kB cap; malformed bodies reject. */
|
|
1114
|
+
function readJsonBody(req) {
|
|
1115
|
+
return new Promise((resolve, reject) => {
|
|
1116
|
+
const chunks = [];
|
|
1117
|
+
req.on("data", (chunk) => {
|
|
1118
|
+
chunks.push(chunk);
|
|
1119
|
+
if (chunks.reduce((n, c) => n + c.length, 0) > 65536) {
|
|
1120
|
+
reject(/* @__PURE__ */ new Error("request body too large"));
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
});
|
|
1124
|
+
req.on("end", () => {
|
|
1125
|
+
try {
|
|
1126
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
1127
|
+
} catch {
|
|
1128
|
+
reject(/* @__PURE__ */ new Error("request body is not valid JSON"));
|
|
1129
|
+
}
|
|
1130
|
+
});
|
|
1131
|
+
});
|
|
1132
|
+
}
|
|
1133
|
+
/**
|
|
1134
|
+
* Register the study tab's API routes under `/lookatstudy/api/*`: the polling
|
|
1135
|
+
* state feed plus the tab's write actions.
|
|
1136
|
+
* @param webServer - the composed webserver's route registry.
|
|
1137
|
+
* @param deps - store plus the study-workspace directory.
|
|
1138
|
+
* @returns the disposer removing every route.
|
|
1139
|
+
*/
|
|
1140
|
+
function registerDashboard(webServer, deps) {
|
|
1141
|
+
const disposeRoutes = webServer.register({
|
|
1142
|
+
kind: "prefix",
|
|
1143
|
+
path: "/lookatstudy",
|
|
1144
|
+
handler: async (req, res) => {
|
|
1145
|
+
const pathname = new URL(req.url ?? "/", "http://x").pathname;
|
|
1146
|
+
if (req.method === "GET" && pathname === "/lookatstudy/api/state") {
|
|
1147
|
+
sendJson(res, 200, workbenchState(deps.store.get(), /* @__PURE__ */ new Date()));
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
if (req.method === "POST" && pathname === "/lookatstudy/api/focus") {
|
|
1151
|
+
const body = await readJsonBodySafe(req, res);
|
|
1152
|
+
if (body === void 0) return;
|
|
1153
|
+
if (typeof body.lessonId !== "string") {
|
|
1154
|
+
sendJson(res, 400, {
|
|
1155
|
+
ok: false,
|
|
1156
|
+
error: "lessonId (string) required"
|
|
1157
|
+
});
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
try {
|
|
1161
|
+
const ref = findLesson(deps.store.get(), body.lessonId);
|
|
1162
|
+
deps.store.get().focus = { lessonId: ref.lesson.id };
|
|
1163
|
+
deps.store.save();
|
|
1164
|
+
sendJson(res, 200, { ok: true });
|
|
1165
|
+
} catch (error) {
|
|
1166
|
+
sendJson(res, 404, {
|
|
1167
|
+
ok: false,
|
|
1168
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
if (req.method === "GET" && pathname === "/lookatstudy/api/study-workspace") {
|
|
1174
|
+
sendJson(res, 200, {
|
|
1175
|
+
ok: true,
|
|
1176
|
+
path: deps.studyAreaPath
|
|
1177
|
+
});
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
if (req.method === "POST" && pathname === "/lookatstudy/api/course/delete") {
|
|
1181
|
+
const body = await readJsonBodySafe(req, res);
|
|
1182
|
+
if (body === void 0) return;
|
|
1183
|
+
if (typeof body.courseId !== "string") {
|
|
1184
|
+
sendJson(res, 400, {
|
|
1185
|
+
ok: false,
|
|
1186
|
+
error: "courseId (string) required"
|
|
1187
|
+
});
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
try {
|
|
1191
|
+
const course = findCourse(deps.store.get(), body.courseId);
|
|
1192
|
+
deleteCourse(deps.store.get(), course.id);
|
|
1193
|
+
if (deps.store.get().focus?.lessonId.startsWith(`${course.id}:`)) deps.store.get().focus = null;
|
|
1194
|
+
deps.store.save();
|
|
1195
|
+
sendJson(res, 200, { ok: true });
|
|
1196
|
+
} catch (error) {
|
|
1197
|
+
sendJson(res, 404, {
|
|
1198
|
+
ok: false,
|
|
1199
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
if (req.method === "POST" && pathname === "/lookatstudy/api/lesson-session") {
|
|
1205
|
+
const body = await readJsonBodySafe(req, res);
|
|
1206
|
+
if (body === void 0) return;
|
|
1207
|
+
if (typeof body.lessonId !== "string" || typeof body.sessionId !== "string") {
|
|
1208
|
+
sendJson(res, 400, {
|
|
1209
|
+
ok: false,
|
|
1210
|
+
error: "lessonId and sessionId (strings) required"
|
|
1211
|
+
});
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
deps.store.get().lessonSessions[body.lessonId] = body.sessionId;
|
|
1215
|
+
deps.store.save();
|
|
1216
|
+
sendJson(res, 200, { ok: true });
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
if (req.method === "POST" && pathname === "/lookatstudy/api/mode") {
|
|
1220
|
+
const body = await readJsonBodySafe(req, res);
|
|
1221
|
+
if (body === void 0) return;
|
|
1222
|
+
if (body.mode !== "direct" && body.mode !== "guide" && body.mode !== "practice") {
|
|
1223
|
+
sendJson(res, 400, {
|
|
1224
|
+
ok: false,
|
|
1225
|
+
error: "mode must be direct | guide | practice"
|
|
1226
|
+
});
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
deps.store.get().mode = body.mode;
|
|
1230
|
+
deps.store.save();
|
|
1231
|
+
sendJson(res, 200, {
|
|
1232
|
+
ok: true,
|
|
1233
|
+
mode: body.mode
|
|
1234
|
+
});
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
sendJson(res, 404, {
|
|
1238
|
+
ok: false,
|
|
1239
|
+
error: "not found"
|
|
1240
|
+
});
|
|
1241
|
+
}
|
|
1242
|
+
});
|
|
1243
|
+
return () => {
|
|
1244
|
+
disposeRoutes();
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1247
|
+
//#endregion
|
|
1248
|
+
//#region src/vendor/markdown-course.ts
|
|
1249
|
+
/**
|
|
1250
|
+
* GitHub 风格的 anchor 生成:小写、去一组标点(保留中文等 unicode)、每个空格单独转 -。
|
|
1251
|
+
* 注意:**不合并多 -**("A & B" → 去 & 留两空格 → "a--b",与 GitHub slugger 一致)。
|
|
1252
|
+
* 与 seed.ts 的锚点对齐。
|
|
1253
|
+
*/
|
|
1254
|
+
function titleToAnchor(title) {
|
|
1255
|
+
return title.toLowerCase().trim().replace(/[!"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~]/g, "").replace(/ /g, "-").replace(/^-|-$/g, "");
|
|
1256
|
+
}
|
|
1257
|
+
/**
|
|
1258
|
+
* 清洗课时/章节标题 — 去 emoji、多余空格、markdown 格式符号。
|
|
1259
|
+
* "🛠 The Modern FDE Stack" → "The Modern FDE Stack"
|
|
1260
|
+
* "## [Pre-lecture quiz](url)" → "Pre-lecture quiz"
|
|
1261
|
+
*/
|
|
1262
|
+
function cleanTitle(raw) {
|
|
1263
|
+
return raw.replace(/[\u{1F000}-\u{1FFFF}\u{2600}-\u{27BF}\u{2190}-\u{21FF}\u{2B00}-\u{2BFF}]/gu, "").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/^#+\s*/, "").trim().replace(/\s+/g, " ").replace(/^[·\-\.\s]+|[·\-\.\s]+$/g, "").trim();
|
|
1264
|
+
}
|
|
1265
|
+
/**
|
|
1266
|
+
* 解析 markdown 为课程树。
|
|
1267
|
+
* 容错:H3 出现在任何 H2 之前 → 归到一个 "(前言)" section。
|
|
1268
|
+
*/
|
|
1269
|
+
function parseMarkdownToCourse(md) {
|
|
1270
|
+
const lines = md.split(/\r?\n/);
|
|
1271
|
+
const sections = [];
|
|
1272
|
+
let title = "(untitled)";
|
|
1273
|
+
let currentSection = null;
|
|
1274
|
+
let bodyBuffer = [];
|
|
1275
|
+
let inCodeFence = false;
|
|
1276
|
+
const flushLessonBody = () => {
|
|
1277
|
+
if (currentSection && currentSection.lessons.length > 0) currentSection.lessons[currentSection.lessons.length - 1].body = bodyBuffer.join("\n").trim();
|
|
1278
|
+
bodyBuffer = [];
|
|
1279
|
+
};
|
|
1280
|
+
for (const line of lines) {
|
|
1281
|
+
if (/^(\s*)(```|~~~)/.test(line)) {
|
|
1282
|
+
inCodeFence = !inCodeFence;
|
|
1283
|
+
bodyBuffer.push(line);
|
|
1284
|
+
continue;
|
|
1285
|
+
}
|
|
1286
|
+
if (inCodeFence) {
|
|
1287
|
+
bodyBuffer.push(line);
|
|
1288
|
+
continue;
|
|
1289
|
+
}
|
|
1290
|
+
if (/^#\s+/.test(line) && title === "(untitled)") {
|
|
1291
|
+
title = cleanTitle(line.replace(/^#\s+/, "").trim());
|
|
1292
|
+
continue;
|
|
1293
|
+
}
|
|
1294
|
+
if (/^##\s+/.test(line)) {
|
|
1295
|
+
flushLessonBody();
|
|
1296
|
+
const sectionTitle = cleanTitle(line.replace(/^##\s+/, "").trim());
|
|
1297
|
+
currentSection = {
|
|
1298
|
+
title: sectionTitle,
|
|
1299
|
+
anchor: titleToAnchor(sectionTitle),
|
|
1300
|
+
lessons: []
|
|
1301
|
+
};
|
|
1302
|
+
sections.push(currentSection);
|
|
1303
|
+
continue;
|
|
1304
|
+
}
|
|
1305
|
+
if (/^###\s+/.test(line)) {
|
|
1306
|
+
flushLessonBody();
|
|
1307
|
+
const lessonTitle = cleanTitle(line.replace(/^###\s+/, "").trim());
|
|
1308
|
+
if (!currentSection) {
|
|
1309
|
+
currentSection = {
|
|
1310
|
+
title: "(前言)",
|
|
1311
|
+
anchor: titleToAnchor("前言"),
|
|
1312
|
+
lessons: []
|
|
1313
|
+
};
|
|
1314
|
+
sections.push(currentSection);
|
|
1315
|
+
}
|
|
1316
|
+
currentSection.lessons.push({
|
|
1317
|
+
title: lessonTitle,
|
|
1318
|
+
anchor: titleToAnchor(lessonTitle),
|
|
1319
|
+
body: ""
|
|
1320
|
+
});
|
|
1321
|
+
continue;
|
|
1322
|
+
}
|
|
1323
|
+
bodyBuffer.push(line);
|
|
1324
|
+
}
|
|
1325
|
+
flushLessonBody();
|
|
1326
|
+
return {
|
|
1327
|
+
title,
|
|
1328
|
+
sections
|
|
1329
|
+
};
|
|
1330
|
+
}
|
|
1331
|
+
//#endregion
|
|
1332
|
+
//#region src/vendor/local-folder-scanner.ts
|
|
1333
|
+
/**
|
|
1334
|
+
* 本地文件夹通用扫描器 —— 把任意课程文件夹(如 Coursera 下载包)递归扫描成文档清单。
|
|
1335
|
+
*
|
|
1336
|
+
* 设计原则:通用,不硬编码某一种文件夹结构。
|
|
1337
|
+
* - 扫描文档类:.txt/.md/.mdx/.markdown/.html/.htm/.pdf/.ipynb/.rst/.rmd/.org/.adoc/.asciidoc
|
|
1338
|
+
* - 扫描代码类:.py/.js/.ts/.go/.rs/.java/.c/.cpp/.rb/.sh/.lua/.sql/.r/.jl/.dart/... (30+ 语言, code-parser 转 markdown)
|
|
1339
|
+
* - 图片文件:.png/.jpg/.jpeg/.gif/.webp/.svg/.bmp/.avif/.ico/.tiff/.heic(多模态 flag on 时收集)
|
|
1340
|
+
* - 中文优先去重(同内容 .zh-CN 和 .en 只留中文)
|
|
1341
|
+
* - 按文件名 NN_ 前缀排序
|
|
1342
|
+
* - HTML 去标签转纯文本(<co-content> 富文本质量足够)
|
|
1343
|
+
* - PDF 用 pdf-renderer 提取文字 + 图片(纯文字/纯图片/混合自动分类)
|
|
1344
|
+
*
|
|
1345
|
+
* 纯函数为主(htmlToText/标题推断/去重/图片引用解析),便于 verify 脚本测。
|
|
1346
|
+
* scanFolder 本身用 fs(异步),verify 用临时目录造文件测。
|
|
1347
|
+
*/
|
|
1348
|
+
/** 支持的扩展名 → kind 映射 */
|
|
1349
|
+
const EXT_KIND = {
|
|
1350
|
+
txt: "txt",
|
|
1351
|
+
md: "md",
|
|
1352
|
+
mdx: "md",
|
|
1353
|
+
markdown: "md",
|
|
1354
|
+
html: "html",
|
|
1355
|
+
htm: "html",
|
|
1356
|
+
pdf: "pdf",
|
|
1357
|
+
pptx: "pptx",
|
|
1358
|
+
ipynb: "ipynb",
|
|
1359
|
+
rst: "rst",
|
|
1360
|
+
rmd: "rmd",
|
|
1361
|
+
org: "org",
|
|
1362
|
+
adoc: "adoc",
|
|
1363
|
+
asciidoc: "adoc",
|
|
1364
|
+
py: "code",
|
|
1365
|
+
js: "code",
|
|
1366
|
+
jsx: "code",
|
|
1367
|
+
ts: "code",
|
|
1368
|
+
tsx: "code",
|
|
1369
|
+
mjs: "code",
|
|
1370
|
+
cjs: "code",
|
|
1371
|
+
go: "code",
|
|
1372
|
+
rs: "code",
|
|
1373
|
+
java: "code",
|
|
1374
|
+
kt: "code",
|
|
1375
|
+
kts: "code",
|
|
1376
|
+
scala: "code",
|
|
1377
|
+
c: "code",
|
|
1378
|
+
h: "code",
|
|
1379
|
+
cpp: "code",
|
|
1380
|
+
cc: "code",
|
|
1381
|
+
cxx: "code",
|
|
1382
|
+
hpp: "code",
|
|
1383
|
+
cs: "code",
|
|
1384
|
+
rb: "code",
|
|
1385
|
+
php: "code",
|
|
1386
|
+
swift: "code",
|
|
1387
|
+
sh: "code",
|
|
1388
|
+
bash: "code",
|
|
1389
|
+
zsh: "code",
|
|
1390
|
+
ps1: "code",
|
|
1391
|
+
lua: "code",
|
|
1392
|
+
r: "code",
|
|
1393
|
+
jl: "code",
|
|
1394
|
+
dart: "code",
|
|
1395
|
+
clj: "code",
|
|
1396
|
+
ex: "code",
|
|
1397
|
+
exs: "code",
|
|
1398
|
+
erl: "code",
|
|
1399
|
+
hs: "code",
|
|
1400
|
+
ml: "code",
|
|
1401
|
+
fs: "code",
|
|
1402
|
+
sql: "code",
|
|
1403
|
+
pl: "code",
|
|
1404
|
+
elm: "code"
|
|
1405
|
+
};
|
|
1406
|
+
/** 图片扩展名 → MIME 映射 */
|
|
1407
|
+
const IMAGE_EXT_MIME = {
|
|
1408
|
+
png: "image/png",
|
|
1409
|
+
jpg: "image/jpeg",
|
|
1410
|
+
jpeg: "image/jpeg",
|
|
1411
|
+
gif: "image/gif",
|
|
1412
|
+
webp: "image/webp",
|
|
1413
|
+
svg: "image/svg+xml",
|
|
1414
|
+
bmp: "image/bmp",
|
|
1415
|
+
avif: "image/avif",
|
|
1416
|
+
ico: "image/x-icon",
|
|
1417
|
+
tiff: "image/tiff",
|
|
1418
|
+
tif: "image/tiff",
|
|
1419
|
+
heic: "image/heic"
|
|
1420
|
+
};
|
|
1421
|
+
/** 排除的目录(非教学内容) */
|
|
1422
|
+
const EXCLUDED_DIRS = new Set([
|
|
1423
|
+
"node_modules",
|
|
1424
|
+
".git",
|
|
1425
|
+
".svn",
|
|
1426
|
+
"dist",
|
|
1427
|
+
"build",
|
|
1428
|
+
"__pycache__",
|
|
1429
|
+
".DS_Store",
|
|
1430
|
+
"translations",
|
|
1431
|
+
".venv",
|
|
1432
|
+
"venv",
|
|
1433
|
+
"env",
|
|
1434
|
+
"vendor",
|
|
1435
|
+
"target",
|
|
1436
|
+
"out",
|
|
1437
|
+
"coverage",
|
|
1438
|
+
".next",
|
|
1439
|
+
".nuxt",
|
|
1440
|
+
".gradle",
|
|
1441
|
+
".idea",
|
|
1442
|
+
".vscode",
|
|
1443
|
+
".cache",
|
|
1444
|
+
".pytest_cache",
|
|
1445
|
+
".mypy_cache",
|
|
1446
|
+
".turbo",
|
|
1447
|
+
".svelte-kit",
|
|
1448
|
+
"bin",
|
|
1449
|
+
"obj",
|
|
1450
|
+
"__pypackages__",
|
|
1451
|
+
".docusaurus"
|
|
1452
|
+
]);
|
|
1453
|
+
/** HTML 转纯文本:去 script/style,标签转段落,<li> 加 •,decode 常见实体。纯函数,可测。 */
|
|
1454
|
+
function htmlToText(html) {
|
|
1455
|
+
let s = html;
|
|
1456
|
+
s = s.replace(/<script[\s\S]*?<\/script>/gi, "");
|
|
1457
|
+
s = s.replace(/<style[\s\S]*?<\/style>/gi, "");
|
|
1458
|
+
s = s.replace(/<head[\s\S]*?<\/head>/gi, "");
|
|
1459
|
+
s = s.replace(/<\/(p|div|section|article|h[1-6]|li|tr|br)>/gi, "\n");
|
|
1460
|
+
s = s.replace(/<br\s*\/?>/gi, "\n");
|
|
1461
|
+
s = s.replace(/<li[^>]*>/gi, "• ");
|
|
1462
|
+
s = s.replace(/<\/td>/gi, " ");
|
|
1463
|
+
s = s.replace(/<\/th>/gi, " ");
|
|
1464
|
+
s = s.replace(/<[^>]+>/g, "");
|
|
1465
|
+
s = s.replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, "\"").replace(/'/g, "'").replace(/…/g, "…").replace(/—/g, "—");
|
|
1466
|
+
s = s.replace(/[ \t]+/g, " ");
|
|
1467
|
+
s = s.replace(/\n[ \t]+/g, "\n");
|
|
1468
|
+
s = s.replace(/\n{3,}/g, "\n\n");
|
|
1469
|
+
return s.trim();
|
|
1470
|
+
}
|
|
1471
|
+
/** 从文件名推断语言(用于中文优先去重)。 */
|
|
1472
|
+
function detectLang(filename) {
|
|
1473
|
+
const lower = filename.toLowerCase();
|
|
1474
|
+
if (/\.zh[-_]?cn\./.test(lower) || /\.zh[-_]?hans\./.test(lower) || /\.zh[-_]?tw\./.test(lower) || /\.zh[-_]?hant\./.test(lower) || /\.zh\./.test(lower)) return "zh";
|
|
1475
|
+
if (/\.en[-_]?us\./.test(lower) || /\.en[-_]?gb\./.test(lower) || /\.en\./.test(lower)) return "en";
|
|
1476
|
+
if (/\.ja\./.test(lower) || /\.ko\./.test(lower) || /\.de\./.test(lower) || /\.fr\./.test(lower) || /\.es\./.test(lower) || /\.pt[-_]?br\./.test(lower) || /\.pt\./.test(lower) || /\.it\./.test(lower) || /\.ru\./.test(lower) || /\.ar\./.test(lower)) return "other";
|
|
1477
|
+
return "other";
|
|
1478
|
+
}
|
|
1479
|
+
/** 从路径推断标题:
|
|
1480
|
+
* 07_derivatives-and-tangents.zh-CN.txt → "Derivatives And Tangents"
|
|
1481
|
+
* 01_lesson-1-intro/README.md → "Lesson 1 Intro"
|
|
1482
|
+
* 去数字前缀 + 扩展名 + 语言后缀,- _ 转空格,首字母大写。纯函数,可测。 */
|
|
1483
|
+
function inferTitle(relPath) {
|
|
1484
|
+
const filename = basename(relPath);
|
|
1485
|
+
let name = filename.replace(/\.(txt|md|mdx|markdown|html?|pdf|ipynb|rst|rmd|org|adoc|asciidoc|py|js|jsx|ts|tsx|mjs|cjs|go|rs|java|kt|kts|scala|c|h|cpp|cc|cxx|hpp|cs|rb|php|swift|sh|bash|zsh|ps1|lua|r|jl|dart|clj|ex|exs|erl|hs|ml|fs|sql|pl|elm)$/i, "");
|
|
1486
|
+
name = name.replace(/\.(zh[-_]?cn|zh[-_]?hans|zh|en[-_]?us|en)$/i, "");
|
|
1487
|
+
if (/^(readme|index)$/i.test(name)) {
|
|
1488
|
+
const parts = relPath.split("/").filter(Boolean);
|
|
1489
|
+
const parent = parts[parts.length - 2];
|
|
1490
|
+
if (parent) name = parent;
|
|
1491
|
+
}
|
|
1492
|
+
name = name.replace(/^(\d+[_-]\s*)/, "");
|
|
1493
|
+
name = name.replace(/[-_]+/g, " ").trim();
|
|
1494
|
+
if (/^[a-z]/.test(name)) name = name.charAt(0).toUpperCase() + name.slice(1);
|
|
1495
|
+
return name || filename;
|
|
1496
|
+
}
|
|
1497
|
+
/** 算 basename 的去重 key(去掉语言后缀 + 扩展名)。
|
|
1498
|
+
* 06_motivation.en.txt 和 06_motivation.zh-CN.txt → key "06_motivation" */
|
|
1499
|
+
function dedupKey(relPath) {
|
|
1500
|
+
const dir = dirname(relPath).toLowerCase();
|
|
1501
|
+
let name = basename(relPath).replace(/\.(txt|md|mdx|markdown|html?|pdf|ipynb|rst|rmd|org|adoc|asciidoc|py|js|jsx|ts|tsx|mjs|cjs|go|rs|java|kt|kts|scala|c|h|cpp|cc|cxx|hpp|cs|rb|php|swift|sh|bash|zsh|ps1|lua|r|jl|dart|clj|ex|exs|erl|hs|ml|fs|sql|pl|elm)$/i, "");
|
|
1502
|
+
name = name.replace(/\.(zh[-_]?cn|zh[-_]?hans|zh|en[-_]?us|en)$/i, "");
|
|
1503
|
+
return (dir === "." ? "" : dir + "/") + name.toLowerCase();
|
|
1504
|
+
}
|
|
1505
|
+
/**
|
|
1506
|
+
* 递归扫描一个目录,返回所有文本类文档(可选:同时收集图片)。
|
|
1507
|
+
* 中文优先去重:同 dedupKey 的多语言文件只保留中文(.zh 优先于 .en/other)。
|
|
1508
|
+
* 按相对路径排序(保持目录顺序 + 文件名 NN_ 前缀)。
|
|
1509
|
+
*
|
|
1510
|
+
* @param rootDir 根目录绝对路径
|
|
1511
|
+
* @param onProgress 可选进度回调(已扫文件数,当前路径)
|
|
1512
|
+
* @param options.collectImages true 时同时收集图片文件 + markdown 图片引用(多模态 flag)
|
|
1513
|
+
* @returns 文档数组,或 { docs, images }(collectImages=true 时)
|
|
1514
|
+
*/
|
|
1515
|
+
async function scanFolder(rootDir, onProgress, options) {
|
|
1516
|
+
const allFiles = [];
|
|
1517
|
+
await walkDir(rootDir, rootDir, allFiles);
|
|
1518
|
+
allFiles.sort((a, b) => naturalPathCompare(a.relPath, b.relPath));
|
|
1519
|
+
const docFiles = allFiles.filter((f) => !f.isImage);
|
|
1520
|
+
const imageFiles = allFiles.filter((f) => f.isImage);
|
|
1521
|
+
const docs = [];
|
|
1522
|
+
let count = 0;
|
|
1523
|
+
for (const f of docFiles) {
|
|
1524
|
+
onProgress?.(++count, f.relPath);
|
|
1525
|
+
const kind = EXT_KIND[f.relPath.toLowerCase().match(/\.([^.]+)$/)?.[1] ?? ""];
|
|
1526
|
+
if (!kind) continue;
|
|
1527
|
+
try {
|
|
1528
|
+
const content = await readFileWithKind(f.absPath, kind);
|
|
1529
|
+
if (!content || content.trim().length < 5) continue;
|
|
1530
|
+
const lang = detectLang(f.relPath);
|
|
1531
|
+
docs.push({
|
|
1532
|
+
path: f.relPath,
|
|
1533
|
+
title: inferTitle(f.relPath),
|
|
1534
|
+
content,
|
|
1535
|
+
lang,
|
|
1536
|
+
kind
|
|
1537
|
+
});
|
|
1538
|
+
} catch {}
|
|
1539
|
+
}
|
|
1540
|
+
const dedupedDocs = dedupByLang(docs);
|
|
1541
|
+
if (!options?.collectImages) return dedupedDocs;
|
|
1542
|
+
const fileImages = imageFiles.map((f) => {
|
|
1543
|
+
const ext = f.relPath.toLowerCase().match(/\.([^.]+)$/)?.[1] ?? "";
|
|
1544
|
+
return {
|
|
1545
|
+
path: f.relPath,
|
|
1546
|
+
absPath: f.absPath,
|
|
1547
|
+
title: inferImageTitle(f.relPath),
|
|
1548
|
+
mime: IMAGE_EXT_MIME[ext] ?? "image/png",
|
|
1549
|
+
source: "image_file",
|
|
1550
|
+
altText: inferImageTitle(f.relPath)
|
|
1551
|
+
};
|
|
1552
|
+
});
|
|
1553
|
+
const refImages = [];
|
|
1554
|
+
for (const doc of dedupedDocs) {
|
|
1555
|
+
if (doc.kind === "txt" || doc.kind === "html") continue;
|
|
1556
|
+
const refs = extractImageRefs(doc.content);
|
|
1557
|
+
for (const ref of refs) {
|
|
1558
|
+
const resolvedPath = resolveImageRef(ref.refPath, doc.path);
|
|
1559
|
+
const ext = resolvedPath.toLowerCase().match(/\.([^.]+)$/)?.[1] ?? "";
|
|
1560
|
+
refImages.push({
|
|
1561
|
+
path: resolvedPath,
|
|
1562
|
+
absPath: join(rootDir, resolvedPath),
|
|
1563
|
+
title: ref.alt || inferImageTitle(resolvedPath),
|
|
1564
|
+
mime: IMAGE_EXT_MIME[ext] ?? "image/png",
|
|
1565
|
+
source: "markdown_ref",
|
|
1566
|
+
altText: ref.alt || inferImageTitle(resolvedPath)
|
|
1567
|
+
});
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
const dedupedFileAndRefImages = dedupImages(fileImages, refImages);
|
|
1571
|
+
const pdfImages = [];
|
|
1572
|
+
for (const doc of dedupedDocs) {
|
|
1573
|
+
if (doc.kind !== "pdf") continue;
|
|
1574
|
+
try {
|
|
1575
|
+
const { processPdf } = await import("../lib/pdf-renderer.js");
|
|
1576
|
+
const result = await processPdf(await readFile(join(rootDir, doc.path)));
|
|
1577
|
+
for (const img of result.images) pdfImages.push({
|
|
1578
|
+
path: `${doc.path}#page${img.pageNumber}.png`,
|
|
1579
|
+
absPath: "",
|
|
1580
|
+
title: `${doc.title} - 图(第${img.pageNumber}页)`,
|
|
1581
|
+
mime: img.mimeType,
|
|
1582
|
+
source: "pdf_page",
|
|
1583
|
+
altText: `${doc.title} 第${img.pageNumber}页`,
|
|
1584
|
+
buffer: img.buffer,
|
|
1585
|
+
pageNumber: img.pageNumber
|
|
1586
|
+
});
|
|
1587
|
+
} catch {}
|
|
1588
|
+
}
|
|
1589
|
+
const pptxImages = [];
|
|
1590
|
+
for (const doc of dedupedDocs) {
|
|
1591
|
+
if (doc.kind !== "pptx") continue;
|
|
1592
|
+
try {
|
|
1593
|
+
const { parsePptx } = await import("../lib/pptx-parser.js");
|
|
1594
|
+
const result = await parsePptx(await readFile(join(rootDir, doc.path)));
|
|
1595
|
+
for (const img of result.images) pptxImages.push({
|
|
1596
|
+
path: `${doc.path}#slide${img.slideNumber}.png`,
|
|
1597
|
+
absPath: "",
|
|
1598
|
+
title: `${doc.title} - 图(第${img.slideNumber}页)`,
|
|
1599
|
+
mime: img.mimeType,
|
|
1600
|
+
source: "pdf_page",
|
|
1601
|
+
altText: `${doc.title} 第${img.slideNumber}页`,
|
|
1602
|
+
buffer: img.buffer,
|
|
1603
|
+
pageNumber: img.slideNumber
|
|
1604
|
+
});
|
|
1605
|
+
} catch {}
|
|
1606
|
+
}
|
|
1607
|
+
const notebookImages = [];
|
|
1608
|
+
for (const doc of dedupedDocs) {
|
|
1609
|
+
if (!doc.path.toLowerCase().endsWith(".ipynb")) continue;
|
|
1610
|
+
try {
|
|
1611
|
+
const { parseNotebook } = await import("./notebook-parser-ChbZBIKJ.mjs");
|
|
1612
|
+
const nbResult = parseNotebook(await readFile(join(rootDir, doc.path), "utf8"));
|
|
1613
|
+
for (const img of nbResult.images) {
|
|
1614
|
+
const buf = Buffer.from(img.base64, "base64");
|
|
1615
|
+
notebookImages.push({
|
|
1616
|
+
path: `${doc.path}#cell${img.cellIndex}.png`,
|
|
1617
|
+
absPath: "",
|
|
1618
|
+
title: `${doc.title} - 输出图(cell ${img.cellIndex})`,
|
|
1619
|
+
mime: img.mimeType,
|
|
1620
|
+
source: "image_file",
|
|
1621
|
+
altText: img.altText,
|
|
1622
|
+
buffer: buf
|
|
1623
|
+
});
|
|
1624
|
+
}
|
|
1625
|
+
} catch {}
|
|
1626
|
+
}
|
|
1627
|
+
return {
|
|
1628
|
+
docs: dedupedDocs,
|
|
1629
|
+
images: [
|
|
1630
|
+
...dedupedFileAndRefImages,
|
|
1631
|
+
...pdfImages,
|
|
1632
|
+
...pptxImages,
|
|
1633
|
+
...notebookImages
|
|
1634
|
+
]
|
|
1635
|
+
};
|
|
1636
|
+
}
|
|
1637
|
+
/**
|
|
1638
|
+
* 同语言类别内部去重(保留双语配对)。
|
|
1639
|
+
*
|
|
1640
|
+
* 历史:旧版是跨语言的"中文优先"(同 dedupKey 只留 zh)——那是翻译管线诞生前的
|
|
1641
|
+
* hack,xxx.en.txt / xxx.zh-CN.txt 成对时英文原稿被直接丢掉,双语信息在扫描层
|
|
1642
|
+
* 就没了,翻译管线永远拿不到配对。现在分类层(excludeSuffixTranslations 规则
|
|
1643
|
+
* 分流 + LLM translation 角色)负责把成对双语分流为 原文+翻译,所以扫描器必须
|
|
1644
|
+
* 把配对双方都保留,只合并同一语言类别内部的真重复(如 08.en.txt vs 08.en.md)。
|
|
1645
|
+
*/
|
|
1646
|
+
function dedupByLang(docs) {
|
|
1647
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
1648
|
+
for (const d of docs) {
|
|
1649
|
+
const key = `${dedupKey(d.path)}|${d.lang}`;
|
|
1650
|
+
if (!byKey.has(key)) byKey.set(key, d);
|
|
1651
|
+
}
|
|
1652
|
+
return docs.filter((d) => byKey.get(`${dedupKey(d.path)}|${d.lang}`) === d);
|
|
1653
|
+
}
|
|
1654
|
+
/**
|
|
1655
|
+
* 从 markdown 内容里提取图片引用 。
|
|
1656
|
+
* 纯函数,便于测试。
|
|
1657
|
+
*
|
|
1658
|
+
* 解析规则:
|
|
1659
|
+
* - 匹配  格式
|
|
1660
|
+
* - 去掉路径里的锚点和查询参数后缀
|
|
1661
|
+
* - 只保留图片扩展名(.png/.jpg/.jpeg/.gif/.webp/.svg/.bmp)
|
|
1662
|
+
* - 跳过 http(s) 绝对 URL(这些是外部资源,本地没有文件)
|
|
1663
|
+
* - 跳过 data: URL
|
|
1664
|
+
*/
|
|
1665
|
+
function extractImageRefs(md) {
|
|
1666
|
+
const refs = [];
|
|
1667
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1668
|
+
const mdPattern = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
|
1669
|
+
let m;
|
|
1670
|
+
while ((m = mdPattern.exec(md)) !== null) {
|
|
1671
|
+
const alt = m[1].trim();
|
|
1672
|
+
let url = m[2].trim();
|
|
1673
|
+
const titleMatch = url.match(/\s+"[^"]*"$/);
|
|
1674
|
+
if (titleMatch) url = url.slice(0, titleMatch.index).trim();
|
|
1675
|
+
url = url.split("#")[0];
|
|
1676
|
+
if (!url || url.startsWith("http://") || url.startsWith("https://") || url.startsWith("data:")) continue;
|
|
1677
|
+
if (!((url.toLowerCase().match(/\.([^.]+)$/)?.[1] ?? "") in IMAGE_EXT_MIME)) continue;
|
|
1678
|
+
const key = alt + "|" + url;
|
|
1679
|
+
if (seen.has(key)) continue;
|
|
1680
|
+
seen.add(key);
|
|
1681
|
+
refs.push({
|
|
1682
|
+
alt,
|
|
1683
|
+
refPath: url
|
|
1684
|
+
});
|
|
1685
|
+
}
|
|
1686
|
+
const htmlPattern = /<img\s+[^>]*>/gi;
|
|
1687
|
+
let hm;
|
|
1688
|
+
while ((hm = htmlPattern.exec(md)) !== null) {
|
|
1689
|
+
const tag = hm[0];
|
|
1690
|
+
const url = (tag.match(/src=['"]([^'"]+)['"]/i)?.[1] ?? "").trim().split("#")[0];
|
|
1691
|
+
const alt = (tag.match(/alt=['"]([^'"]*)['"]/i)?.[1] ?? "").trim();
|
|
1692
|
+
if (!url || url.startsWith("http://") || url.startsWith("https://") || url.startsWith("data:")) continue;
|
|
1693
|
+
if (!((url.toLowerCase().match(/\.([^.]+)$/)?.[1] ?? "") in IMAGE_EXT_MIME)) continue;
|
|
1694
|
+
const key = alt + "|" + url;
|
|
1695
|
+
if (seen.has(key)) continue;
|
|
1696
|
+
seen.add(key);
|
|
1697
|
+
refs.push({
|
|
1698
|
+
alt: alt || (url.split("/").pop() ?? url),
|
|
1699
|
+
refPath: url
|
|
1700
|
+
});
|
|
1701
|
+
}
|
|
1702
|
+
return refs;
|
|
1703
|
+
}
|
|
1704
|
+
/**
|
|
1705
|
+
* 把 markdown 图片引用解析成相对于扫描根目录的路径。
|
|
1706
|
+
* 处理 ./ ../ 等相对引用。
|
|
1707
|
+
*
|
|
1708
|
+
* @param refPath markdown 里的原始引用(如 ./img.png)
|
|
1709
|
+
* @param docRelPath 引用所在文档的相对路径(如 ch1/lesson1/notes.md)
|
|
1710
|
+
* @returns 相对根目录的标准化路径(如 ch1/lesson1/img.png),用 / 分隔
|
|
1711
|
+
*
|
|
1712
|
+
* 纯函数,便于测试。
|
|
1713
|
+
*/
|
|
1714
|
+
function resolveImageRef(refPath, docRelPath) {
|
|
1715
|
+
const docDir = dirname(docRelPath).replace(/\\/g, "/");
|
|
1716
|
+
const normalized = refPath.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
1717
|
+
const parts = docDir === "." ? [] : docDir.split("/").filter(Boolean);
|
|
1718
|
+
const refParts = normalized.split("/");
|
|
1719
|
+
for (const p of refParts) if (p === "..") parts.pop();
|
|
1720
|
+
else if (p !== "." && p !== "") parts.push(p);
|
|
1721
|
+
return parts.join("/");
|
|
1722
|
+
}
|
|
1723
|
+
/** 从图片文件名推断 alt 文本(去扩展名 + 数字前缀) */
|
|
1724
|
+
function inferImageTitle(filename) {
|
|
1725
|
+
let name = basename(filename);
|
|
1726
|
+
name = name.replace(/\.(png|jpe?g|gif|webp|svg|bmp)$/i, "");
|
|
1727
|
+
name = name.replace(/^(\d+[_-]\s*)/, "");
|
|
1728
|
+
name = name.replace(/[-_]+/g, " ").trim();
|
|
1729
|
+
if (/^[a-z]/.test(name)) name = name.charAt(0).toUpperCase() + name.slice(1);
|
|
1730
|
+
return name || basename(filename);
|
|
1731
|
+
}
|
|
1732
|
+
/**
|
|
1733
|
+
* 把独立图片文件 + markdown 引用合并去重。
|
|
1734
|
+
* 去重规则:按相对根目录路径归一。同一图既被 .md 引用又是独立文件 → 只留一份(image_file 优先,因为它肯定存在)。
|
|
1735
|
+
*
|
|
1736
|
+
* 纯函数,便于测试。
|
|
1737
|
+
*/
|
|
1738
|
+
function dedupImages(fileImages, refImages) {
|
|
1739
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1740
|
+
for (const img of fileImages) if (!seen.has(img.path)) seen.set(img.path, img);
|
|
1741
|
+
for (const img of refImages) if (!seen.has(img.path)) seen.set(img.path, img);
|
|
1742
|
+
return Array.from(seen.values());
|
|
1743
|
+
}
|
|
1744
|
+
async function walkDir(root, current, acc) {
|
|
1745
|
+
let entries;
|
|
1746
|
+
try {
|
|
1747
|
+
entries = await readdir(current, { withFileTypes: true });
|
|
1748
|
+
} catch {
|
|
1749
|
+
return;
|
|
1750
|
+
}
|
|
1751
|
+
for (const entry of entries) {
|
|
1752
|
+
if (EXCLUDED_DIRS.has(entry.name)) continue;
|
|
1753
|
+
const abs = join(current, entry.name);
|
|
1754
|
+
if (entry.isDirectory()) await walkDir(root, abs, acc);
|
|
1755
|
+
else if (entry.isFile()) {
|
|
1756
|
+
const ext = entry.name.toLowerCase().match(/\.([^.]+)$/)?.[1] ?? "";
|
|
1757
|
+
if (ext in EXT_KIND) {
|
|
1758
|
+
const rel = relative(root, abs).split(sep).join("/");
|
|
1759
|
+
acc.push({
|
|
1760
|
+
absPath: abs,
|
|
1761
|
+
relPath: rel,
|
|
1762
|
+
isImage: false
|
|
1763
|
+
});
|
|
1764
|
+
} else if (ext in IMAGE_EXT_MIME) {
|
|
1765
|
+
const rel = relative(root, abs).split(sep).join("/");
|
|
1766
|
+
acc.push({
|
|
1767
|
+
absPath: abs,
|
|
1768
|
+
relPath: rel,
|
|
1769
|
+
isImage: true
|
|
1770
|
+
});
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
async function readFileWithKind(absPath, kind) {
|
|
1776
|
+
if (kind === "pdf") {
|
|
1777
|
+
const buf = await readFile(absPath);
|
|
1778
|
+
const { parsePdfText } = await import("../lib/pdf-text.js");
|
|
1779
|
+
return parsePdfText(buf);
|
|
1780
|
+
}
|
|
1781
|
+
if (kind === "pptx") {
|
|
1782
|
+
const buf = await readFile(absPath);
|
|
1783
|
+
const { parsePptx } = await import("../lib/pptx-parser.js");
|
|
1784
|
+
return (await parsePptx(buf)).markdown;
|
|
1785
|
+
}
|
|
1786
|
+
if (kind === "ipynb") {
|
|
1787
|
+
const raw = await readFile(absPath, "utf8");
|
|
1788
|
+
const { parseNotebook } = await import("./notebook-parser-ChbZBIKJ.mjs");
|
|
1789
|
+
return parseNotebook(raw).markdown;
|
|
1790
|
+
}
|
|
1791
|
+
if (kind === "rst" || kind === "rmd" || kind === "org" || kind === "adoc") {
|
|
1792
|
+
const raw = await readFile(absPath, "utf8");
|
|
1793
|
+
const parser = {
|
|
1794
|
+
rst: "rst-parser",
|
|
1795
|
+
rmd: "rmd-parser",
|
|
1796
|
+
org: "org-parser",
|
|
1797
|
+
adoc: "adoc-parser"
|
|
1798
|
+
}[kind];
|
|
1799
|
+
if (parser) try {
|
|
1800
|
+
const mod = await import(`./${parser}.js`);
|
|
1801
|
+
return (mod.parseRst ?? mod.parseRmd ?? mod.parseOrg ?? mod.parseAdoc)(raw).markdown;
|
|
1802
|
+
} catch {
|
|
1803
|
+
return raw;
|
|
1804
|
+
}
|
|
1805
|
+
return raw;
|
|
1806
|
+
}
|
|
1807
|
+
if (kind === "code") {
|
|
1808
|
+
const raw = await readFile(absPath, "utf8");
|
|
1809
|
+
const ext = absPath.toLowerCase().match(/\.([^.]+)$/)?.[1] ?? "";
|
|
1810
|
+
try {
|
|
1811
|
+
const { parseCode } = await import("./code-parser-BOOk9IWV.mjs");
|
|
1812
|
+
return parseCode(raw, ext).markdown;
|
|
1813
|
+
} catch {
|
|
1814
|
+
return "```\n" + raw + "\n```";
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
const raw = await readFile(absPath, "utf8");
|
|
1818
|
+
return kind === "html" ? htmlToText(raw) : raw;
|
|
1819
|
+
}
|
|
1820
|
+
/** 路径自然排序:按段拆分,数字段按数值比较(02_ 在 10_ 前,不是字典序)。 */
|
|
1821
|
+
function naturalPathCompare(a, b) {
|
|
1822
|
+
const pa = a.split("/");
|
|
1823
|
+
const pb = b.split("/");
|
|
1824
|
+
for (let i = 0; i < Math.min(pa.length, pb.length); i++) {
|
|
1825
|
+
const na = pa[i].match(/^(\d+)/)?.[1];
|
|
1826
|
+
const nb = pb[i].match(/^(\d+)/)?.[1];
|
|
1827
|
+
if (na && nb && na !== nb) return Number(na) - Number(nb);
|
|
1828
|
+
if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1;
|
|
1829
|
+
}
|
|
1830
|
+
return pa.length - pb.length;
|
|
1831
|
+
}
|
|
1832
|
+
//#endregion
|
|
1833
|
+
//#region src/vendor/file-classifier.ts
|
|
1834
|
+
/** 仓库元数据文件名(忽略大小写,匹配文件名 stem) */
|
|
1835
|
+
const META_FILE_NAMES = new Set([
|
|
1836
|
+
"license",
|
|
1837
|
+
"licence",
|
|
1838
|
+
"contributing",
|
|
1839
|
+
"code_of_conduct",
|
|
1840
|
+
"security",
|
|
1841
|
+
"changelog",
|
|
1842
|
+
"authors",
|
|
1843
|
+
"maintainers",
|
|
1844
|
+
"pull_request_template",
|
|
1845
|
+
"issue_template",
|
|
1846
|
+
"support",
|
|
1847
|
+
"citation"
|
|
1848
|
+
]);
|
|
1849
|
+
/** 配套练习目录/文件名关键词(路径含这些子串即判定) */
|
|
1850
|
+
const LAB_KEYWORDS = [
|
|
1851
|
+
"/lab/",
|
|
1852
|
+
"/labs/",
|
|
1853
|
+
"/exercise/",
|
|
1854
|
+
"/exercises/",
|
|
1855
|
+
"/assignment/",
|
|
1856
|
+
"/assignments/",
|
|
1857
|
+
"/quiz/",
|
|
1858
|
+
"/quizzes/",
|
|
1859
|
+
"/homework/",
|
|
1860
|
+
"/practice/",
|
|
1861
|
+
"/solution/",
|
|
1862
|
+
"labs/",
|
|
1863
|
+
"exercises/",
|
|
1864
|
+
"assignments/"
|
|
1865
|
+
];
|
|
1866
|
+
/** 示例代码目录关键词(路径含这些子串即判定,含根目录开头) */
|
|
1867
|
+
const EXAMPLE_KEYWORDS = [
|
|
1868
|
+
"/examples/",
|
|
1869
|
+
"/example/",
|
|
1870
|
+
"/demo/",
|
|
1871
|
+
"/demos/",
|
|
1872
|
+
"/samples/",
|
|
1873
|
+
"/sample/",
|
|
1874
|
+
"examples/",
|
|
1875
|
+
"example/",
|
|
1876
|
+
"demo/",
|
|
1877
|
+
"demos/",
|
|
1878
|
+
"samples/"
|
|
1879
|
+
];
|
|
1880
|
+
/**
|
|
1881
|
+
* 判断一个文件是否是 section-intro:它是某个 section 的 README.md,
|
|
1882
|
+
* 且同 section 下有**更深一级的 README.md lesson**(不是 lab/notebook)。
|
|
1883
|
+
*
|
|
1884
|
+
* 例:
|
|
1885
|
+
* `lessons/3-NN/README.md` 是 section-intro ← 因为有 `lessons/3-NN/03-Perceptron/README.md`
|
|
1886
|
+
* `lessons/3-NN/03-Perceptron/README.md` 不是 ← 虽然 03-Perceptron/ 下有 lab/README.md,
|
|
1887
|
+
* 但 lab 不是 lesson,不能用来判定 lesson 是 intro
|
|
1888
|
+
*/
|
|
1889
|
+
function isSectionIntro(path, siblingPaths) {
|
|
1890
|
+
const parts = path.split("/").filter(Boolean);
|
|
1891
|
+
const last = parts[parts.length - 1];
|
|
1892
|
+
if (!last || !(/^readme/i.test(last) || last === "index.md")) return false;
|
|
1893
|
+
const myDepth = parts.length;
|
|
1894
|
+
if (myDepth < 3) return false;
|
|
1895
|
+
const prefix = parts.slice(0, -1).join("/");
|
|
1896
|
+
return siblingPaths.some((sib) => {
|
|
1897
|
+
if (sib === path) return false;
|
|
1898
|
+
const sibLower = sib.toLowerCase();
|
|
1899
|
+
if (sibLower.includes("/lab/") || sibLower.includes("/exercise/") || sibLower.includes("/assignment/")) return false;
|
|
1900
|
+
if (sibLower.endsWith(".ipynb")) return false;
|
|
1901
|
+
const sibParts = sib.split("/").filter(Boolean);
|
|
1902
|
+
return sibParts.slice(0, -1).join("/").startsWith(prefix + "/") && sibParts.length > myDepth;
|
|
1903
|
+
});
|
|
1904
|
+
}
|
|
1905
|
+
/**
|
|
1906
|
+
* 主分类函数:first-match-wins 级联规则。
|
|
1907
|
+
*
|
|
1908
|
+
* @param path 文件路径(相对 repo 根,/ 分隔)
|
|
1909
|
+
* @param md 文件正文(已转成 markdown)
|
|
1910
|
+
* @param context 分类上下文(siblingPaths = 同批次所有文件路径)
|
|
1911
|
+
*/
|
|
1912
|
+
function classifyFile(path, _md, context) {
|
|
1913
|
+
const lowerPath = path.toLowerCase();
|
|
1914
|
+
const parts = path.split("/").filter(Boolean);
|
|
1915
|
+
const stem = (parts[parts.length - 1] ?? path).replace(/\.[^.]+$/, "").toLowerCase();
|
|
1916
|
+
if (lowerPath.includes("translations/") || lowerPath.includes("translated_images/")) return {
|
|
1917
|
+
role: "translation",
|
|
1918
|
+
confidence: "high",
|
|
1919
|
+
keepAsLesson: false,
|
|
1920
|
+
world: null,
|
|
1921
|
+
reason: "路径含 translations/,是翻译副本"
|
|
1922
|
+
};
|
|
1923
|
+
if (META_FILE_NAMES.has(stem)) return {
|
|
1924
|
+
role: "meta",
|
|
1925
|
+
confidence: "high",
|
|
1926
|
+
keepAsLesson: false,
|
|
1927
|
+
world: null,
|
|
1928
|
+
reason: `文件名 ${stem} 是仓库元数据`
|
|
1929
|
+
};
|
|
1930
|
+
if (lowerPath.endsWith(".ipynb")) return {
|
|
1931
|
+
role: "uncertain",
|
|
1932
|
+
confidence: "low",
|
|
1933
|
+
keepAsLesson: true,
|
|
1934
|
+
world: null,
|
|
1935
|
+
reason: ".ipynb notebook——可能是主课程(fast.ai/d2l 风格)也可能是补充代码,交给 LLM 判断"
|
|
1936
|
+
};
|
|
1937
|
+
for (const kw of LAB_KEYWORDS) if (lowerPath.includes(kw)) return {
|
|
1938
|
+
role: "uncertain",
|
|
1939
|
+
confidence: "low",
|
|
1940
|
+
keepAsLesson: true,
|
|
1941
|
+
world: null,
|
|
1942
|
+
reason: `路径含 ${kw}——可能是配套练习也可能是课时正文,交给 LLM 判断`
|
|
1943
|
+
};
|
|
1944
|
+
for (const kw of EXAMPLE_KEYWORDS) if (lowerPath.includes(kw)) return {
|
|
1945
|
+
role: "uncertain",
|
|
1946
|
+
confidence: "low",
|
|
1947
|
+
keepAsLesson: true,
|
|
1948
|
+
world: null,
|
|
1949
|
+
reason: `路径含 ${kw}——可能是示例代码也可能是课时正文,交给 LLM 判断`
|
|
1950
|
+
};
|
|
1951
|
+
if (isSectionIntro(path, context.siblingPaths)) return {
|
|
1952
|
+
role: "section-intro",
|
|
1953
|
+
confidence: "high",
|
|
1954
|
+
keepAsLesson: false,
|
|
1955
|
+
world: "study",
|
|
1956
|
+
reason: "章节介绍页(同 section 有更深的 lesson 文件)"
|
|
1957
|
+
};
|
|
1958
|
+
return {
|
|
1959
|
+
role: "uncertain",
|
|
1960
|
+
confidence: "low",
|
|
1961
|
+
keepAsLesson: true,
|
|
1962
|
+
world: null,
|
|
1963
|
+
reason: "规则未命中高置信度分类,交给 LLM 判断"
|
|
1964
|
+
};
|
|
1965
|
+
}
|
|
1966
|
+
//#endregion
|
|
1967
|
+
//#region src/vendor/repo-fetcher.ts
|
|
1968
|
+
/**
|
|
1969
|
+
* 仓库导入器 —— 从学习型 GitHub 仓库构建课程结构。
|
|
1970
|
+
*
|
|
1971
|
+
* 核心策略:不依赖文件列表 API(api.github.com / api.jsdelivr.net 在很多网络环境下不可达),
|
|
1972
|
+
* 而是从 README.md 的 markdown 内部链接发现课程结构。
|
|
1973
|
+
*
|
|
1974
|
+
* 学习仓库的 README 通常有完整的课程大纲,链接指向每个课时:
|
|
1975
|
+
* - 形态 A(课程型): 链接指向 lessons/N-Topic/README.md + .ipynb
|
|
1976
|
+
* - 形态 B(单文件型): README 本身是超长文档,无子文件链接
|
|
1977
|
+
*
|
|
1978
|
+
* 数据源: cdn.jsdelivr.net/gh/{owner}/{repo}@{branch}/{path}(全球 CDN,无速率限制,
|
|
1979
|
+
* 在大多数网络环境下可用,包括 raw.githubusercontent.com 被墙的情况)
|
|
1980
|
+
*
|
|
1981
|
+
* 纯函数设计: fetchFn 由调用方注入(生产用 global fetch,测试用 mock)。
|
|
1982
|
+
*/
|
|
1983
|
+
/** CDN URL 构造 */
|
|
1984
|
+
function cdnUrl(owner, repo, branch, path) {
|
|
1985
|
+
return `https://cdn.jsdelivr.net/gh/${owner}/${repo}@${branch}/${path.replace(/^\.\//, "").replace(/^\//, "")}`;
|
|
1986
|
+
}
|
|
1987
|
+
/** 代码文件扩展名(代码即教学内容) */
|
|
1988
|
+
const CODE_EXTENSIONS = [
|
|
1989
|
+
".py",
|
|
1990
|
+
".js",
|
|
1991
|
+
".jsx",
|
|
1992
|
+
".ts",
|
|
1993
|
+
".tsx",
|
|
1994
|
+
".mjs",
|
|
1995
|
+
".cjs",
|
|
1996
|
+
".go",
|
|
1997
|
+
".rs",
|
|
1998
|
+
".java",
|
|
1999
|
+
".kt",
|
|
2000
|
+
".kts",
|
|
2001
|
+
".scala",
|
|
2002
|
+
".c",
|
|
2003
|
+
".h",
|
|
2004
|
+
".cpp",
|
|
2005
|
+
".cc",
|
|
2006
|
+
".cxx",
|
|
2007
|
+
".hpp",
|
|
2008
|
+
".cs",
|
|
2009
|
+
".rb",
|
|
2010
|
+
".php",
|
|
2011
|
+
".swift",
|
|
2012
|
+
".sh",
|
|
2013
|
+
".bash",
|
|
2014
|
+
".zsh",
|
|
2015
|
+
".ps1",
|
|
2016
|
+
".lua",
|
|
2017
|
+
".r",
|
|
2018
|
+
".jl",
|
|
2019
|
+
".dart",
|
|
2020
|
+
".clj",
|
|
2021
|
+
".ex",
|
|
2022
|
+
".exs",
|
|
2023
|
+
".erl",
|
|
2024
|
+
".hs",
|
|
2025
|
+
".ml",
|
|
2026
|
+
".fs",
|
|
2027
|
+
".sql",
|
|
2028
|
+
".pl",
|
|
2029
|
+
".elm"
|
|
2030
|
+
];
|
|
2031
|
+
/**
|
|
2032
|
+
* 从 README 的 markdown 链接提取内部文件引用。
|
|
2033
|
+
* 只看相对路径(非 http/锚点),且指向 .md/.ipynb 文件。
|
|
2034
|
+
*/
|
|
2035
|
+
function extractInternalLinks(readmeMd) {
|
|
2036
|
+
const linkPattern = /\[([^\]]*)\]\(([^)]+)\)/g;
|
|
2037
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2038
|
+
const files = [];
|
|
2039
|
+
let m;
|
|
2040
|
+
while ((m = linkPattern.exec(readmeMd)) !== null) {
|
|
2041
|
+
const title = m[1].trim();
|
|
2042
|
+
let href = m[2].trim();
|
|
2043
|
+
href = href.split("#")[0];
|
|
2044
|
+
if (!href || href.startsWith("http") || href.startsWith("mailto:")) continue;
|
|
2045
|
+
href = href.replace(/^\.\//, "");
|
|
2046
|
+
let kind = "other";
|
|
2047
|
+
if (href.endsWith(".md") || href.endsWith(".mdx")) kind = "md";
|
|
2048
|
+
else if (href.endsWith(".ipynb")) kind = "ipynb";
|
|
2049
|
+
else if (href.endsWith(".rst")) kind = "rst";
|
|
2050
|
+
else if (href.endsWith(".rmd")) kind = "rmd";
|
|
2051
|
+
else if (href.endsWith(".org")) kind = "org";
|
|
2052
|
+
else if (href.endsWith(".adoc") || href.endsWith(".asciidoc")) kind = "adoc";
|
|
2053
|
+
else if (CODE_EXTENSIONS.some((ext) => href.endsWith(ext))) kind = "code";
|
|
2054
|
+
else continue;
|
|
2055
|
+
if (seen.has(href)) continue;
|
|
2056
|
+
seen.add(href);
|
|
2057
|
+
files.push({
|
|
2058
|
+
path: href,
|
|
2059
|
+
title: title || href,
|
|
2060
|
+
kind
|
|
2061
|
+
});
|
|
2062
|
+
}
|
|
2063
|
+
return files;
|
|
2064
|
+
}
|
|
2065
|
+
/**
|
|
2066
|
+
* 过滤:只保留像课时文件的(排除 translations/、lab/、translations、LICENSE 等)
|
|
2067
|
+
*/
|
|
2068
|
+
function filterLessonFiles(files) {
|
|
2069
|
+
return files.filter((f) => {
|
|
2070
|
+
const p = f.path.toLowerCase();
|
|
2071
|
+
if (p.includes("translations/")) return false;
|
|
2072
|
+
if (p.endsWith("license.md") || p.endsWith("contributing.md") || p.endsWith("code_of_conduct.md")) return false;
|
|
2073
|
+
return true;
|
|
2074
|
+
});
|
|
2075
|
+
}
|
|
2076
|
+
/**
|
|
2077
|
+
* 规则高置信度检测:仓库是否已用编号目录组织好课程结构。
|
|
2078
|
+
*
|
|
2079
|
+
* 判定依据:文件路径里有 ≥3 个不同的编号顶层目录(如 lessons/1-Intro/,
|
|
2080
|
+
* lessons/2-Symbolic/, lessons/3-NeuralNetworks/)。编号前缀 = 作者刻意组织。
|
|
2081
|
+
*
|
|
2082
|
+
* 这是确定性判断(规则管),不交给 LLM。
|
|
2083
|
+
* 命中 → pattern: "well-organized",下游只判 world 不重组章节。
|
|
2084
|
+
*/
|
|
2085
|
+
function detectWellOrganized(files) {
|
|
2086
|
+
const topicDirs = /* @__PURE__ */ new Set();
|
|
2087
|
+
const ORGANIZED_PREFIXES = /^(week|unit|part|topic|lecture|session|day|step)(\d|[-_])/i;
|
|
2088
|
+
for (const f of files) {
|
|
2089
|
+
const parts = f.path.split("/").filter(Boolean);
|
|
2090
|
+
for (const part of parts) {
|
|
2091
|
+
if (part.includes(".")) continue;
|
|
2092
|
+
if (part.match(/^(\d+[-_])/i)) {
|
|
2093
|
+
topicDirs.add(part.toLowerCase());
|
|
2094
|
+
break;
|
|
2095
|
+
}
|
|
2096
|
+
if (ORGANIZED_PREFIXES.test(part)) {
|
|
2097
|
+
topicDirs.add(part.toLowerCase());
|
|
2098
|
+
break;
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
return topicDirs.size >= 3;
|
|
2103
|
+
}
|
|
2104
|
+
/**
|
|
2105
|
+
* 检测仓库形态。
|
|
2106
|
+
*
|
|
2107
|
+
* 原则:规则管确定性,不确定的给 LLM 兜底(通过下游 analyzeCourseStructure)。
|
|
2108
|
+
*
|
|
2109
|
+
* - well-organized: README 链接 ≥1 个且路径有编号/组织目录(数字/week/unit/topic) → 保留原始结构
|
|
2110
|
+
* - course: README 链接里有 ≥1 个课程文件(.md/.ipynb/.py 等) → LLM 重组
|
|
2111
|
+
* - single-file: 无子文件链接但 README 有实质教学正文(prose >1000 字)
|
|
2112
|
+
* - docs-rich: README 无链接但文件树可能有内容 → 不急着拒绝,让 fetchRepoInventory 用文件树补全
|
|
2113
|
+
* - unsupported: awesome-list(外链占比>60%且正文极少)
|
|
2114
|
+
*/
|
|
2115
|
+
function detectRepoPattern(readmeMd) {
|
|
2116
|
+
const lessonLinks = filterLessonFiles(extractInternalLinks(readmeMd)).filter((f) => f.kind !== "other");
|
|
2117
|
+
if (lessonLinks.length >= 1) {
|
|
2118
|
+
if (detectWellOrganized(lessonLinks)) return {
|
|
2119
|
+
pattern: "well-organized",
|
|
2120
|
+
reason: `README 含 ${lessonLinks.length} 个文件,路径有编号目录组织,判定为已组织好的课程仓库`,
|
|
2121
|
+
lessonFiles: lessonLinks
|
|
2122
|
+
};
|
|
2123
|
+
return {
|
|
2124
|
+
pattern: "course",
|
|
2125
|
+
reason: `README 含 ${lessonLinks.length} 个内部课程文件链接,判定为课程型仓库`,
|
|
2126
|
+
lessonFiles: lessonLinks
|
|
2127
|
+
};
|
|
2128
|
+
}
|
|
2129
|
+
const proseChars = readmeMd.replace(/!\[[^\]]*\]\([^)]*\)/g, "").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/<[^>]+>/g, "").replace(/^---[\s\S]*?---/m, "").replace(/\s/g, "").length;
|
|
2130
|
+
if (proseChars > 1e3) return {
|
|
2131
|
+
pattern: "single-file",
|
|
2132
|
+
reason: `README 无子文件链接,但实质正文 ${proseChars} 字,判定为单文件型`,
|
|
2133
|
+
readmeLength: readmeMd.length
|
|
2134
|
+
};
|
|
2135
|
+
const externalLinks = (readmeMd.match(/\]\(https?:\/\//g) || []).length;
|
|
2136
|
+
const totalLinks = (readmeMd.match(/\]\(/g) || []).length;
|
|
2137
|
+
if (totalLinks > 10 && externalLinks / totalLinks > .6 && proseChars < 500) return {
|
|
2138
|
+
pattern: "unsupported",
|
|
2139
|
+
reason: `README 外链占比 ${(externalLinks / totalLinks * 100).toFixed(0)}%,实质正文仅 ${proseChars} 字,疑似 awesome-list 资源索引(非课程)`
|
|
2140
|
+
};
|
|
2141
|
+
return {
|
|
2142
|
+
pattern: "docs-rich",
|
|
2143
|
+
reason: `README 无课程文件链接,实质正文 ${proseChars} 字 → 将用文件树补全课程文件`
|
|
2144
|
+
};
|
|
2145
|
+
}
|
|
2146
|
+
/**
|
|
2147
|
+
* 并发拉取多个 markdown 文件(5 并发,防 CDN 过载)。
|
|
2148
|
+
*
|
|
2149
|
+
* @param files 要拉取的文件列表
|
|
2150
|
+
* @param owner repo owner
|
|
2151
|
+
* @param repo repo name
|
|
2152
|
+
* @param branch 分支名
|
|
2153
|
+
* @param fetchFn 注入的 fetch 函数
|
|
2154
|
+
* @param onProgress 进度回调 (done, total, currentPath)
|
|
2155
|
+
*/
|
|
2156
|
+
async function fetchMarkdownContents(files, owner, repo, branch, fetchFn, onProgress) {
|
|
2157
|
+
const ok = [];
|
|
2158
|
+
const failed = [];
|
|
2159
|
+
const CONCURRENCY = 5;
|
|
2160
|
+
let done = 0;
|
|
2161
|
+
for (let i = 0; i < files.length; i += CONCURRENCY) {
|
|
2162
|
+
const batch = files.slice(i, i + CONCURRENCY);
|
|
2163
|
+
const results = await Promise.allSettled(batch.map(async (f) => {
|
|
2164
|
+
const r = await fetchFn(cdnUrl(owner, repo, branch, f.path));
|
|
2165
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
2166
|
+
const text = await r.text();
|
|
2167
|
+
if (f.path.toLowerCase().endsWith(".ipynb")) try {
|
|
2168
|
+
const { parseNotebook } = await import("./notebook-parser-ChbZBIKJ.mjs");
|
|
2169
|
+
const nbResult = parseNotebook(text);
|
|
2170
|
+
return {
|
|
2171
|
+
path: f.path,
|
|
2172
|
+
title: f.title,
|
|
2173
|
+
md: nbResult.markdown
|
|
2174
|
+
};
|
|
2175
|
+
} catch {
|
|
2176
|
+
return {
|
|
2177
|
+
path: f.path,
|
|
2178
|
+
title: f.title,
|
|
2179
|
+
md: text
|
|
2180
|
+
};
|
|
2181
|
+
}
|
|
2182
|
+
const lowerPath = f.path.toLowerCase();
|
|
2183
|
+
if (lowerPath.endsWith(".rst") || lowerPath.endsWith(".rmd") || lowerPath.endsWith(".org") || lowerPath.endsWith(".adoc") || lowerPath.endsWith(".asciidoc")) {
|
|
2184
|
+
const parserName = {
|
|
2185
|
+
".rst": "rst-parser",
|
|
2186
|
+
".rmd": "rmd-parser",
|
|
2187
|
+
".org": "org-parser",
|
|
2188
|
+
".adoc": "adoc-parser",
|
|
2189
|
+
".asciidoc": "adoc-parser"
|
|
2190
|
+
}[lowerPath.match(/\.[^.]+$/)?.[0] ?? ""];
|
|
2191
|
+
if (parserName) try {
|
|
2192
|
+
const mod = await import(`./${parserName}.js`);
|
|
2193
|
+
const fn = mod.parseRst ?? mod.parseRmd ?? mod.parseOrg ?? mod.parseAdoc;
|
|
2194
|
+
return {
|
|
2195
|
+
path: f.path,
|
|
2196
|
+
title: f.title,
|
|
2197
|
+
md: fn(text).markdown
|
|
2198
|
+
};
|
|
2199
|
+
} catch {
|
|
2200
|
+
return {
|
|
2201
|
+
path: f.path,
|
|
2202
|
+
title: f.title,
|
|
2203
|
+
md: text
|
|
2204
|
+
};
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
if (CODE_EXTENSIONS.some((ext) => lowerPath.endsWith(ext))) {
|
|
2208
|
+
const ext = lowerPath.split(".").pop() ?? "";
|
|
2209
|
+
try {
|
|
2210
|
+
const { parseCode } = await import("./code-parser-BOOk9IWV.mjs");
|
|
2211
|
+
return {
|
|
2212
|
+
path: f.path,
|
|
2213
|
+
title: f.title,
|
|
2214
|
+
md: parseCode(text, ext).markdown
|
|
2215
|
+
};
|
|
2216
|
+
} catch {
|
|
2217
|
+
return {
|
|
2218
|
+
path: f.path,
|
|
2219
|
+
title: f.title,
|
|
2220
|
+
md: "```\n" + text + "\n```"
|
|
2221
|
+
};
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
return {
|
|
2225
|
+
path: f.path,
|
|
2226
|
+
title: f.title,
|
|
2227
|
+
md: text
|
|
2228
|
+
};
|
|
2229
|
+
}));
|
|
2230
|
+
for (let j = 0; j < results.length; j++) {
|
|
2231
|
+
done++;
|
|
2232
|
+
const file = batch[j];
|
|
2233
|
+
const result = results[j];
|
|
2234
|
+
if (file) onProgress?.(done, files.length, file.path);
|
|
2235
|
+
if (result && result.status === "fulfilled") ok.push(result.value);
|
|
2236
|
+
else if (result && result.status === "rejected") failed.push({
|
|
2237
|
+
path: file?.path ?? "(unknown)",
|
|
2238
|
+
error: result.reason instanceof Error ? result.reason.message : String(result.reason)
|
|
2239
|
+
});
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
2242
|
+
return {
|
|
2243
|
+
ok,
|
|
2244
|
+
failed
|
|
2245
|
+
};
|
|
2246
|
+
}
|
|
2247
|
+
/**
|
|
2248
|
+
* 把课程型仓库的多个课时文件合并成 ParsedCourse 结构。
|
|
2249
|
+
*
|
|
2250
|
+
* v3 改进:集成 file-classifier 规则引擎。
|
|
2251
|
+
* - 先对每个文件调 classifyFile 判定角色(lesson/notebook/lab/section-intro/uncertain 等)
|
|
2252
|
+
* - keepAsLesson=false 的文件(translation/meta/notebook/lab/example/section-intro)不进 lesson 列表
|
|
2253
|
+
* - section-intro 的正文追加到同 section 摘要(作为章节概述)
|
|
2254
|
+
* - uncertain 的文件进 lesson 列表但标 uncertain=true,后续 LLM 结构化时优先判断 keep/skip
|
|
2255
|
+
*
|
|
2256
|
+
* 分组策略保留 v2 的"第一个非通用目录"启发式(减少碎片)。
|
|
2257
|
+
*
|
|
2258
|
+
* 每个文件的内部 H2/H3 → 该 section 下的 lessons;无 H2/H3 则整个文件作一个 lesson。
|
|
2259
|
+
*/
|
|
2260
|
+
function buildCourseFromFiles(courseTitle, files) {
|
|
2261
|
+
const allPaths = files.map((f) => f.path);
|
|
2262
|
+
for (const file of files) if (!file.classification) file.classification = classifyFile(file.path, file.md, { siblingPaths: allPaths });
|
|
2263
|
+
const groupMap = /* @__PURE__ */ new Map();
|
|
2264
|
+
const groupOrder = [];
|
|
2265
|
+
const GENERIC_DIRS = new Set([
|
|
2266
|
+
"lessons",
|
|
2267
|
+
"docs",
|
|
2268
|
+
"doc",
|
|
2269
|
+
"src",
|
|
2270
|
+
"content",
|
|
2271
|
+
"modules",
|
|
2272
|
+
"chapters",
|
|
2273
|
+
"tutorials",
|
|
2274
|
+
"guide",
|
|
2275
|
+
"week",
|
|
2276
|
+
"unit",
|
|
2277
|
+
"part",
|
|
2278
|
+
"topic",
|
|
2279
|
+
"lecture",
|
|
2280
|
+
"session",
|
|
2281
|
+
"day",
|
|
2282
|
+
"step"
|
|
2283
|
+
]);
|
|
2284
|
+
/**
|
|
2285
|
+
* 计算文件的 section 分组键(和 lesson 用同一个逻辑)。
|
|
2286
|
+
*/
|
|
2287
|
+
function sectionKeyOf(path) {
|
|
2288
|
+
const parts = path.split("/").filter(Boolean);
|
|
2289
|
+
const dirParts = parts[parts.length - 1]?.match(/^readme/i) || parts[parts.length - 1] === "index.md" ? parts.slice(0, -1) : parts;
|
|
2290
|
+
const specificDir = dirParts.find((p) => !GENERIC_DIRS.has(p.toLowerCase()) && !/\.(md|mdx)$/i.test(p));
|
|
2291
|
+
if (dirParts.length >= 2 && specificDir) {
|
|
2292
|
+
const gk = specificDir.replace(/\.md$/i, "");
|
|
2293
|
+
return {
|
|
2294
|
+
groupKey: gk,
|
|
2295
|
+
sectionTitle: gk
|
|
2296
|
+
};
|
|
2297
|
+
} else if (dirParts.length === 1) return {
|
|
2298
|
+
groupKey: path,
|
|
2299
|
+
sectionTitle: dirParts[0].replace(/\.md$/i, "")
|
|
2300
|
+
};
|
|
2301
|
+
return {
|
|
2302
|
+
groupKey: path,
|
|
2303
|
+
sectionTitle: parts[parts.length - 1] ?? path
|
|
2304
|
+
};
|
|
2305
|
+
}
|
|
2306
|
+
const sortedFiles = [...files].sort((a, b) => a.path.localeCompare(b.path));
|
|
2307
|
+
for (const file of sortedFiles) {
|
|
2308
|
+
const classification = file.classification;
|
|
2309
|
+
const { groupKey, sectionTitle } = sectionKeyOf(file.path);
|
|
2310
|
+
if (!groupMap.has(groupKey)) {
|
|
2311
|
+
groupMap.set(groupKey, {
|
|
2312
|
+
sectionTitle,
|
|
2313
|
+
orderKey: file.path,
|
|
2314
|
+
lessons: []
|
|
2315
|
+
});
|
|
2316
|
+
if (!groupOrder.includes(groupKey)) groupOrder.push(groupKey);
|
|
2317
|
+
}
|
|
2318
|
+
const group = groupMap.get(groupKey);
|
|
2319
|
+
if (!classification.keepAsLesson) {
|
|
2320
|
+
if (classification.role === "section-intro") group.pendingIntro = file.md;
|
|
2321
|
+
continue;
|
|
2322
|
+
}
|
|
2323
|
+
const lowerP = file.path.toLowerCase();
|
|
2324
|
+
const isNotebook = lowerP.endsWith(".ipynb");
|
|
2325
|
+
const isLab = /\/lab\//.test(lowerP) || /\/labs\//.test(lowerP) || /\/exercise/.test(lowerP);
|
|
2326
|
+
const isExample = /\/examples?\//.test(lowerP) || /\/demos?\//.test(lowerP);
|
|
2327
|
+
if (isNotebook || isLab || isExample) {
|
|
2328
|
+
const h1Match = file.md.match(/^#\s+(.+)$/m);
|
|
2329
|
+
const lessonTitle = h1Match ? h1Match[1].trim() : file.title;
|
|
2330
|
+
group.lessons.push({
|
|
2331
|
+
title: lessonTitle,
|
|
2332
|
+
anchor: file.path.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
|
|
2333
|
+
body: file.md,
|
|
2334
|
+
uncertain: true,
|
|
2335
|
+
sourceFilePath: file.path,
|
|
2336
|
+
world: null
|
|
2337
|
+
});
|
|
2338
|
+
continue;
|
|
2339
|
+
}
|
|
2340
|
+
const parsed = parseMarkdownToCourse(file.md);
|
|
2341
|
+
const parsedLessonCount = parsed.sections.reduce((sum, s) => sum + s.lessons.length, 0);
|
|
2342
|
+
const isUncertain = classification.role === "uncertain";
|
|
2343
|
+
const fileWorld = classification.world;
|
|
2344
|
+
const lessonCandidates = parsedLessonCount > 0 ? parsed.sections.filter((s) => s.lessons.length > 0).flatMap((s) => s.lessons.map((l) => ({
|
|
2345
|
+
title: l.title,
|
|
2346
|
+
anchor: l.title.toLowerCase().replace(/\s+/g, "-"),
|
|
2347
|
+
body: l.body,
|
|
2348
|
+
uncertain: isUncertain,
|
|
2349
|
+
sourceFilePath: file.path,
|
|
2350
|
+
world: fileWorld
|
|
2351
|
+
}))) : (() => {
|
|
2352
|
+
const h1Match = file.md.match(/^#\s+(.+)$/m);
|
|
2353
|
+
const lessonTitle = h1Match ? h1Match[1].trim() : file.title;
|
|
2354
|
+
return [{
|
|
2355
|
+
title: lessonTitle,
|
|
2356
|
+
anchor: lessonTitle.toLowerCase().replace(/\s+/g, "-"),
|
|
2357
|
+
body: file.md,
|
|
2358
|
+
uncertain: isUncertain,
|
|
2359
|
+
sourceFilePath: file.path,
|
|
2360
|
+
world: fileWorld
|
|
2361
|
+
}];
|
|
2362
|
+
})();
|
|
2363
|
+
group.lessons.push(...lessonCandidates);
|
|
2364
|
+
}
|
|
2365
|
+
for (const key of groupOrder) {
|
|
2366
|
+
const g = groupMap.get(key);
|
|
2367
|
+
if (g.pendingIntro && g.lessons.length > 0) g.lessons[0].body = `> **📖 章节概述**\n>\n> ${g.pendingIntro.replace(/\n/g, "\n> ")}\n\n---\n\n${g.lessons[0].body}`;
|
|
2368
|
+
}
|
|
2369
|
+
return {
|
|
2370
|
+
title: courseTitle,
|
|
2371
|
+
sections: groupOrder.filter((key) => groupMap.get(key).lessons.length > 0).map((key) => {
|
|
2372
|
+
const g = groupMap.get(key);
|
|
2373
|
+
const practiceCount = g.lessons.filter((l) => l.world === "practice").length;
|
|
2374
|
+
const studyCount = g.lessons.filter((l) => l.world === "study").length;
|
|
2375
|
+
return {
|
|
2376
|
+
title: g.sectionTitle,
|
|
2377
|
+
anchor: g.sectionTitle.toLowerCase().replace(/\s+/g, "-"),
|
|
2378
|
+
world: practiceCount > 0 && studyCount === 0 ? "practice" : "study",
|
|
2379
|
+
lessons: g.lessons
|
|
2380
|
+
};
|
|
2381
|
+
})
|
|
2382
|
+
};
|
|
2383
|
+
}
|
|
2384
|
+
/** 从 .md 路径列表构造 DiscoveredFile[](复用 filterLessonFiles 排除规则 + 标题推断)。 */
|
|
2385
|
+
function pathsToDiscoveredFiles(paths) {
|
|
2386
|
+
const files = [];
|
|
2387
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2388
|
+
for (const p of paths) {
|
|
2389
|
+
const lower = p.toLowerCase();
|
|
2390
|
+
if (seen.has(p)) continue;
|
|
2391
|
+
let kind = "other";
|
|
2392
|
+
if (lower.endsWith(".md") || lower.endsWith(".mdx")) kind = "md";
|
|
2393
|
+
else if (lower.endsWith(".ipynb")) kind = "ipynb";
|
|
2394
|
+
else if (lower.endsWith(".rst")) kind = "rst";
|
|
2395
|
+
else if (lower.endsWith(".rmd")) kind = "rmd";
|
|
2396
|
+
else if (lower.endsWith(".org")) kind = "org";
|
|
2397
|
+
else if (lower.endsWith(".adoc") || lower.endsWith(".asciidoc")) kind = "adoc";
|
|
2398
|
+
else if (CODE_EXTENSIONS.some((ext) => lower.endsWith(ext))) kind = "code";
|
|
2399
|
+
else continue;
|
|
2400
|
+
if (lower.includes("node_modules/") || lower.startsWith(".git/") || lower.includes("translations/")) continue;
|
|
2401
|
+
if (lower.endsWith("license.md") || lower.endsWith("contributing.md") || lower.endsWith("code_of_conduct.md")) continue;
|
|
2402
|
+
seen.add(p);
|
|
2403
|
+
const parts = p.split("/").filter(Boolean);
|
|
2404
|
+
const last = parts[parts.length - 1] ?? p;
|
|
2405
|
+
const title = last.replace(/\.(md|mdx|ipynb|rst|rmd|org|adoc|asciidoc|py|js|jsx|ts|tsx|go|rs|java|c|cpp|rb|sh|sql|lua|r|jl|dart|scala|kt|cs|php|swift|hs|clj|ex|erl|ml|fs|pl|elm)$/i, "").replace(/^readme$/i, parts[parts.length - 2] ?? last);
|
|
2406
|
+
files.push({
|
|
2407
|
+
path: p,
|
|
2408
|
+
title,
|
|
2409
|
+
kind
|
|
2410
|
+
});
|
|
2411
|
+
}
|
|
2412
|
+
return files;
|
|
2413
|
+
}
|
|
2414
|
+
/**
|
|
2415
|
+
* 主方式:GitHub Tree API 一次拿全仓文件树。
|
|
2416
|
+
* https://api.github.com/repos/{owner}/{repo}/git/trees/{branch}?recursive=1
|
|
2417
|
+
* 返回 { tree: [{ path, type }] }。筛 blob + .md/.ipynb。
|
|
2418
|
+
* 网络失败/限流 → 抛错(由调用方降级)。
|
|
2419
|
+
*/
|
|
2420
|
+
/**
|
|
2421
|
+
* 用 Node 的 https 模块拉取(可单独控制 SSL 验证)。
|
|
2422
|
+
* GitHub Tree API 的证书链在部分环境(Node 内置 CA)验证失败(中间证书缺失),
|
|
2423
|
+
* 对这一个获取公开文件树的请求用 rejectUnauthorized:false 绕过。
|
|
2424
|
+
* 风险可控:获取的是公开文件路径列表(无敏感数据),且只用于此请求。
|
|
2425
|
+
*/
|
|
2426
|
+
function httpsGet(url, opts = {}) {
|
|
2427
|
+
return new Promise((resolve) => {
|
|
2428
|
+
const req = https.get(url, {
|
|
2429
|
+
headers: {
|
|
2430
|
+
"User-Agent": "lookatstudy-import",
|
|
2431
|
+
...opts.headers
|
|
2432
|
+
},
|
|
2433
|
+
rejectUnauthorized: opts.rejectUnauthorized ?? true,
|
|
2434
|
+
timeout: 2e4
|
|
2435
|
+
}, (res) => {
|
|
2436
|
+
let body = "";
|
|
2437
|
+
res.on("data", (d) => {
|
|
2438
|
+
body += d.toString();
|
|
2439
|
+
});
|
|
2440
|
+
res.on("end", () => resolve({
|
|
2441
|
+
ok: res.statusCode === 200,
|
|
2442
|
+
status: res.statusCode,
|
|
2443
|
+
body
|
|
2444
|
+
}));
|
|
2445
|
+
});
|
|
2446
|
+
req.on("error", (e) => resolve({
|
|
2447
|
+
ok: false,
|
|
2448
|
+
error: e.message
|
|
2449
|
+
}));
|
|
2450
|
+
req.on("timeout", () => {
|
|
2451
|
+
req.destroy();
|
|
2452
|
+
resolve({
|
|
2453
|
+
ok: false,
|
|
2454
|
+
error: "timeout"
|
|
2455
|
+
});
|
|
2456
|
+
});
|
|
2457
|
+
});
|
|
2458
|
+
}
|
|
2459
|
+
async function fetchRepoFileTree(owner, repo, branch, _fetchFn) {
|
|
2460
|
+
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/git/trees/${branch}?recursive=1`;
|
|
2461
|
+
try {
|
|
2462
|
+
const r = await httpsGet(apiUrl, { rejectUnauthorized: false });
|
|
2463
|
+
console.error(`[import] GitHub Tree API: HTTP ${r.status ?? r.error}`);
|
|
2464
|
+
if (r.ok && r.body) {
|
|
2465
|
+
const paths = (JSON.parse(r.body).tree ?? []).filter((n) => n.type === "blob").map((n) => n.path);
|
|
2466
|
+
if (paths.length > 0) return {
|
|
2467
|
+
paths,
|
|
2468
|
+
source: "github-tree-api"
|
|
2469
|
+
};
|
|
2470
|
+
}
|
|
2471
|
+
} catch (e) {
|
|
2472
|
+
console.error(`[import] GitHub Tree API 异常: ${e instanceof Error ? e.message : e}`);
|
|
2473
|
+
}
|
|
2474
|
+
return {
|
|
2475
|
+
paths: [],
|
|
2476
|
+
source: "none"
|
|
2477
|
+
};
|
|
2478
|
+
}
|
|
2479
|
+
/** 文件数上限(防爆,和 IPC handler 一致) */
|
|
2480
|
+
const MAX_FILES = 500;
|
|
2481
|
+
/**
|
|
2482
|
+
* 从 GitHub 仓库构建课程结构 —— 纯编排函数。
|
|
2483
|
+
*
|
|
2484
|
+
* 流程: fetch README → detectRepoPattern → 发现文件树 → fetchMarkdownContents
|
|
2485
|
+
* → classifyFile(在 buildCourseFromFiles 内)→ buildCourseFromFiles
|
|
2486
|
+
*
|
|
2487
|
+
* 不落库、不发进度事件(onProgress 回调只传消息字符串,由调用方决定怎么用)。
|
|
2488
|
+
*
|
|
2489
|
+
* @param owner GitHub owner
|
|
2490
|
+
* @param repo GitHub repo
|
|
2491
|
+
* @param branch 起始分支(README 先试 main 再试 master)
|
|
2492
|
+
* @param fetchFn 注入的 fetch(生产用 global fetch,测试用 mock)
|
|
2493
|
+
* @param onProgress 进度回调(可选)
|
|
2494
|
+
*/
|
|
2495
|
+
async function importRepoToParsedCourse(owner, repo, branch, fetchFn, onProgress) {
|
|
2496
|
+
const send = (msg) => onProgress?.(msg);
|
|
2497
|
+
send("正在拉取 README…");
|
|
2498
|
+
const branches = branch === "master" ? ["master", "main"] : ["main", "master"];
|
|
2499
|
+
let readmeMd = null;
|
|
2500
|
+
let readmeBranch = branch;
|
|
2501
|
+
for (const br of branches) try {
|
|
2502
|
+
const r = await fetchFn(cdnUrl(owner, repo, br, "README.md"));
|
|
2503
|
+
if (r.ok) {
|
|
2504
|
+
readmeMd = await r.text();
|
|
2505
|
+
readmeBranch = br;
|
|
2506
|
+
break;
|
|
2507
|
+
}
|
|
2508
|
+
} catch {}
|
|
2509
|
+
if (!readmeMd) throw new Error(`无法拉取 README(试过分支: ${branches.join(", ")})`);
|
|
2510
|
+
send(`README 拉取成功(${readmeMd.length} 字符,分支 ${readmeBranch})`);
|
|
2511
|
+
const detection = detectRepoPattern(readmeMd);
|
|
2512
|
+
if (detection.pattern === "unsupported") throw new Error(`仓库不支持: ${detection.reason}`);
|
|
2513
|
+
if (detection.pattern === "single-file") return {
|
|
2514
|
+
course: parseMarkdownToCourse(readmeMd),
|
|
2515
|
+
detection,
|
|
2516
|
+
fetchedFiles: [],
|
|
2517
|
+
readmeBranch,
|
|
2518
|
+
readmeMd
|
|
2519
|
+
};
|
|
2520
|
+
let lessonFiles = filterLessonFiles(detection.lessonFiles ?? []);
|
|
2521
|
+
const readmeLinkCount = lessonFiles.length;
|
|
2522
|
+
if (readmeLinkCount < 5) try {
|
|
2523
|
+
send("README 链接较少,扫描文件树补充…");
|
|
2524
|
+
const tree = await fetchRepoFileTree(owner, repo, readmeBranch, fetchFn);
|
|
2525
|
+
if (tree.paths.length > 0) {
|
|
2526
|
+
const treeLessonFiles = filterLessonFiles(pathsToDiscoveredFiles(tree.paths)).filter((f) => f.kind !== "other");
|
|
2527
|
+
if (treeLessonFiles.length > lessonFiles.length) {
|
|
2528
|
+
lessonFiles = treeLessonFiles;
|
|
2529
|
+
send(`文件树发现 ${lessonFiles.length} 个课时文件(来源: ${tree.source})`);
|
|
2530
|
+
}
|
|
2531
|
+
}
|
|
2532
|
+
} catch {
|
|
2533
|
+
send("文件树拉取失败,使用 README 链接发现");
|
|
2534
|
+
}
|
|
2535
|
+
else send(`README 链接发现 ${readmeLinkCount} 个课时文件(人工策展,优先使用)`);
|
|
2536
|
+
if (lessonFiles.length === 0) {
|
|
2537
|
+
send("未发现课时文件,降级为单文件导入");
|
|
2538
|
+
return {
|
|
2539
|
+
course: parseMarkdownToCourse(readmeMd),
|
|
2540
|
+
detection: {
|
|
2541
|
+
...detection,
|
|
2542
|
+
pattern: "single-file",
|
|
2543
|
+
reason: "无课时文件,降级"
|
|
2544
|
+
},
|
|
2545
|
+
fetchedFiles: [],
|
|
2546
|
+
readmeBranch,
|
|
2547
|
+
readmeMd
|
|
2548
|
+
};
|
|
2549
|
+
}
|
|
2550
|
+
if (lessonFiles.length > MAX_FILES) {
|
|
2551
|
+
send(`文件数 ${lessonFiles.length} 超过上限 ${MAX_FILES},截断`);
|
|
2552
|
+
if (readmeLinkCount > 0 && readmeLinkCount < MAX_FILES) {
|
|
2553
|
+
const readmePaths = new Set(filterLessonFiles(detection.lessonFiles ?? []).map((f) => f.path));
|
|
2554
|
+
const fromReadme = lessonFiles.filter((f) => readmePaths.has(f.path));
|
|
2555
|
+
const fromTree = lessonFiles.filter((f) => !readmePaths.has(f.path)).slice(0, MAX_FILES - fromReadme.length);
|
|
2556
|
+
lessonFiles = [...fromReadme, ...fromTree];
|
|
2557
|
+
} else lessonFiles = lessonFiles.slice(0, MAX_FILES);
|
|
2558
|
+
}
|
|
2559
|
+
send(`检测到课程型仓库(${lessonFiles.length} 个文件),开始拉取…`);
|
|
2560
|
+
const fetchResult = await fetchMarkdownContents(lessonFiles, owner, repo, readmeBranch, fetchFn, (done, total, path) => send(`拉取 ${done}/${total}: ${path}`));
|
|
2561
|
+
if (fetchResult.ok.length === 0) throw new Error(`检测到 ${lessonFiles.length} 个课时文件,但全部拉取失败。可能是网络受限。请稍后重试或改用「粘贴 Markdown」方式手动导入。`);
|
|
2562
|
+
const h1Match = readmeMd.match(/^#\s+(.+)$/m);
|
|
2563
|
+
const course = buildCourseFromFiles(h1Match ? h1Match[1].trim() : repo, fetchResult.ok);
|
|
2564
|
+
send(`解析完成:${course.sections.length} 章节,构建课程…`);
|
|
2565
|
+
return {
|
|
2566
|
+
course,
|
|
2567
|
+
detection,
|
|
2568
|
+
fetchedFiles: fetchResult.ok,
|
|
2569
|
+
readmeBranch,
|
|
2570
|
+
readmeMd
|
|
2571
|
+
};
|
|
2572
|
+
}
|
|
2573
|
+
//#endregion
|
|
2574
|
+
//#region src/cards.ts
|
|
2575
|
+
/**
|
|
2576
|
+
* Status glyph for one lesson line on the map (LookatStudy's map icons).
|
|
2577
|
+
* @param kind - lesson kind.
|
|
2578
|
+
* @param status - lesson status.
|
|
2579
|
+
* @returns the glyph prefix.
|
|
2580
|
+
*/
|
|
2581
|
+
function statusGlyph(kind, status) {
|
|
2582
|
+
if (kind === "exam") return "🎯";
|
|
2583
|
+
if (status === "mastered") return "👑";
|
|
2584
|
+
if (status === "in_progress") return "📖";
|
|
2585
|
+
if (status === "available") return "⭐";
|
|
2586
|
+
return "🔒";
|
|
2587
|
+
}
|
|
2588
|
+
/**
|
|
2589
|
+
* Display lines for an import result.
|
|
2590
|
+
* @param value - import tool value.
|
|
2591
|
+
* @returns card lines.
|
|
2592
|
+
*/
|
|
2593
|
+
function importLines(value) {
|
|
2594
|
+
const lines = [`📘 ${value.title}`, `${value.sections} sections · ${value.lessons} lessons · id ${value.courseId}`];
|
|
2595
|
+
if (value.firstLessonId !== null) lines.push(`Start at “${value.firstLessonTitle}” (${value.firstLessonId})`);
|
|
2596
|
+
return lines;
|
|
2597
|
+
}
|
|
2598
|
+
/**
|
|
2599
|
+
* Display lines for the skill-tree map.
|
|
2600
|
+
* @param value - map tool value.
|
|
2601
|
+
* @returns card lines.
|
|
2602
|
+
*/
|
|
2603
|
+
function mapLines(value) {
|
|
2604
|
+
const lines = [`🗺 ${value.title} — ${value.counts.mastered}/${value.counts.total} mastered`];
|
|
2605
|
+
for (const section of value.tree) {
|
|
2606
|
+
lines.push(`▍${section.title}`);
|
|
2607
|
+
for (const lesson of section.lessons) {
|
|
2608
|
+
const mastery = lesson.masteryPct === null ? "" : ` · ${lesson.masteryPct}%${lesson.crown >= 4 ? " 👑" : ""}`;
|
|
2609
|
+
const weak = lesson.weakConcepts > 0 ? ` · ⚡${lesson.weakConcepts}` : "";
|
|
2610
|
+
const friction = lesson.frictionCount > 0 ? ` · 😣${lesson.frictionCount}` : "";
|
|
2611
|
+
lines.push(` ${statusGlyph(lesson.kind, lesson.status)} ${lesson.title}${mastery}${weak}${friction}`);
|
|
2612
|
+
}
|
|
2613
|
+
}
|
|
2614
|
+
return lines;
|
|
2615
|
+
}
|
|
2616
|
+
/**
|
|
2617
|
+
* Display line for a graded answer.
|
|
2618
|
+
* @param value - answer tool value.
|
|
2619
|
+
* @returns single feedback line.
|
|
2620
|
+
*/
|
|
2621
|
+
function answerLine(value) {
|
|
2622
|
+
const mark = value.correct ? "✓ correct" : "✗ incorrect";
|
|
2623
|
+
const crown = value.mastered ? " · 👑 mastered" : "";
|
|
2624
|
+
return `${mark} — mastery ${value.prevMasteryPct}% → ${value.newMasteryPct}% (crown ${value.crown})${crown}`;
|
|
2625
|
+
}
|
|
2626
|
+
/**
|
|
2627
|
+
* Display lines for the due-review list.
|
|
2628
|
+
* @param value - due tool value.
|
|
2629
|
+
* @returns card lines.
|
|
2630
|
+
*/
|
|
2631
|
+
function dueLines(value) {
|
|
2632
|
+
if (value.total === 0) return ["🎉 No reviews due — everything is scheduled ahead."];
|
|
2633
|
+
const lines = [`🔁 ${value.total} due`];
|
|
2634
|
+
for (const item of value.due) {
|
|
2635
|
+
const overdue = item.overdueDays > 0 ? ` · ${item.overdueDays}d overdue` : "";
|
|
2636
|
+
lines.push(` ⏰ ${item.lessonTitle} — ${item.courseTitle}${overdue}`);
|
|
2637
|
+
}
|
|
2638
|
+
return lines;
|
|
2639
|
+
}
|
|
2640
|
+
/**
|
|
2641
|
+
* Display line for a recorded review grade.
|
|
2642
|
+
* @param value - review tool value.
|
|
2643
|
+
* @returns single schedule line.
|
|
2644
|
+
*/
|
|
2645
|
+
function reviewLine(value) {
|
|
2646
|
+
return `🔁 quality ${value.quality}/5 — next review in ${value.intervalDays}d (${value.repetitions} in a row), due ${value.dueAt.slice(0, 10)}`;
|
|
2647
|
+
}
|
|
2648
|
+
/**
|
|
2649
|
+
* Display lines for a completed lesson.
|
|
2650
|
+
* @param value - complete tool value.
|
|
2651
|
+
* @returns card lines.
|
|
2652
|
+
*/
|
|
2653
|
+
function completeLines(value) {
|
|
2654
|
+
const lines = [`🎓 Mastered “${value.lessonTitle}”`];
|
|
2655
|
+
for (const title of value.unlockedLessonTitles) lines.push(`🔓 Unlocked “${title}”`);
|
|
2656
|
+
lines.push(`🔁 First review due ${value.reviewDueAt.slice(0, 10)}`);
|
|
2657
|
+
if (value.courseComplete) lines.push("🏁 Course complete!");
|
|
2658
|
+
return lines;
|
|
2659
|
+
}
|
|
2660
|
+
//#endregion
|
|
2661
|
+
//#region src/tools.ts
|
|
2662
|
+
/**
|
|
2663
|
+
* The `study_*` tool surface, ported from LookatStudy's agent contract:
|
|
2664
|
+
* import (markdown / folder / GitHub), course map, lesson content with
|
|
2665
|
+
* concepts/starters/memory, KC-attributed answer recording with
|
|
2666
|
+
* mastery-driven progression, spaced reviews, mastery proposals, friction
|
|
2667
|
+
* logging, learner memory, Cornell notes, and soul switching. All state
|
|
2668
|
+
* mutations persist synchronously through the shared store.
|
|
2669
|
+
* @module dsh-plugin-lookatstudy/tools
|
|
2670
|
+
*/
|
|
2671
|
+
/** SM-2 quality grades, shared by the parameter enum and the state layer. */
|
|
2672
|
+
const QUALITIES = [
|
|
2673
|
+
0,
|
|
2674
|
+
1,
|
|
2675
|
+
2,
|
|
2676
|
+
3,
|
|
2677
|
+
4,
|
|
2678
|
+
5
|
|
2679
|
+
];
|
|
2680
|
+
const LESSON_STATUSES = [
|
|
2681
|
+
"locked",
|
|
2682
|
+
"available",
|
|
2683
|
+
"in_progress",
|
|
2684
|
+
"mastered"
|
|
2685
|
+
];
|
|
2686
|
+
const LESSON_KINDS = [
|
|
2687
|
+
"study",
|
|
2688
|
+
"practice",
|
|
2689
|
+
"exam"
|
|
2690
|
+
];
|
|
2691
|
+
const FRICTION_CATEGORIES = [
|
|
2692
|
+
"confused",
|
|
2693
|
+
"blocked",
|
|
2694
|
+
"frustrated"
|
|
2695
|
+
];
|
|
2696
|
+
const MEMORY_CATEGORIES = [
|
|
2697
|
+
"global",
|
|
2698
|
+
"pattern",
|
|
2699
|
+
"lesson"
|
|
2700
|
+
];
|
|
2701
|
+
const NOTE_ZONES = [
|
|
2702
|
+
"understand",
|
|
2703
|
+
"record",
|
|
2704
|
+
"practice"
|
|
2705
|
+
];
|
|
2706
|
+
const NOTE_SOURCES = [
|
|
2707
|
+
"ai",
|
|
2708
|
+
"content",
|
|
2709
|
+
"chat"
|
|
2710
|
+
];
|
|
2711
|
+
const MODES = [
|
|
2712
|
+
"direct",
|
|
2713
|
+
"guide",
|
|
2714
|
+
"practice"
|
|
2715
|
+
];
|
|
2716
|
+
const nullableInteger = { oneOf: [{ type: "integer" }, { type: "null" }] };
|
|
2717
|
+
const nullableString = { oneOf: [{ type: "string" }, { type: "null" }] };
|
|
2718
|
+
/**
|
|
2719
|
+
* Fail loud when an importer produced no lessons — a course with an empty
|
|
2720
|
+
* path is useless and hides upstream parsing problems. Runs before any state
|
|
2721
|
+
* mutation so a failed import leaves persisted state untouched.
|
|
2722
|
+
* @param parsed - parsed course about to be imported.
|
|
2723
|
+
*/
|
|
2724
|
+
function requireParsedLessons(parsed) {
|
|
2725
|
+
if (parsed.sections.reduce((n, s) => n + s.lessons.length, 0) === 0) throw new Error("lookatstudy-plugin: import produced 0 lessons — the source needs ## sections containing ### lessons, or lesson-like files in a folder");
|
|
2726
|
+
}
|
|
2727
|
+
/** Canonical value shared by the three import tools. */
|
|
2728
|
+
function toImportValue(course) {
|
|
2729
|
+
const lessons = course.sections.flatMap((s) => s.lessons);
|
|
2730
|
+
const first = lessons.find((l) => l.status === "available") ?? lessons[0];
|
|
2731
|
+
return {
|
|
2732
|
+
courseId: course.id,
|
|
2733
|
+
title: course.title,
|
|
2734
|
+
sections: course.sections.length,
|
|
2735
|
+
lessons: lessons.length,
|
|
2736
|
+
firstLessonId: first.id,
|
|
2737
|
+
firstLessonTitle: first.title
|
|
2738
|
+
};
|
|
2739
|
+
}
|
|
2740
|
+
/** Canonical value of `study_map`. */
|
|
2741
|
+
function toMapValue(course) {
|
|
2742
|
+
const lessons = course.sections.flatMap((s) => s.lessons);
|
|
2743
|
+
return {
|
|
2744
|
+
courseId: course.id,
|
|
2745
|
+
title: course.title,
|
|
2746
|
+
counts: {
|
|
2747
|
+
total: lessons.length,
|
|
2748
|
+
mastered: lessons.filter((l) => l.status === "mastered").length,
|
|
2749
|
+
available: lessons.filter((l) => l.status === "available").length
|
|
2750
|
+
},
|
|
2751
|
+
tree: course.sections.map((section) => ({
|
|
2752
|
+
title: section.title,
|
|
2753
|
+
lessons: section.lessons.map((lesson) => ({
|
|
2754
|
+
id: lesson.id,
|
|
2755
|
+
title: lesson.title,
|
|
2756
|
+
kind: lesson.kind,
|
|
2757
|
+
status: lesson.status,
|
|
2758
|
+
masteryPct: lesson.mastery === null ? null : Math.round(lesson.mastery * 100),
|
|
2759
|
+
crown: masteryToCrown(lesson.mastery),
|
|
2760
|
+
weakConcepts: (conceptViews(lesson) ?? []).filter((c) => c.weak).length,
|
|
2761
|
+
frictionCount: lesson.friction.length
|
|
2762
|
+
}))
|
|
2763
|
+
}))
|
|
2764
|
+
};
|
|
2765
|
+
}
|
|
2766
|
+
/** Canonical value of `study_lesson`. */
|
|
2767
|
+
function toLessonValue(ref, state) {
|
|
2768
|
+
const next = nextLesson(ref.course, ref.lesson.id);
|
|
2769
|
+
const pending = state.proposals.find((p) => p.lessonId === ref.lesson.id && p.status === "pending");
|
|
2770
|
+
return {
|
|
2771
|
+
lessonId: ref.lesson.id,
|
|
2772
|
+
courseId: ref.course.id,
|
|
2773
|
+
courseTitle: ref.course.title,
|
|
2774
|
+
sectionTitle: ref.section.title,
|
|
2775
|
+
title: ref.lesson.title,
|
|
2776
|
+
kind: ref.lesson.kind,
|
|
2777
|
+
status: ref.lesson.status,
|
|
2778
|
+
body: ref.lesson.body,
|
|
2779
|
+
masteryPct: ref.lesson.mastery === null ? null : Math.round(ref.lesson.mastery * 100),
|
|
2780
|
+
crown: masteryToCrown(ref.lesson.mastery),
|
|
2781
|
+
attempts: ref.lesson.attempts,
|
|
2782
|
+
correctCount: ref.lesson.correctCount,
|
|
2783
|
+
strategy: strategyBand(ref.lesson.mastery),
|
|
2784
|
+
concepts: conceptViews(ref.lesson),
|
|
2785
|
+
starters: starterPrompts(ref.lesson.title),
|
|
2786
|
+
memory: {
|
|
2787
|
+
lesson: ref.lesson.memory,
|
|
2788
|
+
global: state.memoryGlobal,
|
|
2789
|
+
pattern: state.memoryPatterns[ref.course.id] ?? null
|
|
2790
|
+
},
|
|
2791
|
+
noteCount: ref.lesson.notes.length,
|
|
2792
|
+
pendingProposal: pending === void 0 ? null : {
|
|
2793
|
+
id: pending.id,
|
|
2794
|
+
rationale: pending.rationale
|
|
2795
|
+
},
|
|
2796
|
+
nextLessonId: next?.id ?? null
|
|
2797
|
+
};
|
|
2798
|
+
}
|
|
2799
|
+
/**
|
|
2800
|
+
* Parse a GitHub repository URL into owner/repo.
|
|
2801
|
+
* @param url - `https://github.com/<owner>/<repo>` (`.git` suffix and subpaths tolerated).
|
|
2802
|
+
* @returns owner and repo.
|
|
2803
|
+
*/
|
|
2804
|
+
function parseGithubUrl(url) {
|
|
2805
|
+
const match = url.match(/^(?:https?:\/\/)?github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?(?:[/?#].*)?$/);
|
|
2806
|
+
if (!match) throw new Error(`lookatstudy-plugin: not a GitHub repository URL: ${JSON.stringify(url)} (expected https://github.com/<owner>/<repo>)`);
|
|
2807
|
+
return {
|
|
2808
|
+
owner: match[1],
|
|
2809
|
+
repo: match[2]
|
|
2810
|
+
};
|
|
2811
|
+
}
|
|
2812
|
+
/** Wrap `fetch` so cancellation of the tool call aborts in-flight repo fetches. */
|
|
2813
|
+
function signalFetch(signal) {
|
|
2814
|
+
return (input, init) => fetch(input, {
|
|
2815
|
+
...init,
|
|
2816
|
+
signal
|
|
2817
|
+
});
|
|
2818
|
+
}
|
|
2819
|
+
/** Total over a missing `meta` (events logged before a presentationMeta existed): renders nothing instead of throwing into the presenter fallback. */
|
|
2820
|
+
const textBlocks = (lines) => (lines ?? []).map((text) => ({
|
|
2821
|
+
type: "text",
|
|
2822
|
+
text
|
|
2823
|
+
}));
|
|
2824
|
+
/**
|
|
2825
|
+
* Build the full study tool set over one store.
|
|
2826
|
+
* @param store - state store owned by `apply`.
|
|
2827
|
+
* @returns tool definitions ready for `ctx.tools.register`.
|
|
2828
|
+
*/
|
|
2829
|
+
function studyTools(store) {
|
|
2830
|
+
/** Run a mutating state operation and persist. */
|
|
2831
|
+
const mutate = (fn) => {
|
|
2832
|
+
const result = fn(store.get());
|
|
2833
|
+
store.save();
|
|
2834
|
+
return result;
|
|
2835
|
+
};
|
|
2836
|
+
const importOutput = { schema: {
|
|
2837
|
+
type: "object",
|
|
2838
|
+
additionalProperties: false,
|
|
2839
|
+
properties: {
|
|
2840
|
+
courseId: {
|
|
2841
|
+
type: "string",
|
|
2842
|
+
required: true
|
|
2843
|
+
},
|
|
2844
|
+
title: {
|
|
2845
|
+
type: "string",
|
|
2846
|
+
required: true
|
|
2847
|
+
},
|
|
2848
|
+
sections: {
|
|
2849
|
+
type: "integer",
|
|
2850
|
+
required: true
|
|
2851
|
+
},
|
|
2852
|
+
lessons: {
|
|
2853
|
+
type: "integer",
|
|
2854
|
+
required: true
|
|
2855
|
+
},
|
|
2856
|
+
firstLessonId: {
|
|
2857
|
+
type: "string",
|
|
2858
|
+
required: true
|
|
2859
|
+
},
|
|
2860
|
+
firstLessonTitle: {
|
|
2861
|
+
type: "string",
|
|
2862
|
+
required: true
|
|
2863
|
+
}
|
|
2864
|
+
}
|
|
2865
|
+
} };
|
|
2866
|
+
const importPresent = {
|
|
2867
|
+
presentationMeta: (_args, value) => importLines(value),
|
|
2868
|
+
presentResult: (_args, result) => ({
|
|
2869
|
+
card: "generic",
|
|
2870
|
+
content: textBlocks(result.meta)
|
|
2871
|
+
})
|
|
2872
|
+
};
|
|
2873
|
+
return [
|
|
2874
|
+
defineTool({
|
|
2875
|
+
name: "study_import_markdown",
|
|
2876
|
+
description: "Import pasted markdown as a structured course: H2 (##) becomes a section, H3 (###) a lesson. Use for notes, single long documents, or content fetched by other means.",
|
|
2877
|
+
parameters: {
|
|
2878
|
+
markdown: {
|
|
2879
|
+
type: "string",
|
|
2880
|
+
required: true,
|
|
2881
|
+
description: "The full markdown source of the course."
|
|
2882
|
+
},
|
|
2883
|
+
title: {
|
|
2884
|
+
type: "string",
|
|
2885
|
+
description: "Optional course title overriding the first H1."
|
|
2886
|
+
}
|
|
2887
|
+
},
|
|
2888
|
+
output: {
|
|
2889
|
+
...importOutput,
|
|
2890
|
+
render: (_args, value) => [{
|
|
2891
|
+
type: "text",
|
|
2892
|
+
text: `Imported course “${value.title}” (${value.sections} sections, ${value.lessons} lessons). First lesson: “${value.firstLessonTitle}” (id ${value.firstLessonId}).`
|
|
2893
|
+
}]
|
|
2894
|
+
},
|
|
2895
|
+
async execute(args) {
|
|
2896
|
+
const parsed = parseMarkdownToCourse(args.markdown);
|
|
2897
|
+
if (args.title !== void 0) parsed.title = args.title;
|
|
2898
|
+
requireParsedLessons(parsed);
|
|
2899
|
+
return mutate((state) => toImportValue(importCourse(state, parsed, "markdown", "pasted markdown")));
|
|
2900
|
+
},
|
|
2901
|
+
presentCall: (args) => ({
|
|
2902
|
+
card: "generic",
|
|
2903
|
+
title: `Import markdown course${args.title === void 0 ? "" : `: ${args.title}`}`,
|
|
2904
|
+
kind: "read"
|
|
2905
|
+
}),
|
|
2906
|
+
...importPresent
|
|
2907
|
+
}),
|
|
2908
|
+
defineTool({
|
|
2909
|
+
name: "study_import_folder",
|
|
2910
|
+
description: "Import a local folder as a course: markdown, txt, html, Jupyter notebooks, rst/Rmd/org/adoc, and 30+ code file types become lessons grouped into sections by directory (code is teaching material too). PDF/PPTX are not supported in this edition.",
|
|
2911
|
+
parameters: {
|
|
2912
|
+
path: {
|
|
2913
|
+
type: "string",
|
|
2914
|
+
required: true,
|
|
2915
|
+
description: "Absolute path of the folder to scan."
|
|
2916
|
+
},
|
|
2917
|
+
title: {
|
|
2918
|
+
type: "string",
|
|
2919
|
+
description: "Optional course title overriding the folder name."
|
|
2920
|
+
}
|
|
2921
|
+
},
|
|
2922
|
+
output: {
|
|
2923
|
+
...importOutput,
|
|
2924
|
+
render: (_args, value) => [{
|
|
2925
|
+
type: "text",
|
|
2926
|
+
text: `Imported folder course “${value.title}” (${value.sections} sections, ${value.lessons} lessons). First lesson: “${value.firstLessonTitle}” (id ${value.firstLessonId}).`
|
|
2927
|
+
}]
|
|
2928
|
+
},
|
|
2929
|
+
async execute(args) {
|
|
2930
|
+
if (!existsSync(args.path)) throw new Error(`lookatstudy-plugin: folder does not exist: ${args.path}`);
|
|
2931
|
+
const files = (await scanFolder(args.path)).map((doc) => ({
|
|
2932
|
+
path: doc.path,
|
|
2933
|
+
title: doc.title,
|
|
2934
|
+
md: doc.content
|
|
2935
|
+
}));
|
|
2936
|
+
const parsed = buildCourseFromFiles(args.title ?? basename(args.path.replaceAll("\\", "/")), files);
|
|
2937
|
+
requireParsedLessons(parsed);
|
|
2938
|
+
return mutate((state) => toImportValue(importCourse(state, parsed, "folder", args.path)));
|
|
2939
|
+
},
|
|
2940
|
+
timeoutMs: 6e4,
|
|
2941
|
+
presentCall: (args) => ({
|
|
2942
|
+
card: "generic",
|
|
2943
|
+
title: `Scan folder: ${args.path}`,
|
|
2944
|
+
kind: "read",
|
|
2945
|
+
rawInput: args.path
|
|
2946
|
+
}),
|
|
2947
|
+
...importPresent
|
|
2948
|
+
}),
|
|
2949
|
+
defineTool({
|
|
2950
|
+
name: "study_import_github",
|
|
2951
|
+
description: "Import a GitHub learning repository as a course. Discovery follows the README outline, files are fetched through the jsDelivr CDN (works where github.com is unreachable). Best for curated curricula (e.g. microsoft/AI-For-Beginners); awesome-lists are rejected.",
|
|
2952
|
+
parameters: {
|
|
2953
|
+
url: {
|
|
2954
|
+
type: "string",
|
|
2955
|
+
required: true,
|
|
2956
|
+
description: "Repository URL, e.g. https://github.com/microsoft/AI-For-Beginners."
|
|
2957
|
+
},
|
|
2958
|
+
branch: {
|
|
2959
|
+
type: "string",
|
|
2960
|
+
description: "Branch to read (main tried, then master); defaults to main."
|
|
2961
|
+
}
|
|
2962
|
+
},
|
|
2963
|
+
output: {
|
|
2964
|
+
...importOutput,
|
|
2965
|
+
render: (_args, value) => [{
|
|
2966
|
+
type: "text",
|
|
2967
|
+
text: `Imported GitHub course “${value.title}” (${value.sections} sections, ${value.lessons} lessons). First lesson: “${value.firstLessonTitle}” (id ${value.firstLessonId}).`
|
|
2968
|
+
}]
|
|
2969
|
+
},
|
|
2970
|
+
async execute(args, exec) {
|
|
2971
|
+
const { owner, repo } = parseGithubUrl(args.url);
|
|
2972
|
+
const result = await importRepoToParsedCourse(owner, repo, args.branch ?? "main", signalFetch(exec.signal));
|
|
2973
|
+
requireParsedLessons(result.course);
|
|
2974
|
+
return mutate((state) => toImportValue(importCourse(state, result.course, "github", args.url)));
|
|
2975
|
+
},
|
|
2976
|
+
timeoutMs: 18e4,
|
|
2977
|
+
presentCall: (args) => ({
|
|
2978
|
+
card: "generic",
|
|
2979
|
+
title: `Import GitHub course: ${args.url}`,
|
|
2980
|
+
kind: "fetch"
|
|
2981
|
+
}),
|
|
2982
|
+
...importPresent
|
|
2983
|
+
}),
|
|
2984
|
+
defineTool({
|
|
2985
|
+
name: "study_courses",
|
|
2986
|
+
description: "List imported courses with progress, average mastery, due reviews, and the current lesson id.",
|
|
2987
|
+
parameters: {},
|
|
2988
|
+
output: {
|
|
2989
|
+
schema: {
|
|
2990
|
+
type: "object",
|
|
2991
|
+
additionalProperties: false,
|
|
2992
|
+
properties: {
|
|
2993
|
+
total: {
|
|
2994
|
+
type: "integer",
|
|
2995
|
+
required: true
|
|
2996
|
+
},
|
|
2997
|
+
courses: {
|
|
2998
|
+
type: "array",
|
|
2999
|
+
required: true,
|
|
3000
|
+
items: {
|
|
3001
|
+
type: "object",
|
|
3002
|
+
additionalProperties: false,
|
|
3003
|
+
properties: {
|
|
3004
|
+
courseId: {
|
|
3005
|
+
type: "string",
|
|
3006
|
+
required: true
|
|
3007
|
+
},
|
|
3008
|
+
title: {
|
|
3009
|
+
type: "string",
|
|
3010
|
+
required: true
|
|
3011
|
+
},
|
|
3012
|
+
source: {
|
|
3013
|
+
type: "string",
|
|
3014
|
+
required: true,
|
|
3015
|
+
enum: [
|
|
3016
|
+
"markdown",
|
|
3017
|
+
"folder",
|
|
3018
|
+
"github"
|
|
3019
|
+
]
|
|
3020
|
+
},
|
|
3021
|
+
total: {
|
|
3022
|
+
type: "integer",
|
|
3023
|
+
required: true
|
|
3024
|
+
},
|
|
3025
|
+
mastered: {
|
|
3026
|
+
type: "integer",
|
|
3027
|
+
required: true
|
|
3028
|
+
},
|
|
3029
|
+
avgMasteryPct: {
|
|
3030
|
+
...nullableInteger,
|
|
3031
|
+
required: true
|
|
3032
|
+
},
|
|
3033
|
+
dueCount: {
|
|
3034
|
+
type: "integer",
|
|
3035
|
+
required: true
|
|
3036
|
+
},
|
|
3037
|
+
currentLessonId: {
|
|
3038
|
+
...nullableString,
|
|
3039
|
+
required: true
|
|
3040
|
+
}
|
|
3041
|
+
}
|
|
3042
|
+
}
|
|
3043
|
+
}
|
|
3044
|
+
}
|
|
3045
|
+
},
|
|
3046
|
+
render: (_args, value) => [{
|
|
3047
|
+
type: "text",
|
|
3048
|
+
text: value.courses.length === 0 ? "No courses imported yet. Import one with study_import_markdown, study_import_folder, or study_import_github." : value.courses.map((c) => `“${c.title}” (${c.source}) — ${c.mastered}/${c.total} lessons mastered${c.avgMasteryPct === null ? "" : `, avg mastery ${c.avgMasteryPct}%`}${c.dueCount === 0 ? "" : `, ${c.dueCount} reviews due`}${c.currentLessonId === null ? "" : `, current lesson ${c.currentLessonId}`}`).join("\n")
|
|
3049
|
+
}]
|
|
3050
|
+
},
|
|
3051
|
+
async execute() {
|
|
3052
|
+
const summaries = courseSummaries(store.get(), /* @__PURE__ */ new Date());
|
|
3053
|
+
return {
|
|
3054
|
+
total: summaries.length,
|
|
3055
|
+
courses: summaries.map((s) => ({
|
|
3056
|
+
courseId: s.courseId,
|
|
3057
|
+
title: s.title,
|
|
3058
|
+
source: s.source,
|
|
3059
|
+
total: s.total,
|
|
3060
|
+
mastered: s.mastered,
|
|
3061
|
+
avgMasteryPct: s.avgMasteryPct,
|
|
3062
|
+
dueCount: s.dueCount,
|
|
3063
|
+
currentLessonId: s.currentLessonId
|
|
3064
|
+
}))
|
|
3065
|
+
};
|
|
3066
|
+
},
|
|
3067
|
+
isConcurrencySafe: () => true,
|
|
3068
|
+
presentCall: () => ({
|
|
3069
|
+
card: "generic",
|
|
3070
|
+
title: "List courses",
|
|
3071
|
+
kind: "read"
|
|
3072
|
+
})
|
|
3073
|
+
}),
|
|
3074
|
+
defineTool({
|
|
3075
|
+
name: "study_map",
|
|
3076
|
+
description: "Show one course's skill tree: sections, lessons with locked/available/in_progress/mastered status, mastery, weak-concept count (⚡), and friction count — the weak spots to target.",
|
|
3077
|
+
parameters: { courseId: {
|
|
3078
|
+
type: "string",
|
|
3079
|
+
required: true,
|
|
3080
|
+
description: "Course id from an import result or study_courses."
|
|
3081
|
+
} },
|
|
3082
|
+
output: {
|
|
3083
|
+
schema: {
|
|
3084
|
+
type: "object",
|
|
3085
|
+
additionalProperties: false,
|
|
3086
|
+
properties: {
|
|
3087
|
+
courseId: {
|
|
3088
|
+
type: "string",
|
|
3089
|
+
required: true
|
|
3090
|
+
},
|
|
3091
|
+
title: {
|
|
3092
|
+
type: "string",
|
|
3093
|
+
required: true
|
|
3094
|
+
},
|
|
3095
|
+
counts: {
|
|
3096
|
+
type: "object",
|
|
3097
|
+
required: true,
|
|
3098
|
+
additionalProperties: false,
|
|
3099
|
+
properties: {
|
|
3100
|
+
total: {
|
|
3101
|
+
type: "integer",
|
|
3102
|
+
required: true
|
|
3103
|
+
},
|
|
3104
|
+
mastered: {
|
|
3105
|
+
type: "integer",
|
|
3106
|
+
required: true
|
|
3107
|
+
},
|
|
3108
|
+
available: {
|
|
3109
|
+
type: "integer",
|
|
3110
|
+
required: true
|
|
3111
|
+
}
|
|
3112
|
+
}
|
|
3113
|
+
},
|
|
3114
|
+
tree: {
|
|
3115
|
+
type: "array",
|
|
3116
|
+
required: true,
|
|
3117
|
+
items: {
|
|
3118
|
+
type: "object",
|
|
3119
|
+
additionalProperties: false,
|
|
3120
|
+
properties: {
|
|
3121
|
+
title: {
|
|
3122
|
+
type: "string",
|
|
3123
|
+
required: true
|
|
3124
|
+
},
|
|
3125
|
+
lessons: {
|
|
3126
|
+
type: "array",
|
|
3127
|
+
required: true,
|
|
3128
|
+
items: {
|
|
3129
|
+
type: "object",
|
|
3130
|
+
additionalProperties: false,
|
|
3131
|
+
properties: {
|
|
3132
|
+
id: {
|
|
3133
|
+
type: "string",
|
|
3134
|
+
required: true
|
|
3135
|
+
},
|
|
3136
|
+
title: {
|
|
3137
|
+
type: "string",
|
|
3138
|
+
required: true
|
|
3139
|
+
},
|
|
3140
|
+
kind: {
|
|
3141
|
+
type: "string",
|
|
3142
|
+
required: true,
|
|
3143
|
+
enum: [...LESSON_KINDS]
|
|
3144
|
+
},
|
|
3145
|
+
status: {
|
|
3146
|
+
type: "string",
|
|
3147
|
+
required: true,
|
|
3148
|
+
enum: [...LESSON_STATUSES]
|
|
3149
|
+
},
|
|
3150
|
+
masteryPct: {
|
|
3151
|
+
...nullableInteger,
|
|
3152
|
+
required: true
|
|
3153
|
+
},
|
|
3154
|
+
crown: {
|
|
3155
|
+
type: "integer",
|
|
3156
|
+
required: true
|
|
3157
|
+
},
|
|
3158
|
+
weakConcepts: {
|
|
3159
|
+
type: "integer",
|
|
3160
|
+
required: true
|
|
3161
|
+
},
|
|
3162
|
+
frictionCount: {
|
|
3163
|
+
type: "integer",
|
|
3164
|
+
required: true
|
|
3165
|
+
}
|
|
3166
|
+
}
|
|
3167
|
+
}
|
|
3168
|
+
}
|
|
3169
|
+
}
|
|
3170
|
+
}
|
|
3171
|
+
}
|
|
3172
|
+
}
|
|
3173
|
+
},
|
|
3174
|
+
render: (_args, value) => textBlocks(mapLines(value))
|
|
3175
|
+
},
|
|
3176
|
+
async execute(args) {
|
|
3177
|
+
return toMapValue(findCourse(store.get(), args.courseId));
|
|
3178
|
+
},
|
|
3179
|
+
isConcurrencySafe: () => true,
|
|
3180
|
+
presentCall: (args) => ({
|
|
3181
|
+
card: "generic",
|
|
3182
|
+
title: `Course map: ${args.courseId}`,
|
|
3183
|
+
kind: "read"
|
|
3184
|
+
}),
|
|
3185
|
+
presentationMeta: (_args, value) => mapLines(value),
|
|
3186
|
+
presentResult: (_args, result) => ({
|
|
3187
|
+
card: "generic",
|
|
3188
|
+
content: textBlocks(result.meta)
|
|
3189
|
+
})
|
|
3190
|
+
}),
|
|
3191
|
+
defineTool({
|
|
3192
|
+
name: "study_lesson",
|
|
3193
|
+
description: "Open one lesson and make it the focus: returns its markdown content (the source of truth to teach from), teaching strategy band, knowledge concepts with mastery/weak flags, four consolidation starters, memory slots, and any pending mastery proposal.",
|
|
3194
|
+
parameters: { lessonId: {
|
|
3195
|
+
type: "string",
|
|
3196
|
+
required: true,
|
|
3197
|
+
description: "Lesson id from a map, import, or courses call."
|
|
3198
|
+
} },
|
|
3199
|
+
output: {
|
|
3200
|
+
schema: {
|
|
3201
|
+
type: "object",
|
|
3202
|
+
additionalProperties: false,
|
|
3203
|
+
properties: {
|
|
3204
|
+
lessonId: {
|
|
3205
|
+
type: "string",
|
|
3206
|
+
required: true
|
|
3207
|
+
},
|
|
3208
|
+
courseId: {
|
|
3209
|
+
type: "string",
|
|
3210
|
+
required: true
|
|
3211
|
+
},
|
|
3212
|
+
courseTitle: {
|
|
3213
|
+
type: "string",
|
|
3214
|
+
required: true
|
|
3215
|
+
},
|
|
3216
|
+
sectionTitle: {
|
|
3217
|
+
type: "string",
|
|
3218
|
+
required: true
|
|
3219
|
+
},
|
|
3220
|
+
title: {
|
|
3221
|
+
type: "string",
|
|
3222
|
+
required: true
|
|
3223
|
+
},
|
|
3224
|
+
kind: {
|
|
3225
|
+
type: "string",
|
|
3226
|
+
required: true,
|
|
3227
|
+
enum: [...LESSON_KINDS]
|
|
3228
|
+
},
|
|
3229
|
+
status: {
|
|
3230
|
+
type: "string",
|
|
3231
|
+
required: true,
|
|
3232
|
+
enum: [...LESSON_STATUSES]
|
|
3233
|
+
},
|
|
3234
|
+
body: {
|
|
3235
|
+
type: "string",
|
|
3236
|
+
required: true
|
|
3237
|
+
},
|
|
3238
|
+
masteryPct: {
|
|
3239
|
+
...nullableInteger,
|
|
3240
|
+
required: true
|
|
3241
|
+
},
|
|
3242
|
+
crown: {
|
|
3243
|
+
type: "integer",
|
|
3244
|
+
required: true
|
|
3245
|
+
},
|
|
3246
|
+
attempts: {
|
|
3247
|
+
type: "integer",
|
|
3248
|
+
required: true
|
|
3249
|
+
},
|
|
3250
|
+
correctCount: {
|
|
3251
|
+
type: "integer",
|
|
3252
|
+
required: true
|
|
3253
|
+
},
|
|
3254
|
+
strategy: {
|
|
3255
|
+
type: "string",
|
|
3256
|
+
required: true
|
|
3257
|
+
},
|
|
3258
|
+
concepts: {
|
|
3259
|
+
oneOf: [{ type: "null" }, {
|
|
3260
|
+
type: "array",
|
|
3261
|
+
items: {
|
|
3262
|
+
type: "object",
|
|
3263
|
+
additionalProperties: false,
|
|
3264
|
+
properties: {
|
|
3265
|
+
title: {
|
|
3266
|
+
type: "string",
|
|
3267
|
+
required: true
|
|
3268
|
+
},
|
|
3269
|
+
masteryPct: {
|
|
3270
|
+
type: "integer",
|
|
3271
|
+
required: true
|
|
3272
|
+
},
|
|
3273
|
+
weak: {
|
|
3274
|
+
type: "boolean",
|
|
3275
|
+
required: true
|
|
3276
|
+
},
|
|
3277
|
+
/** 1 once this concept has been quizzed at least once, else 0 (ConceptView.tested). */
|
|
3278
|
+
tested: {
|
|
3279
|
+
type: "integer",
|
|
3280
|
+
required: true
|
|
3281
|
+
}
|
|
3282
|
+
}
|
|
3283
|
+
}
|
|
3284
|
+
}],
|
|
3285
|
+
required: true
|
|
3286
|
+
},
|
|
3287
|
+
starters: {
|
|
3288
|
+
type: "array",
|
|
3289
|
+
required: true,
|
|
3290
|
+
items: {
|
|
3291
|
+
type: "object",
|
|
3292
|
+
additionalProperties: false,
|
|
3293
|
+
properties: {
|
|
3294
|
+
label: {
|
|
3295
|
+
type: "string",
|
|
3296
|
+
required: true
|
|
3297
|
+
},
|
|
3298
|
+
message: {
|
|
3299
|
+
type: "string",
|
|
3300
|
+
required: true
|
|
3301
|
+
},
|
|
3302
|
+
effect: {
|
|
3303
|
+
type: "string",
|
|
3304
|
+
required: true,
|
|
3305
|
+
enum: [
|
|
3306
|
+
"mastery",
|
|
3307
|
+
"friction",
|
|
3308
|
+
"none"
|
|
3309
|
+
]
|
|
3310
|
+
}
|
|
3311
|
+
}
|
|
3312
|
+
}
|
|
3313
|
+
},
|
|
3314
|
+
memory: {
|
|
3315
|
+
type: "object",
|
|
3316
|
+
required: true,
|
|
3317
|
+
additionalProperties: false,
|
|
3318
|
+
properties: {
|
|
3319
|
+
lesson: {
|
|
3320
|
+
...nullableString,
|
|
3321
|
+
required: true
|
|
3322
|
+
},
|
|
3323
|
+
global: {
|
|
3324
|
+
...nullableString,
|
|
3325
|
+
required: true
|
|
3326
|
+
},
|
|
3327
|
+
pattern: {
|
|
3328
|
+
...nullableString,
|
|
3329
|
+
required: true
|
|
3330
|
+
}
|
|
3331
|
+
}
|
|
3332
|
+
},
|
|
3333
|
+
noteCount: {
|
|
3334
|
+
type: "integer",
|
|
3335
|
+
required: true
|
|
3336
|
+
},
|
|
3337
|
+
pendingProposal: {
|
|
3338
|
+
oneOf: [{ type: "null" }, {
|
|
3339
|
+
type: "object",
|
|
3340
|
+
additionalProperties: false,
|
|
3341
|
+
properties: {
|
|
3342
|
+
id: {
|
|
3343
|
+
type: "string",
|
|
3344
|
+
required: true
|
|
3345
|
+
},
|
|
3346
|
+
rationale: {
|
|
3347
|
+
type: "string",
|
|
3348
|
+
required: true
|
|
3349
|
+
}
|
|
3350
|
+
}
|
|
3351
|
+
}],
|
|
3352
|
+
required: true
|
|
3353
|
+
},
|
|
3354
|
+
nextLessonId: {
|
|
3355
|
+
...nullableString,
|
|
3356
|
+
required: true
|
|
3357
|
+
}
|
|
3358
|
+
}
|
|
3359
|
+
},
|
|
3360
|
+
render: (_args, value) => [{
|
|
3361
|
+
type: "text",
|
|
3362
|
+
text: `Lesson “${value.title}” — ${value.courseTitle} / ${value.sectionTitle}\nstatus ${value.status}${value.masteryPct === null ? "" : `, mastery ${value.masteryPct}%`}, ${value.correctCount}/${value.attempts} answers correct\nstrategy: ${value.strategy}\n` + (value.concepts === null ? "" : `concepts: ${value.concepts.map((c) => `${c.title} ${c.masteryPct}%${c.weak ? " ⚡weak" : ""}`).join(" · ")}\n`) + `starters: ${value.starters.map((s) => s.label).join(" / ")}\n\n${value.body}${value.nextLessonId === null ? "\n\n(this is the last lesson)" : `\n\n(next lesson: ${value.nextLessonId})`}`
|
|
3363
|
+
}]
|
|
3364
|
+
},
|
|
3365
|
+
async execute(args) {
|
|
3366
|
+
return mutate((state) => {
|
|
3367
|
+
const { ref } = attemptLesson(state, args.lessonId, /* @__PURE__ */ new Date());
|
|
3368
|
+
state.focus = { lessonId: ref.lesson.id };
|
|
3369
|
+
return toLessonValue(ref, state);
|
|
3370
|
+
});
|
|
3371
|
+
},
|
|
3372
|
+
presentCall: (args) => ({
|
|
3373
|
+
card: "generic",
|
|
3374
|
+
title: `Open lesson: ${args.lessonId}`,
|
|
3375
|
+
kind: "read"
|
|
3376
|
+
})
|
|
3377
|
+
}),
|
|
3378
|
+
defineTool({
|
|
3379
|
+
name: "study_record_answer",
|
|
3380
|
+
description: "Record one graded answer and update mastery — call after EVERY learner answer to a scored question. Name the `concept` the question tested (from study_lesson / study_define_concepts) so per-concept mastery stays accurate; lesson mastery is the WEAKEST concept. Mastery ≥50% unlocks the next lesson early; ≥90% graduates automatically and schedules the first review. Also pass the question text and the learner's answer to keep a practice log.",
|
|
3381
|
+
parameters: {
|
|
3382
|
+
lessonId: {
|
|
3383
|
+
type: "string",
|
|
3384
|
+
required: true,
|
|
3385
|
+
description: "Lesson the question tested."
|
|
3386
|
+
},
|
|
3387
|
+
correct: {
|
|
3388
|
+
type: "boolean",
|
|
3389
|
+
required: true,
|
|
3390
|
+
description: "Whether the learner answered correctly."
|
|
3391
|
+
},
|
|
3392
|
+
concept: {
|
|
3393
|
+
type: "string",
|
|
3394
|
+
description: "Concept title the question tested (required once concepts are defined)."
|
|
3395
|
+
},
|
|
3396
|
+
rationale: {
|
|
3397
|
+
type: "string",
|
|
3398
|
+
description: "One line: why you graded it this way."
|
|
3399
|
+
},
|
|
3400
|
+
question: {
|
|
3401
|
+
type: "string",
|
|
3402
|
+
description: "The question text, for the practice log."
|
|
3403
|
+
},
|
|
3404
|
+
givenAnswer: {
|
|
3405
|
+
type: "string",
|
|
3406
|
+
description: "The learner's answer, for the practice log."
|
|
3407
|
+
}
|
|
3408
|
+
},
|
|
3409
|
+
output: {
|
|
3410
|
+
schema: {
|
|
3411
|
+
type: "object",
|
|
3412
|
+
additionalProperties: false,
|
|
3413
|
+
properties: {
|
|
3414
|
+
lessonId: {
|
|
3415
|
+
type: "string",
|
|
3416
|
+
required: true
|
|
3417
|
+
},
|
|
3418
|
+
lessonTitle: {
|
|
3419
|
+
type: "string",
|
|
3420
|
+
required: true
|
|
3421
|
+
},
|
|
3422
|
+
correct: {
|
|
3423
|
+
type: "boolean",
|
|
3424
|
+
required: true
|
|
3425
|
+
},
|
|
3426
|
+
concept: {
|
|
3427
|
+
oneOf: [{ type: "null" }, {
|
|
3428
|
+
type: "object",
|
|
3429
|
+
additionalProperties: false,
|
|
3430
|
+
properties: {
|
|
3431
|
+
title: {
|
|
3432
|
+
type: "string",
|
|
3433
|
+
required: true
|
|
3434
|
+
},
|
|
3435
|
+
masteryPct: {
|
|
3436
|
+
type: "integer",
|
|
3437
|
+
required: true
|
|
3438
|
+
},
|
|
3439
|
+
weak: {
|
|
3440
|
+
type: "boolean",
|
|
3441
|
+
required: true
|
|
3442
|
+
}
|
|
3443
|
+
}
|
|
3444
|
+
}],
|
|
3445
|
+
required: true
|
|
3446
|
+
},
|
|
3447
|
+
prevMasteryPct: {
|
|
3448
|
+
type: "integer",
|
|
3449
|
+
required: true
|
|
3450
|
+
},
|
|
3451
|
+
newMasteryPct: {
|
|
3452
|
+
type: "integer",
|
|
3453
|
+
required: true
|
|
3454
|
+
},
|
|
3455
|
+
crown: {
|
|
3456
|
+
type: "integer",
|
|
3457
|
+
required: true
|
|
3458
|
+
},
|
|
3459
|
+
mastered: {
|
|
3460
|
+
type: "boolean",
|
|
3461
|
+
required: true
|
|
3462
|
+
},
|
|
3463
|
+
attempts: {
|
|
3464
|
+
type: "integer",
|
|
3465
|
+
required: true
|
|
3466
|
+
},
|
|
3467
|
+
correctCount: {
|
|
3468
|
+
type: "integer",
|
|
3469
|
+
required: true
|
|
3470
|
+
},
|
|
3471
|
+
graduated: {
|
|
3472
|
+
type: "boolean",
|
|
3473
|
+
required: true
|
|
3474
|
+
},
|
|
3475
|
+
unlockedLessonIds: {
|
|
3476
|
+
type: "array",
|
|
3477
|
+
required: true,
|
|
3478
|
+
items: { type: "string" }
|
|
3479
|
+
},
|
|
3480
|
+
reviewDueAt: {
|
|
3481
|
+
...nullableString,
|
|
3482
|
+
required: true
|
|
3483
|
+
}
|
|
3484
|
+
}
|
|
3485
|
+
},
|
|
3486
|
+
render: (_args, value) => [{
|
|
3487
|
+
type: "text",
|
|
3488
|
+
text: answerLine(value) + (value.concept === null ? "" : `\nconcept: ${value.concept.title} ${value.concept.masteryPct}%${value.concept.weak ? " ⚡weak" : ""}`) + (value.graduated ? "\n🎓 mastery ≥90% — lesson graduated, first review scheduled." : "") + (value.unlockedLessonIds.length === 0 ? "" : `\n🔓 unlocked: ${value.unlockedLessonIds.join(", ")}`)
|
|
3489
|
+
}]
|
|
3490
|
+
},
|
|
3491
|
+
async execute(args) {
|
|
3492
|
+
return mutate((state) => {
|
|
3493
|
+
const r = recordAnswer(state, args.lessonId, args.correct, args.concept, /* @__PURE__ */ new Date());
|
|
3494
|
+
if (args.question !== void 0) addNote(state, args.lessonId, "practice", args.question.slice(0, 80), `${args.question}\n\nlearner answered: ${args.givenAnswer ?? "(not recorded)"} — ${args.correct ? "✓ correct" : "✗ incorrect"}${args.rationale === void 0 ? "" : `\nrationale: ${args.rationale}`}`, "ai", null, /* @__PURE__ */ new Date());
|
|
3495
|
+
return {
|
|
3496
|
+
lessonId: r.ref.lesson.id,
|
|
3497
|
+
lessonTitle: r.ref.lesson.title,
|
|
3498
|
+
correct: args.correct,
|
|
3499
|
+
concept: r.concept === null ? null : {
|
|
3500
|
+
title: r.concept.title,
|
|
3501
|
+
masteryPct: Math.round(r.concept.mastery * 100),
|
|
3502
|
+
weak: r.concept.mastery < .7
|
|
3503
|
+
},
|
|
3504
|
+
prevMasteryPct: Math.round(r.prevMastery * 100),
|
|
3505
|
+
newMasteryPct: Math.round(r.newMastery * 100),
|
|
3506
|
+
crown: r.crown,
|
|
3507
|
+
mastered: r.mastered,
|
|
3508
|
+
attempts: r.ref.lesson.attempts,
|
|
3509
|
+
correctCount: r.ref.lesson.correctCount,
|
|
3510
|
+
graduated: r.progression.graduated,
|
|
3511
|
+
unlockedLessonIds: r.progression.unlocked.map((u) => u.id),
|
|
3512
|
+
reviewDueAt: r.progression.nextDue
|
|
3513
|
+
};
|
|
3514
|
+
});
|
|
3515
|
+
},
|
|
3516
|
+
presentCall: (args) => ({
|
|
3517
|
+
card: "generic",
|
|
3518
|
+
title: `Record answer (${args.correct ? "correct" : "incorrect"}): ${args.lessonId}`
|
|
3519
|
+
}),
|
|
3520
|
+
presentationMeta: (_args, value) => [answerLine(value)],
|
|
3521
|
+
presentResult: (_args, result) => ({
|
|
3522
|
+
card: "generic",
|
|
3523
|
+
content: textBlocks(result.meta)
|
|
3524
|
+
})
|
|
3525
|
+
}),
|
|
3526
|
+
defineTool({
|
|
3527
|
+
name: "study_complete_lesson",
|
|
3528
|
+
description: "Mark a lesson mastered manually (graduation at 90% mastery is the automatic path — this is the override). Unlocks the next lesson and schedules the first spaced review for tomorrow. Call only when the learner has genuinely worked through the lesson.",
|
|
3529
|
+
parameters: { lessonId: {
|
|
3530
|
+
type: "string",
|
|
3531
|
+
required: true,
|
|
3532
|
+
description: "Lesson to complete."
|
|
3533
|
+
} },
|
|
3534
|
+
output: {
|
|
3535
|
+
schema: {
|
|
3536
|
+
type: "object",
|
|
3537
|
+
additionalProperties: false,
|
|
3538
|
+
properties: {
|
|
3539
|
+
lessonId: {
|
|
3540
|
+
type: "string",
|
|
3541
|
+
required: true
|
|
3542
|
+
},
|
|
3543
|
+
lessonTitle: {
|
|
3544
|
+
type: "string",
|
|
3545
|
+
required: true
|
|
3546
|
+
},
|
|
3547
|
+
unlockedLessonIds: {
|
|
3548
|
+
type: "array",
|
|
3549
|
+
required: true,
|
|
3550
|
+
items: { type: "string" }
|
|
3551
|
+
},
|
|
3552
|
+
unlockedLessonTitles: {
|
|
3553
|
+
type: "array",
|
|
3554
|
+
required: true,
|
|
3555
|
+
items: { type: "string" }
|
|
3556
|
+
},
|
|
3557
|
+
reviewDueAt: {
|
|
3558
|
+
type: "string",
|
|
3559
|
+
required: true
|
|
3560
|
+
},
|
|
3561
|
+
courseComplete: {
|
|
3562
|
+
type: "boolean",
|
|
3563
|
+
required: true
|
|
3564
|
+
}
|
|
3565
|
+
}
|
|
3566
|
+
},
|
|
3567
|
+
render: (_args, value) => [{
|
|
3568
|
+
type: "text",
|
|
3569
|
+
text: completeLines(value).join("\n")
|
|
3570
|
+
}]
|
|
3571
|
+
},
|
|
3572
|
+
async execute(args) {
|
|
3573
|
+
return mutate((state) => {
|
|
3574
|
+
const r = completeLesson(state, args.lessonId, /* @__PURE__ */ new Date());
|
|
3575
|
+
return {
|
|
3576
|
+
lessonId: r.ref.lesson.id,
|
|
3577
|
+
lessonTitle: r.ref.lesson.title,
|
|
3578
|
+
unlockedLessonIds: r.unlocked.map((u) => u.id),
|
|
3579
|
+
unlockedLessonTitles: r.unlocked.map((u) => u.title),
|
|
3580
|
+
reviewDueAt: r.dueAt,
|
|
3581
|
+
courseComplete: r.courseComplete
|
|
3582
|
+
};
|
|
3583
|
+
});
|
|
3584
|
+
},
|
|
3585
|
+
presentCall: (args) => ({
|
|
3586
|
+
card: "generic",
|
|
3587
|
+
title: `Complete lesson: ${args.lessonId}`
|
|
3588
|
+
}),
|
|
3589
|
+
presentationMeta: (_args, value) => completeLines(value),
|
|
3590
|
+
presentResult: (_args, result) => ({
|
|
3591
|
+
card: "generic",
|
|
3592
|
+
content: textBlocks(result.meta)
|
|
3593
|
+
})
|
|
3594
|
+
}),
|
|
3595
|
+
defineTool({
|
|
3596
|
+
name: "study_due_reviews",
|
|
3597
|
+
description: "List mastered lessons whose spaced-repetition review is due (optionally within one course), oldest first. Start every session here.",
|
|
3598
|
+
parameters: { courseId: {
|
|
3599
|
+
type: "string",
|
|
3600
|
+
description: "Restrict to one course; omit to scan all courses."
|
|
3601
|
+
} },
|
|
3602
|
+
output: {
|
|
3603
|
+
schema: {
|
|
3604
|
+
type: "object",
|
|
3605
|
+
additionalProperties: false,
|
|
3606
|
+
properties: {
|
|
3607
|
+
total: {
|
|
3608
|
+
type: "integer",
|
|
3609
|
+
required: true
|
|
3610
|
+
},
|
|
3611
|
+
due: {
|
|
3612
|
+
type: "array",
|
|
3613
|
+
required: true,
|
|
3614
|
+
items: {
|
|
3615
|
+
type: "object",
|
|
3616
|
+
additionalProperties: false,
|
|
3617
|
+
properties: {
|
|
3618
|
+
lessonId: {
|
|
3619
|
+
type: "string",
|
|
3620
|
+
required: true
|
|
3621
|
+
},
|
|
3622
|
+
courseTitle: {
|
|
3623
|
+
type: "string",
|
|
3624
|
+
required: true
|
|
3625
|
+
},
|
|
3626
|
+
lessonTitle: {
|
|
3627
|
+
type: "string",
|
|
3628
|
+
required: true
|
|
3629
|
+
},
|
|
3630
|
+
dueAt: {
|
|
3631
|
+
type: "string",
|
|
3632
|
+
required: true
|
|
3633
|
+
},
|
|
3634
|
+
overdueDays: {
|
|
3635
|
+
type: "integer",
|
|
3636
|
+
required: true
|
|
3637
|
+
}
|
|
3638
|
+
}
|
|
3639
|
+
}
|
|
3640
|
+
}
|
|
3641
|
+
}
|
|
3642
|
+
},
|
|
3643
|
+
render: (_args, value) => textBlocks(dueLines(value))
|
|
3644
|
+
},
|
|
3645
|
+
async execute(args) {
|
|
3646
|
+
const due = dueReviews(store.get(), args.courseId, /* @__PURE__ */ new Date());
|
|
3647
|
+
return {
|
|
3648
|
+
total: due.length,
|
|
3649
|
+
due: due.map((d) => ({
|
|
3650
|
+
lessonId: d.lessonId,
|
|
3651
|
+
courseTitle: d.courseTitle,
|
|
3652
|
+
lessonTitle: d.lessonTitle,
|
|
3653
|
+
dueAt: d.dueAt,
|
|
3654
|
+
overdueDays: d.overdueDays
|
|
3655
|
+
}))
|
|
3656
|
+
};
|
|
3657
|
+
},
|
|
3658
|
+
isConcurrencySafe: () => true,
|
|
3659
|
+
presentCall: () => ({
|
|
3660
|
+
card: "generic",
|
|
3661
|
+
title: "List due reviews",
|
|
3662
|
+
kind: "search"
|
|
3663
|
+
}),
|
|
3664
|
+
presentationMeta: (_args, value) => dueLines(value),
|
|
3665
|
+
presentResult: (_args, result) => ({
|
|
3666
|
+
card: "generic",
|
|
3667
|
+
content: textBlocks(result.meta)
|
|
3668
|
+
})
|
|
3669
|
+
}),
|
|
3670
|
+
defineTool({
|
|
3671
|
+
name: "study_record_review",
|
|
3672
|
+
description: "Record an SM-2 review grade for a mastered lesson and advance its schedule. Grade how well the learner recalled the material: 5 perfect, 4 hesitant, 3 recalled with effort, 2 incorrect but recognized, 1 incorrect, 0 complete blackout. Target weak concepts (⚡) first.",
|
|
3673
|
+
parameters: {
|
|
3674
|
+
lessonId: {
|
|
3675
|
+
type: "string",
|
|
3676
|
+
required: true,
|
|
3677
|
+
description: "Lesson being reviewed."
|
|
3678
|
+
},
|
|
3679
|
+
quality: {
|
|
3680
|
+
type: "integer",
|
|
3681
|
+
required: true,
|
|
3682
|
+
enum: [...QUALITIES],
|
|
3683
|
+
description: "SM-2 recall quality, 0 (blackout) to 5 (perfect)."
|
|
3684
|
+
}
|
|
3685
|
+
},
|
|
3686
|
+
output: {
|
|
3687
|
+
schema: {
|
|
3688
|
+
type: "object",
|
|
3689
|
+
additionalProperties: false,
|
|
3690
|
+
properties: {
|
|
3691
|
+
lessonId: {
|
|
3692
|
+
type: "string",
|
|
3693
|
+
required: true
|
|
3694
|
+
},
|
|
3695
|
+
lessonTitle: {
|
|
3696
|
+
type: "string",
|
|
3697
|
+
required: true
|
|
3698
|
+
},
|
|
3699
|
+
quality: {
|
|
3700
|
+
type: "integer",
|
|
3701
|
+
required: true
|
|
3702
|
+
},
|
|
3703
|
+
intervalDays: {
|
|
3704
|
+
type: "integer",
|
|
3705
|
+
required: true
|
|
3706
|
+
},
|
|
3707
|
+
repetitions: {
|
|
3708
|
+
type: "integer",
|
|
3709
|
+
required: true
|
|
3710
|
+
},
|
|
3711
|
+
easeFactor: {
|
|
3712
|
+
type: "number",
|
|
3713
|
+
required: true
|
|
3714
|
+
},
|
|
3715
|
+
dueAt: {
|
|
3716
|
+
type: "string",
|
|
3717
|
+
required: true
|
|
3718
|
+
}
|
|
3719
|
+
}
|
|
3720
|
+
},
|
|
3721
|
+
render: (_args, value) => [{
|
|
3722
|
+
type: "text",
|
|
3723
|
+
text: reviewLine(value)
|
|
3724
|
+
}]
|
|
3725
|
+
},
|
|
3726
|
+
async execute(args) {
|
|
3727
|
+
return mutate((state) => {
|
|
3728
|
+
const r = recordReview(state, args.lessonId, args.quality, /* @__PURE__ */ new Date());
|
|
3729
|
+
return {
|
|
3730
|
+
lessonId: r.ref.lesson.id,
|
|
3731
|
+
lessonTitle: r.ref.lesson.title,
|
|
3732
|
+
quality: args.quality,
|
|
3733
|
+
intervalDays: r.intervalDays,
|
|
3734
|
+
repetitions: r.repetitions,
|
|
3735
|
+
easeFactor: r.easeFactor,
|
|
3736
|
+
dueAt: r.dueAt
|
|
3737
|
+
};
|
|
3738
|
+
});
|
|
3739
|
+
},
|
|
3740
|
+
presentCall: (args) => ({
|
|
3741
|
+
card: "generic",
|
|
3742
|
+
title: `Record review (quality ${args.quality}): ${args.lessonId}`
|
|
3743
|
+
}),
|
|
3744
|
+
presentationMeta: (_args, value) => [reviewLine(value)],
|
|
3745
|
+
presentResult: (_args, result) => ({
|
|
3746
|
+
card: "generic",
|
|
3747
|
+
content: textBlocks(result.meta)
|
|
3748
|
+
})
|
|
3749
|
+
}),
|
|
3750
|
+
defineTool({
|
|
3751
|
+
name: "study_delete_course",
|
|
3752
|
+
description: "Delete one course and all its progress. Ask the learner before calling.",
|
|
3753
|
+
parameters: { courseId: {
|
|
3754
|
+
type: "string",
|
|
3755
|
+
required: true,
|
|
3756
|
+
description: "Course to delete."
|
|
3757
|
+
} },
|
|
3758
|
+
output: {
|
|
3759
|
+
schema: {
|
|
3760
|
+
type: "object",
|
|
3761
|
+
additionalProperties: false,
|
|
3762
|
+
properties: {
|
|
3763
|
+
deletedCourseId: {
|
|
3764
|
+
type: "string",
|
|
3765
|
+
required: true
|
|
3766
|
+
},
|
|
3767
|
+
remaining: {
|
|
3768
|
+
type: "integer",
|
|
3769
|
+
required: true
|
|
3770
|
+
}
|
|
3771
|
+
}
|
|
3772
|
+
},
|
|
3773
|
+
render: (_args, value) => [{
|
|
3774
|
+
type: "text",
|
|
3775
|
+
text: `Deleted course ${value.deletedCourseId}. ${value.remaining} courses remain.`
|
|
3776
|
+
}]
|
|
3777
|
+
},
|
|
3778
|
+
async execute(args) {
|
|
3779
|
+
return mutate((state) => {
|
|
3780
|
+
findCourse(state, args.courseId);
|
|
3781
|
+
deleteCourse(state, args.courseId);
|
|
3782
|
+
return {
|
|
3783
|
+
deletedCourseId: args.courseId,
|
|
3784
|
+
remaining: state.courses.length
|
|
3785
|
+
};
|
|
3786
|
+
});
|
|
3787
|
+
},
|
|
3788
|
+
presentCall: (args) => ({
|
|
3789
|
+
card: "generic",
|
|
3790
|
+
title: `Delete course: ${args.courseId}`,
|
|
3791
|
+
kind: "delete",
|
|
3792
|
+
rawInput: args.courseId
|
|
3793
|
+
})
|
|
3794
|
+
}),
|
|
3795
|
+
defineTool({
|
|
3796
|
+
name: "study_define_concepts",
|
|
3797
|
+
description: "Define a lesson's knowledge components — the 2–7 independently quizzable units mastery tracks. Call this the FIRST time you teach a lesson, derived from its content. Titles ≤10 characters; descriptions say what understanding this concept means. Lesson mastery is the WEAKEST concept; cover weak ones (⚡) first when quizzing.",
|
|
3798
|
+
parameters: {
|
|
3799
|
+
lessonId: {
|
|
3800
|
+
type: "string",
|
|
3801
|
+
required: true,
|
|
3802
|
+
description: "Lesson to describe."
|
|
3803
|
+
},
|
|
3804
|
+
concepts: {
|
|
3805
|
+
type: "array",
|
|
3806
|
+
required: true,
|
|
3807
|
+
description: "2–7 concepts.",
|
|
3808
|
+
items: {
|
|
3809
|
+
type: "object",
|
|
3810
|
+
additionalProperties: false,
|
|
3811
|
+
properties: {
|
|
3812
|
+
title: {
|
|
3813
|
+
type: "string",
|
|
3814
|
+
required: true,
|
|
3815
|
+
description: "Short concept title (≤10 chars)."
|
|
3816
|
+
},
|
|
3817
|
+
description: {
|
|
3818
|
+
type: "string",
|
|
3819
|
+
required: true,
|
|
3820
|
+
description: "What understanding this concept means."
|
|
3821
|
+
}
|
|
3822
|
+
}
|
|
3823
|
+
}
|
|
3824
|
+
}
|
|
3825
|
+
},
|
|
3826
|
+
output: {
|
|
3827
|
+
schema: {
|
|
3828
|
+
type: "object",
|
|
3829
|
+
additionalProperties: false,
|
|
3830
|
+
properties: {
|
|
3831
|
+
lessonId: {
|
|
3832
|
+
type: "string",
|
|
3833
|
+
required: true
|
|
3834
|
+
},
|
|
3835
|
+
concepts: {
|
|
3836
|
+
type: "array",
|
|
3837
|
+
required: true,
|
|
3838
|
+
items: {
|
|
3839
|
+
type: "object",
|
|
3840
|
+
additionalProperties: false,
|
|
3841
|
+
properties: {
|
|
3842
|
+
title: {
|
|
3843
|
+
type: "string",
|
|
3844
|
+
required: true
|
|
3845
|
+
},
|
|
3846
|
+
masteryPct: {
|
|
3847
|
+
type: "integer",
|
|
3848
|
+
required: true
|
|
3849
|
+
}
|
|
3850
|
+
}
|
|
3851
|
+
}
|
|
3852
|
+
}
|
|
3853
|
+
}
|
|
3854
|
+
},
|
|
3855
|
+
render: (_args, value) => [{
|
|
3856
|
+
type: "text",
|
|
3857
|
+
text: `Concepts defined: ${value.concepts.map((c) => `${c.title} (${c.masteryPct}%)`).join(" · ")}. Attribute quiz answers with the \`concept\` parameter.`
|
|
3858
|
+
}]
|
|
3859
|
+
},
|
|
3860
|
+
async execute(args) {
|
|
3861
|
+
return mutate((state) => {
|
|
3862
|
+
defineConcepts(state, args.lessonId, args.concepts);
|
|
3863
|
+
const ref = findLesson(state, args.lessonId);
|
|
3864
|
+
return {
|
|
3865
|
+
lessonId: ref.lesson.id,
|
|
3866
|
+
concepts: (conceptViews(ref.lesson) ?? []).map((c) => ({
|
|
3867
|
+
title: c.title,
|
|
3868
|
+
masteryPct: c.masteryPct
|
|
3869
|
+
}))
|
|
3870
|
+
};
|
|
3871
|
+
});
|
|
3872
|
+
},
|
|
3873
|
+
presentCall: (args) => ({
|
|
3874
|
+
card: "generic",
|
|
3875
|
+
title: `Define concepts: ${args.lessonId}`
|
|
3876
|
+
})
|
|
3877
|
+
}),
|
|
3878
|
+
defineTool({
|
|
3879
|
+
name: "study_propose_mastery",
|
|
3880
|
+
description: "Propose graduating a lesson as mastered ahead of the 90% threshold — use when mastery is ≥85% and the learner has convincingly demonstrated understanding (e.g. a Feynman-style explanation back to you). Creates a PENDING proposal: present it with your rationale and WAIT for the learner's decision, then resolve with study_resolve_proposal. Never apply it yourself.",
|
|
3881
|
+
parameters: {
|
|
3882
|
+
lessonId: {
|
|
3883
|
+
type: "string",
|
|
3884
|
+
required: true,
|
|
3885
|
+
description: "Lesson judged mastered."
|
|
3886
|
+
},
|
|
3887
|
+
rationale: {
|
|
3888
|
+
type: "string",
|
|
3889
|
+
required: true,
|
|
3890
|
+
description: "Why you believe it is mastered — the learner reads this."
|
|
3891
|
+
}
|
|
3892
|
+
},
|
|
3893
|
+
output: {
|
|
3894
|
+
schema: {
|
|
3895
|
+
type: "object",
|
|
3896
|
+
additionalProperties: false,
|
|
3897
|
+
properties: {
|
|
3898
|
+
proposalId: {
|
|
3899
|
+
type: "string",
|
|
3900
|
+
required: true
|
|
3901
|
+
},
|
|
3902
|
+
lessonTitle: {
|
|
3903
|
+
type: "string",
|
|
3904
|
+
required: true
|
|
3905
|
+
},
|
|
3906
|
+
status: {
|
|
3907
|
+
type: "string",
|
|
3908
|
+
required: true,
|
|
3909
|
+
enum: [
|
|
3910
|
+
"pending",
|
|
3911
|
+
"applied",
|
|
3912
|
+
"rejected"
|
|
3913
|
+
]
|
|
3914
|
+
},
|
|
3915
|
+
rationale: {
|
|
3916
|
+
type: "string",
|
|
3917
|
+
required: true
|
|
3918
|
+
}
|
|
3919
|
+
}
|
|
3920
|
+
},
|
|
3921
|
+
render: (_args, value) => [{
|
|
3922
|
+
type: "text",
|
|
3923
|
+
text: `Proposal ${value.proposalId} (${value.status}): “${value.lessonTitle}” — ${value.rationale}\nPresent this to the learner and wait; resolve via study_resolve_proposal.`
|
|
3924
|
+
}]
|
|
3925
|
+
},
|
|
3926
|
+
async execute(args) {
|
|
3927
|
+
return mutate((state) => {
|
|
3928
|
+
const ref = findLesson(state, args.lessonId);
|
|
3929
|
+
const proposal = proposeMastery(state, args.lessonId, args.rationale, /* @__PURE__ */ new Date());
|
|
3930
|
+
return {
|
|
3931
|
+
proposalId: proposal.id,
|
|
3932
|
+
lessonTitle: ref.lesson.title,
|
|
3933
|
+
status: proposal.status,
|
|
3934
|
+
rationale: proposal.rationale
|
|
3935
|
+
};
|
|
3936
|
+
});
|
|
3937
|
+
},
|
|
3938
|
+
presentCall: (args) => ({
|
|
3939
|
+
card: "generic",
|
|
3940
|
+
title: `Propose mastery: ${args.lessonId}`
|
|
3941
|
+
}),
|
|
3942
|
+
presentationMeta: (_args, value) => ({
|
|
3943
|
+
kind: "study-proposal-created",
|
|
3944
|
+
proposalId: value.proposalId,
|
|
3945
|
+
lessonTitle: value.lessonTitle,
|
|
3946
|
+
rationale: value.rationale
|
|
3947
|
+
}),
|
|
3948
|
+
presentResult: (_args, result) => ({
|
|
3949
|
+
card: "generic",
|
|
3950
|
+
content: textBlocks([`🎓 Proposed mastery for “${result.meta?.lessonTitle ?? "lesson"}”: ${result.meta?.rationale ?? ""}`])
|
|
3951
|
+
})
|
|
3952
|
+
}),
|
|
3953
|
+
defineTool({
|
|
3954
|
+
name: "study_resolve_proposal",
|
|
3955
|
+
description: "Resolve a pending mastery proposal with the learner's explicit decision (they said yes / no in chat). Accepting floors every concept to 95%, graduates the lesson, and unlocks the next one.",
|
|
3956
|
+
parameters: {
|
|
3957
|
+
proposalId: {
|
|
3958
|
+
type: "string",
|
|
3959
|
+
required: true,
|
|
3960
|
+
description: "Proposal id from study_propose_mastery."
|
|
3961
|
+
},
|
|
3962
|
+
accept: {
|
|
3963
|
+
type: "boolean",
|
|
3964
|
+
required: true,
|
|
3965
|
+
description: "The learner's decision."
|
|
3966
|
+
}
|
|
3967
|
+
},
|
|
3968
|
+
output: {
|
|
3969
|
+
schema: {
|
|
3970
|
+
type: "object",
|
|
3971
|
+
additionalProperties: false,
|
|
3972
|
+
properties: {
|
|
3973
|
+
proposalId: {
|
|
3974
|
+
type: "string",
|
|
3975
|
+
required: true
|
|
3976
|
+
},
|
|
3977
|
+
lessonId: {
|
|
3978
|
+
type: "string",
|
|
3979
|
+
required: true
|
|
3980
|
+
},
|
|
3981
|
+
status: {
|
|
3982
|
+
type: "string",
|
|
3983
|
+
required: true,
|
|
3984
|
+
enum: ["applied", "rejected"]
|
|
3985
|
+
}
|
|
3986
|
+
}
|
|
3987
|
+
},
|
|
3988
|
+
render: (_args, value) => [{
|
|
3989
|
+
type: "text",
|
|
3990
|
+
text: value.status === "applied" ? `🎓 Proposal applied — lesson ${value.lessonId} mastered (all concepts ≥95%), next lesson unlocked, review scheduled.` : `Proposal rejected — continuing practice on ${value.lessonId}.`
|
|
3991
|
+
}]
|
|
3992
|
+
},
|
|
3993
|
+
async execute(args) {
|
|
3994
|
+
return mutate((state) => {
|
|
3995
|
+
const proposal = resolveProposal(state, args.proposalId, args.accept, /* @__PURE__ */ new Date());
|
|
3996
|
+
return {
|
|
3997
|
+
proposalId: proposal.id,
|
|
3998
|
+
lessonId: proposal.lessonId,
|
|
3999
|
+
status: proposal.status
|
|
4000
|
+
};
|
|
4001
|
+
});
|
|
4002
|
+
},
|
|
4003
|
+
presentCall: (args) => ({
|
|
4004
|
+
card: "generic",
|
|
4005
|
+
title: `Resolve proposal: ${args.proposalId}`
|
|
4006
|
+
}),
|
|
4007
|
+
presentationMeta: (_args, value) => ({
|
|
4008
|
+
kind: "study-proposal-resolved",
|
|
4009
|
+
proposalId: value.proposalId,
|
|
4010
|
+
status: value.status
|
|
4011
|
+
}),
|
|
4012
|
+
presentResult: (_args, result) => ({
|
|
4013
|
+
card: "generic",
|
|
4014
|
+
content: textBlocks([`Proposal ${result.meta?.proposalId ?? "?"} ${result.meta?.status ?? ""}.`])
|
|
4015
|
+
})
|
|
4016
|
+
}),
|
|
4017
|
+
defineTool({
|
|
4018
|
+
name: "study_report_friction",
|
|
4019
|
+
description: "SILENTLY log a learning-friction moment — call when the learner seems confused (糊涂), stuck (卡住), or frustrated (受挫), or when they say \"我没太懂\". One short line. Never mention that you logged it; it feeds the weak-spot map and adapts difficulty.",
|
|
4020
|
+
parameters: {
|
|
4021
|
+
category: {
|
|
4022
|
+
type: "string",
|
|
4023
|
+
required: true,
|
|
4024
|
+
enum: [...FRICTION_CATEGORIES],
|
|
4025
|
+
description: "confused | blocked | frustrated."
|
|
4026
|
+
},
|
|
4027
|
+
summary: {
|
|
4028
|
+
type: "string",
|
|
4029
|
+
description: "One short line: what specifically is hard."
|
|
4030
|
+
},
|
|
4031
|
+
lessonId: {
|
|
4032
|
+
type: "string",
|
|
4033
|
+
description: "Lesson it happened on, when known."
|
|
4034
|
+
}
|
|
4035
|
+
},
|
|
4036
|
+
output: {
|
|
4037
|
+
schema: {
|
|
4038
|
+
type: "object",
|
|
4039
|
+
additionalProperties: false,
|
|
4040
|
+
properties: { logged: {
|
|
4041
|
+
type: "boolean",
|
|
4042
|
+
required: true
|
|
4043
|
+
} }
|
|
4044
|
+
},
|
|
4045
|
+
render: () => [{
|
|
4046
|
+
type: "text",
|
|
4047
|
+
text: "Noted."
|
|
4048
|
+
}]
|
|
4049
|
+
},
|
|
4050
|
+
async execute(args) {
|
|
4051
|
+
return mutate((state) => {
|
|
4052
|
+
addFriction(state, args.lessonId ?? null, args.category, args.summary ?? null, /* @__PURE__ */ new Date());
|
|
4053
|
+
return { logged: true };
|
|
4054
|
+
});
|
|
4055
|
+
},
|
|
4056
|
+
presentCall: () => ({
|
|
4057
|
+
card: "generic",
|
|
4058
|
+
title: "Log friction"
|
|
4059
|
+
})
|
|
4060
|
+
}),
|
|
4061
|
+
defineTool({
|
|
4062
|
+
name: "study_remember",
|
|
4063
|
+
description: "Write a learner-memory slot — call only when you learn something worth keeping across sessions (how they best learn, a recurring pattern, a specific gap). NOT for transient chat. To merge: read the current slot first (study_lesson's memory field), then send the merged 1–3 sentence version — this REPLACES the slot.",
|
|
4064
|
+
parameters: {
|
|
4065
|
+
category: {
|
|
4066
|
+
type: "string",
|
|
4067
|
+
required: true,
|
|
4068
|
+
enum: [...MEMORY_CATEGORIES],
|
|
4069
|
+
description: "global (cross-course style) | pattern (per-course recurring pattern) | lesson (this lesson's specific gap)."
|
|
4070
|
+
},
|
|
4071
|
+
content: {
|
|
4072
|
+
type: "string",
|
|
4073
|
+
required: true,
|
|
4074
|
+
description: "The merged 1–3 sentence slot content."
|
|
4075
|
+
},
|
|
4076
|
+
lessonId: {
|
|
4077
|
+
type: "string",
|
|
4078
|
+
description: "Lesson (for the lesson slot) or any lesson of the course (for the pattern slot)."
|
|
4079
|
+
}
|
|
4080
|
+
},
|
|
4081
|
+
output: {
|
|
4082
|
+
schema: {
|
|
4083
|
+
type: "object",
|
|
4084
|
+
additionalProperties: false,
|
|
4085
|
+
properties: {
|
|
4086
|
+
previous: {
|
|
4087
|
+
...nullableString,
|
|
4088
|
+
required: true
|
|
4089
|
+
},
|
|
4090
|
+
stored: {
|
|
4091
|
+
type: "string",
|
|
4092
|
+
required: true
|
|
4093
|
+
}
|
|
4094
|
+
}
|
|
4095
|
+
},
|
|
4096
|
+
render: () => [{
|
|
4097
|
+
type: "text",
|
|
4098
|
+
text: "Remembered."
|
|
4099
|
+
}]
|
|
4100
|
+
},
|
|
4101
|
+
async execute(args) {
|
|
4102
|
+
return mutate((state) => ({
|
|
4103
|
+
previous: setMemory(state, args.category, args.content, args.lessonId),
|
|
4104
|
+
stored: args.content
|
|
4105
|
+
}));
|
|
4106
|
+
},
|
|
4107
|
+
presentCall: () => ({
|
|
4108
|
+
card: "generic",
|
|
4109
|
+
title: "Update learner memory"
|
|
4110
|
+
})
|
|
4111
|
+
}),
|
|
4112
|
+
defineTool({
|
|
4113
|
+
name: "study_note_save",
|
|
4114
|
+
description: "Save an entry to the learner's Cornell notebook. Zones: `understand` (knowledge structures you generated — concept maps as mermaid, compare tables, diagrams; sediment your best structures here after showing them), `record` (the learner's own words — when they ask to take a note, or when they write something worth keeping, with the verbatim `quote`), `practice` (quiz log — normally written automatically by study_record_answer).",
|
|
4115
|
+
parameters: {
|
|
4116
|
+
lessonId: {
|
|
4117
|
+
type: "string",
|
|
4118
|
+
required: true,
|
|
4119
|
+
description: "Lesson the note belongs to."
|
|
4120
|
+
},
|
|
4121
|
+
zone: {
|
|
4122
|
+
type: "string",
|
|
4123
|
+
required: true,
|
|
4124
|
+
enum: [...NOTE_ZONES],
|
|
4125
|
+
description: "understand | record | practice."
|
|
4126
|
+
},
|
|
4127
|
+
title: {
|
|
4128
|
+
type: "string",
|
|
4129
|
+
required: true,
|
|
4130
|
+
description: "Short entry title."
|
|
4131
|
+
},
|
|
4132
|
+
text: {
|
|
4133
|
+
type: "string",
|
|
4134
|
+
required: true,
|
|
4135
|
+
description: "Entry body — markdown for the understand zone."
|
|
4136
|
+
},
|
|
4137
|
+
source: {
|
|
4138
|
+
type: "string",
|
|
4139
|
+
required: true,
|
|
4140
|
+
enum: [...NOTE_SOURCES],
|
|
4141
|
+
description: "ai (you generated) | content (quoted from lesson) | chat (quoted from conversation)."
|
|
4142
|
+
},
|
|
4143
|
+
quote: {
|
|
4144
|
+
type: "string",
|
|
4145
|
+
description: "Verbatim source quote, for record-zone notes."
|
|
4146
|
+
}
|
|
4147
|
+
},
|
|
4148
|
+
output: {
|
|
4149
|
+
schema: {
|
|
4150
|
+
type: "object",
|
|
4151
|
+
additionalProperties: false,
|
|
4152
|
+
properties: {
|
|
4153
|
+
noteId: {
|
|
4154
|
+
type: "string",
|
|
4155
|
+
required: true
|
|
4156
|
+
},
|
|
4157
|
+
zone: {
|
|
4158
|
+
type: "string",
|
|
4159
|
+
required: true
|
|
4160
|
+
}
|
|
4161
|
+
}
|
|
4162
|
+
},
|
|
4163
|
+
render: (_args, value) => [{
|
|
4164
|
+
type: "text",
|
|
4165
|
+
text: `Saved ${value.zone}-zone note ${value.noteId}.`
|
|
4166
|
+
}]
|
|
4167
|
+
},
|
|
4168
|
+
async execute(args) {
|
|
4169
|
+
return mutate((state) => {
|
|
4170
|
+
const note = addNote(state, args.lessonId, args.zone, args.title, args.text, args.source, args.quote ?? null, /* @__PURE__ */ new Date());
|
|
4171
|
+
return {
|
|
4172
|
+
noteId: note.id,
|
|
4173
|
+
zone: note.zone
|
|
4174
|
+
};
|
|
4175
|
+
});
|
|
4176
|
+
},
|
|
4177
|
+
presentCall: (args) => ({
|
|
4178
|
+
card: "generic",
|
|
4179
|
+
title: `Save ${args.zone} note: ${args.title}`
|
|
4180
|
+
})
|
|
4181
|
+
}),
|
|
4182
|
+
defineTool({
|
|
4183
|
+
name: "study_notes",
|
|
4184
|
+
description: "Read the learner's Cornell notebook: three zones per lesson (understand structures, learner records, practice log).",
|
|
4185
|
+
parameters: { lessonId: {
|
|
4186
|
+
type: "string",
|
|
4187
|
+
description: "One lesson's notes; omit for all lessons (most recent last)."
|
|
4188
|
+
} },
|
|
4189
|
+
output: {
|
|
4190
|
+
schema: {
|
|
4191
|
+
type: "object",
|
|
4192
|
+
additionalProperties: false,
|
|
4193
|
+
properties: {
|
|
4194
|
+
total: {
|
|
4195
|
+
type: "integer",
|
|
4196
|
+
required: true
|
|
4197
|
+
},
|
|
4198
|
+
notes: {
|
|
4199
|
+
type: "array",
|
|
4200
|
+
required: true,
|
|
4201
|
+
items: {
|
|
4202
|
+
type: "object",
|
|
4203
|
+
additionalProperties: false,
|
|
4204
|
+
properties: {
|
|
4205
|
+
id: {
|
|
4206
|
+
type: "string",
|
|
4207
|
+
required: true
|
|
4208
|
+
},
|
|
4209
|
+
lessonTitle: {
|
|
4210
|
+
type: "string",
|
|
4211
|
+
required: true
|
|
4212
|
+
},
|
|
4213
|
+
zone: {
|
|
4214
|
+
type: "string",
|
|
4215
|
+
required: true,
|
|
4216
|
+
enum: [...NOTE_ZONES]
|
|
4217
|
+
},
|
|
4218
|
+
title: {
|
|
4219
|
+
type: "string",
|
|
4220
|
+
required: true
|
|
4221
|
+
},
|
|
4222
|
+
text: {
|
|
4223
|
+
type: "string",
|
|
4224
|
+
required: true
|
|
4225
|
+
},
|
|
4226
|
+
source: {
|
|
4227
|
+
type: "string",
|
|
4228
|
+
required: true,
|
|
4229
|
+
enum: [...NOTE_SOURCES]
|
|
4230
|
+
},
|
|
4231
|
+
quote: {
|
|
4232
|
+
...nullableString,
|
|
4233
|
+
required: true
|
|
4234
|
+
}
|
|
4235
|
+
}
|
|
4236
|
+
}
|
|
4237
|
+
}
|
|
4238
|
+
}
|
|
4239
|
+
},
|
|
4240
|
+
render: (_args, value) => [{
|
|
4241
|
+
type: "text",
|
|
4242
|
+
text: value.total === 0 ? "Notebook is empty." : value.notes.map((n) => `[${n.zone}] ${n.lessonTitle} — ${n.title}${n.quote === null ? "" : ` (quote: “${n.quote.slice(0, 60)}”)`}`).join("\n")
|
|
4243
|
+
}]
|
|
4244
|
+
},
|
|
4245
|
+
async execute(args) {
|
|
4246
|
+
const state = store.get();
|
|
4247
|
+
const notes = (args.lessonId === void 0 ? state.courses.flatMap((c) => c.sections.flatMap((s) => s.lessons)) : [findLesson(state, args.lessonId).lesson]).flatMap((l) => l.notes.map((n) => ({
|
|
4248
|
+
id: n.id,
|
|
4249
|
+
lessonTitle: l.title,
|
|
4250
|
+
zone: n.zone,
|
|
4251
|
+
title: n.title,
|
|
4252
|
+
text: n.text,
|
|
4253
|
+
source: n.source,
|
|
4254
|
+
quote: n.quote
|
|
4255
|
+
})));
|
|
4256
|
+
return {
|
|
4257
|
+
total: notes.length,
|
|
4258
|
+
notes
|
|
4259
|
+
};
|
|
4260
|
+
},
|
|
4261
|
+
isConcurrencySafe: () => true,
|
|
4262
|
+
presentCall: () => ({
|
|
4263
|
+
card: "generic",
|
|
4264
|
+
title: "Read notebook",
|
|
4265
|
+
kind: "read"
|
|
4266
|
+
})
|
|
4267
|
+
}),
|
|
4268
|
+
defineTool({
|
|
4269
|
+
name: "study_set_mode",
|
|
4270
|
+
description: "Switch the tutoring soul when the learner asks for a different style: `direct` 精讲 (explain first, then verify), `guide` 引导 (questions first, hand over steps), `practice` 实战 (learn inside real, messy problems). Takes effect from the next reply.",
|
|
4271
|
+
parameters: { mode: {
|
|
4272
|
+
type: "string",
|
|
4273
|
+
required: true,
|
|
4274
|
+
enum: [...MODES],
|
|
4275
|
+
description: "direct | guide | practice."
|
|
4276
|
+
} },
|
|
4277
|
+
output: {
|
|
4278
|
+
schema: {
|
|
4279
|
+
type: "object",
|
|
4280
|
+
additionalProperties: false,
|
|
4281
|
+
properties: { mode: {
|
|
4282
|
+
type: "string",
|
|
4283
|
+
required: true,
|
|
4284
|
+
enum: [...MODES]
|
|
4285
|
+
} }
|
|
4286
|
+
},
|
|
4287
|
+
render: (_args, value) => [{
|
|
4288
|
+
type: "text",
|
|
4289
|
+
text: `Tutoring soul switched to ${value.mode} (effective next reply).`
|
|
4290
|
+
}]
|
|
4291
|
+
},
|
|
4292
|
+
async execute(args) {
|
|
4293
|
+
return mutate((state) => {
|
|
4294
|
+
state.mode = args.mode;
|
|
4295
|
+
return { mode: state.mode };
|
|
4296
|
+
});
|
|
4297
|
+
},
|
|
4298
|
+
presentCall: (args) => ({
|
|
4299
|
+
card: "generic",
|
|
4300
|
+
title: `Switch soul: ${args.mode}`
|
|
4301
|
+
})
|
|
4302
|
+
})
|
|
4303
|
+
];
|
|
4304
|
+
}
|
|
4305
|
+
//#endregion
|
|
4306
|
+
//#region src/index.ts
|
|
4307
|
+
/**
|
|
4308
|
+
* dsh-plugin-lookatstudy — turn any markdown, local folder, or GitHub learning
|
|
4309
|
+
* repo into a guided course inside DeepSeek Harness. Registers the `study_*`
|
|
4310
|
+
* tool surface (ported from LookatStudy's agent contract), a stable tutor
|
|
4311
|
+
* persona plus a switchable soul section, and a dynamic learner-snapshot
|
|
4312
|
+
* context. Learning state persists in one JSON file shared across sessions.
|
|
4313
|
+
* @module dsh-plugin-lookatstudy
|
|
4314
|
+
*/
|
|
4315
|
+
const name = "lookatstudy-plugin";
|
|
4316
|
+
const inject = ["tools", "systemPrompt"];
|
|
4317
|
+
/**
|
|
4318
|
+
* Stable tutor core (ported from LookatStudy's BASE_AGENT_PROMPT plus its
|
|
4319
|
+
* tool contract). Deliberately static so the prefix hits the provider's
|
|
4320
|
+
* prompt cache; volatile facts live in the learner-snapshot context below.
|
|
4321
|
+
*/
|
|
4322
|
+
const TUTOR_CORE = `## Study tutor (lookatstudy-plugin)
|
|
4323
|
+
|
|
4324
|
+
You are the learner's AI study tutor for a course imported via the study tools. Your job is genuine understanding, not reciting the material. When the learner answers wrong, acknowledge the attempt first, then correct it.
|
|
4325
|
+
|
|
4326
|
+
### Grounding (hard rule)
|
|
4327
|
+
Teach strictly from the current lesson's content (study_lesson's body is the source of truth). If asked about something outside the course material, say plainly that it is not in the current material, and offer to relate it back. Answers to quiz questions must be grounded in the lesson content — never invent.
|
|
4328
|
+
|
|
4329
|
+
### Vague confusion
|
|
4330
|
+
When the learner says "我不懂 / 不太理解" without specifics, ask which concept is unclear, or list the lesson's 2–3 core concepts and let them pick. Log it silently with study_report_friction.
|
|
4331
|
+
|
|
4332
|
+
### Interaction form
|
|
4333
|
+
- ONE question or interactive block per reply — never a wall of quiz questions.
|
|
4334
|
+
- Structure answers with markdown (headings, lists, GFM tables); for structures prefer visuals: concept maps and flow diagrams as mermaid code blocks, comparisons as GFM tables, code walkthroughs as fenced code with line-referenced annotations.
|
|
4335
|
+
- When the learner quotes text in「」, treat it as quote-to-explain: explain that specific passage in the lesson's context.
|
|
4336
|
+
- Opening a brand-new lesson: start with a short hook and one fun two-option guess (curiosity-driven, NOT scored, revealed next turn) — no opening lecture, no scored question.
|
|
4337
|
+
- After opening a lesson, offer its four starters (from study_lesson) as suggestions.
|
|
4338
|
+
- Celebrate graduations and crowns briefly — earned joy, no confetti spam.
|
|
4339
|
+
|
|
4340
|
+
### The tutoring loop
|
|
4341
|
+
1. Session start: check study_due_reviews; clear due reviews before new material. Open the focus lesson with study_lesson. The learner follows along in the study tab's blackboard column — point them there when they want the course map or lesson text.
|
|
4342
|
+
2. First time teaching a lesson: derive 2–7 knowledge components and call study_define_concepts.
|
|
4343
|
+
3. Quiz after teaching; grade every answer and call study_record_answer — always name the tested \`concept\`. Lesson mastery is the WEAKEST concept, so target ⚡weak ones first.
|
|
4344
|
+
4. Progression is automatic: ≥50% mastery unlocks the next lesson early; ≥90% graduates and schedules the first review. study_complete_lesson is only the manual override.
|
|
4345
|
+
5. Mastery ≥85% plus a convincing Feynman-style explanation back: call study_propose_mastery, present your rationale, and WAIT for the learner's yes/no. Resolve only with their explicit answer via study_resolve_proposal. You never graduate a lesson on your own judgment alone.
|
|
4346
|
+
6. Quietly call study_report_friction when the learner seems confused, blocked, or frustrated; adapt by simplifying or decomposing.
|
|
4347
|
+
7. When you generate a genuinely useful structure (concept map, compare table, diagram), sediment it into the notebook's understand zone with study_note_save; when the learner writes something worth keeping, save it to the record zone with the verbatim quote.
|
|
4348
|
+
8. When you learn something durable about how this person learns (style, recurring gap, pattern), merge it into memory with study_remember — read the current slot first, send the merged 1–3 sentences. No transient chat.
|
|
4349
|
+
|
|
4350
|
+
### Quiz quality
|
|
4351
|
+
3–4 questions per quiz block is best (5 max), 4 options each. Distractors must come from real misconceptions, not absurd fillers. Test understanding, not recall: "in scenario Y, use X or Z?" rather than "define X". Every question carries an explanation of why the right answer is right. One scored block at a time.
|
|
4352
|
+
|
|
4353
|
+
### Integrity
|
|
4354
|
+
Never claim progress you did not record through the tools. Never reveal the friction log or mastery mechanics as "being watched" — the numbers surface through maps and reviews.
|
|
4355
|
+
`;
|
|
4356
|
+
/** The three builtin souls, verbatim from LookatStudy (direct/guide/practice). */
|
|
4357
|
+
const SOULS = {
|
|
4358
|
+
direct: `### Soul: direct 精讲
|
|
4359
|
+
你是讲解型教练。核心原则:**先讲清楚,再确认懂没懂**。
|
|
4360
|
+
1. 学习者问什么,先用一两句把核心讲透——不绕弯子、不反问让他猜。给定义时配一个最小例子。
|
|
4361
|
+
2. 讲完一个点,立刻出一个轻量确认题(是非/选择,不计掌握度),答对再往下;答错针对性补一句,不换题海。
|
|
4362
|
+
3. 抽象概念优先给完整范例(worked example),再让他在范例上动手改一个数。
|
|
4363
|
+
4. 他说"懂了"时,让他用自己的话复述一遍(费曼检验)——复述不出就再讲。
|
|
4364
|
+
5. 一次只推进一个核心点。讲透一个,不扫过一片。`,
|
|
4365
|
+
guide: `### Soul: guide 引导
|
|
4366
|
+
你是引导型教练。核心原则:**让他自己往前推一步,你只递台阶**。
|
|
4367
|
+
1. 学习者问"X 是什么/为什么",不直接给答案,先抛一个引导性问题让他用已有知识推。
|
|
4368
|
+
2. 推对往深推一层;推偏给更具体的提示(不是答案),让他再试。
|
|
4369
|
+
3. 连着两次推不动、或他明说"直接告诉我",才给答案——给时附一句"为什么",建因果链。
|
|
4370
|
+
4. 检测到他连续答对三次,主动提议进入更深的子主题(不让他停舒适区)。
|
|
4371
|
+
5. 鼓励他费曼式复述刚推出的结论,验是否真懂。`,
|
|
4372
|
+
practice: `### Soul: practice 实战
|
|
4373
|
+
你是实战型 mentor。核心原则:**在真实世界的乱问题里学,不在干净的练习题里学**。
|
|
4374
|
+
1. 每个概念落在一个真实的、边界模糊的问题上——不是"已知 A 求 B",而是"给你一笔预算/一个真实场景/一堆乱数据,你怎么决策"这类没有标准答案的问题。
|
|
4375
|
+
2. 先让他面对问题自己想思路(哪怕错),再把他卡住的地方和刚学的概念连起来——概念是工具,问题是主。
|
|
4376
|
+
3. 他卡住时给"下一步具体动作"(如"先把你要的变量列出来"),不给完整解;做完一步再推进。
|
|
4377
|
+
4. 一个问题走完,要求他复盘:哪步用了哪个概念、重来会怎么改。复盘比答对更重要。
|
|
4378
|
+
5. 主动串联:把当前问题和已学概念织成网,让他看到知识点在真实任务里怎么协作。`
|
|
4379
|
+
};
|
|
4380
|
+
/**
|
|
4381
|
+
* Render the learner snapshot (LookatStudy's per-turn volatile tail) as the
|
|
4382
|
+
* dynamic runtime context: focus, strategy band, concepts with weak flags,
|
|
4383
|
+
* recent friction, memory slots, due count, pending proposal.
|
|
4384
|
+
*/
|
|
4385
|
+
function snapshotText(store) {
|
|
4386
|
+
const snap = learnerSnapshot(store.get(), /* @__PURE__ */ new Date());
|
|
4387
|
+
if (snap.focus === null) return snap.dueCount === 0 ? "" : `【学习者当前状态】\n今日待复习: ${snap.dueCount} 项(study_due_reviews)`;
|
|
4388
|
+
const lines = ["【学习者当前状态】"];
|
|
4389
|
+
lines.push(`焦点: ${snap.focus.courseTitle} / ${snap.focus.lessonTitle}(${snap.focus.status}${snap.focus.masteryPct === null ? "" : `, 掌握度 ${snap.focus.masteryPct}%`})`);
|
|
4390
|
+
if (snap.strategy !== null) lines.push(`教学策略: ${snap.strategy}`);
|
|
4391
|
+
if (snap.concepts !== null && snap.concepts.length > 0) lines.push(`知识点(课级掌握度 = 最薄弱知识点): ${snap.concepts.map((c) => `${c.title} ${c.masteryPct}%${c.weak ? " ⚡薄弱" : ""}`).join(" · ")}`);
|
|
4392
|
+
if (snap.friction.length > 0) lines.push(`近期卡点(共 ${snap.friction.length} 条): ${snap.friction.map((f) => `${f.category}${f.summary === null ? "" : `: ${f.summary}`}`).join(" / ")}`);
|
|
4393
|
+
const memory = [
|
|
4394
|
+
snap.memoryGlobal === null ? "" : `整体: ${snap.memoryGlobal}`,
|
|
4395
|
+
snap.memoryLesson === null ? "" : `本课: ${snap.memoryLesson}`,
|
|
4396
|
+
snap.memoryPattern === null ? "" : `模式: ${snap.memoryPattern}`
|
|
4397
|
+
].filter(Boolean);
|
|
4398
|
+
if (memory.length > 0) lines.push(`记忆: ${memory.join(" | ")}`);
|
|
4399
|
+
if (snap.dueCount > 0) lines.push(`今日待复习: ${snap.dueCount} 项`);
|
|
4400
|
+
if (snap.pendingProposal !== null) lines.push(`待决提案 ${snap.pendingProposal.id}: ${snap.pendingProposal.rationale}(等学习者表态)`);
|
|
4401
|
+
return lines.join("\n");
|
|
4402
|
+
}
|
|
4403
|
+
/**
|
|
4404
|
+
* Register the study tools, the tutor persona (stable core + soul), and the
|
|
4405
|
+
* dynamic learner-snapshot context.
|
|
4406
|
+
* @param ctx - plugin context carrying the tool registry and system prompt.
|
|
4407
|
+
* @param config - validated plugin configuration.
|
|
4408
|
+
*/
|
|
4409
|
+
function apply(ctx, config) {
|
|
4410
|
+
const statePath = resolveStatePath(config.statePath);
|
|
4411
|
+
const fresh = !existsSync(statePath);
|
|
4412
|
+
const state = loadState(statePath);
|
|
4413
|
+
if (fresh) state.mode = config.mode;
|
|
4414
|
+
const store = {
|
|
4415
|
+
get: () => state,
|
|
4416
|
+
save: () => saveState(statePath, state)
|
|
4417
|
+
};
|
|
4418
|
+
for (const tool of studyTools(store)) ctx.tools.register(tool);
|
|
4419
|
+
ctx.systemPrompt.section({
|
|
4420
|
+
name: "lookatstudy:tutor-core",
|
|
4421
|
+
order: 120,
|
|
4422
|
+
text: TUTOR_CORE
|
|
4423
|
+
});
|
|
4424
|
+
ctx.systemPrompt.section({
|
|
4425
|
+
name: "lookatstudy:soul",
|
|
4426
|
+
order: 121,
|
|
4427
|
+
text: () => SOULS[store.get().mode]
|
|
4428
|
+
});
|
|
4429
|
+
ctx.systemPrompt.context({
|
|
4430
|
+
name: "lookatstudy:learner-snapshot",
|
|
4431
|
+
order: 50,
|
|
4432
|
+
text: () => snapshotText(store)
|
|
4433
|
+
});
|
|
4434
|
+
ctx.inject(["webServer"], (webCtx) => {
|
|
4435
|
+
const studyAreaPath = join(dirname(statePath), "study-area");
|
|
4436
|
+
mkdirSync(studyAreaPath, { recursive: true });
|
|
4437
|
+
const disposeDashboard = registerDashboard(webCtx.webServer, {
|
|
4438
|
+
store,
|
|
4439
|
+
studyAreaPath
|
|
4440
|
+
});
|
|
4441
|
+
webCtx.effect(() => disposeDashboard, "lookatstudy.dashboard()");
|
|
4442
|
+
});
|
|
4443
|
+
}
|
|
4444
|
+
//#endregion
|
|
4445
|
+
export { Config, apply, inject, name };
|
|
4446
|
+
|
|
4447
|
+
//# sourceMappingURL=index.mjs.map
|