dsh-aha-tutor 1.4.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -3
- package/CHANGELOG.md +27 -0
- package/README.en.md +4 -4
- package/README.md +4 -4
- package/docs/guide.zh-CN.md +37 -5
- package/lib/agent.js +1 -1
- package/lib/client.js +100 -45
- package/lib/index.js +1235 -78
- package/lib/{runtime-DqCjJ0yL.js → runtime-B6zUpJjA.js} +783 -16
- package/package.json +1 -1
- package/samples/harmonic-motion.aha-course.json +92 -388
|
@@ -20,6 +20,716 @@ function assertId(value, prefix) {
|
|
|
20
20
|
return value;
|
|
21
21
|
}
|
|
22
22
|
//#endregion
|
|
23
|
+
//#region src/core/expr.js
|
|
24
|
+
const EXPR_LIMITS = Object.freeze({
|
|
25
|
+
chars: 240,
|
|
26
|
+
nodes: 400,
|
|
27
|
+
depth: 40
|
|
28
|
+
});
|
|
29
|
+
var ExprError = class extends Error {
|
|
30
|
+
constructor(message) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = "ExprError";
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
const FUNCTIONS = Object.freeze({
|
|
36
|
+
sin: [1, Math.sin],
|
|
37
|
+
cos: [1, Math.cos],
|
|
38
|
+
tan: [1, Math.tan],
|
|
39
|
+
cot: [1, (x) => 1 / Math.tan(x)],
|
|
40
|
+
sec: [1, (x) => 1 / Math.cos(x)],
|
|
41
|
+
csc: [1, (x) => 1 / Math.sin(x)],
|
|
42
|
+
asin: [1, Math.asin],
|
|
43
|
+
acos: [1, Math.acos],
|
|
44
|
+
atan: [1, Math.atan],
|
|
45
|
+
arcsin: [1, Math.asin],
|
|
46
|
+
arccos: [1, Math.acos],
|
|
47
|
+
arctan: [1, Math.atan],
|
|
48
|
+
atan2: [2, Math.atan2],
|
|
49
|
+
sinh: [1, Math.sinh],
|
|
50
|
+
cosh: [1, Math.cosh],
|
|
51
|
+
tanh: [1, Math.tanh],
|
|
52
|
+
asinh: [1, Math.asinh],
|
|
53
|
+
acosh: [1, Math.acosh],
|
|
54
|
+
atanh: [1, Math.atanh],
|
|
55
|
+
exp: [1, Math.exp],
|
|
56
|
+
ln: [1, Math.log],
|
|
57
|
+
log: [1, Math.log10],
|
|
58
|
+
lg: [1, Math.log10],
|
|
59
|
+
log10: [1, Math.log10],
|
|
60
|
+
log2: [1, Math.log2],
|
|
61
|
+
sqrt: [1, Math.sqrt],
|
|
62
|
+
cbrt: [1, Math.cbrt],
|
|
63
|
+
abs: [1, Math.abs],
|
|
64
|
+
sign: [1, Math.sign],
|
|
65
|
+
floor: [1, Math.floor],
|
|
66
|
+
ceil: [1, Math.ceil],
|
|
67
|
+
round: [1, Math.round],
|
|
68
|
+
min: [-2, Math.min],
|
|
69
|
+
max: [-2, Math.max],
|
|
70
|
+
pow: [2, Math.pow]
|
|
71
|
+
});
|
|
72
|
+
const CONSTANTS = Object.freeze({
|
|
73
|
+
pi: Math.PI,
|
|
74
|
+
π: Math.PI,
|
|
75
|
+
e: Math.E
|
|
76
|
+
});
|
|
77
|
+
const isFunctionName = (name) => Object.hasOwn(FUNCTIONS, name);
|
|
78
|
+
const isConstantName = (name) => Object.hasOwn(CONSTANTS, name);
|
|
79
|
+
const REPLACEMENTS = [
|
|
80
|
+
[/[−–—]/gu, "-"],
|
|
81
|
+
[/[×·⋅∙]/gu, "*"],
|
|
82
|
+
[/÷/gu, "/"],
|
|
83
|
+
[/\*\*/gu, "^"],
|
|
84
|
+
[/²/gu, "^2"],
|
|
85
|
+
[/³/gu, "^3"],
|
|
86
|
+
[/[(]/gu, "("],
|
|
87
|
+
[/[)]/gu, ")"],
|
|
88
|
+
[/[,]/gu, ","]
|
|
89
|
+
];
|
|
90
|
+
function tokenize$1(source) {
|
|
91
|
+
let input = String(source ?? "");
|
|
92
|
+
for (const [pattern, value] of REPLACEMENTS) input = input.replace(pattern, value);
|
|
93
|
+
const tokens = [];
|
|
94
|
+
let at = 0;
|
|
95
|
+
while (at < input.length) {
|
|
96
|
+
const char = input[at];
|
|
97
|
+
if (/\s/u.test(char)) {
|
|
98
|
+
at += 1;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const number = /^(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/u.exec(input.slice(at));
|
|
102
|
+
if (number) {
|
|
103
|
+
tokens.push({
|
|
104
|
+
type: "num",
|
|
105
|
+
value: Number(number[0])
|
|
106
|
+
});
|
|
107
|
+
at += number[0].length;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const name = /^[\p{L}_][\p{L}\p{N}_]*/u.exec(input.slice(at));
|
|
111
|
+
if (name) {
|
|
112
|
+
tokens.push({
|
|
113
|
+
type: "name",
|
|
114
|
+
value: name[0]
|
|
115
|
+
});
|
|
116
|
+
at += name[0].length;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if ("+-*/^(),|√".includes(char)) {
|
|
120
|
+
tokens.push({ type: char });
|
|
121
|
+
at += 1;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
throw new ExprError(`Unexpected "${char}"`);
|
|
125
|
+
}
|
|
126
|
+
return tokens;
|
|
127
|
+
}
|
|
128
|
+
function parseExpr(source, names = []) {
|
|
129
|
+
const text = String(source ?? "");
|
|
130
|
+
if (!text.trim()) throw new ExprError("Empty formula");
|
|
131
|
+
if (text.length > EXPR_LIMITS.chars) throw new ExprError("Formula too long");
|
|
132
|
+
const known = new Set(names);
|
|
133
|
+
const tokens = tokenize$1(text);
|
|
134
|
+
let index = 0;
|
|
135
|
+
let nodes = 0;
|
|
136
|
+
let depth = 0;
|
|
137
|
+
const peek = () => tokens[index];
|
|
138
|
+
const take = (type) => {
|
|
139
|
+
const token = tokens[index];
|
|
140
|
+
if (!token || type && token.type !== type) throw new ExprError(type ? `Expected "${type}"` : "Unexpected end");
|
|
141
|
+
index += 1;
|
|
142
|
+
return token;
|
|
143
|
+
};
|
|
144
|
+
const node = (value) => {
|
|
145
|
+
nodes += 1;
|
|
146
|
+
if (nodes > EXPR_LIMITS.nodes) throw new ExprError("Formula too long");
|
|
147
|
+
return value;
|
|
148
|
+
};
|
|
149
|
+
const nested = (parse) => {
|
|
150
|
+
depth += 1;
|
|
151
|
+
if (depth > EXPR_LIMITS.depth) throw new ExprError("Formula nested too deeply");
|
|
152
|
+
try {
|
|
153
|
+
return parse();
|
|
154
|
+
} finally {
|
|
155
|
+
depth -= 1;
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
const startsPrimary = (token) => token && (token.type === "num" || token.type === "name" || token.type === "(" || token.type === "√");
|
|
159
|
+
function sum() {
|
|
160
|
+
let left = product();
|
|
161
|
+
while (peek()?.type === "+" || peek()?.type === "-") {
|
|
162
|
+
const op = take().type;
|
|
163
|
+
left = node({
|
|
164
|
+
k: "bin",
|
|
165
|
+
op,
|
|
166
|
+
a: left,
|
|
167
|
+
b: product()
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
return left;
|
|
171
|
+
}
|
|
172
|
+
function product() {
|
|
173
|
+
let left = unary();
|
|
174
|
+
for (;;) {
|
|
175
|
+
const token = peek();
|
|
176
|
+
if (token?.type === "*" || token?.type === "/") {
|
|
177
|
+
take();
|
|
178
|
+
left = node({
|
|
179
|
+
k: "bin",
|
|
180
|
+
op: token.type,
|
|
181
|
+
a: left,
|
|
182
|
+
b: unary()
|
|
183
|
+
});
|
|
184
|
+
} else if (startsPrimary(token)) left = node({
|
|
185
|
+
k: "bin",
|
|
186
|
+
op: "*",
|
|
187
|
+
a: left,
|
|
188
|
+
b: power()
|
|
189
|
+
});
|
|
190
|
+
else return left;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
function unary() {
|
|
194
|
+
const token = peek();
|
|
195
|
+
if (token?.type === "-") {
|
|
196
|
+
take();
|
|
197
|
+
return node({
|
|
198
|
+
k: "neg",
|
|
199
|
+
a: nested(unary)
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
if (token?.type === "+") {
|
|
203
|
+
take();
|
|
204
|
+
return nested(unary);
|
|
205
|
+
}
|
|
206
|
+
return power();
|
|
207
|
+
}
|
|
208
|
+
function power() {
|
|
209
|
+
const base = primary();
|
|
210
|
+
if (peek()?.type !== "^") return base;
|
|
211
|
+
take();
|
|
212
|
+
return node({
|
|
213
|
+
k: "bin",
|
|
214
|
+
op: "^",
|
|
215
|
+
a: base,
|
|
216
|
+
b: nested(unary)
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
function call(name) {
|
|
220
|
+
const [arity] = FUNCTIONS[name];
|
|
221
|
+
take("(");
|
|
222
|
+
const args = [nested(sum)];
|
|
223
|
+
while (peek()?.type === ",") {
|
|
224
|
+
take();
|
|
225
|
+
args.push(nested(sum));
|
|
226
|
+
}
|
|
227
|
+
take(")");
|
|
228
|
+
if (arity > 0 ? args.length !== arity : args.length < -arity) throw new ExprError(`${name} takes ${Math.abs(arity)} argument${Math.abs(arity) > 1 ? "s" : ""}`);
|
|
229
|
+
return node({
|
|
230
|
+
k: "call",
|
|
231
|
+
f: name,
|
|
232
|
+
args
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
function named(value) {
|
|
236
|
+
if (known.has(value)) return node({
|
|
237
|
+
k: "var",
|
|
238
|
+
n: value
|
|
239
|
+
});
|
|
240
|
+
if (isConstantName(value)) return node({
|
|
241
|
+
k: "num",
|
|
242
|
+
v: CONSTANTS[value]
|
|
243
|
+
});
|
|
244
|
+
if (isFunctionName(value)) {
|
|
245
|
+
if (peek()?.type !== "(") throw new ExprError(`Write ${value}(…) with parentheses`);
|
|
246
|
+
return call(value);
|
|
247
|
+
}
|
|
248
|
+
const parts = splitName(value);
|
|
249
|
+
if (!parts) throw new ExprError(`Unknown name "${value}"`);
|
|
250
|
+
let result = null;
|
|
251
|
+
for (const [partIndex, part] of parts.entries()) {
|
|
252
|
+
let factor;
|
|
253
|
+
if (isFunctionName(part)) {
|
|
254
|
+
if (partIndex !== parts.length - 1 || peek()?.type !== "(") throw new ExprError(`Unknown name "${value}"`);
|
|
255
|
+
factor = call(part);
|
|
256
|
+
} else factor = node(known.has(part) ? {
|
|
257
|
+
k: "var",
|
|
258
|
+
n: part
|
|
259
|
+
} : {
|
|
260
|
+
k: "num",
|
|
261
|
+
v: CONSTANTS[part]
|
|
262
|
+
});
|
|
263
|
+
result = result ? node({
|
|
264
|
+
k: "bin",
|
|
265
|
+
op: "*",
|
|
266
|
+
a: result,
|
|
267
|
+
b: factor
|
|
268
|
+
}) : factor;
|
|
269
|
+
}
|
|
270
|
+
return result;
|
|
271
|
+
}
|
|
272
|
+
function splitName(value) {
|
|
273
|
+
const candidates = [
|
|
274
|
+
...known,
|
|
275
|
+
...Object.keys(CONSTANTS),
|
|
276
|
+
...Object.keys(FUNCTIONS)
|
|
277
|
+
].sort((a, b) => b.length - a.length);
|
|
278
|
+
const parts = [];
|
|
279
|
+
let rest = value;
|
|
280
|
+
while (rest) {
|
|
281
|
+
const found = candidates.find((candidate) => rest.startsWith(candidate));
|
|
282
|
+
if (!found) return null;
|
|
283
|
+
parts.push(found);
|
|
284
|
+
rest = rest.slice(found.length);
|
|
285
|
+
}
|
|
286
|
+
return parts.length > 1 ? parts : null;
|
|
287
|
+
}
|
|
288
|
+
function primary() {
|
|
289
|
+
const token = take();
|
|
290
|
+
if (token.type === "num") return node({
|
|
291
|
+
k: "num",
|
|
292
|
+
v: token.value
|
|
293
|
+
});
|
|
294
|
+
if (token.type === "name") return named(token.value);
|
|
295
|
+
if (token.type === "(") {
|
|
296
|
+
const inner = nested(sum);
|
|
297
|
+
take(")");
|
|
298
|
+
return inner;
|
|
299
|
+
}
|
|
300
|
+
if (token.type === "|") {
|
|
301
|
+
const inner = nested(sum);
|
|
302
|
+
take("|");
|
|
303
|
+
return node({
|
|
304
|
+
k: "call",
|
|
305
|
+
f: "abs",
|
|
306
|
+
args: [inner]
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
if (token.type === "√") return node({
|
|
310
|
+
k: "call",
|
|
311
|
+
f: "sqrt",
|
|
312
|
+
args: [nested(power)]
|
|
313
|
+
});
|
|
314
|
+
throw new ExprError(`Unexpected "${token.type}"`);
|
|
315
|
+
}
|
|
316
|
+
const tree = sum();
|
|
317
|
+
if (index < tokens.length) throw new ExprError(`Unexpected "${tokens[index].type === "name" || tokens[index].type === "num" ? tokens[index].value : tokens[index].type}"`);
|
|
318
|
+
return tree;
|
|
319
|
+
}
|
|
320
|
+
function compileExpr(tree, names = []) {
|
|
321
|
+
const slot = new Map(names.map((name, position) => [name, position]));
|
|
322
|
+
const build = (item) => {
|
|
323
|
+
switch (item.k) {
|
|
324
|
+
case "num": {
|
|
325
|
+
const value = item.v;
|
|
326
|
+
return () => value;
|
|
327
|
+
}
|
|
328
|
+
case "var": {
|
|
329
|
+
const position = slot.get(item.n);
|
|
330
|
+
if (position === void 0) throw new ExprError(`Unknown name "${item.n}"`);
|
|
331
|
+
return (values) => values[position];
|
|
332
|
+
}
|
|
333
|
+
case "neg": {
|
|
334
|
+
const a = build(item.a);
|
|
335
|
+
return (values) => -a(values);
|
|
336
|
+
}
|
|
337
|
+
case "bin": {
|
|
338
|
+
const a = build(item.a);
|
|
339
|
+
const b = build(item.b);
|
|
340
|
+
if (item.op === "+") return (values) => a(values) + b(values);
|
|
341
|
+
if (item.op === "-") return (values) => a(values) - b(values);
|
|
342
|
+
if (item.op === "*") return (values) => a(values) * b(values);
|
|
343
|
+
if (item.op === "/") return (values) => a(values) / b(values);
|
|
344
|
+
return (values) => power(a(values), b(values));
|
|
345
|
+
}
|
|
346
|
+
case "call": {
|
|
347
|
+
const implementation = FUNCTIONS[item.f][1];
|
|
348
|
+
const args = item.args.map(build);
|
|
349
|
+
if (args.length === 1) {
|
|
350
|
+
const [a] = args;
|
|
351
|
+
return (values) => implementation(a(values));
|
|
352
|
+
}
|
|
353
|
+
return (values) => implementation(...args.map((arg) => arg(values)));
|
|
354
|
+
}
|
|
355
|
+
default: throw new ExprError("Bad formula");
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
return build(tree);
|
|
359
|
+
}
|
|
360
|
+
function power(base, exponent) {
|
|
361
|
+
const value = base ** exponent;
|
|
362
|
+
if (!Number.isNaN(value) || base >= 0 || !Number.isFinite(exponent)) return value;
|
|
363
|
+
for (const denominator of [
|
|
364
|
+
3,
|
|
365
|
+
5,
|
|
366
|
+
7,
|
|
367
|
+
9
|
|
368
|
+
]) {
|
|
369
|
+
const numerator = Math.round(exponent * denominator);
|
|
370
|
+
if (Math.abs(numerator / denominator - exponent) < 1e-9) return (-((-base) ** (1 / denominator))) ** numerator;
|
|
371
|
+
}
|
|
372
|
+
return value;
|
|
373
|
+
}
|
|
374
|
+
function constantValue(value) {
|
|
375
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
|
376
|
+
if (typeof value !== "string" || !value.trim()) return null;
|
|
377
|
+
try {
|
|
378
|
+
const result = compileExpr(parseExpr(value))([]);
|
|
379
|
+
return Number.isFinite(result) ? result : null;
|
|
380
|
+
} catch {
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
const SUPERSCRIPT = {
|
|
385
|
+
0: "⁰",
|
|
386
|
+
1: "¹",
|
|
387
|
+
2: "²",
|
|
388
|
+
3: "³",
|
|
389
|
+
4: "⁴",
|
|
390
|
+
5: "⁵",
|
|
391
|
+
6: "⁶",
|
|
392
|
+
7: "⁷",
|
|
393
|
+
8: "⁸",
|
|
394
|
+
9: "⁹",
|
|
395
|
+
"-": "⁻"
|
|
396
|
+
};
|
|
397
|
+
const DISPLAY_NAMES = {
|
|
398
|
+
pi: "π",
|
|
399
|
+
theta: "θ",
|
|
400
|
+
omega: "ω",
|
|
401
|
+
phi: "φ",
|
|
402
|
+
alpha: "α",
|
|
403
|
+
beta: "β",
|
|
404
|
+
lambda: "λ",
|
|
405
|
+
mu: "μ",
|
|
406
|
+
sigma: "σ",
|
|
407
|
+
tau: "τ"
|
|
408
|
+
};
|
|
409
|
+
const PRECEDENCE = {
|
|
410
|
+
"+": 1,
|
|
411
|
+
"-": 1,
|
|
412
|
+
"*": 2,
|
|
413
|
+
"/": 2,
|
|
414
|
+
"^": 4
|
|
415
|
+
};
|
|
416
|
+
function formatNumber(value) {
|
|
417
|
+
if (Math.abs(value - Math.PI) < 1e-12) return "π";
|
|
418
|
+
if (Math.abs(value - Math.E) < 1e-12) return "e";
|
|
419
|
+
return Number(value.toPrecision(6)).toString();
|
|
420
|
+
}
|
|
421
|
+
const superscript = (value) => [...String(value)].map((char) => SUPERSCRIPT[char]).join("");
|
|
422
|
+
const symbol = (value) => [...value].length === 1 && /\p{L}/u.test(value);
|
|
423
|
+
function exprToText(tree, display = DISPLAY_NAMES) {
|
|
424
|
+
const show = (name) => display[name] ?? name;
|
|
425
|
+
const write = (item, parent = 0) => {
|
|
426
|
+
switch (item.k) {
|
|
427
|
+
case "num": return formatNumber(item.v);
|
|
428
|
+
case "var": return show(item.n);
|
|
429
|
+
case "neg": {
|
|
430
|
+
const inner = write(item.a, 3);
|
|
431
|
+
return parent > 2 ? `(−${inner})` : `−${inner}`;
|
|
432
|
+
}
|
|
433
|
+
case "call":
|
|
434
|
+
if (item.f === "abs") return `|${write(item.args[0])}|`;
|
|
435
|
+
if (item.f === "sqrt") return `√(${write(item.args[0])})`;
|
|
436
|
+
return `${item.f}(${item.args.map((arg) => write(arg)).join(", ")})`;
|
|
437
|
+
case "bin": {
|
|
438
|
+
const own = PRECEDENCE[item.op];
|
|
439
|
+
let out;
|
|
440
|
+
if (item.op === "^") {
|
|
441
|
+
const base = write(item.a, 5);
|
|
442
|
+
const integer = (value) => value.k === "num" && Number.isInteger(value.v) && Math.abs(value.v) < 100;
|
|
443
|
+
const top = write(item.b);
|
|
444
|
+
out = `${base}${integer(item.b) ? superscript(item.b.v) : item.b.k === "neg" && integer(item.b.a) ? `⁻${superscript(item.b.a.v)}` : symbol(top) ? `^${top}` : `^(${top})`}`;
|
|
445
|
+
} else if (item.op === "*") {
|
|
446
|
+
const a = write(item.a, own);
|
|
447
|
+
const b = write(item.b, own);
|
|
448
|
+
out = item.a.k === "num" && /\d$/u.test(a) && /^[\p{L}(√|]/u.test(b) || symbol(a) && (symbol(b) || b.startsWith("(")) || item.a.k !== "call" && /^[\d\p{L}]+$/u.test(a) && /\p{L}$/u.test(a) && symbol(b) || item.a.k !== "call" && a.endsWith(")") && b.startsWith("(") ? `${a}${b}` : `${a}·${b}`;
|
|
449
|
+
} else {
|
|
450
|
+
const a = write(item.a, own);
|
|
451
|
+
const b = write(item.b, item.op === "+" ? own : own + .5);
|
|
452
|
+
out = `${a} ${item.op === "-" ? "−" : item.op} ${b}`;
|
|
453
|
+
}
|
|
454
|
+
return own < parent ? `(${out})` : out;
|
|
455
|
+
}
|
|
456
|
+
default: return "";
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
return write(tree);
|
|
460
|
+
}
|
|
461
|
+
//#endregion
|
|
462
|
+
//#region src/core/plot.js
|
|
463
|
+
const PLOT_LIMITS = Object.freeze({
|
|
464
|
+
fns: 4,
|
|
465
|
+
params: 4,
|
|
466
|
+
points: 8,
|
|
467
|
+
lines: 6,
|
|
468
|
+
shades: 3,
|
|
469
|
+
tangents: 3,
|
|
470
|
+
samples: 480
|
|
471
|
+
});
|
|
472
|
+
const clip = (value, max) => value === null || value === void 0 ? "" : String(value).replace(/\u0000/gu, "").trim().slice(0, max);
|
|
473
|
+
const list$1 = (value, max) => (Array.isArray(value) ? value : value && typeof value === "object" ? [value] : []).slice(0, max);
|
|
474
|
+
const clamp$1 = (value, min, max) => Math.min(max, Math.max(min, value));
|
|
475
|
+
const mentionsPi = (value) => /pi|π/iu.test(String(value ?? ""));
|
|
476
|
+
const VARIABLES = Object.freeze({
|
|
477
|
+
fx: ["x", "t"],
|
|
478
|
+
param: [
|
|
479
|
+
"t",
|
|
480
|
+
"θ",
|
|
481
|
+
"theta"
|
|
482
|
+
],
|
|
483
|
+
polar: [
|
|
484
|
+
"θ",
|
|
485
|
+
"theta",
|
|
486
|
+
"t"
|
|
487
|
+
]
|
|
488
|
+
});
|
|
489
|
+
const RESERVED = /* @__PURE__ */ new Set([
|
|
490
|
+
"x",
|
|
491
|
+
"y",
|
|
492
|
+
"r",
|
|
493
|
+
"θ",
|
|
494
|
+
"theta"
|
|
495
|
+
]);
|
|
496
|
+
function curveVariables(kind, paramNames) {
|
|
497
|
+
return VARIABLES[kind].filter((name) => !paramNames.includes(name));
|
|
498
|
+
}
|
|
499
|
+
function niceStep(raw) {
|
|
500
|
+
const power = 10 ** Math.floor(Math.log10(raw));
|
|
501
|
+
const unit = raw / power;
|
|
502
|
+
return (unit >= 5 ? 5 : unit >= 2 ? 2 : 1) * power;
|
|
503
|
+
}
|
|
504
|
+
function readRange(value) {
|
|
505
|
+
const pair = Array.isArray(value) ? value : value && typeof value === "object" ? Array.isArray(value.range) ? value.range : [value.min, value.max] : null;
|
|
506
|
+
if (!pair || pair.length < 2) return null;
|
|
507
|
+
const a = constantValue(pair[0]);
|
|
508
|
+
const b = constantValue(pair[1]);
|
|
509
|
+
return a !== null && b !== null && b > a ? [a, b] : null;
|
|
510
|
+
}
|
|
511
|
+
function normalizeParams(raw) {
|
|
512
|
+
const seen = /* @__PURE__ */ new Set();
|
|
513
|
+
const params = [];
|
|
514
|
+
for (const param of list$1(raw, PLOT_LIMITS.params)) {
|
|
515
|
+
const name = clip(param?.name ?? param?.symbol ?? param?.id, 12);
|
|
516
|
+
if (!/^\p{L}[\p{L}\p{N}_]{0,11}$/u.test(name) || isFunctionName(name) || isConstantName(name) || RESERVED.has(name) || seen.has(name)) continue;
|
|
517
|
+
const min = constantValue(param.min);
|
|
518
|
+
const max = constantValue(param.max);
|
|
519
|
+
if (min === null || max === null || !(max > min)) continue;
|
|
520
|
+
const start = constantValue(param.value ?? param.default ?? param.initial);
|
|
521
|
+
let step = constantValue(param.step);
|
|
522
|
+
if (!(step > 0) || step > max - min) step = niceStep((max - min) / 100);
|
|
523
|
+
seen.add(name);
|
|
524
|
+
params.push({
|
|
525
|
+
name,
|
|
526
|
+
value: clamp$1(start ?? (min + max) / 2, min, max),
|
|
527
|
+
min,
|
|
528
|
+
max,
|
|
529
|
+
step,
|
|
530
|
+
label: clip(param.label ?? param.title, 30)
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
return params;
|
|
534
|
+
}
|
|
535
|
+
const formulaText = (value) => clip(value, 240).replace(/^[\p{L}](?:\s*\(\s*[\p{L}]+\s*\))?\s*=(?!=)/u, "").trim();
|
|
536
|
+
function checks(source, names) {
|
|
537
|
+
try {
|
|
538
|
+
parseExpr(source, names);
|
|
539
|
+
return true;
|
|
540
|
+
} catch {
|
|
541
|
+
return false;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
function normalizeCurve(raw, paramNames) {
|
|
545
|
+
const curve = typeof raw === "string" ? { y: raw } : raw;
|
|
546
|
+
if (!curve || typeof curve !== "object") return null;
|
|
547
|
+
const name = clip(curve.name ?? curve.label, 40);
|
|
548
|
+
const known = (kind) => [...curveVariables(kind, paramNames), ...paramNames];
|
|
549
|
+
if (curve.r !== void 0) {
|
|
550
|
+
const r = formulaText(curve.r);
|
|
551
|
+
if (!checks(r, known("polar"))) return null;
|
|
552
|
+
return {
|
|
553
|
+
kind: "polar",
|
|
554
|
+
r,
|
|
555
|
+
t: readRange(curve.t ?? curve.theta ?? curve.θ ?? curve.range) ?? [0, 2 * Math.PI],
|
|
556
|
+
name
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
if (typeof curve.x === "string" && typeof curve.y === "string") {
|
|
560
|
+
const x = formulaText(curve.x);
|
|
561
|
+
const y = formulaText(curve.y);
|
|
562
|
+
if (!checks(x, known("param")) || !checks(y, known("param"))) return null;
|
|
563
|
+
return {
|
|
564
|
+
kind: "param",
|
|
565
|
+
x,
|
|
566
|
+
y,
|
|
567
|
+
t: readRange(curve.t ?? curve.range) ?? [0, 2 * Math.PI],
|
|
568
|
+
name
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
const y = formulaText(curve.y ?? curve.fn ?? curve.f ?? curve.expr ?? curve.formula);
|
|
572
|
+
if (!y || !checks(y, known("fx"))) return null;
|
|
573
|
+
const domain = readRange(curve.domain ?? (Array.isArray(curve.x) ? curve.x : null));
|
|
574
|
+
return {
|
|
575
|
+
kind: "fx",
|
|
576
|
+
y,
|
|
577
|
+
...domain ? { domain } : {},
|
|
578
|
+
name
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
function fxIndex(value, fns, fallback = true) {
|
|
582
|
+
const index = Number(value);
|
|
583
|
+
if (Number.isInteger(index) && fns[index]?.kind === "fx") return index;
|
|
584
|
+
if (value !== void 0 && value !== null && value !== "") return null;
|
|
585
|
+
if (!fallback) return null;
|
|
586
|
+
const first = fns.findIndex((fn) => fn.kind === "fx");
|
|
587
|
+
return first >= 0 ? first : null;
|
|
588
|
+
}
|
|
589
|
+
function normalizePlot(raw) {
|
|
590
|
+
if (!raw || typeof raw !== "object") return null;
|
|
591
|
+
const params = normalizeParams(raw.params ?? raw.parameters ?? raw.sliders);
|
|
592
|
+
const paramNames = params.map((param) => param.name);
|
|
593
|
+
const single = typeof raw.y === "string" || raw.fn !== void 0 || raw.expr !== void 0 || raw.f !== void 0 || raw.formula !== void 0 ? [{
|
|
594
|
+
...raw,
|
|
595
|
+
x: void 0,
|
|
596
|
+
name: raw.name ?? raw.label
|
|
597
|
+
}] : [];
|
|
598
|
+
const fns = list$1(raw.fns ?? raw.functions ?? raw.curves ?? single, PLOT_LIMITS.fns * 2).map((curve) => normalizeCurve(curve, paramNames)).filter(Boolean).slice(0, PLOT_LIMITS.fns);
|
|
599
|
+
if (!fns.length) return null;
|
|
600
|
+
const xSource = raw.x !== void 0 && typeof raw.x !== "string" ? raw.x : raw.xRange ?? raw.domain ?? (raw.xMin !== void 0 ? [raw.xMin, raw.xMax] : void 0);
|
|
601
|
+
const ySource = raw.y !== void 0 && typeof raw.y !== "string" ? raw.y : raw.yRange ?? (raw.yMin !== void 0 ? [raw.yMin, raw.yMax] : void 0);
|
|
602
|
+
const x = readRange(xSource) ?? (fns.some((fn) => fn.kind === "fx") ? [-5, 5] : null);
|
|
603
|
+
const y = readRange(ySource);
|
|
604
|
+
const points = [];
|
|
605
|
+
for (const point of list$1(raw.points ?? raw.marks, PLOT_LIMITS.points)) {
|
|
606
|
+
const px = constantValue(point?.x);
|
|
607
|
+
if (px === null) continue;
|
|
608
|
+
const py = constantValue(point?.y);
|
|
609
|
+
const fn = py === null ? fxIndex(point?.fn, fns) : null;
|
|
610
|
+
if (py === null && fn === null) continue;
|
|
611
|
+
points.push({
|
|
612
|
+
x: px,
|
|
613
|
+
...py === null ? { fn } : { y: py },
|
|
614
|
+
label: clip(point?.label ?? point?.name, 30)
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
const lines = [];
|
|
618
|
+
for (const line of list$1(raw.lines ?? raw.guides, PLOT_LIMITS.lines)) {
|
|
619
|
+
const lx = constantValue(line?.x);
|
|
620
|
+
const ly = lx === null ? constantValue(line?.y) : null;
|
|
621
|
+
if (lx === null && ly === null) continue;
|
|
622
|
+
lines.push({
|
|
623
|
+
...lx === null ? { y: ly } : { x: lx },
|
|
624
|
+
label: clip(line?.label, 30)
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
const shades = [];
|
|
628
|
+
for (const shade of list$1(raw.shade ?? raw.shades ?? raw.areas, PLOT_LIMITS.shades)) {
|
|
629
|
+
const fn = fxIndex(shade?.fn, fns);
|
|
630
|
+
const from = constantValue(shade?.from ?? shade?.a);
|
|
631
|
+
const to = constantValue(shade?.to ?? shade?.b);
|
|
632
|
+
if (fn === null || from === null || to === null || !(to > from)) continue;
|
|
633
|
+
const fn2 = fxIndex(shade?.fn2 ?? shade?.to_fn ?? shade?.below, fns, false);
|
|
634
|
+
shades.push({
|
|
635
|
+
fn,
|
|
636
|
+
from,
|
|
637
|
+
to,
|
|
638
|
+
...fn2 !== null && fn2 !== fn ? { fn2 } : {},
|
|
639
|
+
label: clip(shade?.label, 30)
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
const tangents = [];
|
|
643
|
+
for (const tangent of list$1(raw.tangents ?? raw.tangent, PLOT_LIMITS.tangents)) {
|
|
644
|
+
const fn = fxIndex(tangent?.fn, fns);
|
|
645
|
+
const at = constantValue(tangent?.x ?? tangent?.at);
|
|
646
|
+
if (fn === null || at === null) continue;
|
|
647
|
+
tangents.push({
|
|
648
|
+
fn,
|
|
649
|
+
x: at,
|
|
650
|
+
label: clip(tangent?.label, 30)
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
return {
|
|
654
|
+
title: clip(raw.title, 120),
|
|
655
|
+
caption: clip(raw.caption, 200),
|
|
656
|
+
x: x ? {
|
|
657
|
+
min: x[0],
|
|
658
|
+
max: x[1]
|
|
659
|
+
} : null,
|
|
660
|
+
y: y ? {
|
|
661
|
+
min: y[0],
|
|
662
|
+
max: y[1]
|
|
663
|
+
} : null,
|
|
664
|
+
xLabel: clip(raw.xLabel ?? raw.x?.label, 40),
|
|
665
|
+
yLabel: clip(raw.yLabel ?? raw.y?.label, 40),
|
|
666
|
+
xPi: mentionsPi(JSON.stringify(xSource ?? "")) || x !== null && isPiMultiple(x[0]) && isPiMultiple(x[1]) && !(Number.isInteger(x[0]) && Number.isInteger(x[1])),
|
|
667
|
+
equal: raw.equal === true || raw.aspect === "equal",
|
|
668
|
+
fns,
|
|
669
|
+
params,
|
|
670
|
+
points,
|
|
671
|
+
lines,
|
|
672
|
+
shades,
|
|
673
|
+
tangents
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
const isPiMultiple = (value) => Math.abs(value / (Math.PI / 4) - Math.round(value / (Math.PI / 4))) < .001;
|
|
677
|
+
function readable(source, plot) {
|
|
678
|
+
try {
|
|
679
|
+
return exprToText(parseExpr(source, [.../* @__PURE__ */ new Set([...Object.values(VARIABLES).flat(), ...plot.params.map((param) => param.name)])]));
|
|
680
|
+
} catch {
|
|
681
|
+
return source;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
const number = (value) => {
|
|
685
|
+
if (!Number.isFinite(value)) return String(value);
|
|
686
|
+
const ratio = value / Math.PI;
|
|
687
|
+
if (value !== 0 && Math.abs(ratio * 4 - Math.round(ratio * 4)) < 1e-6) {
|
|
688
|
+
const quarters = Math.round(ratio * 4);
|
|
689
|
+
const [top, bottom] = quarters % 4 === 0 ? [quarters / 4, 1] : quarters % 2 === 0 ? [quarters / 2, 2] : [quarters, 4];
|
|
690
|
+
const head = top === 1 ? "π" : top === -1 ? "−π" : `${top}π`.replace("-", "−");
|
|
691
|
+
return bottom === 1 ? head : `${head}/${bottom}`;
|
|
692
|
+
}
|
|
693
|
+
return Number(value.toPrecision(4)).toString().replace("-", "−");
|
|
694
|
+
};
|
|
695
|
+
function plotCurveLabels(plot) {
|
|
696
|
+
return plot.fns.map((fn) => {
|
|
697
|
+
if (fn.kind === "polar") return `r = ${readable(fn.r, plot)}`;
|
|
698
|
+
if (fn.kind === "param") return `x = ${readable(fn.x, plot)}, y = ${readable(fn.y, plot)}`;
|
|
699
|
+
return `y = ${readable(fn.y, plot)}`;
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
function plotToText(plot) {
|
|
703
|
+
const parts = [plot.title || plot.caption].filter(Boolean);
|
|
704
|
+
const curves = plotCurveLabels(plot).map((label, index) => {
|
|
705
|
+
const fn = plot.fns[index];
|
|
706
|
+
const range = fn.kind === "fx" ? plot.x ? `, x ${number(fn.domain?.[0] ?? plot.x.min)} … ${number(fn.domain?.[1] ?? plot.x.max)}` : "" : `, ${fn.kind === "polar" ? "θ" : "t"} ${number(fn.t[0])} … ${number(fn.t[1])}`;
|
|
707
|
+
return `${fn.name ? `${fn.name}: ` : ""}${label}${range}`;
|
|
708
|
+
});
|
|
709
|
+
parts.push(...curves);
|
|
710
|
+
if (plot.params.length) parts.push(plot.params.map((param) => `${param.label || param.name} = ${number(param.value)} (${number(param.min)} … ${number(param.max)})`).join("; "));
|
|
711
|
+
return parts.join("\n");
|
|
712
|
+
}
|
|
713
|
+
function plotToSource(plot) {
|
|
714
|
+
return JSON.stringify({
|
|
715
|
+
fns: plot.fns.map((fn) => Object.fromEntries(Object.entries(fn).filter(([, value]) => value !== "" && value !== void 0))),
|
|
716
|
+
...plot.params.length ? { params: plot.params.map(({ name, value, min, max, label }) => ({
|
|
717
|
+
name,
|
|
718
|
+
value,
|
|
719
|
+
min,
|
|
720
|
+
max,
|
|
721
|
+
...label ? { label } : {}
|
|
722
|
+
})) } : {},
|
|
723
|
+
...plot.x ? { x: [plot.x.min, plot.x.max] } : {},
|
|
724
|
+
...plot.y ? { y: [plot.y.min, plot.y.max] } : {},
|
|
725
|
+
...plot.points.length ? { points: plot.points } : {},
|
|
726
|
+
...plot.lines.length ? { lines: plot.lines } : {},
|
|
727
|
+
...plot.shades.length ? { shade: plot.shades } : {},
|
|
728
|
+
...plot.tangents.length ? { tangents: plot.tangents } : {},
|
|
729
|
+
...plot.title ? { title: plot.title } : {}
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
//#endregion
|
|
23
733
|
//#region src/core/course-model.js
|
|
24
734
|
const LIMITS = Object.freeze({
|
|
25
735
|
questions: 6,
|
|
@@ -285,6 +995,10 @@ function normalizeItem(raw, index = 0, conceptIds = []) {
|
|
|
285
995
|
};
|
|
286
996
|
const figure = text(raw?.svg ?? raw?.figure, 16e3);
|
|
287
997
|
if (/^<svg[\s>]/iu.test(figure)) item.svg = figure;
|
|
998
|
+
else if (raw?.plot && typeof raw.plot === "object") {
|
|
999
|
+
const plot = normalizePlot(raw.plot);
|
|
1000
|
+
if (plot) item.plot = plot;
|
|
1001
|
+
}
|
|
288
1002
|
if (kind === "choice" || kind === "multi" || kind === "order") {
|
|
289
1003
|
item.options = list(raw?.options, LIMITS.options4, (option, optionIndex) => {
|
|
290
1004
|
const label = text(typeof option === "string" ? option : option?.text ?? option?.label, 400);
|
|
@@ -492,6 +1206,7 @@ const OP_TYPES = Object.freeze([
|
|
|
492
1206
|
"list",
|
|
493
1207
|
"svg",
|
|
494
1208
|
"chart",
|
|
1209
|
+
"plot",
|
|
495
1210
|
"table",
|
|
496
1211
|
"code",
|
|
497
1212
|
"note",
|
|
@@ -516,7 +1231,8 @@ const OP_ALIASES = Object.freeze({
|
|
|
516
1231
|
drawing: "svg",
|
|
517
1232
|
figure: "svg",
|
|
518
1233
|
graph: "chart",
|
|
519
|
-
|
|
1234
|
+
function: "plot",
|
|
1235
|
+
curve: "plot",
|
|
520
1236
|
callout: "note",
|
|
521
1237
|
highlight: "mark",
|
|
522
1238
|
underline: "mark",
|
|
@@ -612,13 +1328,22 @@ function normalizeOp(raw, index) {
|
|
|
612
1328
|
wide: raw?.wide === true
|
|
613
1329
|
};
|
|
614
1330
|
}
|
|
615
|
-
case "chart":
|
|
1331
|
+
case "chart":
|
|
1332
|
+
case "plot": {
|
|
1333
|
+
if (raw?.fns ?? raw?.functions ?? raw?.curves ?? (typeof raw?.y === "string" || raw?.fn !== void 0 || raw?.expr !== void 0 ? raw : null)) {
|
|
1334
|
+
const plot = normalizePlot(raw);
|
|
1335
|
+
if (plot) return {
|
|
1336
|
+
id,
|
|
1337
|
+
t: "plot",
|
|
1338
|
+
...plot
|
|
1339
|
+
};
|
|
1340
|
+
}
|
|
616
1341
|
const kind = CHART_KINDS.includes(raw?.kind) ? raw.kind : "line";
|
|
617
1342
|
const { series, data } = chartSeries(raw);
|
|
618
1343
|
if (!series.length && !data.length) return null;
|
|
619
1344
|
return {
|
|
620
1345
|
id,
|
|
621
|
-
t,
|
|
1346
|
+
t: "chart",
|
|
622
1347
|
kind,
|
|
623
1348
|
title: text(raw?.title, 120),
|
|
624
1349
|
xLabel: text(raw?.xLabel ?? raw?.x?.label, 60),
|
|
@@ -754,6 +1479,14 @@ function normalizeBoard(raw, { conceptIds = [] } = {}) {
|
|
|
754
1479
|
steps
|
|
755
1480
|
};
|
|
756
1481
|
}
|
|
1482
|
+
const CJK = /[-ヿ㐀-䶿一-鿿가-]/gu;
|
|
1483
|
+
function estimateSpeechMs(value, rate = 1) {
|
|
1484
|
+
const content = String(value ?? "");
|
|
1485
|
+
const cjk = (content.match(CJK) ?? []).length;
|
|
1486
|
+
const words = content.replace(CJK, " ").split(/\s+/u).filter(Boolean).length;
|
|
1487
|
+
const seconds = cjk / 4.4 + words / 2.6;
|
|
1488
|
+
return Math.round(seconds * 1e3 / Math.max(.5, Math.min(2, rate || 1)));
|
|
1489
|
+
}
|
|
757
1490
|
function boardToPlainText(board, { maxChars = 8e3, uptoStep } = {}) {
|
|
758
1491
|
const lines = [];
|
|
759
1492
|
for (const [index, step] of board.steps.entries()) {
|
|
@@ -763,6 +1496,7 @@ function boardToPlainText(board, { maxChars = 8e3, uptoStep } = {}) {
|
|
|
763
1496
|
else if (op.t === "list") lines.push(...op.items.map((item) => ` • ${item}`));
|
|
764
1497
|
else if (op.t === "table") lines.push(` | ${op.head.join(" | ")} |`, ...op.rows.map((row) => ` | ${row.join(" | ")} |`));
|
|
765
1498
|
else if (op.t === "code") lines.push(` \`\`\`${op.lang}\n${op.code}\n \`\`\``);
|
|
1499
|
+
else if (op.t === "plot") lines.push(` (plot: ${plotToSource(op)})`);
|
|
766
1500
|
else if (op.text) lines.push(` ${op.text}`);
|
|
767
1501
|
else if (op.caption) lines.push(` (${op.t}: ${op.caption})`);
|
|
768
1502
|
}
|
|
@@ -1381,9 +2115,10 @@ var TutorError = class extends Error {
|
|
|
1381
2115
|
}
|
|
1382
2116
|
};
|
|
1383
2117
|
const REPAIR = "Your previous reply could not be parsed as JSON. Reply again with the complete answer as valid JSON only — no code fences, no commentary.";
|
|
1384
|
-
async function generateJson(llm, { purpose = "teach", prompt, maxTokens = 8e3, images, signal, normalize = (value) => value, onText }) {
|
|
2118
|
+
async function generateJson(llm, { purpose = "teach", effort, prompt, maxTokens = 8e3, images, signal, normalize = (value) => value, onText }) {
|
|
1385
2119
|
const request = {
|
|
1386
2120
|
purpose,
|
|
2121
|
+
...effort ? { effort } : {},
|
|
1387
2122
|
system: prompt.system,
|
|
1388
2123
|
user: prompt.user,
|
|
1389
2124
|
images,
|
|
@@ -1400,12 +2135,8 @@ async function generateJson(llm, { purpose = "teach", prompt, maxTokens = 8e3, i
|
|
|
1400
2135
|
if (signal?.aborted) throw signal.reason ?? error;
|
|
1401
2136
|
const retryUser = `${prompt.user}\n\n<previous_reply>\n${String(first).slice(0, 12e3)}\n</previous_reply>\n\n${REPAIR}${error instanceof JsonExtractError ? "" : `\nProblem: ${error.message}`}`;
|
|
1402
2137
|
const second = await llm.complete({
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
user: retryUser,
|
|
1406
|
-
images,
|
|
1407
|
-
maxTokens,
|
|
1408
|
-
signal
|
|
2138
|
+
...request,
|
|
2139
|
+
user: retryUser
|
|
1409
2140
|
});
|
|
1410
2141
|
try {
|
|
1411
2142
|
return normalize(extractJson(second));
|
|
@@ -1614,7 +2345,8 @@ Board operations (each is a JSON object with field "t"; give an op an "id" only
|
|
|
1614
2345
|
- {"t":"math","tex":"…","text":"…"} one display formula in KaTeX-compatible LaTeX; "text" is a plain-Unicode reading of it (e.g. "θ(t) = θ₀·cos(ωt + φ)")
|
|
1615
2346
|
- {"t":"list","items":["…","…"],"ordered":false}
|
|
1616
2347
|
- {"t":"svg","svg":"<svg viewBox=\\"0 0 480 300\\" xmlns=\\"http://www.w3.org/2000/svg\\">…</svg>","caption":"…"} a clean hand-drawn-style diagram: viewBox at most 640×420, strokes and simple shapes, <text> labels (font-size 14–18), colors only from ink #2b2b2b, blue #2f6fde, red #d64545, green #2f9e6e, orange #e08a1e, purple #7a4fd6; no scripts, no images, no foreignObject, no external links
|
|
1617
|
-
- {"t":"chart","kind":"line|bar|scatter|pie|area","title":"…","xLabel":"…","yLabel":"…","series":[{"name":"…","points":[[x,y],…]}]} or "data":[{"label":"…","value":n}] for bar/pie;
|
|
2348
|
+
- {"t":"chart","kind":"line|bar|scatter|pie|area","title":"…","xLabel":"…","yLabel":"…","series":[{"name":"…","points":[[x,y],…]}]} or "data":[{"label":"…","value":n}] for bar/pie; for data that was measured or counted, with real numbers (a curve given by a formula is a plot)
|
|
2349
|
+
- {"t":"plot","fns":[{"y":"A*sin(w*x)","name":"…"}],"x":[-6.28,6.28],"y":[-2,2],"params":[{"name":"A","value":1,"min":0.5,"max":2,"label":"振幅 A"}],"points":[{"x":"pi/2","label":"…"}],"lines":[{"x":0,"label":"…"}],"shade":[{"fn":0,"from":0,"to":"pi"}],"tangents":[{"fn":0,"x":0}],"xLabel":"…","yLabel":"…","caption":"…"} an exact graph the app computes from formulas: use it for every curve given by a formula. A curve in "fns" is {"y":"f(x)"}, parametric {"x":"…","y":"…","t":[a,b]} or polar {"r":"f(θ)","t":[a,b]}, written in calculator syntax: x (t, θ), numbers, + - * / ^ ( ), pi, e, sin cos tan asin acos atan sinh cosh tanh exp ln log10 sqrt abs min max, always with parentheses as in sin(2*x). "params" (at most 4, used in the formulas) become sliders the learner can drag: mention them in "say" and suggest what to try. Optional: "points" (only an "x" puts the point on curve "fn"), "lines" (dashed guides such as asymptotes), "shade" (the area under curve "fn" from "from" to "to", or between "fn" and "fn2"), "tangents", "equal":true (the same scale on both axes, so circles stay round). Ranges and coordinates may be formulas such as "2*pi".
|
|
1618
2350
|
- {"t":"table","head":["…"],"rows":[["…","…"]]}
|
|
1619
2351
|
- {"t":"code","lang":"python","code":"…"}
|
|
1620
2352
|
- {"t":"note","tone":"key|tip|warn|example","text":"…"} boxed takeaway or warning
|
|
@@ -1694,7 +2426,7 @@ function revisePrompt({ blueprint, instruction, language }) {
|
|
|
1694
2426
|
user: join(block("current_blueprint", blueprint), block("change_request", instruction), "Apply the change request and return the complete revised blueprint in the same JSON shape. Keep the \"id\" field of every unit, lecture, lesson and concept you do not substantially change; new items get no id.")
|
|
1695
2427
|
};
|
|
1696
2428
|
}
|
|
1697
|
-
const ITEM_FIGURE = "When a figure makes a question clearer (a shape to measure, a graph to read, a circuit, a diagram to label), give the item \"svg\": a small diagram following the same rules as the board's svg op (viewBox at most 480×320). Most items need no figure.";
|
|
2429
|
+
const ITEM_FIGURE = "When a figure makes a question clearer (a shape to measure, a graph to read, a circuit, a diagram to label), give the item \"svg\": a small diagram following the same rules as the board's svg op (viewBox at most 480×320). For the graph of a formula give \"plot\" instead: an object shaped like the board's plot op, without \"params\". Most items need no figure.";
|
|
1698
2430
|
function reworkPrompt({ outline, unitTitle, lectureTitle, lesson, mode, language }) {
|
|
1699
2431
|
return {
|
|
1700
2432
|
system: join(PERSONA, `You design personal courses with the care of an experienced curriculum designer: clear prerequisites first, one new idea per lesson. ${languageRule(language)}`, JSON_ONLY),
|
|
@@ -1735,7 +2467,7 @@ function lessonPrompt({ course, unit, lecture, lesson, language, learner, passag
|
|
|
1735
2467
|
user: join(block("course", `${course.title} (${course.level || "mixed level"}) — ${course.summary ?? ""}`), block("position", `Unit: ${unit.title}\nLecture: ${lecture.title}\nLesson: ${lesson.title}\nObjectives: ${lesson.objectives.join("; ")}\nConcepts (id=name): ${lesson.concepts.map((concept) => `${concept.id}=${concept.name}`).join(", ")}`), block("previous_lesson", previous), learnerBlock(learner), block("source_passages", passages), style ? block("style_request", style) : "", DIRECTIVES[directive] ?? "", `Teach this lesson in about ${steps} steps (~${lesson.estMinutes || 15} minutes).
|
|
1736
2468
|
- Open with a hook: a question, a surprising fact, or a concrete situation the idea explains. Connect to the previous lesson when there is one.
|
|
1737
2469
|
- Build intuition before formalism; then make it precise; then work at least one example fully on the board.
|
|
1738
|
-
- Draw a diagram or
|
|
2470
|
+
- Draw a diagram, chart or plot whenever a picture makes the idea click; a plot with a slider is often the clearest way to show how a formula behaves.
|
|
1739
2471
|
- Put 2–4 checks after the key ideas, plus one at the end; every check must name its conceptIds.
|
|
1740
2472
|
- If the lesson works through a calculation or a derivation, make exactly one check a "work" check, right after the key worked example: a similar problem the learner solves alone, writing every step. A lesson without a calculation or derivation gets no "work" check.
|
|
1741
2473
|
- If source passages are given, stay consistent with them and mention the page when you lean on one.
|
|
@@ -1752,7 +2484,7 @@ function askPrompt({ course, lesson, language, boardSoFar, question, learner })
|
|
|
1752
2484
|
}
|
|
1753
2485
|
const REEXPLAIN_STYLES = Object.freeze({
|
|
1754
2486
|
analogy: "with an everyday analogy that maps closely onto the idea",
|
|
1755
|
-
visual: "mainly with a diagram or
|
|
2487
|
+
visual: "mainly with a diagram, chart or plot, explaining what each part of the picture means",
|
|
1756
2488
|
example: "through a concrete worked example with real numbers, step by step",
|
|
1757
2489
|
simpler: "more simply, with shorter sentences, no jargon, and smaller steps",
|
|
1758
2490
|
deeper: "more rigorously, showing why it is true and where it breaks down"
|
|
@@ -1783,6 +2515,41 @@ function correctionPrompt({ course, lesson, language, boardSoFar, item, response
|
|
|
1783
2515
|
user: join(block("course", course ? course.title : ""), block("lesson", lesson ? `${lesson.title}: ${lesson.objectives?.join("; ") ?? ""}` : ""), block("board_so_far", boardSoFar), block("problem", item.prompt), block("model_working", item.answer), block("learner_working", response), block("grader_notes", notes), "The learner just wrote out their working for this problem and went wrong somewhere. Correct it at the board in 2–3 steps, continuing on the current board: (1) write the learner's wrong line as they wrote it, in an op with an \"id\", then {\"t\":\"mark\",\"ref\":\"<that id>\",\"style\":\"box\"}, and say kindly what went wrong there and why; (2) write the corrected line and carry the working on to the right answer; (3) end with a short {\"t\":\"note\",\"tone\":\"key\",\"text\":\"…\"} holding the rule to remember. Speak to the learner directly and keep it brief. Add no check, and do not continue the lesson itself.")
|
|
1784
2516
|
};
|
|
1785
2517
|
}
|
|
2518
|
+
function verifyStepPrompt({ course, lesson, language, board, step, stepId, note }) {
|
|
2519
|
+
return {
|
|
2520
|
+
system: join(PERSONA, `The learner thinks a step of your lesson is wrong. Check it the way a careful examiner would: recompute every number, re-derive every formula, and check every fact, unit, sign, label, graph and answer key. Never defend a mistake and never invent one; a different but valid method or notation is not a mistake. ${languageRule(language)}`, JSON_ONLY),
|
|
2521
|
+
user: join(block("course", course ? course.title : ""), block("lesson", lesson ? `${lesson.title}: ${lesson.objectives?.join("; ") ?? ""}` : ""), block("board", board), block("challenged_step", step), block("learner_says", note || "(no reason given)"), `Decide whether step ${stepId} is right, and check the steps it relies on too: a mistake may have started earlier.
|
|
2522
|
+
- "wrong": something on the board is wrong. List every fix: an op replaced by its corrected version {"step":"s3","op":"o7","replace":{…the whole op, in the same JSON shape…}}, corrected narration {"step":"s3","say":"…"}, or a corrected check {"step":"s3","check":{…the whole check…}}. Also fix later steps that carried the same mistake forward. Change only what is wrong.
|
|
2523
|
+
- "right": the step is correct; "summary" says why in one sentence, answering the learner.
|
|
2524
|
+
- "unclear": it depends on a convention or on something the board does not say; "summary" says what.
|
|
2525
|
+
Return {"verdict":"wrong|right|unclear","summary":"one sentence for the learner","fixes":[…]}`)
|
|
2526
|
+
};
|
|
2527
|
+
}
|
|
2528
|
+
function verifyItemPrompt({ scope, language, item, kind, response, grading, note }) {
|
|
2529
|
+
const graded = kind === "short" || kind === "work";
|
|
2530
|
+
const verdicts = graded ? "wrong|unclear|misgraded|right" : "wrong|unclear|right";
|
|
2531
|
+
const regrade = graded ? `Unless the verdict is "right" (or "credit" is true), grade the learner's answer again against the corrected question in "grade": {${GRADE_FIELDS}${kind === "work" ? ",\"wrongStep\":\"the first wrong line of the learner's working, copied exactly, or empty\",\"why\":\"one sentence\",\"fix\":\"that line written correctly, with inline math $…$\"" : ""}}.\n` : "";
|
|
2532
|
+
return {
|
|
2533
|
+
system: join(PERSONA, `The learner thinks a practice question, its answer key or the grading of their answer is wrong. Check it the way a careful examiner would: solve the question yourself first, then recompute every number and check every fact, unit, option, the answer key, the explanation and the grading. Never defend a mistake and never invent one; an equivalent answer in another valid form is right. ${languageRule(language)}`, JSON_ONLY),
|
|
2534
|
+
user: join(block("scope", scope), block("question", item), block("learner_answer", response || "(no answer)"), block("grading", grading), block("learner_says", note || "(no reason given)"), `Decide which is true:
|
|
2535
|
+
- "wrong": the question has a mistake: its answer key, an option, the prompt, the explanation or a hint${graded ? ", its model answer or its rubric" : "; a key that rejects a right answer written another valid way is a mistake too"}. Put the whole corrected question in "item", in the same JSON shape, with the same "kind" and the same number of options, and change only what is wrong. Leave out "svg" and "plot" to keep the figure as it is.
|
|
2536
|
+
- "unclear": whether an answer is right depends on a convention or an assumption the question does not state. Put the question in "item" with that convention written into the prompt, and the key and explanation under it. Set "credit": true when the learner's answer is right under another reasonable reading.
|
|
2537
|
+
${graded ? "- \"misgraded\": the question is right, but the learner's answer was graded wrongly.\n" : ""}- "right": the question, its answer and the grading are all right.
|
|
2538
|
+
${regrade}"summary": one sentence for the learner. "explanation": 2–5 sentences that answer the learner's doubt: what was wrong and the right reasoning, or why it is right (inline math $…$). Speak to the learner in plain words: never quote scores, grading labels or field names.
|
|
2539
|
+
Return {"verdict":"${verdicts}","summary":"…","explanation":"…","item":{…the corrected question, for wrong or unclear…},"credit":false${graded ? ",\"grade\":{…}" : ""}}`)
|
|
2540
|
+
};
|
|
2541
|
+
}
|
|
2542
|
+
const CHALLENGE_REPLIES = Object.freeze({
|
|
2543
|
+
wrong: "The learner was right: this step had a mistake, and the board has now been corrected (see fixes_made). Answer at the board in 1–3 steps, continuing on the current board. Thank the learner in a few words. Write the mistaken part as it was, in an op with an \"id\", then {\"t\":\"mark\",\"ref\":\"<that id>\",\"style\":\"box\"}, and say plainly what was wrong. Then write the corrected version and why it is right. If later steps were corrected too, say so in one sentence. End with a short {\"t\":\"note\",\"tone\":\"key\",\"text\":\"…\"} only if there is a rule worth remembering.",
|
|
2544
|
+
right: "The step is correct. Answer the learner's doubt at the board in 1–3 steps, continuing on the current board: say kindly that it is right, then show why (recompute or re-derive the part in question, or use a tiny example), and name what most likely made it look wrong.",
|
|
2545
|
+
unclear: "Whether the step is right depends on a convention or an assumption (see check_result). In 1–2 steps at the board, continuing on the current board, say which one the lesson uses, what changes under the other, and which one the learner will usually meet."
|
|
2546
|
+
});
|
|
2547
|
+
function challengePrompt({ course, lesson, language, boardSoFar, step, note, verdict, summary, fixes }) {
|
|
2548
|
+
return {
|
|
2549
|
+
system: join(PERSONA, BOARD_SPEC, STEP_SPEC, languageRule(language), JSONL_ONLY),
|
|
2550
|
+
user: join(block("course", course ? course.title : ""), block("lesson", lesson ? `${lesson.title}: ${lesson.objectives?.join("; ") ?? ""}` : ""), block("board_so_far", boardSoFar), block("challenged_step", step), block("learner_says", note), block("check_result", `${verdict}: ${summary}`), block("fixes_made", fixes), `${CHALLENGE_REPLIES[verdict] ?? CHALLENGE_REPLIES.unclear} Speak to the learner directly and keep it brief. Add no check, and do not continue the lesson itself.`)
|
|
2551
|
+
};
|
|
2552
|
+
}
|
|
1786
2553
|
function projectReviewPrompt({ project, submission, language }) {
|
|
1787
2554
|
return {
|
|
1788
2555
|
system: join(PERSONA, `You review learner projects like a supportive senior mentor: specific, honest, and actionable. ${languageRule(language)}`, JSON_ONLY),
|
|
@@ -1839,7 +2606,7 @@ function digestPartPrompt({ title, part, parts, range, passages, language }) {
|
|
|
1839
2606
|
const ASSIST_MODES = Object.freeze({
|
|
1840
2607
|
explain: "Explain the concept the learner asked about, at their level, intuition first, then precise, with one worked example. 5–9 steps, 1–2 checks.",
|
|
1841
2608
|
solve: "Solve the problem WITH the learner: restate it, identify the method, then work it step by step on the board, explaining why each move is made. Show the final answer clearly in a key note and add one similar practice check at the end. 4–10 steps.",
|
|
1842
|
-
visualize: "Make the idea visible: lead with charts, diagrams or an animation built from correct numbers, and narrate what to notice in each picture. 3–7 steps."
|
|
2609
|
+
visualize: "Make the idea visible: lead with plots of formulas, charts of data, diagrams or an animation built from correct numbers, and narrate what to notice in each picture. 3–7 steps."
|
|
1843
2610
|
});
|
|
1844
2611
|
function assistPrompt({ mode, request, language, passages, history, learner }) {
|
|
1845
2612
|
return {
|
|
@@ -1880,4 +2647,4 @@ function getRuntime() {
|
|
|
1880
2647
|
return runtime;
|
|
1881
2648
|
}
|
|
1882
2649
|
//#endregion
|
|
1883
|
-
export {
|
|
2650
|
+
export { BOARD_LIMITS as $, TutorError as A, passagesFor as B, researchQueriesPrompt as C, text as Ct, summaryPrompt as D, shortPrompt as E, newId as Et, EMPTY_PROFILE as F, formatPassages as G, readProfile as H, emptyProgress as I, applyEvidence as J, passageRange as K, masteryFor as L, generateSteps as M, generateText as N, verifyItemPrompt as O, streamCounter as P, extractJson as Q, materialImages as R, reexplainPrompt as S, safeRelativePath as St, reworkPrompt as T, assertId as Tt, readProgress as U, readMaterials as V, documentParts as W, masteryPercent as X, createConceptState as Y, recommendNext as Z, profilePrompt as _, normalizeLesson as _t, blueprintPrompt as a, conceptIndex as at, questionnairePrompt as b, normalizeQuestionnaire as bt, correctionPrompt as c, flattenLessons as ct, digestPrompt as d, lessonAfter as dt, boardToPlainText as et, flashcardsPrompt as f, nextLesson as ft, practicePrompt as g, normalizeItem as gt, materialBriefPrompt as h, normalizeCheatsheet as ht, assistPrompt as i, blueprintOutline as it, generateJson as j, verifyStepPrompt as k, detectLanguage as l, insertLesson as lt, lessonPrompt as m, normalizeCards as mt, publishRuntime as n, normalizeBoard as nt, challengePrompt as o, courseProgress as ot, gradePrompt as p, normalizeBlueprint as pt, spreadPassages as q, askPrompt as r, normalizeOp as rt, cheatsheetPrompt as s, findLesson as st, getRuntime as t, estimateSpeechMs as tt, digestPartPrompt as u, intakeSummary as ut, projectReviewPrompt as v, normalizePractice as vt, revisePrompt as w, plotToText as wt, quizPrompt as x, outlineForPrompt as xt, projectsPrompt as y, normalizeProject as yt, materialsSummary as z };
|