dsh-aha-tutor 1.5.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 +2 -2
- package/CHANGELOG.md +20 -0
- package/README.en.md +4 -4
- package/README.md +4 -4
- package/docs/guide.zh-CN.md +20 -3
- package/lib/agent.js +1 -1
- package/lib/client.js +71 -41
- package/lib/index.js +1126 -76
- package/lib/{runtime-DqCjJ0yL.js → runtime-B6zUpJjA.js} +783 -16
- package/package.json +1 -1
- package/samples/harmonic-motion.aha-course.json +92 -388
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as
|
|
1
|
+
import { $ as BOARD_LIMITS, A as TutorError, B as passagesFor, C as researchQueriesPrompt, Ct as text, D as summaryPrompt, E as shortPrompt, Et as newId, F as EMPTY_PROFILE, G as formatPassages, H as readProfile, I as emptyProgress, J as applyEvidence, K as passageRange, L as masteryFor, M as generateSteps, N as generateText, O as verifyItemPrompt, P as streamCounter, Q as extractJson, R as materialImages, S as reexplainPrompt, St as safeRelativePath, T as reworkPrompt, Tt as assertId, U as readProgress, V as readMaterials, W as documentParts, X as masteryPercent, Y as createConceptState, Z as recommendNext, _ as profilePrompt, _t as normalizeLesson, a as blueprintPrompt, at as conceptIndex, b as questionnairePrompt, bt as normalizeQuestionnaire, c as correctionPrompt, ct as flattenLessons, d as digestPrompt, dt as lessonAfter, et as boardToPlainText, f as flashcardsPrompt, ft as nextLesson, g as practicePrompt, gt as normalizeItem, h as materialBriefPrompt, ht as normalizeCheatsheet, i as assistPrompt, it as blueprintOutline, j as generateJson, k as verifyStepPrompt, l as detectLanguage, lt as insertLesson, m as lessonPrompt, mt as normalizeCards, n as publishRuntime, nt as normalizeBoard, o as challengePrompt, ot as courseProgress, p as gradePrompt, pt as normalizeBlueprint, q as spreadPassages, r as askPrompt, rt as normalizeOp, s as cheatsheetPrompt, st as findLesson, tt as estimateSpeechMs, u as digestPartPrompt, ut as intakeSummary, v as projectReviewPrompt, vt as normalizePractice, w as revisePrompt, wt as plotToText, x as quizPrompt, xt as outlineForPrompt, y as projectsPrompt, yt as normalizeProject, z as materialsSummary } from "./runtime-B6zUpJjA.js";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import { dirname, join, relative, sep } from "node:path";
|
|
4
4
|
import { credentialKey } from "@deepseek-ai/dsh-credentials";
|
|
@@ -932,7 +932,7 @@ function createJobs({ logger, now = Date.now } = {}) {
|
|
|
932
932
|
const aborted = new Promise((_, reject) => {
|
|
933
933
|
controller.signal.addEventListener("abort", () => reject(controller.signal.reason ?? /* @__PURE__ */ new Error("cancelled")), { once: true });
|
|
934
934
|
});
|
|
935
|
-
Promise.race([Promise.resolve().then(() => run(api)), aborted]).then((result) => {
|
|
935
|
+
job.settled = Promise.race([Promise.resolve().then(() => run(api)), aborted]).then((result) => {
|
|
936
936
|
job.status = "done";
|
|
937
937
|
job.result = result;
|
|
938
938
|
}).catch((error) => {
|
|
@@ -979,6 +979,12 @@ function createJobs({ logger, now = Date.now } = {}) {
|
|
|
979
979
|
const job = id ? jobs.get(id) : void 0;
|
|
980
980
|
return job && !job.finishedAt && !job.controller.signal.aborted ? snapshot(job) : null;
|
|
981
981
|
}
|
|
982
|
+
async function wait(id) {
|
|
983
|
+
const job = jobs.get(id);
|
|
984
|
+
if (!job) return null;
|
|
985
|
+
await job.settled;
|
|
986
|
+
return snapshot(job);
|
|
987
|
+
}
|
|
982
988
|
function cancelKey(key) {
|
|
983
989
|
const job = running(key);
|
|
984
990
|
return job ? cancel(job.id) : null;
|
|
@@ -991,6 +997,7 @@ function createJobs({ logger, now = Date.now } = {}) {
|
|
|
991
997
|
return Object.freeze({
|
|
992
998
|
start,
|
|
993
999
|
get,
|
|
1000
|
+
wait,
|
|
994
1001
|
cancel,
|
|
995
1002
|
cancelKey,
|
|
996
1003
|
running,
|
|
@@ -2368,16 +2375,33 @@ function createLearner({ store, llm, now = () => /* @__PURE__ */ new Date(), log
|
|
|
2368
2375
|
const lesson = findLesson(course, concept.lessonId)?.lesson;
|
|
2369
2376
|
const set = await store.readJson(store.paths.practice(course.id, concept.lessonId), null);
|
|
2370
2377
|
const board = await store.readJson(store.paths.board(course.id, concept.lessonId), null);
|
|
2371
|
-
const candidates = [...set?.items ?? []
|
|
2372
|
-
|
|
2373
|
-
|
|
2378
|
+
const candidates = [...(set?.items ?? []).map((item) => ({
|
|
2379
|
+
item,
|
|
2380
|
+
source: {
|
|
2381
|
+
kind: "practice",
|
|
2382
|
+
courseId: course.id,
|
|
2383
|
+
lessonId: concept.lessonId,
|
|
2384
|
+
itemId: item.id
|
|
2385
|
+
}
|
|
2386
|
+
})), ...(board?.steps ?? []).filter((step) => step.check).map((step) => ({
|
|
2387
|
+
item: step.check,
|
|
2388
|
+
source: {
|
|
2389
|
+
kind: "check",
|
|
2390
|
+
courseId: course.id,
|
|
2391
|
+
lessonId: concept.lessonId,
|
|
2392
|
+
stepId: step.id
|
|
2393
|
+
}
|
|
2394
|
+
}))].filter(({ item }) => item.conceptIds?.includes(conceptId) && item.kind !== "short" && item.kind !== "work");
|
|
2395
|
+
const pick = candidates[Math.floor(Math.random() * candidates.length)];
|
|
2396
|
+
if (pick) items.push({
|
|
2374
2397
|
courseId: course.id,
|
|
2375
2398
|
courseTitle: course.title,
|
|
2376
2399
|
lessonId: concept.lessonId,
|
|
2377
2400
|
lessonTitle: lesson?.title ?? "",
|
|
2378
2401
|
conceptId,
|
|
2379
2402
|
conceptName: concept.name,
|
|
2380
|
-
item
|
|
2403
|
+
item: pick.item,
|
|
2404
|
+
source: pick.source
|
|
2381
2405
|
});
|
|
2382
2406
|
}
|
|
2383
2407
|
}
|
|
@@ -2426,6 +2450,223 @@ function cleanProfile(profile) {
|
|
|
2426
2450
|
};
|
|
2427
2451
|
}
|
|
2428
2452
|
//#endregion
|
|
2453
|
+
//#region src/core/board-fix.js
|
|
2454
|
+
const VERDICTS = Object.freeze([
|
|
2455
|
+
"wrong",
|
|
2456
|
+
"right",
|
|
2457
|
+
"unclear"
|
|
2458
|
+
]);
|
|
2459
|
+
const LAYOUT = /* @__PURE__ */ new Set([
|
|
2460
|
+
"col",
|
|
2461
|
+
"page",
|
|
2462
|
+
"mark"
|
|
2463
|
+
]);
|
|
2464
|
+
function opPlain(op) {
|
|
2465
|
+
switch (op?.t) {
|
|
2466
|
+
case "title":
|
|
2467
|
+
case "h":
|
|
2468
|
+
case "p":
|
|
2469
|
+
case "note": return op.text;
|
|
2470
|
+
case "math": return op.text || op.tex;
|
|
2471
|
+
case "list": return op.items.join("; ");
|
|
2472
|
+
case "table": return [op.head, ...op.rows].filter((row) => row.length).map((row) => row.join(" | ")).join(" / ");
|
|
2473
|
+
case "code": return op.code;
|
|
2474
|
+
case "chart": return [
|
|
2475
|
+
op.title,
|
|
2476
|
+
...op.series.map((series) => `${series.name} ${series.points.slice(0, 6).map((point) => `(${point.join(", ")})`).join(" ")}`),
|
|
2477
|
+
...op.data.map((item) => `${item.label} ${item.value}`)
|
|
2478
|
+
].filter(Boolean).join("; ");
|
|
2479
|
+
case "plot": return plotToText(op).replace(/\n/gu, "; ");
|
|
2480
|
+
case "svg":
|
|
2481
|
+
case "anim": return op.caption ?? "";
|
|
2482
|
+
default: return "";
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
const withoutPlace = (op) => JSON.stringify({
|
|
2486
|
+
...op,
|
|
2487
|
+
id: void 0,
|
|
2488
|
+
at: void 0,
|
|
2489
|
+
fixed: void 0
|
|
2490
|
+
});
|
|
2491
|
+
function normalizeVerdict(raw, steps) {
|
|
2492
|
+
const verdict = VERDICTS.includes(raw?.verdict) ? raw.verdict : null;
|
|
2493
|
+
if (!verdict) throw new Error("the check names no verdict");
|
|
2494
|
+
const summary = text(raw?.summary ?? raw?.reason, 400);
|
|
2495
|
+
const fixes = [];
|
|
2496
|
+
if (verdict !== "wrong") return {
|
|
2497
|
+
verdict,
|
|
2498
|
+
summary,
|
|
2499
|
+
fixes
|
|
2500
|
+
};
|
|
2501
|
+
const byId = new Map(steps.map((step) => [step.id, step]));
|
|
2502
|
+
const done = /* @__PURE__ */ new Set();
|
|
2503
|
+
const keep = (key, fix) => {
|
|
2504
|
+
if (done.has(key) || fixes.length >= 12) return;
|
|
2505
|
+
done.add(key);
|
|
2506
|
+
fixes.push(fix);
|
|
2507
|
+
};
|
|
2508
|
+
for (const fix of (Array.isArray(raw?.fixes) ? raw.fixes : []).slice(0, 36)) {
|
|
2509
|
+
const step = byId.get(String(fix?.step ?? fix?.stepId ?? ""));
|
|
2510
|
+
if (!step) continue;
|
|
2511
|
+
const replacement = fix.replace ?? fix.with ?? fix.op_new;
|
|
2512
|
+
if (replacement && typeof replacement === "object") {
|
|
2513
|
+
const old = step.ops.find((op) => op.id === String(fix.op ?? fix.opId ?? ""));
|
|
2514
|
+
const next = normalizeOp(replacement, 0);
|
|
2515
|
+
if (old && next && !LAYOUT.has(old.t) && !LAYOUT.has(next.t) && withoutPlace(next) !== withoutPlace(old)) keep(`${step.id}:${old.id}`, {
|
|
2516
|
+
step: step.id,
|
|
2517
|
+
op: old.id,
|
|
2518
|
+
replace: next
|
|
2519
|
+
});
|
|
2520
|
+
}
|
|
2521
|
+
if (typeof fix.say === "string") {
|
|
2522
|
+
const say = text(fix.say, BOARD_LIMITS.say);
|
|
2523
|
+
if (say && say !== step.say) keep(`${step.id}:say`, {
|
|
2524
|
+
step: step.id,
|
|
2525
|
+
say
|
|
2526
|
+
});
|
|
2527
|
+
}
|
|
2528
|
+
if (fix.check && typeof fix.check === "object" && step.check) {
|
|
2529
|
+
const check = normalizeItem(fix.check, 0, step.check.conceptIds ?? []);
|
|
2530
|
+
if (check && check.kind !== "order") {
|
|
2531
|
+
const next = {
|
|
2532
|
+
...check,
|
|
2533
|
+
id: step.check.id,
|
|
2534
|
+
conceptIds: step.check.conceptIds ?? []
|
|
2535
|
+
};
|
|
2536
|
+
if (JSON.stringify(next) !== JSON.stringify(step.check)) keep(`${step.id}:check`, {
|
|
2537
|
+
step: step.id,
|
|
2538
|
+
check: next
|
|
2539
|
+
});
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
}
|
|
2543
|
+
return {
|
|
2544
|
+
verdict,
|
|
2545
|
+
summary,
|
|
2546
|
+
fixes
|
|
2547
|
+
};
|
|
2548
|
+
}
|
|
2549
|
+
function applyFixes(steps, fixes, { at = (/* @__PURE__ */ new Date()).toISOString() } = {}) {
|
|
2550
|
+
const applied = [];
|
|
2551
|
+
return {
|
|
2552
|
+
steps: steps.map((step) => {
|
|
2553
|
+
const own = fixes.filter((fix) => fix.step === step.id);
|
|
2554
|
+
if (!own.length) return step;
|
|
2555
|
+
const copy = {
|
|
2556
|
+
...step,
|
|
2557
|
+
ops: [...step.ops]
|
|
2558
|
+
};
|
|
2559
|
+
for (const fix of own) {
|
|
2560
|
+
if (fix.replace) {
|
|
2561
|
+
const index = copy.ops.findIndex((op) => op.id === fix.op);
|
|
2562
|
+
if (index < 0) continue;
|
|
2563
|
+
const old = copy.ops[index];
|
|
2564
|
+
const note = fix.replace.fixed;
|
|
2565
|
+
const op = {
|
|
2566
|
+
...fix.replace,
|
|
2567
|
+
id: old.id,
|
|
2568
|
+
...old.at !== void 0 ? { at: old.at } : {},
|
|
2569
|
+
fixed: {
|
|
2570
|
+
was: old.fixed?.was ?? note?.was ?? text(opPlain(old), 300),
|
|
2571
|
+
at: note?.at ?? at
|
|
2572
|
+
}
|
|
2573
|
+
};
|
|
2574
|
+
copy.ops[index] = op;
|
|
2575
|
+
applied.push({
|
|
2576
|
+
step: step.id,
|
|
2577
|
+
op: old.id,
|
|
2578
|
+
replace: op
|
|
2579
|
+
});
|
|
2580
|
+
}
|
|
2581
|
+
if (fix.say !== void 0) {
|
|
2582
|
+
copy.say = fix.say;
|
|
2583
|
+
applied.push({
|
|
2584
|
+
step: step.id,
|
|
2585
|
+
say: fix.say
|
|
2586
|
+
});
|
|
2587
|
+
}
|
|
2588
|
+
if (fix.check && copy.check) {
|
|
2589
|
+
copy.check = {
|
|
2590
|
+
...fix.check,
|
|
2591
|
+
id: copy.check.id,
|
|
2592
|
+
conceptIds: copy.check.conceptIds ?? []
|
|
2593
|
+
};
|
|
2594
|
+
applied.push({
|
|
2595
|
+
step: step.id,
|
|
2596
|
+
check: copy.check
|
|
2597
|
+
});
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2600
|
+
return copy;
|
|
2601
|
+
}),
|
|
2602
|
+
applied
|
|
2603
|
+
};
|
|
2604
|
+
}
|
|
2605
|
+
function sanitizeStep(raw) {
|
|
2606
|
+
const id = String(raw?.id ?? "");
|
|
2607
|
+
if (!/^[\w-]{1,48}$/u.test(id) || !Array.isArray(raw?.ops)) return null;
|
|
2608
|
+
const ops = raw.ops.slice(0, BOARD_LIMITS.opsPerStep).map((op) => {
|
|
2609
|
+
const clean = normalizeOp(op, 0);
|
|
2610
|
+
if (!clean || typeof op.id !== "string" || !/^[\w-]{1,48}$/u.test(op.id)) return null;
|
|
2611
|
+
return {
|
|
2612
|
+
...clean,
|
|
2613
|
+
id: op.id,
|
|
2614
|
+
...Number.isInteger(op.at) && op.at >= 0 && op.at < 20 ? { at: op.at } : {}
|
|
2615
|
+
};
|
|
2616
|
+
}).filter(Boolean);
|
|
2617
|
+
const say = text(raw.say, BOARD_LIMITS.say);
|
|
2618
|
+
if (!say && !ops.length) return null;
|
|
2619
|
+
return {
|
|
2620
|
+
id,
|
|
2621
|
+
say,
|
|
2622
|
+
ops
|
|
2623
|
+
};
|
|
2624
|
+
}
|
|
2625
|
+
function stepJson(step, { full = false } = {}) {
|
|
2626
|
+
const ops = step.ops.map(({ fixed, ...op }) => {
|
|
2627
|
+
if (op.t === "svg") return full ? {
|
|
2628
|
+
...op,
|
|
2629
|
+
svg: op.svg.slice(0, 16e3)
|
|
2630
|
+
} : {
|
|
2631
|
+
...op,
|
|
2632
|
+
svg: "…"
|
|
2633
|
+
};
|
|
2634
|
+
if (op.t === "anim") return {
|
|
2635
|
+
...op,
|
|
2636
|
+
html: full ? op.html.slice(0, 8e3) : "…",
|
|
2637
|
+
poster: "…"
|
|
2638
|
+
};
|
|
2639
|
+
return op;
|
|
2640
|
+
});
|
|
2641
|
+
return JSON.stringify({
|
|
2642
|
+
id: step.id,
|
|
2643
|
+
say: step.say,
|
|
2644
|
+
ops,
|
|
2645
|
+
...step.check ? { check: step.check } : {}
|
|
2646
|
+
});
|
|
2647
|
+
}
|
|
2648
|
+
function boardForCheck(steps, stepId, { maxChars = 14e3 } = {}) {
|
|
2649
|
+
const lines = steps.map((step) => stepJson(step));
|
|
2650
|
+
const center = Math.max(0, steps.findIndex((step) => step.id === stepId));
|
|
2651
|
+
const kept = /* @__PURE__ */ new Set([center]);
|
|
2652
|
+
let used = lines[center]?.length ?? 0;
|
|
2653
|
+
for (let distance = 1; distance < steps.length; distance += 1) for (const index of [center - distance, center + distance]) {
|
|
2654
|
+
if (index < 0 || index >= steps.length || used + lines[index].length > maxChars) continue;
|
|
2655
|
+
kept.add(index);
|
|
2656
|
+
used += lines[index].length;
|
|
2657
|
+
}
|
|
2658
|
+
return lines.filter((line, index) => kept.has(index)).join("\n");
|
|
2659
|
+
}
|
|
2660
|
+
function describeFixes(fixes, steps) {
|
|
2661
|
+
const byId = new Map(steps.map((step) => [step.id, step]));
|
|
2662
|
+
return fixes.map((fix) => {
|
|
2663
|
+
const step = byId.get(fix.step);
|
|
2664
|
+
if (fix.replace) return `${fix.step} ${fix.op}: ${opPlain(step?.ops.find((op) => op.id === fix.op))} → ${opPlain(fix.replace)}`;
|
|
2665
|
+
if (fix.say !== void 0) return `${fix.step} narration: ${step?.say ?? ""} → ${fix.say}`;
|
|
2666
|
+
return `${fix.step} check answer: ${JSON.stringify(step?.check?.answer ?? "")} → ${JSON.stringify(fix.check?.answer ?? "")}`;
|
|
2667
|
+
}).join("\n");
|
|
2668
|
+
}
|
|
2669
|
+
//#endregion
|
|
2429
2670
|
//#region src/core/srs.js
|
|
2430
2671
|
const GRADES$1 = Object.freeze([
|
|
2431
2672
|
"again",
|
|
@@ -2457,7 +2698,7 @@ function review(card, grade, now = Date.now()) {
|
|
|
2457
2698
|
if (!GRADES$1.includes(grade)) throw new TypeError(`grade must be one of ${GRADES$1.join(", ")}`);
|
|
2458
2699
|
const quality = GRADE_QUALITY[grade];
|
|
2459
2700
|
const next = { ...card };
|
|
2460
|
-
next.ease = Math.max(MIN_EASE, round2(card.ease + .1 - (5 - quality) * (.08 + (5 - quality) * .02)));
|
|
2701
|
+
next.ease = Math.max(MIN_EASE, round2$1(card.ease + .1 - (5 - quality) * (.08 + (5 - quality) * .02)));
|
|
2461
2702
|
if (grade === "again") {
|
|
2462
2703
|
next.reps = 0;
|
|
2463
2704
|
next.lapses = card.lapses + 1;
|
|
@@ -2483,10 +2724,371 @@ function gradeFromScore(score, confidence) {
|
|
|
2483
2724
|
if (score >= .95 && confidence === "sure") return "easy";
|
|
2484
2725
|
return "good";
|
|
2485
2726
|
}
|
|
2727
|
+
function round2$1(value) {
|
|
2728
|
+
return Math.round(value * 100) / 100;
|
|
2729
|
+
}
|
|
2730
|
+
//#endregion
|
|
2731
|
+
//#region src/core/evidence-log.js
|
|
2732
|
+
const scored = (evidence) => evidence?.kind !== "reexplain" && evidence?.kind !== "self" && Number.isFinite(Number(evidence?.score));
|
|
2733
|
+
const withoutHistory = ({ history, ...state }) => state;
|
|
2734
|
+
function takeEvidence(state, card, evidence, at) {
|
|
2735
|
+
const next = applyEvidence(state, evidence, at);
|
|
2736
|
+
if (!scored(evidence)) return {
|
|
2737
|
+
state: next,
|
|
2738
|
+
card
|
|
2739
|
+
};
|
|
2740
|
+
return {
|
|
2741
|
+
state: next,
|
|
2742
|
+
card: review(card ?? createCard(state.id, at), gradeFromScore(Number(evidence.score), evidence.confidence), at)
|
|
2743
|
+
};
|
|
2744
|
+
}
|
|
2745
|
+
function stateBefore(state, card) {
|
|
2746
|
+
return {
|
|
2747
|
+
state: state ? withoutHistory(state) : null,
|
|
2748
|
+
card: card ?? null
|
|
2749
|
+
};
|
|
2750
|
+
}
|
|
2751
|
+
function logEvidence(progress, entry) {
|
|
2752
|
+
progress.evidenceLog = [...Array.isArray(progress.evidenceLog) ? progress.evidenceLog : [], entry].slice(-50);
|
|
2753
|
+
}
|
|
2754
|
+
function correctEvidence(progress, id, patch, { known = (conceptId) => conceptId, at = (/* @__PURE__ */ new Date()).toISOString() } = {}) {
|
|
2755
|
+
const log = Array.isArray(progress.evidenceLog) ? progress.evidenceLog : [];
|
|
2756
|
+
const index = log.findIndex((entry) => entry.id === id);
|
|
2757
|
+
if (index < 0) return null;
|
|
2758
|
+
const entry = log[index];
|
|
2759
|
+
const evidence = {
|
|
2760
|
+
...entry.evidence,
|
|
2761
|
+
...patch
|
|
2762
|
+
};
|
|
2763
|
+
if (!scored(entry.evidence) || !scored(evidence)) return null;
|
|
2764
|
+
const later = log.slice(index + 1);
|
|
2765
|
+
progress.concepts ??= {};
|
|
2766
|
+
progress.reviews ??= {};
|
|
2767
|
+
const changed = [];
|
|
2768
|
+
for (const conceptId of entry.conceptIds ?? []) {
|
|
2769
|
+
const name = known(conceptId);
|
|
2770
|
+
if (name === null || name === void 0) continue;
|
|
2771
|
+
const after = later.filter((item) => item.conceptIds?.includes(conceptId));
|
|
2772
|
+
const history = progress.concepts[conceptId]?.history ?? [];
|
|
2773
|
+
const base = entry.before?.[conceptId];
|
|
2774
|
+
let state = {
|
|
2775
|
+
...base?.state ?? createConceptState(conceptId, name),
|
|
2776
|
+
history: history.slice(0, Math.max(0, history.length - 1 - after.length))
|
|
2777
|
+
};
|
|
2778
|
+
let card = base?.card ?? null;
|
|
2779
|
+
({state, card} = takeEvidence(state, card, evidence, Date.parse(entry.at)));
|
|
2780
|
+
for (const item of after) {
|
|
2781
|
+
item.before = {
|
|
2782
|
+
...item.before,
|
|
2783
|
+
[conceptId]: stateBefore(state, card)
|
|
2784
|
+
};
|
|
2785
|
+
({state, card} = takeEvidence(state, card, item.evidence, Date.parse(item.at)));
|
|
2786
|
+
}
|
|
2787
|
+
progress.concepts[conceptId] = state;
|
|
2788
|
+
if (card) progress.reviews[conceptId] = card;
|
|
2789
|
+
changed.push(conceptId);
|
|
2790
|
+
}
|
|
2791
|
+
entry.corrected = {
|
|
2792
|
+
score: entry.corrected?.score ?? entry.evidence.score,
|
|
2793
|
+
at
|
|
2794
|
+
};
|
|
2795
|
+
entry.evidence = evidence;
|
|
2796
|
+
return { conceptIds: changed };
|
|
2797
|
+
}
|
|
2798
|
+
//#endregion
|
|
2799
|
+
//#region src/core/grading.js
|
|
2800
|
+
const OBJECTIVE_KINDS = Object.freeze([
|
|
2801
|
+
"choice",
|
|
2802
|
+
"multi",
|
|
2803
|
+
"blank",
|
|
2804
|
+
"number",
|
|
2805
|
+
"order"
|
|
2806
|
+
]);
|
|
2807
|
+
const FULLWIDTH = /[!-~]/gu;
|
|
2808
|
+
function normalizeAnswerText(value) {
|
|
2809
|
+
return String(value ?? "").normalize("NFKC").replace(FULLWIDTH, (char) => String.fromCharCode(char.charCodeAt(0) - 65248)).toLowerCase().replace(/[\s ]+/gu, " ").replace(/^[\s.,;:!?'"`,。;:!?、“”‘’]+|[\s.,;:!?'"`,。;:!?、“”‘’]+$/gu, "").trim();
|
|
2810
|
+
}
|
|
2811
|
+
function parseNumber(value) {
|
|
2812
|
+
if (typeof value === "number") return value;
|
|
2813
|
+
const text = normalizeAnswerText(value).replace(/,/gu, "").replace(/\s/gu, "");
|
|
2814
|
+
const fraction = /^(-?\d+(?:\.\d+)?)\/(\d+(?:\.\d+)?)$/u.exec(text);
|
|
2815
|
+
if (fraction) return Number(fraction[1]) / Number(fraction[2]);
|
|
2816
|
+
const percent = /^(-?\d+(?:\.\d+)?)%$/u.exec(text);
|
|
2817
|
+
if (percent) return Number(percent[1]) / 100;
|
|
2818
|
+
const number = Number(text);
|
|
2819
|
+
return Number.isFinite(number) ? number : NaN;
|
|
2820
|
+
}
|
|
2821
|
+
function gradeObjective(item, response) {
|
|
2822
|
+
if (!item || !OBJECTIVE_KINDS.includes(item.kind)) return {
|
|
2823
|
+
score: 0,
|
|
2824
|
+
correct: false,
|
|
2825
|
+
gradable: false
|
|
2826
|
+
};
|
|
2827
|
+
if (item.kind === "choice") {
|
|
2828
|
+
const correct = String(response ?? "") === String(item.answer ?? "");
|
|
2829
|
+
return {
|
|
2830
|
+
score: correct ? 1 : 0,
|
|
2831
|
+
correct,
|
|
2832
|
+
gradable: true
|
|
2833
|
+
};
|
|
2834
|
+
}
|
|
2835
|
+
if (item.kind === "multi") {
|
|
2836
|
+
const expected = new Set((Array.isArray(item.answer) ? item.answer : [item.answer]).map(String));
|
|
2837
|
+
const given = new Set((Array.isArray(response) ? response : []).map(String));
|
|
2838
|
+
if (!expected.size) return {
|
|
2839
|
+
score: 0,
|
|
2840
|
+
correct: false,
|
|
2841
|
+
gradable: false
|
|
2842
|
+
};
|
|
2843
|
+
let hits = 0;
|
|
2844
|
+
let wrong = 0;
|
|
2845
|
+
for (const id of given) if (expected.has(id)) hits += 1;
|
|
2846
|
+
else wrong += 1;
|
|
2847
|
+
const score = Math.max(0, (hits - wrong) / expected.size);
|
|
2848
|
+
const correct = hits === expected.size && wrong === 0;
|
|
2849
|
+
return {
|
|
2850
|
+
score: round2(score),
|
|
2851
|
+
correct,
|
|
2852
|
+
gradable: true
|
|
2853
|
+
};
|
|
2854
|
+
}
|
|
2855
|
+
if (item.kind === "order") {
|
|
2856
|
+
const expected = (Array.isArray(item.answer) ? item.answer : []).map(String);
|
|
2857
|
+
const given = (Array.isArray(response) ? response : []).map(String);
|
|
2858
|
+
if (!expected.length) return {
|
|
2859
|
+
score: 0,
|
|
2860
|
+
correct: false,
|
|
2861
|
+
gradable: false
|
|
2862
|
+
};
|
|
2863
|
+
let inPlace = 0;
|
|
2864
|
+
expected.forEach((id, index) => {
|
|
2865
|
+
if (given[index] === id) inPlace += 1;
|
|
2866
|
+
});
|
|
2867
|
+
return {
|
|
2868
|
+
score: round2(inPlace / expected.length),
|
|
2869
|
+
correct: inPlace === expected.length,
|
|
2870
|
+
gradable: true
|
|
2871
|
+
};
|
|
2872
|
+
}
|
|
2873
|
+
if (item.kind === "number") {
|
|
2874
|
+
const expected = parseNumber(item.answer);
|
|
2875
|
+
const given = parseNumber(response);
|
|
2876
|
+
if (!Number.isFinite(expected)) return {
|
|
2877
|
+
score: 0,
|
|
2878
|
+
correct: false,
|
|
2879
|
+
gradable: false
|
|
2880
|
+
};
|
|
2881
|
+
if (!Number.isFinite(given)) return {
|
|
2882
|
+
score: 0,
|
|
2883
|
+
correct: false,
|
|
2884
|
+
gradable: true
|
|
2885
|
+
};
|
|
2886
|
+
const tolerance = Number.isFinite(Number(item.tolerance)) && Number(item.tolerance) >= 0 ? Number(item.tolerance) : Math.max(1e-9, Math.abs(expected) * .01);
|
|
2887
|
+
const correct = Math.abs(given - expected) <= tolerance;
|
|
2888
|
+
return {
|
|
2889
|
+
score: correct ? 1 : 0,
|
|
2890
|
+
correct,
|
|
2891
|
+
gradable: true
|
|
2892
|
+
};
|
|
2893
|
+
}
|
|
2894
|
+
const accepted = (Array.isArray(item.answer) ? item.answer : [item.answer]).map(normalizeAnswerText).filter(Boolean);
|
|
2895
|
+
if (!accepted.length) return {
|
|
2896
|
+
score: 0,
|
|
2897
|
+
correct: false,
|
|
2898
|
+
gradable: false
|
|
2899
|
+
};
|
|
2900
|
+
const given = normalizeAnswerText(response);
|
|
2901
|
+
const correct = accepted.includes(given);
|
|
2902
|
+
if (correct) return {
|
|
2903
|
+
score: 1,
|
|
2904
|
+
correct,
|
|
2905
|
+
gradable: true
|
|
2906
|
+
};
|
|
2907
|
+
const asNumber = parseNumber(response);
|
|
2908
|
+
const numeric = accepted.map(parseNumber).filter(Number.isFinite);
|
|
2909
|
+
if (Number.isFinite(asNumber) && numeric.some((value) => Math.abs(value - asNumber) <= Math.max(1e-9, Math.abs(value) * .001))) return {
|
|
2910
|
+
score: 1,
|
|
2911
|
+
correct: true,
|
|
2912
|
+
gradable: true
|
|
2913
|
+
};
|
|
2914
|
+
return {
|
|
2915
|
+
score: 0,
|
|
2916
|
+
correct: false,
|
|
2917
|
+
gradable: true
|
|
2918
|
+
};
|
|
2919
|
+
}
|
|
2486
2920
|
function round2(value) {
|
|
2487
2921
|
return Math.round(value * 100) / 100;
|
|
2488
2922
|
}
|
|
2489
2923
|
//#endregion
|
|
2924
|
+
//#region src/core/item-fix.js
|
|
2925
|
+
const ITEM_VERDICTS = Object.freeze([
|
|
2926
|
+
"wrong",
|
|
2927
|
+
"unclear",
|
|
2928
|
+
"misgraded",
|
|
2929
|
+
"right"
|
|
2930
|
+
]);
|
|
2931
|
+
const MODEL_GRADED = Object.freeze(["short", "work"]);
|
|
2932
|
+
const FIELDS = [
|
|
2933
|
+
"prompt",
|
|
2934
|
+
"options",
|
|
2935
|
+
"answer",
|
|
2936
|
+
"tolerance",
|
|
2937
|
+
"unit",
|
|
2938
|
+
"explanation",
|
|
2939
|
+
"hints",
|
|
2940
|
+
"rubric",
|
|
2941
|
+
"figure"
|
|
2942
|
+
];
|
|
2943
|
+
const modelGraded = (item) => MODEL_GRADED.includes(item?.kind);
|
|
2944
|
+
function cleanResponse(item, response) {
|
|
2945
|
+
const ids = (item?.options ?? []).map((option) => option.id);
|
|
2946
|
+
if (item?.kind === "choice") return ids.includes(response) ? response : "";
|
|
2947
|
+
if (item?.kind === "multi" || item?.kind === "order") return [...new Set((Array.isArray(response) ? response : []).filter((id) => ids.includes(id)))];
|
|
2948
|
+
return typeof response === "string" || typeof response === "number" ? text(response, 4e3) : "";
|
|
2949
|
+
}
|
|
2950
|
+
function responseText(item, response) {
|
|
2951
|
+
const option = (id) => {
|
|
2952
|
+
const found = item.options?.find((entry) => entry.id === id);
|
|
2953
|
+
return found ? `${id.toUpperCase()}. ${found.text}` : String(id);
|
|
2954
|
+
};
|
|
2955
|
+
if (item.kind === "choice") return response ? option(response) : "";
|
|
2956
|
+
if (item.kind === "multi") return response.map(option).join("; ");
|
|
2957
|
+
if (item.kind === "order") return response.map(option).join(" → ");
|
|
2958
|
+
if (item.kind === "number" && item.unit && response) return `${response} ${item.unit}`;
|
|
2959
|
+
return String(response ?? "");
|
|
2960
|
+
}
|
|
2961
|
+
function itemForCheck(item) {
|
|
2962
|
+
const { id, conceptIds, difficulty, fixed, svg, plot, ...rest } = item;
|
|
2963
|
+
return JSON.stringify({
|
|
2964
|
+
...rest,
|
|
2965
|
+
...svg ? { svg: svg.slice(0, 8e3) } : {},
|
|
2966
|
+
...plot ? { plot } : {}
|
|
2967
|
+
});
|
|
2968
|
+
}
|
|
2969
|
+
function normalizeGrade(raw, kind) {
|
|
2970
|
+
if (!raw || typeof raw !== "object" || !Number.isFinite(Number(raw.score))) return null;
|
|
2971
|
+
const score = Math.min(1, Math.max(0, Number(raw.score)));
|
|
2972
|
+
return {
|
|
2973
|
+
score,
|
|
2974
|
+
verdict: [
|
|
2975
|
+
"correct",
|
|
2976
|
+
"partial",
|
|
2977
|
+
"incorrect"
|
|
2978
|
+
].includes(raw.verdict) ? raw.verdict : score >= .8 ? "correct" : score >= .4 ? "partial" : "incorrect",
|
|
2979
|
+
feedback: text(raw.feedback, 1200),
|
|
2980
|
+
errorTag: text(raw.errorTag, 80),
|
|
2981
|
+
...kind === "work" ? {
|
|
2982
|
+
wrongStep: text(raw.wrongStep, 400),
|
|
2983
|
+
why: text(raw.why, 400),
|
|
2984
|
+
fix: text(raw.fix, 400)
|
|
2985
|
+
} : {}
|
|
2986
|
+
};
|
|
2987
|
+
}
|
|
2988
|
+
const figureOf = (item) => item.svg ? { svg: item.svg } : item.plot ? { plot: item.plot } : {};
|
|
2989
|
+
function correctedItem(old, raw) {
|
|
2990
|
+
if (!raw || typeof raw !== "object" || raw.kind !== void 0 && raw.kind !== old.kind) return null;
|
|
2991
|
+
const next = normalizeItem({
|
|
2992
|
+
...raw,
|
|
2993
|
+
kind: old.kind
|
|
2994
|
+
}, 0);
|
|
2995
|
+
if (!next || (old.options?.length ?? 0) !== (next.options?.length ?? 0)) return null;
|
|
2996
|
+
const redrawn = [
|
|
2997
|
+
"svg",
|
|
2998
|
+
"figure",
|
|
2999
|
+
"plot"
|
|
3000
|
+
].some((key) => raw[key] !== void 0);
|
|
3001
|
+
const { svg, plot, ...rest } = next;
|
|
3002
|
+
return {
|
|
3003
|
+
...rest,
|
|
3004
|
+
...redrawn ? figureOf(next) : figureOf(old),
|
|
3005
|
+
id: old.id,
|
|
3006
|
+
conceptIds: old.conceptIds ?? [],
|
|
3007
|
+
difficulty: old.difficulty ?? next.difficulty
|
|
3008
|
+
};
|
|
3009
|
+
}
|
|
3010
|
+
const empty = (value) => value === void 0 || value === null || value === "" || Array.isArray(value) && !value.length;
|
|
3011
|
+
const same = (a, b) => empty(a) && empty(b) || JSON.stringify(a) === JSON.stringify(b);
|
|
3012
|
+
function changedParts(old, next) {
|
|
3013
|
+
const was = {};
|
|
3014
|
+
for (const field of FIELDS) if (field === "options") {
|
|
3015
|
+
const options = Object.fromEntries((old.options ?? []).filter((option, index) => option.text !== next.options?.[index]?.text).map((option) => [option.id, option.text]));
|
|
3016
|
+
if (Object.keys(options).length) was.options = options;
|
|
3017
|
+
} else if (field === "figure") {
|
|
3018
|
+
if (!same(figureOf(old), figureOf(next))) was.figure = true;
|
|
3019
|
+
} else if (!same(old[field], next[field])) was[field] = empty(old[field]) ? null : old[field];
|
|
3020
|
+
return was;
|
|
3021
|
+
}
|
|
3022
|
+
function normalizeItemVerdict(raw, item) {
|
|
3023
|
+
const verdict = ITEM_VERDICTS.includes(raw?.verdict) ? raw.verdict : null;
|
|
3024
|
+
if (!verdict) throw new Error("the check names no verdict");
|
|
3025
|
+
if (verdict === "misgraded" && !modelGraded(item)) throw new Error("a choice, multi, blank, number or order question is graded by its answer key: when the key misjudged the answer, the verdict is \"wrong\" with the key corrected in \"item\"");
|
|
3026
|
+
const summary = text(raw?.summary ?? raw?.reason, 400);
|
|
3027
|
+
const explanation = text(raw?.explanation, 2e3);
|
|
3028
|
+
const credit = verdict === "unclear" && raw?.credit === true;
|
|
3029
|
+
let fixed = null;
|
|
3030
|
+
if (verdict === "wrong" || verdict === "unclear") {
|
|
3031
|
+
fixed = correctedItem(item, raw?.item);
|
|
3032
|
+
if (!fixed) throw new Error(`a "${verdict}" verdict needs the whole corrected question in "item": the same kind, the same number of options`);
|
|
3033
|
+
if (!Object.keys(changedParts(item, fixed)).length) throw new Error("the corrected question in \"item\" changes nothing");
|
|
3034
|
+
}
|
|
3035
|
+
const grade = modelGraded(item) && verdict !== "right" && !credit ? normalizeGrade(raw?.grade, item.kind) : null;
|
|
3036
|
+
if (modelGraded(item) && verdict !== "right" && !credit && !grade) throw new Error("give the new \"grade\" of the learner's answer against the corrected question");
|
|
3037
|
+
return {
|
|
3038
|
+
verdict,
|
|
3039
|
+
summary,
|
|
3040
|
+
explanation,
|
|
3041
|
+
item: fixed,
|
|
3042
|
+
credit,
|
|
3043
|
+
grade
|
|
3044
|
+
};
|
|
3045
|
+
}
|
|
3046
|
+
function fixItem(stored, next, { at = (/* @__PURE__ */ new Date()).toISOString() } = {}) {
|
|
3047
|
+
const { fixed: note, ...old } = stored;
|
|
3048
|
+
const changed = changedParts(old, next);
|
|
3049
|
+
const was = {
|
|
3050
|
+
...changed,
|
|
3051
|
+
...note?.was
|
|
3052
|
+
};
|
|
3053
|
+
if (changed.options && note?.was?.options) was.options = {
|
|
3054
|
+
...changed.options,
|
|
3055
|
+
...note.was.options
|
|
3056
|
+
};
|
|
3057
|
+
return {
|
|
3058
|
+
...next,
|
|
3059
|
+
id: stored.id,
|
|
3060
|
+
conceptIds: stored.conceptIds ?? next.conceptIds ?? [],
|
|
3061
|
+
fixed: {
|
|
3062
|
+
was,
|
|
3063
|
+
at: note?.at ?? at
|
|
3064
|
+
}
|
|
3065
|
+
};
|
|
3066
|
+
}
|
|
3067
|
+
function regrade(item, response, checked) {
|
|
3068
|
+
if (checked.verdict === "right") return null;
|
|
3069
|
+
if (checked.credit) return {
|
|
3070
|
+
score: 1,
|
|
3071
|
+
correct: true,
|
|
3072
|
+
partial: false,
|
|
3073
|
+
errorTag: ""
|
|
3074
|
+
};
|
|
3075
|
+
if (OBJECTIVE_KINDS.includes(item.kind)) {
|
|
3076
|
+
const graded = gradeObjective(item, response);
|
|
3077
|
+
return {
|
|
3078
|
+
score: graded.score,
|
|
3079
|
+
correct: graded.correct,
|
|
3080
|
+
partial: !graded.correct && graded.score > 0
|
|
3081
|
+
};
|
|
3082
|
+
}
|
|
3083
|
+
if (!checked.grade) return null;
|
|
3084
|
+
const { verdict, ...grade } = checked.grade;
|
|
3085
|
+
return {
|
|
3086
|
+
...grade,
|
|
3087
|
+
correct: verdict === "correct",
|
|
3088
|
+
partial: verdict === "partial"
|
|
3089
|
+
};
|
|
3090
|
+
}
|
|
3091
|
+
//#endregion
|
|
2490
3092
|
//#region src/host/lessons.js
|
|
2491
3093
|
const EVENTS_KEPT = 200;
|
|
2492
3094
|
const STEP_ID = /^s\d{1,4}$/u;
|
|
@@ -2661,7 +3263,8 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
|
|
|
2661
3263
|
course,
|
|
2662
3264
|
lesson: row.lesson,
|
|
2663
3265
|
board,
|
|
2664
|
-
language: course.language
|
|
3266
|
+
language: course.language,
|
|
3267
|
+
writing
|
|
2665
3268
|
};
|
|
2666
3269
|
}
|
|
2667
3270
|
if (scope?.kind === "thread" || scope?.kind === "short") {
|
|
@@ -2678,7 +3281,8 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
|
|
|
2678
3281
|
objectives: []
|
|
2679
3282
|
},
|
|
2680
3283
|
board,
|
|
2681
|
-
language
|
|
3284
|
+
language,
|
|
3285
|
+
writing
|
|
2682
3286
|
};
|
|
2683
3287
|
}
|
|
2684
3288
|
throw new TutorError("invalid", "Unknown board");
|
|
@@ -2779,11 +3383,166 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
|
|
|
2779
3383
|
};
|
|
2780
3384
|
});
|
|
2781
3385
|
}
|
|
3386
|
+
async function challengedPlace(scope, board, target) {
|
|
3387
|
+
const stepId = String(target?.stepId ?? "");
|
|
3388
|
+
if (target?.asideId) {
|
|
3389
|
+
const aside = (scope.kind === "lesson" ? await store.readJson(store.paths.asides(scope.courseId, scope.lessonId), null) : null)?.items?.find((item) => item.id === target.asideId);
|
|
3390
|
+
const index = aside?.steps?.findIndex((item) => item.id === stepId) ?? -1;
|
|
3391
|
+
if (index < 0) throw new TutorError("not-found", "Not found");
|
|
3392
|
+
const anchor = board?.steps?.findIndex((step) => step.id === aside.anchorStepId) ?? -1;
|
|
3393
|
+
return {
|
|
3394
|
+
ref: { asideId: aside.id },
|
|
3395
|
+
steps: aside.steps,
|
|
3396
|
+
step: aside.steps[index],
|
|
3397
|
+
context: [...(board?.steps ?? []).slice(0, anchor + 1), ...aside.steps.slice(0, index + 1)]
|
|
3398
|
+
};
|
|
3399
|
+
}
|
|
3400
|
+
if (target?.step) {
|
|
3401
|
+
const step = sanitizeStep(target.step);
|
|
3402
|
+
if (!step) throw new TutorError("invalid", "Nothing to check");
|
|
3403
|
+
const anchor = board?.steps?.findIndex((item) => item.id === target.anchorStepId) ?? -1;
|
|
3404
|
+
return {
|
|
3405
|
+
ref: { local: true },
|
|
3406
|
+
steps: [step],
|
|
3407
|
+
step,
|
|
3408
|
+
context: [...(board?.steps ?? []).slice(0, anchor + 1), step]
|
|
3409
|
+
};
|
|
3410
|
+
}
|
|
3411
|
+
const index = board?.steps?.findIndex((step) => step.id === stepId) ?? -1;
|
|
3412
|
+
if (index < 0) throw new TutorError(board?.steps?.length ? "not-found" : "no-board", board?.steps?.length ? "Not found" : "The board is not written yet. Try again once its first step is up.");
|
|
3413
|
+
return {
|
|
3414
|
+
ref: { main: true },
|
|
3415
|
+
steps: board.steps,
|
|
3416
|
+
step: board.steps[index],
|
|
3417
|
+
context: board.steps.slice(0, index + 1)
|
|
3418
|
+
};
|
|
3419
|
+
}
|
|
3420
|
+
async function storeFixes(scope, place, fixes, writing) {
|
|
3421
|
+
let applied = [];
|
|
3422
|
+
const fix = (steps) => {
|
|
3423
|
+
const result = applyFixes(Array.isArray(steps) ? steps : [], fixes);
|
|
3424
|
+
applied = result.applied;
|
|
3425
|
+
return result.steps;
|
|
3426
|
+
};
|
|
3427
|
+
if (place.ref.local) {
|
|
3428
|
+
fix(place.steps);
|
|
3429
|
+
return applied;
|
|
3430
|
+
}
|
|
3431
|
+
if (place.ref.asideId) {
|
|
3432
|
+
await store.updateJson(store.paths.asides(scope.courseId, scope.lessonId), null, (value) => ({ items: (Array.isArray(value?.items) ? value.items : []).map((item) => item.id === place.ref.asideId ? {
|
|
3433
|
+
...item,
|
|
3434
|
+
steps: fix(item.steps)
|
|
3435
|
+
} : item) }));
|
|
3436
|
+
return applied;
|
|
3437
|
+
}
|
|
3438
|
+
if (writing && (await jobs.wait(writing.id))?.status !== "done") return applyFixes(place.steps, fixes).applied;
|
|
3439
|
+
const missing = () => new TutorError("not-found", "Not found");
|
|
3440
|
+
if (scope.kind === "lesson") await store.updateJson(store.paths.board(scope.courseId, scope.lessonId), null, (saved) => {
|
|
3441
|
+
if (!saved) throw missing();
|
|
3442
|
+
return {
|
|
3443
|
+
...saved,
|
|
3444
|
+
steps: fix(saved.steps)
|
|
3445
|
+
};
|
|
3446
|
+
});
|
|
3447
|
+
else if (scope.kind === "thread") await store.updateJson(store.paths.item("assist", scope.id), null, (thread) => {
|
|
3448
|
+
const turn = thread?.turns?.[Math.max(0, Math.min(thread.turns.length - 1, Number(scope.turn) || 0))];
|
|
3449
|
+
if (!turn?.board) throw missing();
|
|
3450
|
+
turn.board = {
|
|
3451
|
+
...turn.board,
|
|
3452
|
+
steps: fix(turn.board.steps)
|
|
3453
|
+
};
|
|
3454
|
+
return thread;
|
|
3455
|
+
});
|
|
3456
|
+
else await store.updateJson(store.paths.item("shorts", scope.id), null, (short) => {
|
|
3457
|
+
if (!short?.board) throw missing();
|
|
3458
|
+
return {
|
|
3459
|
+
...short,
|
|
3460
|
+
board: {
|
|
3461
|
+
...short.board,
|
|
3462
|
+
steps: fix(short.board.steps)
|
|
3463
|
+
}
|
|
3464
|
+
};
|
|
3465
|
+
});
|
|
3466
|
+
return applied;
|
|
3467
|
+
}
|
|
3468
|
+
function challenge({ scope, stepIndex = 0, anchorStepId, target = {}, note }) {
|
|
3469
|
+
const doubt = text(note, 800);
|
|
3470
|
+
return jobs.start("lesson/challenge", async (api) => {
|
|
3471
|
+
const { course, lesson, board, language, writing } = await scopeBoard(scope);
|
|
3472
|
+
const place = await challengedPlace(scope, board, {
|
|
3473
|
+
...target,
|
|
3474
|
+
anchorStepId
|
|
3475
|
+
});
|
|
3476
|
+
api.preview({ phase: "checking" });
|
|
3477
|
+
const checked = await generateJson(llm, {
|
|
3478
|
+
purpose: "teach",
|
|
3479
|
+
effort: "low",
|
|
3480
|
+
prompt: verifyStepPrompt({
|
|
3481
|
+
course,
|
|
3482
|
+
lesson,
|
|
3483
|
+
language,
|
|
3484
|
+
board: boardForCheck(place.steps, place.step.id),
|
|
3485
|
+
step: stepJson(place.step, { full: true }),
|
|
3486
|
+
stepId: place.step.id,
|
|
3487
|
+
note: doubt
|
|
3488
|
+
}),
|
|
3489
|
+
maxTokens: 6e3,
|
|
3490
|
+
signal: api.signal,
|
|
3491
|
+
normalize: (raw) => normalizeVerdict(raw, place.steps)
|
|
3492
|
+
});
|
|
3493
|
+
api.preview({
|
|
3494
|
+
phase: "answering",
|
|
3495
|
+
verdict: checked.verdict
|
|
3496
|
+
});
|
|
3497
|
+
const answer = await generateSteps(llm, {
|
|
3498
|
+
purpose: "teach",
|
|
3499
|
+
effort: "lowest",
|
|
3500
|
+
prompt: challengePrompt({
|
|
3501
|
+
course,
|
|
3502
|
+
lesson,
|
|
3503
|
+
language,
|
|
3504
|
+
boardSoFar: boardToPlainText({ steps: place.context }, { maxChars: 5e3 }),
|
|
3505
|
+
step: boardToPlainText({ steps: [place.step] }, { maxChars: 3e3 }),
|
|
3506
|
+
note: doubt,
|
|
3507
|
+
verdict: checked.verdict,
|
|
3508
|
+
summary: checked.summary,
|
|
3509
|
+
fixes: describeFixes(checked.fixes, place.steps)
|
|
3510
|
+
}),
|
|
3511
|
+
maxTokens: 5e3,
|
|
3512
|
+
signal: api.signal,
|
|
3513
|
+
conceptIds: lesson?.concepts?.map((concept) => concept.id) ?? [],
|
|
3514
|
+
onStep: (step) => api.push(step)
|
|
3515
|
+
});
|
|
3516
|
+
const fixes = checked.fixes.length ? await storeFixes(scope, place, checked.fixes, writing) : [];
|
|
3517
|
+
const aside = await keepAside(scope, {
|
|
3518
|
+
kind: "challenge",
|
|
3519
|
+
verdict: checked.verdict,
|
|
3520
|
+
note: doubt,
|
|
3521
|
+
summary: checked.summary,
|
|
3522
|
+
anchorStepId: anchorFor(board, stepIndex, anchorStepId),
|
|
3523
|
+
steps: answer.steps
|
|
3524
|
+
});
|
|
3525
|
+
if (scope.kind === "lesson") await logEvent(scope.courseId, {
|
|
3526
|
+
type: "challenge",
|
|
3527
|
+
lessonId: scope.lessonId,
|
|
3528
|
+
verdict: checked.verdict
|
|
3529
|
+
});
|
|
3530
|
+
return {
|
|
3531
|
+
verdict: checked.verdict,
|
|
3532
|
+
summary: checked.summary,
|
|
3533
|
+
target: place.ref,
|
|
3534
|
+
fixes,
|
|
3535
|
+
steps: answer.steps,
|
|
3536
|
+
aside
|
|
3537
|
+
};
|
|
3538
|
+
});
|
|
3539
|
+
}
|
|
2782
3540
|
async function record({ courseId, lessonId, conceptIds = [], evidence = {} }) {
|
|
2783
3541
|
const course = await courses.readCourse(courseId);
|
|
2784
3542
|
const index = conceptIndex(course);
|
|
2785
3543
|
const ids = (Array.isArray(conceptIds) && conceptIds.length ? conceptIds : findLesson(course, lessonId)?.lesson.concepts.map((concept) => concept.id) ?? []).filter((id) => index.has(id)).slice(0, 8);
|
|
2786
3544
|
const at = now().getTime();
|
|
3545
|
+
const evidenceId = newId("evd");
|
|
2787
3546
|
const next = await store.updateJson(store.paths.progress(courseId), null, (progress) => {
|
|
2788
3547
|
const value = progress ?? {
|
|
2789
3548
|
courseId,
|
|
@@ -2796,14 +3555,21 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
|
|
|
2796
3555
|
};
|
|
2797
3556
|
value.concepts ??= {};
|
|
2798
3557
|
value.reviews ??= {};
|
|
3558
|
+
const before = {};
|
|
2799
3559
|
for (const id of ids) {
|
|
2800
|
-
|
|
2801
|
-
value.concepts[id]
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
value.reviews[id] = review(card, gradeFromScore(Number(evidence.score), evidence.confidence), at);
|
|
2805
|
-
}
|
|
3560
|
+
before[id] = stateBefore(value.concepts[id], value.reviews[id]);
|
|
3561
|
+
const taken = takeEvidence(value.concepts[id] ?? createConceptState(id, index.get(id).name), value.reviews[id], evidence, at);
|
|
3562
|
+
value.concepts[id] = taken.state;
|
|
3563
|
+
if (taken.card) value.reviews[id] = taken.card;
|
|
2806
3564
|
}
|
|
3565
|
+
if (ids.length) logEvidence(value, {
|
|
3566
|
+
id: evidenceId,
|
|
3567
|
+
at: new Date(at).toISOString(),
|
|
3568
|
+
lessonId: lessonId ?? null,
|
|
3569
|
+
conceptIds: ids,
|
|
3570
|
+
evidence: JSON.parse(JSON.stringify(evidence)),
|
|
3571
|
+
before
|
|
3572
|
+
});
|
|
2807
3573
|
pushEvent(value, {
|
|
2808
3574
|
type: "evidence",
|
|
2809
3575
|
lessonId: lessonId ?? null,
|
|
@@ -2816,11 +3582,177 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
|
|
|
2816
3582
|
return value;
|
|
2817
3583
|
});
|
|
2818
3584
|
const states = ids.map((id) => next.concepts[id]).filter(Boolean);
|
|
3585
|
+
return {
|
|
3586
|
+
mastery: Object.fromEntries(states.map((state) => [state.id, masteryPercent(state)])),
|
|
3587
|
+
recommendation: recommendNext(states),
|
|
3588
|
+
evidenceId: ids.length ? evidenceId : null
|
|
3589
|
+
};
|
|
3590
|
+
}
|
|
3591
|
+
async function regradeEvidence(course, evidenceId, patch) {
|
|
3592
|
+
const index = conceptIndex(course);
|
|
3593
|
+
let changed = null;
|
|
3594
|
+
const next = await store.updateJson(store.paths.progress(course.id), null, (progress) => {
|
|
3595
|
+
const value = progress ?? {
|
|
3596
|
+
courseId: course.id,
|
|
3597
|
+
lessons: {},
|
|
3598
|
+
concepts: {},
|
|
3599
|
+
reviews: {},
|
|
3600
|
+
events: []
|
|
3601
|
+
};
|
|
3602
|
+
changed = correctEvidence(value, evidenceId, patch, {
|
|
3603
|
+
known: (id) => index.get(id)?.name ?? null,
|
|
3604
|
+
at: stamp()
|
|
3605
|
+
});
|
|
3606
|
+
if (changed) pushEvent(value, {
|
|
3607
|
+
type: "regrade",
|
|
3608
|
+
evidenceId,
|
|
3609
|
+
conceptIds: changed.conceptIds,
|
|
3610
|
+
score: patch.score,
|
|
3611
|
+
at: stamp()
|
|
3612
|
+
});
|
|
3613
|
+
return value;
|
|
3614
|
+
});
|
|
3615
|
+
if (!changed?.conceptIds.length) return null;
|
|
3616
|
+
const states = changed.conceptIds.map((id) => next.concepts[id]).filter(Boolean);
|
|
2819
3617
|
return {
|
|
2820
3618
|
mastery: Object.fromEntries(states.map((state) => [state.id, masteryPercent(state)])),
|
|
2821
3619
|
recommendation: recommendNext(states)
|
|
2822
3620
|
};
|
|
2823
3621
|
}
|
|
3622
|
+
async function itemPlace(source) {
|
|
3623
|
+
const missing = () => new TutorError("not-found", "Not found");
|
|
3624
|
+
const rewrite = (path, listOf) => async (change) => {
|
|
3625
|
+
let saved = null;
|
|
3626
|
+
await store.updateJson(path, null, (value) => {
|
|
3627
|
+
const items = listOf(value);
|
|
3628
|
+
const index = Array.isArray(items) ? items.findIndex((entry) => entry.id === source.itemId) : -1;
|
|
3629
|
+
if (index < 0) throw missing();
|
|
3630
|
+
saved = change(items[index]);
|
|
3631
|
+
items[index] = saved;
|
|
3632
|
+
return value;
|
|
3633
|
+
});
|
|
3634
|
+
return saved;
|
|
3635
|
+
};
|
|
3636
|
+
if (source.kind === "deck") {
|
|
3637
|
+
const path = store.paths.item("decks", source.deckId);
|
|
3638
|
+
const deck = await store.readJson(path, null);
|
|
3639
|
+
const item = deck?.kind === "quiz" ? deck.items?.find((entry) => entry.id === source.itemId) : null;
|
|
3640
|
+
if (!item) throw missing();
|
|
3641
|
+
return {
|
|
3642
|
+
item,
|
|
3643
|
+
course: null,
|
|
3644
|
+
lessonId: null,
|
|
3645
|
+
language: deck.language,
|
|
3646
|
+
scope: deck.title,
|
|
3647
|
+
save: rewrite(path, (value) => value?.kind === "quiz" ? value.items : null)
|
|
3648
|
+
};
|
|
3649
|
+
}
|
|
3650
|
+
const course = await courses.readCourse(source.courseId);
|
|
3651
|
+
if (source.kind === "quiz") {
|
|
3652
|
+
const unit = course.units?.find((entry) => entry.id === source.unitId);
|
|
3653
|
+
const path = unit ? store.paths.unitQuiz(course.id, unit.id) : null;
|
|
3654
|
+
const item = path ? (await store.readJson(path, null))?.items?.find((entry) => entry.id === source.itemId) : null;
|
|
3655
|
+
if (!item) throw missing();
|
|
3656
|
+
return {
|
|
3657
|
+
item,
|
|
3658
|
+
course,
|
|
3659
|
+
lessonId: null,
|
|
3660
|
+
language: course.language,
|
|
3661
|
+
scope: `${course.title} — ${unit.title}`,
|
|
3662
|
+
save: rewrite(path, (value) => value?.items)
|
|
3663
|
+
};
|
|
3664
|
+
}
|
|
3665
|
+
const row = findLesson(course, source.lessonId);
|
|
3666
|
+
if (!row) throw missing();
|
|
3667
|
+
const scope = `${course.title} — ${row.lesson.title}`;
|
|
3668
|
+
if (source.kind === "check") {
|
|
3669
|
+
const path = store.paths.board(course.id, row.lesson.id);
|
|
3670
|
+
const item = (await store.readJson(path, null))?.steps?.find((step) => step.id === source.stepId)?.check;
|
|
3671
|
+
if (!item) throw missing();
|
|
3672
|
+
const save = async (change) => {
|
|
3673
|
+
let saved = null;
|
|
3674
|
+
await store.updateJson(path, null, (board) => {
|
|
3675
|
+
const step = board?.steps?.find((entry) => entry.id === source.stepId);
|
|
3676
|
+
if (!step?.check) throw missing();
|
|
3677
|
+
saved = change(step.check);
|
|
3678
|
+
step.check = saved;
|
|
3679
|
+
return board;
|
|
3680
|
+
});
|
|
3681
|
+
return saved;
|
|
3682
|
+
};
|
|
3683
|
+
return {
|
|
3684
|
+
item,
|
|
3685
|
+
course,
|
|
3686
|
+
lessonId: row.lesson.id,
|
|
3687
|
+
language: course.language,
|
|
3688
|
+
scope,
|
|
3689
|
+
save
|
|
3690
|
+
};
|
|
3691
|
+
}
|
|
3692
|
+
const path = store.paths.practice(course.id, row.lesson.id);
|
|
3693
|
+
const item = (await store.readJson(path, null))?.items?.find((entry) => entry.id === source.itemId);
|
|
3694
|
+
if (!item) throw missing();
|
|
3695
|
+
return {
|
|
3696
|
+
item,
|
|
3697
|
+
course,
|
|
3698
|
+
lessonId: row.lesson.id,
|
|
3699
|
+
language: course.language,
|
|
3700
|
+
scope,
|
|
3701
|
+
save: rewrite(path, (value) => value?.items)
|
|
3702
|
+
};
|
|
3703
|
+
}
|
|
3704
|
+
function challengeItem({ source, response, shown = {}, evidenceId, note }) {
|
|
3705
|
+
const doubt = text(note, 800);
|
|
3706
|
+
return jobs.start("item/challenge", async (api) => {
|
|
3707
|
+
const place = await itemPlace(source);
|
|
3708
|
+
const answer = cleanResponse(place.item, response);
|
|
3709
|
+
const told = place.item.kind === "short" || place.item.kind === "work" ? [
|
|
3710
|
+
`A grader gave this answer a score of ${shown.score} (${shown.correct ? "correct" : shown.partial ? "partial" : "incorrect"}).`,
|
|
3711
|
+
shown.feedback ? `Feedback shown: ${shown.feedback}` : "",
|
|
3712
|
+
shown.wrongStep ? `First wrong line found: ${shown.wrongStep}` : "",
|
|
3713
|
+
shown.fix ? `Fix shown: ${shown.fix}` : ""
|
|
3714
|
+
].filter(Boolean).join("\n") : `The answer key marked this answer ${shown.correct ? "correct" : shown.partial ? "partly correct" : "incorrect"}.`;
|
|
3715
|
+
api.preview({ phase: "checking" });
|
|
3716
|
+
const checked = await generateJson(llm, {
|
|
3717
|
+
purpose: "teach",
|
|
3718
|
+
effort: "low",
|
|
3719
|
+
prompt: verifyItemPrompt({
|
|
3720
|
+
scope: place.scope,
|
|
3721
|
+
language: place.language,
|
|
3722
|
+
item: itemForCheck(place.item),
|
|
3723
|
+
kind: place.item.kind,
|
|
3724
|
+
response: responseText(place.item, answer),
|
|
3725
|
+
grading: told,
|
|
3726
|
+
note: doubt
|
|
3727
|
+
}),
|
|
3728
|
+
maxTokens: 5e3,
|
|
3729
|
+
signal: api.signal,
|
|
3730
|
+
normalize: (raw) => normalizeItemVerdict(raw, place.item)
|
|
3731
|
+
});
|
|
3732
|
+
const item = checked.item ? await place.save((stored) => fixItem(stored, checked.item, { at: stamp() })) : place.item;
|
|
3733
|
+
const result = regrade(item, answer, checked);
|
|
3734
|
+
const changed = Boolean(result) && (result.correct !== Boolean(shown.correct) || Math.abs(result.score - (Number(shown.score) || 0)) > 1e-6);
|
|
3735
|
+
const corrected = changed && evidenceId && place.course ? await regradeEvidence(place.course, evidenceId, {
|
|
3736
|
+
score: result.score,
|
|
3737
|
+
...result.correct ? { errorTag: "" } : result.errorTag ? { errorTag: result.errorTag } : {}
|
|
3738
|
+
}) : null;
|
|
3739
|
+
if (place.course) await logEvent(place.course.id, {
|
|
3740
|
+
type: "item-challenge",
|
|
3741
|
+
lessonId: place.lessonId,
|
|
3742
|
+
verdict: checked.verdict,
|
|
3743
|
+
changed
|
|
3744
|
+
});
|
|
3745
|
+
return {
|
|
3746
|
+
verdict: checked.verdict,
|
|
3747
|
+
summary: checked.summary,
|
|
3748
|
+
explanation: checked.explanation,
|
|
3749
|
+
item: checked.item ? item : null,
|
|
3750
|
+
result: changed ? result : null,
|
|
3751
|
+
mastery: corrected?.mastery ?? null,
|
|
3752
|
+
recommendation: corrected?.recommendation ?? null
|
|
3753
|
+
};
|
|
3754
|
+
});
|
|
3755
|
+
}
|
|
2824
3756
|
async function grade({ item, response, language = "zh-CN" }) {
|
|
2825
3757
|
if (!item?.prompt) throw new TutorError("invalid", "Missing question");
|
|
2826
3758
|
const work = item.kind === "work";
|
|
@@ -3183,6 +4115,8 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
|
|
|
3183
4115
|
prepare,
|
|
3184
4116
|
ask,
|
|
3185
4117
|
reexplain,
|
|
4118
|
+
challenge,
|
|
4119
|
+
challengeItem,
|
|
3186
4120
|
record,
|
|
3187
4121
|
grade,
|
|
3188
4122
|
correct,
|
|
@@ -4251,7 +5185,9 @@ const IDS = {
|
|
|
4251
5185
|
chs: ID("chs"),
|
|
4252
5186
|
dck: ID("dck"),
|
|
4253
5187
|
exm: ID("exm"),
|
|
4254
|
-
nte: ID("nte")
|
|
5188
|
+
nte: ID("nte"),
|
|
5189
|
+
asd: ID("asd"),
|
|
5190
|
+
evd: ID("evd")
|
|
4255
5191
|
};
|
|
4256
5192
|
function need(payload, key, prefix) {
|
|
4257
5193
|
const value = payload?.[key];
|
|
@@ -4287,6 +5223,66 @@ function scopeOf(payload) {
|
|
|
4287
5223
|
};
|
|
4288
5224
|
throw new TutorError("invalid", "Invalid board scope");
|
|
4289
5225
|
}
|
|
5226
|
+
function challengeTarget(payload) {
|
|
5227
|
+
const target = payload?.target && typeof payload.target === "object" ? payload.target : {};
|
|
5228
|
+
if (target.step && typeof target.step === "object") return { step: target.step };
|
|
5229
|
+
if (typeof target.stepId !== "string" || !/^s\d{1,4}$/u.test(target.stepId)) throw new TutorError("invalid", "Invalid step");
|
|
5230
|
+
return {
|
|
5231
|
+
stepId: target.stepId,
|
|
5232
|
+
...target.asideId === void 0 ? {} : { asideId: need(target, "asideId", "asd") }
|
|
5233
|
+
};
|
|
5234
|
+
}
|
|
5235
|
+
function itemSource(payload) {
|
|
5236
|
+
const source = payload?.source && typeof payload.source === "object" ? payload.source : {};
|
|
5237
|
+
const itemId = () => {
|
|
5238
|
+
if (typeof source.itemId !== "string" || !/^[a-z0-9_-]{1,24}$/iu.test(source.itemId)) throw new TutorError("invalid", "Invalid question");
|
|
5239
|
+
return source.itemId;
|
|
5240
|
+
};
|
|
5241
|
+
if (source.kind === "practice") return {
|
|
5242
|
+
kind: "practice",
|
|
5243
|
+
courseId: need(source, "courseId", "crs"),
|
|
5244
|
+
lessonId: need(source, "lessonId", "les"),
|
|
5245
|
+
itemId: itemId()
|
|
5246
|
+
};
|
|
5247
|
+
if (source.kind === "quiz") return {
|
|
5248
|
+
kind: "quiz",
|
|
5249
|
+
courseId: need(source, "courseId", "crs"),
|
|
5250
|
+
unitId: need(source, "unitId", "unt"),
|
|
5251
|
+
itemId: itemId()
|
|
5252
|
+
};
|
|
5253
|
+
if (source.kind === "deck") return {
|
|
5254
|
+
kind: "deck",
|
|
5255
|
+
deckId: need(source, "deckId", "dck"),
|
|
5256
|
+
itemId: itemId()
|
|
5257
|
+
};
|
|
5258
|
+
if (source.kind === "check") {
|
|
5259
|
+
if (typeof source.stepId !== "string" || !/^s\d{1,4}$/u.test(source.stepId)) throw new TutorError("invalid", "Invalid step");
|
|
5260
|
+
return {
|
|
5261
|
+
kind: "check",
|
|
5262
|
+
courseId: need(source, "courseId", "crs"),
|
|
5263
|
+
lessonId: need(source, "lessonId", "les"),
|
|
5264
|
+
stepId: source.stepId
|
|
5265
|
+
};
|
|
5266
|
+
}
|
|
5267
|
+
throw new TutorError("invalid", "Invalid question");
|
|
5268
|
+
}
|
|
5269
|
+
function answerOf(value) {
|
|
5270
|
+
if (typeof value === "string") return value.slice(0, 4e3);
|
|
5271
|
+
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
5272
|
+
return Array.isArray(value) ? value.filter((id) => typeof id === "string" && /^[a-z]$/u.test(id)).slice(0, 12) : "";
|
|
5273
|
+
}
|
|
5274
|
+
function shownGrade(value) {
|
|
5275
|
+
const grade = value && typeof value === "object" ? value : {};
|
|
5276
|
+
const words = (key, max) => typeof grade[key] === "string" ? grade[key].slice(0, max) : "";
|
|
5277
|
+
return {
|
|
5278
|
+
score: Math.min(1, Math.max(0, Number(grade.score) || 0)),
|
|
5279
|
+
correct: grade.correct === true,
|
|
5280
|
+
partial: grade.partial === true,
|
|
5281
|
+
feedback: words("feedback", 1200),
|
|
5282
|
+
wrongStep: words("wrongStep", 400),
|
|
5283
|
+
fix: words("fix", 400)
|
|
5284
|
+
};
|
|
5285
|
+
}
|
|
4290
5286
|
const ITEM_KINDS = {
|
|
4291
5287
|
sheets: "chs",
|
|
4292
5288
|
decks: "dck",
|
|
@@ -4396,6 +5392,13 @@ function createRoutes({ courses, lessons, learner, studio, projects, jobs, setti
|
|
|
4396
5392
|
style: STYLES.includes(payload.style) ? payload.style : "example",
|
|
4397
5393
|
anchorStepId: payload.anchorStepId
|
|
4398
5394
|
}) }),
|
|
5395
|
+
"lesson/challenge": (payload) => ({ job: lessons.challenge({
|
|
5396
|
+
scope: scopeOf(payload),
|
|
5397
|
+
stepIndex: payload.stepIndex,
|
|
5398
|
+
anchorStepId: payload.anchorStepId,
|
|
5399
|
+
target: challengeTarget(payload),
|
|
5400
|
+
note: typeof payload.note === "string" ? payload.note : ""
|
|
5401
|
+
}) }),
|
|
4399
5402
|
"lesson/record": (payload) => lessons.record({
|
|
4400
5403
|
courseId: need(payload, "courseId", "crs"),
|
|
4401
5404
|
lessonId: optional(payload, "lessonId", "les"),
|
|
@@ -4416,6 +5419,13 @@ function createRoutes({ courses, lessons, learner, studio, projects, jobs, setti
|
|
|
4416
5419
|
response: payload.response,
|
|
4417
5420
|
language: typeof payload.language === "string" ? payload.language.slice(0, 12) : void 0
|
|
4418
5421
|
}),
|
|
5422
|
+
"item/challenge": (payload) => ({ job: lessons.challengeItem({
|
|
5423
|
+
source: itemSource(payload),
|
|
5424
|
+
response: answerOf(payload.response),
|
|
5425
|
+
shown: shownGrade(payload.shown),
|
|
5426
|
+
evidenceId: optional(payload, "evidenceId", "evd"),
|
|
5427
|
+
note: typeof payload.note === "string" ? payload.note : ""
|
|
5428
|
+
}) }),
|
|
4419
5429
|
"lesson/correct": (payload) => ({ job: lessons.correct({
|
|
4420
5430
|
scope: scopeOf(payload),
|
|
4421
5431
|
stepIndex: payload.stepIndex,
|
|
@@ -4686,8 +5696,12 @@ const TTS_PROVIDERS = Object.freeze([
|
|
|
4686
5696
|
"minimax",
|
|
4687
5697
|
"azure"
|
|
4688
5698
|
]);
|
|
4689
|
-
const
|
|
5699
|
+
const TTS_LIMITS = Object.freeze({
|
|
5700
|
+
totalMs: 45e3,
|
|
5701
|
+
quietMs: 1e4
|
|
5702
|
+
});
|
|
4690
5703
|
const MAX_AUDIO_BYTES = 12 * 1024 * 1024;
|
|
5704
|
+
const MAX_JSON_BYTES = 3 * MAX_AUDIO_BYTES;
|
|
4691
5705
|
const CACHE_MAX_BYTES = 300 * 1024 * 1024;
|
|
4692
5706
|
var TtsError = class extends Error {
|
|
4693
5707
|
constructor(code, message, status) {
|
|
@@ -4826,28 +5840,75 @@ function splitForTts(content, maxUnits) {
|
|
|
4826
5840
|
if (current) chunks.push(current);
|
|
4827
5841
|
return chunks;
|
|
4828
5842
|
}
|
|
4829
|
-
|
|
4830
|
-
|
|
4831
|
-
|
|
4832
|
-
|
|
5843
|
+
function firstByteMs(text, limits = TTS_LIMITS) {
|
|
5844
|
+
return Math.min(limits.totalMs, limits.quietMs + Math.round(estimateSpeechMs(text) / 10));
|
|
5845
|
+
}
|
|
5846
|
+
async function boundedFetch(fetchImpl, url, init, { text, signal, limits, maxBytes }) {
|
|
5847
|
+
const limit = new AbortController();
|
|
5848
|
+
let reason = null;
|
|
5849
|
+
const stop = (code) => {
|
|
5850
|
+
reason ??= code;
|
|
5851
|
+
limit.abort();
|
|
5852
|
+
};
|
|
5853
|
+
const total = setTimeout(() => stop("timeout"), limits.totalMs);
|
|
5854
|
+
let quiet = setTimeout(() => stop("stalled"), firstByteMs(text, limits));
|
|
5855
|
+
const combined = signal ? AbortSignal.any([signal, limit.signal]) : limit.signal;
|
|
5856
|
+
const failure = (error) => {
|
|
5857
|
+
if (signal?.aborted) return signal.reason ?? error;
|
|
5858
|
+
if (reason === "stalled") return new TtsError("stalled", "The TTS service is not responding");
|
|
5859
|
+
if (reason === "timeout") return new TtsError("timeout", "TTS request timed out");
|
|
5860
|
+
return null;
|
|
5861
|
+
};
|
|
4833
5862
|
try {
|
|
4834
|
-
response
|
|
4835
|
-
|
|
4836
|
-
|
|
4837
|
-
|
|
4838
|
-
|
|
4839
|
-
|
|
4840
|
-
|
|
4841
|
-
|
|
5863
|
+
let response;
|
|
5864
|
+
try {
|
|
5865
|
+
response = await fetchImpl(url, {
|
|
5866
|
+
...init,
|
|
5867
|
+
redirect: "error",
|
|
5868
|
+
signal: combined
|
|
5869
|
+
});
|
|
5870
|
+
} catch (error) {
|
|
5871
|
+
throw failure(error) ?? new TtsError("network", "Could not reach the TTS service");
|
|
5872
|
+
}
|
|
5873
|
+
if (!response.ok) {
|
|
5874
|
+
response.body?.cancel().catch(() => void 0);
|
|
5875
|
+
throw httpError(response.status);
|
|
5876
|
+
}
|
|
5877
|
+
const length = Number(response.headers.get("content-length"));
|
|
5878
|
+
if (Number.isFinite(length) && length > maxBytes) throw new TtsError("too-large", "TTS response is too large");
|
|
5879
|
+
const parts = [];
|
|
5880
|
+
let size = 0;
|
|
5881
|
+
const reader = response.body?.getReader();
|
|
5882
|
+
while (reader) {
|
|
5883
|
+
let chunk;
|
|
5884
|
+
try {
|
|
5885
|
+
chunk = await reader.read();
|
|
5886
|
+
} catch (error) {
|
|
5887
|
+
throw failure(error) ?? error;
|
|
5888
|
+
}
|
|
5889
|
+
if (chunk.done) break;
|
|
5890
|
+
if (!chunk.value?.byteLength) continue;
|
|
5891
|
+
clearTimeout(quiet);
|
|
5892
|
+
quiet = setTimeout(() => stop("stalled"), limits.quietMs);
|
|
5893
|
+
size += chunk.value.byteLength;
|
|
5894
|
+
if (size > maxBytes) {
|
|
5895
|
+
reader.cancel().catch(() => void 0);
|
|
5896
|
+
throw new TtsError("too-large", "TTS response is too large");
|
|
5897
|
+
}
|
|
5898
|
+
parts.push(chunk.value);
|
|
5899
|
+
}
|
|
5900
|
+
return Buffer.concat(parts);
|
|
5901
|
+
} finally {
|
|
5902
|
+
clearTimeout(total);
|
|
5903
|
+
clearTimeout(quiet);
|
|
4842
5904
|
}
|
|
4843
|
-
return response;
|
|
4844
5905
|
}
|
|
4845
|
-
|
|
4846
|
-
|
|
4847
|
-
|
|
4848
|
-
|
|
4849
|
-
|
|
4850
|
-
|
|
5906
|
+
function parseJson(body, service) {
|
|
5907
|
+
try {
|
|
5908
|
+
return JSON.parse(body.toString("utf8"));
|
|
5909
|
+
} catch {
|
|
5910
|
+
throw new TtsError("response", `${service} returned an unreadable response`);
|
|
5911
|
+
}
|
|
4851
5912
|
}
|
|
4852
5913
|
function httpError(status) {
|
|
4853
5914
|
if (status === 401 || status === 403) return new TtsError("auth", "The TTS service rejected the key", status);
|
|
@@ -4869,10 +5930,10 @@ function xmlEscape(value) {
|
|
|
4869
5930
|
})[char]);
|
|
4870
5931
|
}
|
|
4871
5932
|
const synthesizers = {
|
|
4872
|
-
async openai({ text, config, secret, rate,
|
|
5933
|
+
async openai({ text, config, secret, rate, send }) {
|
|
4873
5934
|
const base = checkEndpoint(config.baseUrl || "https://api.openai.com/v1");
|
|
4874
5935
|
const path = base.pathname.replace(/\/+$/u, "");
|
|
4875
|
-
|
|
5936
|
+
return send(new URL(`${path}/audio/speech`, base), {
|
|
4876
5937
|
method: "POST",
|
|
4877
5938
|
headers: {
|
|
4878
5939
|
Authorization: `Bearer ${secret}`,
|
|
@@ -4886,13 +5947,11 @@ const synthesizers = {
|
|
|
4886
5947
|
response_format: "mp3",
|
|
4887
5948
|
speed: rateNumber(rate)
|
|
4888
5949
|
})
|
|
4889
|
-
},
|
|
4890
|
-
if (!response.ok) throw httpError(response.status);
|
|
4891
|
-
return readBounded(response);
|
|
5950
|
+
}, MAX_AUDIO_BYTES);
|
|
4892
5951
|
},
|
|
4893
|
-
async volcengine({ text, config, secret, rate,
|
|
5952
|
+
async volcengine({ text, config, secret, rate, send }) {
|
|
4894
5953
|
if (!config.appId) throw new TtsError("app-id", "Volcengine App ID is required");
|
|
4895
|
-
const
|
|
5954
|
+
const body = parseJson(await send("https://openspeech.bytedance.com/api/v1/tts", {
|
|
4896
5955
|
method: "POST",
|
|
4897
5956
|
headers: {
|
|
4898
5957
|
Authorization: `Bearer;${secret}`,
|
|
@@ -4916,24 +5975,17 @@ const synthesizers = {
|
|
|
4916
5975
|
operation: "query"
|
|
4917
5976
|
}
|
|
4918
5977
|
})
|
|
4919
|
-
},
|
|
4920
|
-
if (!response.ok) throw httpError(response.status);
|
|
4921
|
-
let body;
|
|
4922
|
-
try {
|
|
4923
|
-
body = await response.json();
|
|
4924
|
-
} catch {
|
|
4925
|
-
throw new TtsError("response", "Volcengine returned an unreadable response");
|
|
4926
|
-
}
|
|
5978
|
+
}, MAX_JSON_BYTES), "Volcengine");
|
|
4927
5979
|
if (body?.code !== 3e3 || typeof body?.data !== "string") throw new TtsError(body?.code === 3001 || body?.code === 3003 ? "auth" : "request", `Volcengine TTS failed (${body?.code ?? "unknown"})`);
|
|
4928
5980
|
const audio = Buffer.from(body.data, "base64");
|
|
4929
5981
|
if (audio.length > MAX_AUDIO_BYTES) throw new TtsError("too-large", "TTS response is too large");
|
|
4930
5982
|
return audio;
|
|
4931
5983
|
},
|
|
4932
|
-
async minimax({ text, config, secret, rate,
|
|
5984
|
+
async minimax({ text, config, secret, rate, send }) {
|
|
4933
5985
|
const base = checkEndpoint(config.baseUrl || "https://api.minimaxi.com");
|
|
4934
5986
|
const url = new URL("/v1/t2a_v2", base);
|
|
4935
5987
|
if (config.groupId) url.searchParams.set("GroupId", config.groupId);
|
|
4936
|
-
const
|
|
5988
|
+
const body = parseJson(await send(url, {
|
|
4937
5989
|
method: "POST",
|
|
4938
5990
|
headers: {
|
|
4939
5991
|
Authorization: `Bearer ${secret}`,
|
|
@@ -4956,21 +6008,14 @@ const synthesizers = {
|
|
|
4956
6008
|
channel: 1
|
|
4957
6009
|
}
|
|
4958
6010
|
})
|
|
4959
|
-
},
|
|
4960
|
-
if (!response.ok) throw httpError(response.status);
|
|
4961
|
-
let body;
|
|
4962
|
-
try {
|
|
4963
|
-
body = await response.json();
|
|
4964
|
-
} catch {
|
|
4965
|
-
throw new TtsError("response", "MiniMax returned an unreadable response");
|
|
4966
|
-
}
|
|
6011
|
+
}, MAX_JSON_BYTES), "MiniMax");
|
|
4967
6012
|
const status = body?.base_resp?.status_code;
|
|
4968
6013
|
if (status !== 0 || typeof body?.data?.audio !== "string") throw new TtsError(status === 1004 || status === 2049 ? "auth" : "request", `MiniMax TTS failed (${status ?? "unknown"})`);
|
|
4969
6014
|
const audio = Buffer.from(body.data.audio, "hex");
|
|
4970
6015
|
if (audio.length > MAX_AUDIO_BYTES) throw new TtsError("too-large", "TTS response is too large");
|
|
4971
6016
|
return audio;
|
|
4972
6017
|
},
|
|
4973
|
-
async azure({ text, config, secret, rate,
|
|
6018
|
+
async azure({ text, config, secret, rate, send }) {
|
|
4974
6019
|
const region = String(config.region || "").trim().toLowerCase();
|
|
4975
6020
|
if (!/^[a-z0-9]{2,30}$/u.test(region)) throw new TtsError("azure", "Azure region is invalid");
|
|
4976
6021
|
const voice = String(config.voice || "zh-CN-XiaoxiaoNeural");
|
|
@@ -4978,7 +6023,7 @@ const synthesizers = {
|
|
|
4978
6023
|
const lang = voice.split("-").slice(0, 2).join("-");
|
|
4979
6024
|
const percent = Math.round((rateNumber(rate) - 1) * 100);
|
|
4980
6025
|
const ssml = `<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="${lang}"><voice name="${voice}"><prosody rate="${percent >= 0 ? "+" : ""}${percent}%">${xmlEscape(text)}</prosody></voice></speak>`;
|
|
4981
|
-
|
|
6026
|
+
return send(`https://${region}.tts.speech.microsoft.com/cognitiveservices/v1`, {
|
|
4982
6027
|
method: "POST",
|
|
4983
6028
|
headers: {
|
|
4984
6029
|
"Ocp-Apim-Subscription-Key": secret,
|
|
@@ -4987,9 +6032,7 @@ const synthesizers = {
|
|
|
4987
6032
|
"User-Agent": "dsh-aha-tutor"
|
|
4988
6033
|
},
|
|
4989
6034
|
body: ssml
|
|
4990
|
-
},
|
|
4991
|
-
if (!response.ok) throw httpError(response.status);
|
|
4992
|
-
return readBounded(response);
|
|
6035
|
+
}, MAX_AUDIO_BYTES);
|
|
4993
6036
|
}
|
|
4994
6037
|
};
|
|
4995
6038
|
const CHUNK_UNITS = Object.freeze({
|
|
@@ -4998,7 +6041,7 @@ const CHUNK_UNITS = Object.freeze({
|
|
|
4998
6041
|
minimax: 9e3,
|
|
4999
6042
|
azure: 6e3
|
|
5000
6043
|
});
|
|
5001
|
-
function createTts({ cacheDir, fetchImpl = globalThis.fetch, getSecret }) {
|
|
6044
|
+
function createTts({ cacheDir, fetchImpl = globalThis.fetch, getSecret, limits = TTS_LIMITS }) {
|
|
5002
6045
|
if (typeof getSecret !== "function") throw new TypeError("createTts needs getSecret");
|
|
5003
6046
|
let pruning;
|
|
5004
6047
|
const cacheKey = (provider, config, rate, text) => createHash("sha256").update(JSON.stringify([
|
|
@@ -5032,18 +6075,25 @@ function createTts({ cacheDir, fetchImpl = globalThis.fetch, getSecret }) {
|
|
|
5032
6075
|
const secret = await getSecret(provider);
|
|
5033
6076
|
if (!secret) throw new TtsError("no-key", "No key saved for this TTS provider");
|
|
5034
6077
|
const parts = [];
|
|
5035
|
-
for (const chunk of splitForTts(content, CHUNK_UNITS[provider]))
|
|
5036
|
-
|
|
5037
|
-
|
|
5038
|
-
|
|
5039
|
-
|
|
5040
|
-
|
|
5041
|
-
|
|
5042
|
-
|
|
6078
|
+
for (const chunk of splitForTts(content, CHUNK_UNITS[provider])) {
|
|
6079
|
+
const send = (url, init, maxBytes) => boundedFetch(fetchImpl, url, init, {
|
|
6080
|
+
text: chunk,
|
|
6081
|
+
signal,
|
|
6082
|
+
limits,
|
|
6083
|
+
maxBytes
|
|
6084
|
+
});
|
|
6085
|
+
parts.push(await synthesizers[provider]({
|
|
6086
|
+
text: chunk,
|
|
6087
|
+
config: merged,
|
|
6088
|
+
secret,
|
|
6089
|
+
rate,
|
|
6090
|
+
send
|
|
6091
|
+
}));
|
|
6092
|
+
}
|
|
5043
6093
|
const audio = Buffer.concat(parts);
|
|
5044
6094
|
if (!audio.length) throw new TtsError("response", "The TTS service returned no audio");
|
|
5045
6095
|
if (file) {
|
|
5046
|
-
await writeFileAtomic(file, audio).catch(() => void 0);
|
|
6096
|
+
await mkdir(cacheDir, { recursive: true }).then(() => writeFileAtomic(file, audio)).catch(() => void 0);
|
|
5047
6097
|
pruning ??= prune().finally(() => {
|
|
5048
6098
|
pruning = void 0;
|
|
5049
6099
|
});
|