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/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as conceptIndex, A as generateText, B as readProgress, C as revisePrompt, D as TutorError, E as summaryPrompt, F as materialImages, G as applyEvidence, H as formatPassages, I as materialsSummary, J as recommendNext, K as createConceptState, L as passagesFor, M as EMPTY_PROFILE, N as emptyProgress, O as generateJson, P as masteryFor, Q as blueprintOutline, R as readMaterials, S as researchQueriesPrompt, T as shortPrompt, U as passageRange, V as documentParts, W as spreadPassages, X as boardToPlainText, Y as extractJson, Z as normalizeBoard, _ as projectReviewPrompt, _t as assertId, a as blueprintPrompt, at as lessonAfter, b as quizPrompt, c as detectLanguage, ct as normalizeCards, d as flashcardsPrompt, dt as normalizePractice, et as courseProgress, f as gradePrompt, ft as normalizeProject, g as profilePrompt, gt as text, h as practicePrompt, ht as safeRelativePath, i as assistPrompt, it as intakeSummary, j as streamCounter, k as generateSteps, l as digestPartPrompt, lt as normalizeCheatsheet, m as materialBriefPrompt, mt as outlineForPrompt, n as publishRuntime, nt as flattenLessons, o as cheatsheetPrompt, ot as nextLesson, p as lessonPrompt, pt as normalizeQuestionnaire, q as masteryPercent, r as askPrompt, rt as insertLesson, s as correctionPrompt, st as normalizeBlueprint, tt as findLesson, u as digestPrompt, ut as normalizeLesson, v as projectsPrompt, vt as newId, w as reworkPrompt, x as reexplainPrompt, y as questionnairePrompt, z as readProfile } from "./runtime-DqCjJ0yL.js";
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 ?? [], ...(board?.steps ?? []).map((step) => step.check).filter(Boolean)].filter((item) => item.conceptIds?.includes(conceptId) && item.kind !== "short" && item.kind !== "work");
2372
- const item = candidates[Math.floor(Math.random() * candidates.length)];
2373
- if (item) items.push({
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
- const state = value.concepts[id] ?? createConceptState(id, index.get(id).name);
2801
- value.concepts[id] = applyEvidence(state, evidence, at);
2802
- if (evidence.kind !== "reexplain" && evidence.kind !== "self" && Number.isFinite(Number(evidence.score))) {
2803
- const card = value.reviews[id] ?? createCard(id, at);
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 REQUEST_TIMEOUT_MS = 45e3;
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
- async function boundedFetch(fetchImpl, url, init, signal) {
4830
- const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
4831
- const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
4832
- let response;
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 = await fetchImpl(url, {
4835
- ...init,
4836
- redirect: "error",
4837
- signal: combined
4838
- });
4839
- } catch (error) {
4840
- if (signal?.aborted) throw signal.reason ?? error;
4841
- throw new TtsError(timeout.aborted ? "timeout" : "network", timeout.aborted ? "TTS request timed out" : "Could not reach the TTS service");
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
- async function readBounded(response) {
4846
- const length = Number(response.headers.get("content-length"));
4847
- if (Number.isFinite(length) && length > MAX_AUDIO_BYTES) throw new TtsError("too-large", "TTS response is too large");
4848
- const buffer = Buffer.from(await response.arrayBuffer());
4849
- if (buffer.length > MAX_AUDIO_BYTES) throw new TtsError("too-large", "TTS response is too large");
4850
- return buffer;
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, fetchImpl, signal }) {
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
- const response = await boundedFetch(fetchImpl, new URL(`${path}/audio/speech`, base), {
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
- }, signal);
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, fetchImpl, signal }) {
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 response = await boundedFetch(fetchImpl, "https://openspeech.bytedance.com/api/v1/tts", {
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
- }, signal);
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, fetchImpl, signal }) {
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 response = await boundedFetch(fetchImpl, url, {
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
- }, signal);
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, fetchImpl, signal }) {
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
- const response = await boundedFetch(fetchImpl, `https://${region}.tts.speech.microsoft.com/cognitiveservices/v1`, {
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
- }, signal);
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])) parts.push(await synthesizers[provider]({
5036
- text: chunk,
5037
- config: merged,
5038
- secret,
5039
- rate,
5040
- fetchImpl,
5041
- signal
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
  });