dsh-aha-tutor 1.5.0 → 1.7.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 passageRange, A as verifyItemPrompt, At as plotToText, B as materialImages, C as quizPrompt, Ct as normalizeLesson, D as reworkPrompt, Dt as outlineForPrompt, E as revisePrompt, Et as normalizeQuestionnaire, F as generateText, G as readProgress, H as passagesFor, I as streamCounter, J as masteryPercent, K as applyEvidence, L as EMPTY_PROFILE, M as TutorError, Mt as newId, N as generateJson, O as shortPrompt, Ot as safeRelativePath, P as generateSteps, Q as formatPassages, R as emptyProgress, S as questionnairePrompt, St as normalizeItem, T as researchQueriesPrompt, Tt as normalizeProject, U as readMaterials, V as materialsSummary, W as readProfile, X as buildIndex, Y as recommendNext, Z as documentParts, _ as materialsRevisePrompt, _t as nextLesson, a as blueprintPrompt, at as boardToPlainText, b as projectReviewPrompt, bt as normalizeCheatsheet, c as correctionPrompt, ct as normalizeOp, d as digestPrompt, dt as courseProgress, et as search, f as flashcardsPrompt, ft as findLesson, g as materialBriefPrompt, gt as lessonAfter, h as lessonPrompt, ht as intakeSummary, i as assistPrompt, it as BOARD_LIMITS, j as verifyStepPrompt, jt as assertId, k as summaryPrompt, kt as text, l as detectLanguage, lt as blueprintOutline, m as handoutPrompt, mt as insertLesson, n as publishRuntime, nt as spreadPassages, o as challengePrompt, ot as estimateSpeechMs, p as gradePrompt, pt as flattenLessons, q as createConceptState, r as askPrompt, rt as extractJson, s as cheatsheetPrompt, st as normalizeBoard, tt as snippetOf, u as digestPartPrompt, ut as conceptIndex, v as practicePrompt, vt as normalizeBlueprint, w as reexplainPrompt, wt as normalizePractice, x as projectsPrompt, xt as normalizeHandout, y as profilePrompt, yt as normalizeCards, z as masteryFor } from "./runtime-DfoeKFPj.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";
@@ -490,15 +490,76 @@ function createCourses({ store, llm, jobs, research, now = () => /* @__PURE__ */
490
490
  meta: { courseId }
491
491
  });
492
492
  }
493
+ function addMaterials({ courseId, materialIds = [], instruction }) {
494
+ const wanted = [...new Set(materialIds)].filter((id) => /^mat_[a-z0-9]+$/u.test(id)).slice(0, 12);
495
+ if (!wanted.length) throw new TutorError("no-materials", "Choose at least one material");
496
+ const request = text(instruction, 600);
497
+ return jobs.start("craft/revise", async (api) => {
498
+ const course = await readCourse(courseId);
499
+ if (!course.units) throw new TutorError("no-blueprint", "There is no blueprint to revise yet");
500
+ const known = course.materialIds ?? [];
501
+ const added = (await readMaterials(store, wanted.filter((id) => !known.includes(id)))).slice(0, Math.max(0, 12 - known.length));
502
+ if (!added.length) throw new TutorError(wanted.every((id) => known.includes(id)) ? "materials-known" : known.length >= 12 ? "materials-full" : "not-found", "These materials cannot be added");
503
+ const current = {
504
+ title: course.title,
505
+ subtitle: course.subtitle,
506
+ emoji: course.emoji,
507
+ summary: course.summary,
508
+ level: course.level,
509
+ units: course.units,
510
+ projects: course.projects
511
+ };
512
+ const share = Math.floor(9e3 / added.length);
513
+ const passages = added.filter((item) => item.chunks.length).map((item) => formatPassages(spreadPassages(item.chunks, {
514
+ maxChars: share,
515
+ samples: 8
516
+ }), item.meta.title)).join("\n\n");
517
+ api.progress(0, 0, "blueprint");
518
+ const revised = await generateJson(llm, {
519
+ purpose: "teach",
520
+ prompt: materialsRevisePrompt({
521
+ blueprint: current,
522
+ materials: materialsSummary(added),
523
+ passages,
524
+ instruction: request,
525
+ language: course.language
526
+ }),
527
+ images: llm.supportsImages?.("teach") ? await materialImages(store, added) : void 0,
528
+ maxTokens: 16e3,
529
+ signal: api.signal,
530
+ onText: streamCounter("\"estMinutes\"", (count) => api.progress(count, 0, "blueprint")),
531
+ normalize: (raw) => attachProjectUnits(raw, normalizeBlueprint(raw, { language: course.language }))
532
+ });
533
+ const proposal = {
534
+ instruction: request,
535
+ materials: added.map((item) => ({
536
+ id: item.meta.id,
537
+ title: item.meta.title
538
+ })),
539
+ at: stamp(),
540
+ blueprint: revised
541
+ };
542
+ await store.writeJson(store.paths.proposal(courseId), proposal);
543
+ return {
544
+ course,
545
+ proposal
546
+ };
547
+ }, {
548
+ key: `blueprint:${courseId}`,
549
+ meta: { courseId }
550
+ });
551
+ }
493
552
  async function applyProposal({ courseId }) {
494
553
  const course = await readCourse(courseId);
495
554
  const proposal = await store.readJson(store.paths.proposal(courseId), null);
496
555
  if (!proposal?.blueprint?.units) throw new TutorError("no-proposal", "There is no proposed change");
556
+ const materialIds = [.../* @__PURE__ */ new Set([...course.materialIds ?? [], ...(proposal.materials ?? []).map((item) => item.id)])].slice(0, 12);
497
557
  const next = {
498
558
  ...course,
499
559
  ...proposal.blueprint,
500
560
  language: course.language,
501
- status: course.status === "ready" ? "ready" : "blueprint"
561
+ status: course.status === "ready" ? "ready" : "blueprint",
562
+ ...materialIds.length ? { materialIds } : {}
502
563
  };
503
564
  await saveCourse(next);
504
565
  await store.removeDir(store.paths.proposal(courseId));
@@ -766,6 +827,7 @@ function createCourses({ store, llm, jobs, research, now = () => /* @__PURE__ */
766
827
  startCraft,
767
828
  submitIntake,
768
829
  revise,
830
+ addMaterials,
769
831
  applyProposal,
770
832
  discardProposal,
771
833
  reworkLesson,
@@ -932,7 +994,7 @@ function createJobs({ logger, now = Date.now } = {}) {
932
994
  const aborted = new Promise((_, reject) => {
933
995
  controller.signal.addEventListener("abort", () => reject(controller.signal.reason ?? /* @__PURE__ */ new Error("cancelled")), { once: true });
934
996
  });
935
- Promise.race([Promise.resolve().then(() => run(api)), aborted]).then((result) => {
997
+ job.settled = Promise.race([Promise.resolve().then(() => run(api)), aborted]).then((result) => {
936
998
  job.status = "done";
937
999
  job.result = result;
938
1000
  }).catch((error) => {
@@ -979,6 +1041,12 @@ function createJobs({ logger, now = Date.now } = {}) {
979
1041
  const job = id ? jobs.get(id) : void 0;
980
1042
  return job && !job.finishedAt && !job.controller.signal.aborted ? snapshot(job) : null;
981
1043
  }
1044
+ async function wait(id) {
1045
+ const job = jobs.get(id);
1046
+ if (!job) return null;
1047
+ await job.settled;
1048
+ return snapshot(job);
1049
+ }
982
1050
  function cancelKey(key) {
983
1051
  const job = running(key);
984
1052
  return job ? cancel(job.id) : null;
@@ -991,6 +1059,7 @@ function createJobs({ logger, now = Date.now } = {}) {
991
1059
  return Object.freeze({
992
1060
  start,
993
1061
  get,
1062
+ wait,
994
1063
  cancel,
995
1064
  cancelKey,
996
1065
  running,
@@ -2037,10 +2106,10 @@ function neededWeeklyMinutes(course, start, deadline, studyDays) {
2037
2106
  const perDay = remaining / studyDayCount / (1 - REVIEW_SHARE);
2038
2107
  return Math.ceil(perDay * studyDays.length / 10) * 10;
2039
2108
  }
2040
- const itemKey = (item) => `${item.kind}:${item.refId}`;
2109
+ const itemKey$1 = (item) => `${item.kind}:${item.refId}`;
2041
2110
  function mergeAgenda(saved = [], planned = []) {
2042
- const seen = new Set(saved.map(itemKey));
2043
- return [...saved, ...planned.filter((item) => !seen.has(itemKey(item)))];
2111
+ const seen = new Set(saved.map(itemKey$1));
2112
+ return [...saved, ...planned.filter((item) => !seen.has(itemKey$1(item)))];
2044
2113
  }
2045
2114
  function planCalendar({ today, agenda = {}, plan, done = {}, checks = [], ahead = 120 }) {
2046
2115
  const lessonsDone = done.lessons ?? /* @__PURE__ */ new Map();
@@ -2049,7 +2118,7 @@ function planCalendar({ today, agenda = {}, plan, done = {}, checks = [], ahead
2049
2118
  const days = /* @__PURE__ */ new Map();
2050
2119
  const put = (day, item) => {
2051
2120
  const list = days.get(day) ?? [];
2052
- if (!list.some((entry) => itemKey(entry) === itemKey(item))) list.push(item);
2121
+ if (!list.some((entry) => itemKey$1(entry) === itemKey$1(item))) list.push(item);
2053
2122
  days.set(day, list);
2054
2123
  };
2055
2124
  for (const [day, items] of Object.entries(agenda)) if (day <= today) for (const item of items) put(day, item);
@@ -2099,9 +2168,10 @@ function createLearner({ store, llm, now = () => /* @__PURE__ */ new Date(), log
2099
2168
  return readProfile(store);
2100
2169
  }
2101
2170
  async function saveProfile(patch = {}) {
2171
+ const given = Object.fromEntries(Object.entries(patch).filter(([, value]) => value !== void 0));
2102
2172
  return store.updateJson(store.paths.profile(), { ...EMPTY_PROFILE }, (current) => cleanProfile({
2103
2173
  ...current,
2104
- ...patch,
2174
+ ...given,
2105
2175
  updatedAt: now().toISOString()
2106
2176
  }));
2107
2177
  }
@@ -2368,16 +2438,33 @@ function createLearner({ store, llm, now = () => /* @__PURE__ */ new Date(), log
2368
2438
  const lesson = findLesson(course, concept.lessonId)?.lesson;
2369
2439
  const set = await store.readJson(store.paths.practice(course.id, concept.lessonId), null);
2370
2440
  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({
2441
+ const candidates = [...(set?.items ?? []).map((item) => ({
2442
+ item,
2443
+ source: {
2444
+ kind: "practice",
2445
+ courseId: course.id,
2446
+ lessonId: concept.lessonId,
2447
+ itemId: item.id
2448
+ }
2449
+ })), ...(board?.steps ?? []).filter((step) => step.check).map((step) => ({
2450
+ item: step.check,
2451
+ source: {
2452
+ kind: "check",
2453
+ courseId: course.id,
2454
+ lessonId: concept.lessonId,
2455
+ stepId: step.id
2456
+ }
2457
+ }))].filter(({ item }) => item.conceptIds?.includes(conceptId) && item.kind !== "short" && item.kind !== "work");
2458
+ const pick = candidates[Math.floor(Math.random() * candidates.length)];
2459
+ if (pick) items.push({
2374
2460
  courseId: course.id,
2375
2461
  courseTitle: course.title,
2376
2462
  lessonId: concept.lessonId,
2377
2463
  lessonTitle: lesson?.title ?? "",
2378
2464
  conceptId,
2379
2465
  conceptName: concept.name,
2380
- item
2466
+ item: pick.item,
2467
+ source: pick.source
2381
2468
  });
2382
2469
  }
2383
2470
  }
@@ -2404,6 +2491,7 @@ function list(value) {
2404
2491
  }
2405
2492
  function cleanProfile(profile) {
2406
2493
  return {
2494
+ name: text(profile.name, 20).replace(/[\r\n]+/gu, " "),
2407
2495
  background: text(profile.background, 600),
2408
2496
  goals: list(profile.goals) ?? [],
2409
2497
  strengths: list(profile.strengths) ?? [],
@@ -2426,6 +2514,223 @@ function cleanProfile(profile) {
2426
2514
  };
2427
2515
  }
2428
2516
  //#endregion
2517
+ //#region src/core/board-fix.js
2518
+ const VERDICTS = Object.freeze([
2519
+ "wrong",
2520
+ "right",
2521
+ "unclear"
2522
+ ]);
2523
+ const LAYOUT = /* @__PURE__ */ new Set([
2524
+ "col",
2525
+ "page",
2526
+ "mark"
2527
+ ]);
2528
+ function opPlain(op) {
2529
+ switch (op?.t) {
2530
+ case "title":
2531
+ case "h":
2532
+ case "p":
2533
+ case "note": return op.text;
2534
+ case "math": return op.text || op.tex;
2535
+ case "list": return op.items.join("; ");
2536
+ case "table": return [op.head, ...op.rows].filter((row) => row.length).map((row) => row.join(" | ")).join(" / ");
2537
+ case "code": return op.code;
2538
+ case "chart": return [
2539
+ op.title,
2540
+ ...op.series.map((series) => `${series.name} ${series.points.slice(0, 6).map((point) => `(${point.join(", ")})`).join(" ")}`),
2541
+ ...op.data.map((item) => `${item.label} ${item.value}`)
2542
+ ].filter(Boolean).join("; ");
2543
+ case "plot": return plotToText(op).replace(/\n/gu, "; ");
2544
+ case "svg":
2545
+ case "anim": return op.caption ?? "";
2546
+ default: return "";
2547
+ }
2548
+ }
2549
+ const withoutPlace = (op) => JSON.stringify({
2550
+ ...op,
2551
+ id: void 0,
2552
+ at: void 0,
2553
+ fixed: void 0
2554
+ });
2555
+ function normalizeVerdict(raw, steps) {
2556
+ const verdict = VERDICTS.includes(raw?.verdict) ? raw.verdict : null;
2557
+ if (!verdict) throw new Error("the check names no verdict");
2558
+ const summary = text(raw?.summary ?? raw?.reason, 400);
2559
+ const fixes = [];
2560
+ if (verdict !== "wrong") return {
2561
+ verdict,
2562
+ summary,
2563
+ fixes
2564
+ };
2565
+ const byId = new Map(steps.map((step) => [step.id, step]));
2566
+ const done = /* @__PURE__ */ new Set();
2567
+ const keep = (key, fix) => {
2568
+ if (done.has(key) || fixes.length >= 12) return;
2569
+ done.add(key);
2570
+ fixes.push(fix);
2571
+ };
2572
+ for (const fix of (Array.isArray(raw?.fixes) ? raw.fixes : []).slice(0, 36)) {
2573
+ const step = byId.get(String(fix?.step ?? fix?.stepId ?? ""));
2574
+ if (!step) continue;
2575
+ const replacement = fix.replace ?? fix.with ?? fix.op_new;
2576
+ if (replacement && typeof replacement === "object") {
2577
+ const old = step.ops.find((op) => op.id === String(fix.op ?? fix.opId ?? ""));
2578
+ const next = normalizeOp(replacement, 0);
2579
+ if (old && next && !LAYOUT.has(old.t) && !LAYOUT.has(next.t) && withoutPlace(next) !== withoutPlace(old)) keep(`${step.id}:${old.id}`, {
2580
+ step: step.id,
2581
+ op: old.id,
2582
+ replace: next
2583
+ });
2584
+ }
2585
+ if (typeof fix.say === "string") {
2586
+ const say = text(fix.say, BOARD_LIMITS.say);
2587
+ if (say && say !== step.say) keep(`${step.id}:say`, {
2588
+ step: step.id,
2589
+ say
2590
+ });
2591
+ }
2592
+ if (fix.check && typeof fix.check === "object" && step.check) {
2593
+ const check = normalizeItem(fix.check, 0, step.check.conceptIds ?? []);
2594
+ if (check && check.kind !== "order") {
2595
+ const next = {
2596
+ ...check,
2597
+ id: step.check.id,
2598
+ conceptIds: step.check.conceptIds ?? []
2599
+ };
2600
+ if (JSON.stringify(next) !== JSON.stringify(step.check)) keep(`${step.id}:check`, {
2601
+ step: step.id,
2602
+ check: next
2603
+ });
2604
+ }
2605
+ }
2606
+ }
2607
+ return {
2608
+ verdict,
2609
+ summary,
2610
+ fixes
2611
+ };
2612
+ }
2613
+ function applyFixes(steps, fixes, { at = (/* @__PURE__ */ new Date()).toISOString() } = {}) {
2614
+ const applied = [];
2615
+ return {
2616
+ steps: steps.map((step) => {
2617
+ const own = fixes.filter((fix) => fix.step === step.id);
2618
+ if (!own.length) return step;
2619
+ const copy = {
2620
+ ...step,
2621
+ ops: [...step.ops]
2622
+ };
2623
+ for (const fix of own) {
2624
+ if (fix.replace) {
2625
+ const index = copy.ops.findIndex((op) => op.id === fix.op);
2626
+ if (index < 0) continue;
2627
+ const old = copy.ops[index];
2628
+ const note = fix.replace.fixed;
2629
+ const op = {
2630
+ ...fix.replace,
2631
+ id: old.id,
2632
+ ...old.at !== void 0 ? { at: old.at } : {},
2633
+ fixed: {
2634
+ was: old.fixed?.was ?? note?.was ?? text(opPlain(old), 300),
2635
+ at: note?.at ?? at
2636
+ }
2637
+ };
2638
+ copy.ops[index] = op;
2639
+ applied.push({
2640
+ step: step.id,
2641
+ op: old.id,
2642
+ replace: op
2643
+ });
2644
+ }
2645
+ if (fix.say !== void 0) {
2646
+ copy.say = fix.say;
2647
+ applied.push({
2648
+ step: step.id,
2649
+ say: fix.say
2650
+ });
2651
+ }
2652
+ if (fix.check && copy.check) {
2653
+ copy.check = {
2654
+ ...fix.check,
2655
+ id: copy.check.id,
2656
+ conceptIds: copy.check.conceptIds ?? []
2657
+ };
2658
+ applied.push({
2659
+ step: step.id,
2660
+ check: copy.check
2661
+ });
2662
+ }
2663
+ }
2664
+ return copy;
2665
+ }),
2666
+ applied
2667
+ };
2668
+ }
2669
+ function sanitizeStep(raw) {
2670
+ const id = String(raw?.id ?? "");
2671
+ if (!/^[\w-]{1,48}$/u.test(id) || !Array.isArray(raw?.ops)) return null;
2672
+ const ops = raw.ops.slice(0, BOARD_LIMITS.opsPerStep).map((op) => {
2673
+ const clean = normalizeOp(op, 0);
2674
+ if (!clean || typeof op.id !== "string" || !/^[\w-]{1,48}$/u.test(op.id)) return null;
2675
+ return {
2676
+ ...clean,
2677
+ id: op.id,
2678
+ ...Number.isInteger(op.at) && op.at >= 0 && op.at < 20 ? { at: op.at } : {}
2679
+ };
2680
+ }).filter(Boolean);
2681
+ const say = text(raw.say, BOARD_LIMITS.say);
2682
+ if (!say && !ops.length) return null;
2683
+ return {
2684
+ id,
2685
+ say,
2686
+ ops
2687
+ };
2688
+ }
2689
+ function stepJson(step, { full = false } = {}) {
2690
+ const ops = step.ops.map(({ fixed, ...op }) => {
2691
+ if (op.t === "svg") return full ? {
2692
+ ...op,
2693
+ svg: op.svg.slice(0, 16e3)
2694
+ } : {
2695
+ ...op,
2696
+ svg: "…"
2697
+ };
2698
+ if (op.t === "anim") return {
2699
+ ...op,
2700
+ html: full ? op.html.slice(0, 8e3) : "…",
2701
+ poster: "…"
2702
+ };
2703
+ return op;
2704
+ });
2705
+ return JSON.stringify({
2706
+ id: step.id,
2707
+ say: step.say,
2708
+ ops,
2709
+ ...step.check ? { check: step.check } : {}
2710
+ });
2711
+ }
2712
+ function boardForCheck(steps, stepId, { maxChars = 14e3 } = {}) {
2713
+ const lines = steps.map((step) => stepJson(step));
2714
+ const center = Math.max(0, steps.findIndex((step) => step.id === stepId));
2715
+ const kept = /* @__PURE__ */ new Set([center]);
2716
+ let used = lines[center]?.length ?? 0;
2717
+ for (let distance = 1; distance < steps.length; distance += 1) for (const index of [center - distance, center + distance]) {
2718
+ if (index < 0 || index >= steps.length || used + lines[index].length > maxChars) continue;
2719
+ kept.add(index);
2720
+ used += lines[index].length;
2721
+ }
2722
+ return lines.filter((line, index) => kept.has(index)).join("\n");
2723
+ }
2724
+ function describeFixes(fixes, steps) {
2725
+ const byId = new Map(steps.map((step) => [step.id, step]));
2726
+ return fixes.map((fix) => {
2727
+ const step = byId.get(fix.step);
2728
+ if (fix.replace) return `${fix.step} ${fix.op}: ${opPlain(step?.ops.find((op) => op.id === fix.op))} → ${opPlain(fix.replace)}`;
2729
+ if (fix.say !== void 0) return `${fix.step} narration: ${step?.say ?? ""} → ${fix.say}`;
2730
+ return `${fix.step} check answer: ${JSON.stringify(step?.check?.answer ?? "")} → ${JSON.stringify(fix.check?.answer ?? "")}`;
2731
+ }).join("\n");
2732
+ }
2733
+ //#endregion
2429
2734
  //#region src/core/srs.js
2430
2735
  const GRADES$1 = Object.freeze([
2431
2736
  "again",
@@ -2457,7 +2762,7 @@ function review(card, grade, now = Date.now()) {
2457
2762
  if (!GRADES$1.includes(grade)) throw new TypeError(`grade must be one of ${GRADES$1.join(", ")}`);
2458
2763
  const quality = GRADE_QUALITY[grade];
2459
2764
  const next = { ...card };
2460
- next.ease = Math.max(MIN_EASE, round2(card.ease + .1 - (5 - quality) * (.08 + (5 - quality) * .02)));
2765
+ next.ease = Math.max(MIN_EASE, round2$1(card.ease + .1 - (5 - quality) * (.08 + (5 - quality) * .02)));
2461
2766
  if (grade === "again") {
2462
2767
  next.reps = 0;
2463
2768
  next.lapses = card.lapses + 1;
@@ -2483,10 +2788,412 @@ function gradeFromScore(score, confidence) {
2483
2788
  if (score >= .95 && confidence === "sure") return "easy";
2484
2789
  return "good";
2485
2790
  }
2791
+ function round2$1(value) {
2792
+ return Math.round(value * 100) / 100;
2793
+ }
2794
+ //#endregion
2795
+ //#region src/core/evidence-log.js
2796
+ const scored = (evidence) => evidence?.kind !== "reexplain" && evidence?.kind !== "self" && Number.isFinite(Number(evidence?.score));
2797
+ const withoutHistory = ({ history, ...state }) => state;
2798
+ function takeEvidence(state, card, evidence, at) {
2799
+ const next = applyEvidence(state, evidence, at);
2800
+ if (!scored(evidence)) return {
2801
+ state: next,
2802
+ card
2803
+ };
2804
+ return {
2805
+ state: next,
2806
+ card: review(card ?? createCard(state.id, at), gradeFromScore(Number(evidence.score), evidence.confidence), at)
2807
+ };
2808
+ }
2809
+ function stateBefore(state, card) {
2810
+ return {
2811
+ state: state ? withoutHistory(state) : null,
2812
+ card: card ?? null
2813
+ };
2814
+ }
2815
+ function logEvidence(progress, entry) {
2816
+ progress.evidenceLog = [...Array.isArray(progress.evidenceLog) ? progress.evidenceLog : [], entry].slice(-50);
2817
+ }
2818
+ function itemKey(source) {
2819
+ if (source?.kind === "practice") return `practice:${source.lessonId}:${source.itemId}`;
2820
+ if (source?.kind === "quiz") return `quiz:${source.unitId}:${source.itemId}`;
2821
+ if (source?.kind === "check") return `check:${source.lessonId}:${source.stepId}`;
2822
+ return null;
2823
+ }
2824
+ function answersTo(progress, key, except = null) {
2825
+ if (!key) return [];
2826
+ return (Array.isArray(progress?.evidenceLog) ? progress.evidenceLog : []).filter((entry) => entry.item?.key === key && entry.id !== except && scored(entry.evidence));
2827
+ }
2828
+ function correctEvidence(progress, id, patch, { known = (conceptId) => conceptId, at = (/* @__PURE__ */ new Date()).toISOString() } = {}) {
2829
+ const log = Array.isArray(progress.evidenceLog) ? progress.evidenceLog : [];
2830
+ const index = log.findIndex((entry) => entry.id === id);
2831
+ if (index < 0) return null;
2832
+ const entry = log[index];
2833
+ const evidence = {
2834
+ ...entry.evidence,
2835
+ ...patch
2836
+ };
2837
+ if (!scored(entry.evidence) || !scored(evidence)) return null;
2838
+ const later = log.slice(index + 1);
2839
+ progress.concepts ??= {};
2840
+ progress.reviews ??= {};
2841
+ const changed = [];
2842
+ for (const conceptId of entry.conceptIds ?? []) {
2843
+ const name = known(conceptId);
2844
+ if (name === null || name === void 0) continue;
2845
+ const after = later.filter((item) => item.conceptIds?.includes(conceptId));
2846
+ const history = progress.concepts[conceptId]?.history ?? [];
2847
+ const base = entry.before?.[conceptId];
2848
+ let state = {
2849
+ ...base?.state ?? createConceptState(conceptId, name),
2850
+ history: history.slice(0, Math.max(0, history.length - 1 - after.length))
2851
+ };
2852
+ let card = base?.card ?? null;
2853
+ ({state, card} = takeEvidence(state, card, evidence, Date.parse(entry.at)));
2854
+ for (const item of after) {
2855
+ item.before = {
2856
+ ...item.before,
2857
+ [conceptId]: stateBefore(state, card)
2858
+ };
2859
+ ({state, card} = takeEvidence(state, card, item.evidence, Date.parse(item.at)));
2860
+ }
2861
+ progress.concepts[conceptId] = state;
2862
+ if (card) progress.reviews[conceptId] = card;
2863
+ changed.push(conceptId);
2864
+ }
2865
+ entry.corrected = {
2866
+ score: entry.corrected?.score ?? entry.evidence.score,
2867
+ at
2868
+ };
2869
+ entry.evidence = evidence;
2870
+ return { conceptIds: changed };
2871
+ }
2872
+ //#endregion
2873
+ //#region src/core/grading.js
2874
+ const OBJECTIVE_KINDS = Object.freeze([
2875
+ "choice",
2876
+ "multi",
2877
+ "blank",
2878
+ "number",
2879
+ "order"
2880
+ ]);
2881
+ const FULLWIDTH = /[!-~]/gu;
2882
+ function normalizeAnswerText(value) {
2883
+ return String(value ?? "").normalize("NFKC").replace(FULLWIDTH, (char) => String.fromCharCode(char.charCodeAt(0) - 65248)).toLowerCase().replace(/[\s ]+/gu, " ").replace(/^[\s.,;:!?'"`,。;:!?、“”‘’]+|[\s.,;:!?'"`,。;:!?、“”‘’]+$/gu, "").trim();
2884
+ }
2885
+ function parseNumber(value) {
2886
+ if (typeof value === "number") return value;
2887
+ const text = normalizeAnswerText(value).replace(/,/gu, "").replace(/\s/gu, "");
2888
+ const fraction = /^(-?\d+(?:\.\d+)?)\/(\d+(?:\.\d+)?)$/u.exec(text);
2889
+ if (fraction) return Number(fraction[1]) / Number(fraction[2]);
2890
+ const percent = /^(-?\d+(?:\.\d+)?)%$/u.exec(text);
2891
+ if (percent) return Number(percent[1]) / 100;
2892
+ const number = Number(text);
2893
+ return Number.isFinite(number) ? number : NaN;
2894
+ }
2895
+ function gradeObjective(item, response) {
2896
+ if (!item || !OBJECTIVE_KINDS.includes(item.kind)) return {
2897
+ score: 0,
2898
+ correct: false,
2899
+ gradable: false
2900
+ };
2901
+ if (item.kind === "choice") {
2902
+ const correct = String(response ?? "") === String(item.answer ?? "");
2903
+ return {
2904
+ score: correct ? 1 : 0,
2905
+ correct,
2906
+ gradable: true
2907
+ };
2908
+ }
2909
+ if (item.kind === "multi") {
2910
+ const expected = new Set((Array.isArray(item.answer) ? item.answer : [item.answer]).map(String));
2911
+ const given = new Set((Array.isArray(response) ? response : []).map(String));
2912
+ if (!expected.size) return {
2913
+ score: 0,
2914
+ correct: false,
2915
+ gradable: false
2916
+ };
2917
+ let hits = 0;
2918
+ let wrong = 0;
2919
+ for (const id of given) if (expected.has(id)) hits += 1;
2920
+ else wrong += 1;
2921
+ const score = Math.max(0, (hits - wrong) / expected.size);
2922
+ const correct = hits === expected.size && wrong === 0;
2923
+ return {
2924
+ score: round2(score),
2925
+ correct,
2926
+ gradable: true
2927
+ };
2928
+ }
2929
+ if (item.kind === "order") {
2930
+ const expected = (Array.isArray(item.answer) ? item.answer : []).map(String);
2931
+ const given = (Array.isArray(response) ? response : []).map(String);
2932
+ if (!expected.length) return {
2933
+ score: 0,
2934
+ correct: false,
2935
+ gradable: false
2936
+ };
2937
+ let inPlace = 0;
2938
+ expected.forEach((id, index) => {
2939
+ if (given[index] === id) inPlace += 1;
2940
+ });
2941
+ return {
2942
+ score: round2(inPlace / expected.length),
2943
+ correct: inPlace === expected.length,
2944
+ gradable: true
2945
+ };
2946
+ }
2947
+ if (item.kind === "number") {
2948
+ const expected = parseNumber(item.answer);
2949
+ const given = parseNumber(response);
2950
+ if (!Number.isFinite(expected)) return {
2951
+ score: 0,
2952
+ correct: false,
2953
+ gradable: false
2954
+ };
2955
+ if (!Number.isFinite(given)) return {
2956
+ score: 0,
2957
+ correct: false,
2958
+ gradable: true
2959
+ };
2960
+ const tolerance = Number.isFinite(Number(item.tolerance)) && Number(item.tolerance) >= 0 ? Number(item.tolerance) : Math.max(1e-9, Math.abs(expected) * .01);
2961
+ const correct = Math.abs(given - expected) <= tolerance;
2962
+ return {
2963
+ score: correct ? 1 : 0,
2964
+ correct,
2965
+ gradable: true
2966
+ };
2967
+ }
2968
+ const accepted = (Array.isArray(item.answer) ? item.answer : [item.answer]).map(normalizeAnswerText).filter(Boolean);
2969
+ if (!accepted.length) return {
2970
+ score: 0,
2971
+ correct: false,
2972
+ gradable: false
2973
+ };
2974
+ const given = normalizeAnswerText(response);
2975
+ const correct = accepted.includes(given);
2976
+ if (correct) return {
2977
+ score: 1,
2978
+ correct,
2979
+ gradable: true
2980
+ };
2981
+ const asNumber = parseNumber(response);
2982
+ const numeric = accepted.map(parseNumber).filter(Number.isFinite);
2983
+ if (Number.isFinite(asNumber) && numeric.some((value) => Math.abs(value - asNumber) <= Math.max(1e-9, Math.abs(value) * .001))) return {
2984
+ score: 1,
2985
+ correct: true,
2986
+ gradable: true
2987
+ };
2988
+ return {
2989
+ score: 0,
2990
+ correct: false,
2991
+ gradable: true
2992
+ };
2993
+ }
2994
+ function wrongLabel(item, response) {
2995
+ if (item.kind === "choice") return `chose ${String(response).toUpperCase()}: ${(item.options?.find((option) => option.id === response)?.text ?? "").slice(0, 50)}`;
2996
+ if (item.kind === "number" || item.kind === "blank") return `answered ${String(response).slice(0, 40)}`;
2997
+ return "wrong";
2998
+ }
2486
2999
  function round2(value) {
2487
3000
  return Math.round(value * 100) / 100;
2488
3001
  }
2489
3002
  //#endregion
3003
+ //#region src/core/item-fix.js
3004
+ const ITEM_VERDICTS = Object.freeze([
3005
+ "wrong",
3006
+ "unclear",
3007
+ "misgraded",
3008
+ "right"
3009
+ ]);
3010
+ const MODEL_GRADED = Object.freeze(["short", "work"]);
3011
+ const FIELDS = [
3012
+ "prompt",
3013
+ "options",
3014
+ "answer",
3015
+ "tolerance",
3016
+ "unit",
3017
+ "explanation",
3018
+ "hints",
3019
+ "rubric",
3020
+ "figure"
3021
+ ];
3022
+ const modelGraded = (item) => MODEL_GRADED.includes(item?.kind);
3023
+ function cleanResponse(item, response) {
3024
+ const ids = (item?.options ?? []).map((option) => option.id);
3025
+ if (item?.kind === "choice") return ids.includes(response) ? response : "";
3026
+ if (item?.kind === "multi" || item?.kind === "order") return [...new Set((Array.isArray(response) ? response : []).filter((id) => ids.includes(id)))];
3027
+ return typeof response === "string" || typeof response === "number" ? text(response, 4e3) : "";
3028
+ }
3029
+ function responseText(item, response) {
3030
+ const option = (id) => {
3031
+ const found = item.options?.find((entry) => entry.id === id);
3032
+ return found ? `${id.toUpperCase()}. ${found.text}` : String(id);
3033
+ };
3034
+ if (item.kind === "choice") return response ? option(response) : "";
3035
+ if (item.kind === "multi") return response.map(option).join("; ");
3036
+ if (item.kind === "order") return response.map(option).join(" → ");
3037
+ if (item.kind === "number" && item.unit && response) return `${response} ${item.unit}`;
3038
+ return String(response ?? "");
3039
+ }
3040
+ function itemForCheck(item) {
3041
+ const { id, conceptIds, difficulty, fixed, svg, plot, ...rest } = item;
3042
+ return JSON.stringify({
3043
+ ...rest,
3044
+ ...svg ? { svg: svg.slice(0, 8e3) } : {},
3045
+ ...plot ? { plot } : {}
3046
+ });
3047
+ }
3048
+ function normalizeGrade(raw, kind) {
3049
+ if (!raw || typeof raw !== "object" || !Number.isFinite(Number(raw.score))) return null;
3050
+ const score = Math.min(1, Math.max(0, Number(raw.score)));
3051
+ return {
3052
+ score,
3053
+ verdict: [
3054
+ "correct",
3055
+ "partial",
3056
+ "incorrect"
3057
+ ].includes(raw.verdict) ? raw.verdict : score >= .8 ? "correct" : score >= .4 ? "partial" : "incorrect",
3058
+ feedback: text(raw.feedback, 1200),
3059
+ errorTag: text(raw.errorTag, 80),
3060
+ ...kind === "work" ? {
3061
+ wrongStep: text(raw.wrongStep, 400),
3062
+ why: text(raw.why, 400),
3063
+ fix: text(raw.fix, 400)
3064
+ } : {}
3065
+ };
3066
+ }
3067
+ const figureOf = (item) => item.svg ? { svg: item.svg } : item.plot ? { plot: item.plot } : {};
3068
+ function correctedItem(old, raw) {
3069
+ if (!raw || typeof raw !== "object" || raw.kind !== void 0 && raw.kind !== old.kind) return null;
3070
+ const next = normalizeItem({
3071
+ ...raw,
3072
+ kind: old.kind
3073
+ }, 0);
3074
+ if (!next || (old.options?.length ?? 0) !== (next.options?.length ?? 0)) return null;
3075
+ const redrawn = [
3076
+ "svg",
3077
+ "figure",
3078
+ "plot"
3079
+ ].some((key) => raw[key] !== void 0);
3080
+ const { svg, plot, ...rest } = next;
3081
+ return {
3082
+ ...rest,
3083
+ ...redrawn ? figureOf(next) : figureOf(old),
3084
+ id: old.id,
3085
+ conceptIds: old.conceptIds ?? [],
3086
+ difficulty: old.difficulty ?? next.difficulty
3087
+ };
3088
+ }
3089
+ const empty = (value) => value === void 0 || value === null || value === "" || Array.isArray(value) && !value.length;
3090
+ const same = (a, b) => empty(a) && empty(b) || JSON.stringify(a) === JSON.stringify(b);
3091
+ function changedParts(old, next) {
3092
+ const was = {};
3093
+ for (const field of FIELDS) if (field === "options") {
3094
+ const options = Object.fromEntries((old.options ?? []).filter((option, index) => option.text !== next.options?.[index]?.text).map((option) => [option.id, option.text]));
3095
+ if (Object.keys(options).length) was.options = options;
3096
+ } else if (field === "figure") {
3097
+ if (!same(figureOf(old), figureOf(next))) was.figure = true;
3098
+ } else if (!same(old[field], next[field])) was[field] = empty(old[field]) ? null : old[field];
3099
+ return was;
3100
+ }
3101
+ function normalizeItemVerdict(raw, item) {
3102
+ const verdict = ITEM_VERDICTS.includes(raw?.verdict) ? raw.verdict : null;
3103
+ if (!verdict) throw new Error("the check names no verdict");
3104
+ 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\"");
3105
+ const summary = text(raw?.summary ?? raw?.reason, 400);
3106
+ const explanation = text(raw?.explanation, 2e3);
3107
+ const credit = verdict === "unclear" && raw?.credit === true;
3108
+ let fixed = null;
3109
+ if (verdict === "wrong" || verdict === "unclear") {
3110
+ fixed = correctedItem(item, raw?.item);
3111
+ if (!fixed) throw new Error(`a "${verdict}" verdict needs the whole corrected question in "item": the same kind, the same number of options`);
3112
+ if (!Object.keys(changedParts(item, fixed)).length) throw new Error("the corrected question in \"item\" changes nothing");
3113
+ }
3114
+ const grade = modelGraded(item) && verdict !== "right" && !credit ? normalizeGrade(raw?.grade, item.kind) : null;
3115
+ if (modelGraded(item) && verdict !== "right" && !credit && !grade) throw new Error("give the new \"grade\" of the learner's answer against the corrected question");
3116
+ return {
3117
+ verdict,
3118
+ summary,
3119
+ explanation,
3120
+ item: fixed,
3121
+ credit,
3122
+ grade
3123
+ };
3124
+ }
3125
+ function fixItem(stored, next, { at = (/* @__PURE__ */ new Date()).toISOString() } = {}) {
3126
+ const { fixed: note, ...old } = stored;
3127
+ const changed = changedParts(old, next);
3128
+ const was = {
3129
+ ...changed,
3130
+ ...note?.was
3131
+ };
3132
+ if (changed.options && note?.was?.options) was.options = {
3133
+ ...changed.options,
3134
+ ...note.was.options
3135
+ };
3136
+ return {
3137
+ ...next,
3138
+ id: stored.id,
3139
+ conceptIds: stored.conceptIds ?? next.conceptIds ?? [],
3140
+ fixed: {
3141
+ was,
3142
+ at: note?.at ?? at
3143
+ }
3144
+ };
3145
+ }
3146
+ function regrade(item, response, checked) {
3147
+ if (checked.verdict === "right") return null;
3148
+ if (checked.credit) return {
3149
+ score: 1,
3150
+ correct: true,
3151
+ partial: false,
3152
+ errorTag: ""
3153
+ };
3154
+ if (OBJECTIVE_KINDS.includes(item.kind)) {
3155
+ const graded = gradeObjective(item, response);
3156
+ return {
3157
+ score: graded.score,
3158
+ correct: graded.correct,
3159
+ partial: !graded.correct && graded.score > 0
3160
+ };
3161
+ }
3162
+ if (!checked.grade) return null;
3163
+ const { verdict, ...grade } = checked.grade;
3164
+ return {
3165
+ ...grade,
3166
+ correct: verdict === "correct",
3167
+ partial: verdict === "partial"
3168
+ };
3169
+ }
3170
+ function earlierGrade(item, response, { verdict, score, grade = null }) {
3171
+ if (verdict !== "wrong" && verdict !== "unclear") return null;
3172
+ let next;
3173
+ if (OBJECTIVE_KINDS.includes(item.kind)) {
3174
+ const answer = cleanResponse(item, response);
3175
+ const graded = gradeObjective(item, answer);
3176
+ if (!graded.gradable) return null;
3177
+ next = {
3178
+ score: graded.score,
3179
+ correct: graded.correct,
3180
+ errorTag: graded.correct ? "" : wrongLabel(item, answer)
3181
+ };
3182
+ } else {
3183
+ if (!grade || !Number.isFinite(Number(grade.score))) return null;
3184
+ const value = Math.min(1, Math.max(0, Number(grade.score)));
3185
+ const correct = grade.verdict ? grade.verdict === "correct" : value >= .8;
3186
+ next = {
3187
+ score: value,
3188
+ correct,
3189
+ errorTag: correct ? "" : text(grade.errorTag, 80)
3190
+ };
3191
+ }
3192
+ const before = Math.min(1, Math.max(0, Number(score) || 0));
3193
+ if (verdict === "unclear" && next.score <= before || Math.abs(next.score - before) < 1e-6) return null;
3194
+ return next;
3195
+ }
3196
+ //#endregion
2490
3197
  //#region src/host/lessons.js
2491
3198
  const EVENTS_KEPT = 200;
2492
3199
  const STEP_ID = /^s\d{1,4}$/u;
@@ -2661,7 +3368,8 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
2661
3368
  course,
2662
3369
  lesson: row.lesson,
2663
3370
  board,
2664
- language: course.language
3371
+ language: course.language,
3372
+ writing
2665
3373
  };
2666
3374
  }
2667
3375
  if (scope?.kind === "thread" || scope?.kind === "short") {
@@ -2678,7 +3386,8 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
2678
3386
  objectives: []
2679
3387
  },
2680
3388
  board,
2681
- language
3389
+ language,
3390
+ writing
2682
3391
  };
2683
3392
  }
2684
3393
  throw new TutorError("invalid", "Unknown board");
@@ -2779,11 +3488,167 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
2779
3488
  };
2780
3489
  });
2781
3490
  }
2782
- async function record({ courseId, lessonId, conceptIds = [], evidence = {} }) {
3491
+ async function challengedPlace(scope, board, target) {
3492
+ const stepId = String(target?.stepId ?? "");
3493
+ if (target?.asideId) {
3494
+ const aside = (scope.kind === "lesson" ? await store.readJson(store.paths.asides(scope.courseId, scope.lessonId), null) : null)?.items?.find((item) => item.id === target.asideId);
3495
+ const index = aside?.steps?.findIndex((item) => item.id === stepId) ?? -1;
3496
+ if (index < 0) throw new TutorError("not-found", "Not found");
3497
+ const anchor = board?.steps?.findIndex((step) => step.id === aside.anchorStepId) ?? -1;
3498
+ return {
3499
+ ref: { asideId: aside.id },
3500
+ steps: aside.steps,
3501
+ step: aside.steps[index],
3502
+ context: [...(board?.steps ?? []).slice(0, anchor + 1), ...aside.steps.slice(0, index + 1)]
3503
+ };
3504
+ }
3505
+ if (target?.step) {
3506
+ const step = sanitizeStep(target.step);
3507
+ if (!step) throw new TutorError("invalid", "Nothing to check");
3508
+ const anchor = board?.steps?.findIndex((item) => item.id === target.anchorStepId) ?? -1;
3509
+ return {
3510
+ ref: { local: true },
3511
+ steps: [step],
3512
+ step,
3513
+ context: [...(board?.steps ?? []).slice(0, anchor + 1), step]
3514
+ };
3515
+ }
3516
+ const index = board?.steps?.findIndex((step) => step.id === stepId) ?? -1;
3517
+ 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.");
3518
+ return {
3519
+ ref: { main: true },
3520
+ steps: board.steps,
3521
+ step: board.steps[index],
3522
+ context: board.steps.slice(0, index + 1)
3523
+ };
3524
+ }
3525
+ async function storeFixes(scope, place, fixes, writing) {
3526
+ let applied = [];
3527
+ const fix = (steps) => {
3528
+ const result = applyFixes(Array.isArray(steps) ? steps : [], fixes);
3529
+ applied = result.applied;
3530
+ return result.steps;
3531
+ };
3532
+ if (place.ref.local) {
3533
+ fix(place.steps);
3534
+ return applied;
3535
+ }
3536
+ if (place.ref.asideId) {
3537
+ 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 ? {
3538
+ ...item,
3539
+ steps: fix(item.steps)
3540
+ } : item) }));
3541
+ return applied;
3542
+ }
3543
+ if (writing && (await jobs.wait(writing.id))?.status !== "done") return applyFixes(place.steps, fixes).applied;
3544
+ const missing = () => new TutorError("not-found", "Not found");
3545
+ if (scope.kind === "lesson") await store.updateJson(store.paths.board(scope.courseId, scope.lessonId), null, (saved) => {
3546
+ if (!saved) throw missing();
3547
+ return {
3548
+ ...saved,
3549
+ steps: fix(saved.steps)
3550
+ };
3551
+ });
3552
+ else if (scope.kind === "thread") await store.updateJson(store.paths.item("assist", scope.id), null, (thread) => {
3553
+ const turn = thread?.turns?.[Math.max(0, Math.min(thread.turns.length - 1, Number(scope.turn) || 0))];
3554
+ if (!turn?.board) throw missing();
3555
+ turn.board = {
3556
+ ...turn.board,
3557
+ steps: fix(turn.board.steps)
3558
+ };
3559
+ return thread;
3560
+ });
3561
+ else await store.updateJson(store.paths.item("shorts", scope.id), null, (short) => {
3562
+ if (!short?.board) throw missing();
3563
+ return {
3564
+ ...short,
3565
+ board: {
3566
+ ...short.board,
3567
+ steps: fix(short.board.steps)
3568
+ }
3569
+ };
3570
+ });
3571
+ return applied;
3572
+ }
3573
+ function challenge({ scope, stepIndex = 0, anchorStepId, target = {}, note }) {
3574
+ const doubt = text(note, 800);
3575
+ return jobs.start("lesson/challenge", async (api) => {
3576
+ const { course, lesson, board, language, writing } = await scopeBoard(scope);
3577
+ const place = await challengedPlace(scope, board, {
3578
+ ...target,
3579
+ anchorStepId
3580
+ });
3581
+ api.preview({ phase: "checking" });
3582
+ const checked = await generateJson(llm, {
3583
+ purpose: "teach",
3584
+ effort: "low",
3585
+ prompt: verifyStepPrompt({
3586
+ course,
3587
+ lesson,
3588
+ language,
3589
+ board: boardForCheck(place.steps, place.step.id),
3590
+ step: stepJson(place.step, { full: true }),
3591
+ stepId: place.step.id,
3592
+ note: doubt
3593
+ }),
3594
+ maxTokens: 6e3,
3595
+ signal: api.signal,
3596
+ normalize: (raw) => normalizeVerdict(raw, place.steps)
3597
+ });
3598
+ api.preview({
3599
+ phase: "answering",
3600
+ verdict: checked.verdict
3601
+ });
3602
+ const answer = await generateSteps(llm, {
3603
+ purpose: "teach",
3604
+ effort: "lowest",
3605
+ prompt: challengePrompt({
3606
+ course,
3607
+ lesson,
3608
+ language,
3609
+ boardSoFar: boardToPlainText({ steps: place.context }, { maxChars: 5e3 }),
3610
+ step: boardToPlainText({ steps: [place.step] }, { maxChars: 3e3 }),
3611
+ note: doubt,
3612
+ verdict: checked.verdict,
3613
+ summary: checked.summary,
3614
+ fixes: describeFixes(checked.fixes, place.steps)
3615
+ }),
3616
+ maxTokens: 5e3,
3617
+ signal: api.signal,
3618
+ conceptIds: lesson?.concepts?.map((concept) => concept.id) ?? [],
3619
+ onStep: (step) => api.push(step)
3620
+ });
3621
+ const fixes = checked.fixes.length ? await storeFixes(scope, place, checked.fixes, writing) : [];
3622
+ const aside = await keepAside(scope, {
3623
+ kind: "challenge",
3624
+ verdict: checked.verdict,
3625
+ note: doubt,
3626
+ summary: checked.summary,
3627
+ anchorStepId: anchorFor(board, stepIndex, anchorStepId),
3628
+ steps: answer.steps
3629
+ });
3630
+ if (scope.kind === "lesson") await logEvent(scope.courseId, {
3631
+ type: "challenge",
3632
+ lessonId: scope.lessonId,
3633
+ verdict: checked.verdict
3634
+ });
3635
+ return {
3636
+ verdict: checked.verdict,
3637
+ summary: checked.summary,
3638
+ target: place.ref,
3639
+ fixes,
3640
+ steps: answer.steps,
3641
+ aside
3642
+ };
3643
+ });
3644
+ }
3645
+ async function record({ courseId, lessonId, conceptIds = [], evidence = {}, item = null }) {
2783
3646
  const course = await courses.readCourse(courseId);
2784
3647
  const index = conceptIndex(course);
2785
3648
  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
3649
  const at = now().getTime();
3650
+ const evidenceId = newId("evd");
3651
+ const key = item?.source?.courseId === courseId ? itemKey(item.source) : null;
2787
3652
  const next = await store.updateJson(store.paths.progress(courseId), null, (progress) => {
2788
3653
  const value = progress ?? {
2789
3654
  courseId,
@@ -2796,14 +3661,25 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
2796
3661
  };
2797
3662
  value.concepts ??= {};
2798
3663
  value.reviews ??= {};
3664
+ const before = {};
2799
3665
  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
- }
3666
+ before[id] = stateBefore(value.concepts[id], value.reviews[id]);
3667
+ const taken = takeEvidence(value.concepts[id] ?? createConceptState(id, index.get(id).name), value.reviews[id], evidence, at);
3668
+ value.concepts[id] = taken.state;
3669
+ if (taken.card) value.reviews[id] = taken.card;
2806
3670
  }
3671
+ if (ids.length) logEvidence(value, {
3672
+ id: evidenceId,
3673
+ at: new Date(at).toISOString(),
3674
+ lessonId: lessonId ?? null,
3675
+ conceptIds: ids,
3676
+ evidence: JSON.parse(JSON.stringify(evidence)),
3677
+ before,
3678
+ ...key ? { item: {
3679
+ key,
3680
+ response: item.response ?? ""
3681
+ } } : {}
3682
+ });
2807
3683
  pushEvent(value, {
2808
3684
  type: "evidence",
2809
3685
  lessonId: lessonId ?? null,
@@ -2816,12 +3692,233 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
2816
3692
  return value;
2817
3693
  });
2818
3694
  const states = ids.map((id) => next.concepts[id]).filter(Boolean);
3695
+ return {
3696
+ mastery: Object.fromEntries(states.map((state) => [state.id, masteryPercent(state)])),
3697
+ recommendation: recommendNext(states),
3698
+ evidenceId: ids.length ? evidenceId : null
3699
+ };
3700
+ }
3701
+ async function regradeEvidence(course, evidenceId, patch) {
3702
+ const index = conceptIndex(course);
3703
+ let changed = null;
3704
+ const next = await store.updateJson(store.paths.progress(course.id), null, (progress) => {
3705
+ const value = progress ?? {
3706
+ courseId: course.id,
3707
+ lessons: {},
3708
+ concepts: {},
3709
+ reviews: {},
3710
+ events: []
3711
+ };
3712
+ changed = correctEvidence(value, evidenceId, patch, {
3713
+ known: (id) => index.get(id)?.name ?? null,
3714
+ at: stamp()
3715
+ });
3716
+ if (changed) pushEvent(value, {
3717
+ type: "regrade",
3718
+ evidenceId,
3719
+ conceptIds: changed.conceptIds,
3720
+ score: patch.score,
3721
+ at: stamp()
3722
+ });
3723
+ return value;
3724
+ });
3725
+ if (!changed?.conceptIds.length) return null;
3726
+ const states = changed.conceptIds.map((id) => next.concepts[id]).filter(Boolean);
2819
3727
  return {
2820
3728
  mastery: Object.fromEntries(states.map((state) => [state.id, masteryPercent(state)])),
2821
3729
  recommendation: recommendNext(states)
2822
3730
  };
2823
3731
  }
2824
- async function grade({ item, response, language = "zh-CN" }) {
3732
+ async function itemPlace(source) {
3733
+ const missing = () => new TutorError("not-found", "Not found");
3734
+ const rewrite = (path, listOf) => async (change) => {
3735
+ let saved = null;
3736
+ await store.updateJson(path, null, (value) => {
3737
+ const items = listOf(value);
3738
+ const index = Array.isArray(items) ? items.findIndex((entry) => entry.id === source.itemId) : -1;
3739
+ if (index < 0) throw missing();
3740
+ saved = change(items[index]);
3741
+ items[index] = saved;
3742
+ return value;
3743
+ });
3744
+ return saved;
3745
+ };
3746
+ if (source.kind === "deck") {
3747
+ const path = store.paths.item("decks", source.deckId);
3748
+ const deck = await store.readJson(path, null);
3749
+ const item = deck?.kind === "quiz" ? deck.items?.find((entry) => entry.id === source.itemId) : null;
3750
+ if (!item) throw missing();
3751
+ return {
3752
+ item,
3753
+ course: null,
3754
+ lessonId: null,
3755
+ language: deck.language,
3756
+ scope: deck.title,
3757
+ save: rewrite(path, (value) => value?.kind === "quiz" ? value.items : null)
3758
+ };
3759
+ }
3760
+ const course = await courses.readCourse(source.courseId);
3761
+ if (source.kind === "quiz") {
3762
+ const unit = course.units?.find((entry) => entry.id === source.unitId);
3763
+ const path = unit ? store.paths.unitQuiz(course.id, unit.id) : null;
3764
+ const item = path ? (await store.readJson(path, null))?.items?.find((entry) => entry.id === source.itemId) : null;
3765
+ if (!item) throw missing();
3766
+ return {
3767
+ item,
3768
+ course,
3769
+ lessonId: null,
3770
+ language: course.language,
3771
+ scope: `${course.title} — ${unit.title}`,
3772
+ save: rewrite(path, (value) => value?.items)
3773
+ };
3774
+ }
3775
+ const row = findLesson(course, source.lessonId);
3776
+ if (!row) throw missing();
3777
+ const scope = `${course.title} — ${row.lesson.title}`;
3778
+ if (source.kind === "check") {
3779
+ const path = store.paths.board(course.id, row.lesson.id);
3780
+ const item = (await store.readJson(path, null))?.steps?.find((step) => step.id === source.stepId)?.check;
3781
+ if (!item) throw missing();
3782
+ const save = async (change) => {
3783
+ let saved = null;
3784
+ await store.updateJson(path, null, (board) => {
3785
+ const step = board?.steps?.find((entry) => entry.id === source.stepId);
3786
+ if (!step?.check) throw missing();
3787
+ saved = change(step.check);
3788
+ step.check = saved;
3789
+ return board;
3790
+ });
3791
+ return saved;
3792
+ };
3793
+ return {
3794
+ item,
3795
+ course,
3796
+ lessonId: row.lesson.id,
3797
+ language: course.language,
3798
+ scope,
3799
+ save
3800
+ };
3801
+ }
3802
+ const path = store.paths.practice(course.id, row.lesson.id);
3803
+ const item = (await store.readJson(path, null))?.items?.find((entry) => entry.id === source.itemId);
3804
+ if (!item) throw missing();
3805
+ return {
3806
+ item,
3807
+ course,
3808
+ lessonId: row.lesson.id,
3809
+ language: course.language,
3810
+ scope,
3811
+ save: rewrite(path, (value) => value?.items)
3812
+ };
3813
+ }
3814
+ async function regradeEarlier({ course, key, item, verdict, except, language, signal }) {
3815
+ const answers = answersTo(await store.readJson(store.paths.progress(course.id), null), key, except);
3816
+ const open = !OBJECTIVE_KINDS.includes(item.kind);
3817
+ const graded = await Promise.all((open ? answers.slice(-3) : answers).map(async (entry) => {
3818
+ const again = open ? await grade({
3819
+ item,
3820
+ response: entry.item.response,
3821
+ language,
3822
+ signal
3823
+ }).catch((error) => {
3824
+ if (signal?.aborted) throw error;
3825
+ return null;
3826
+ }) : null;
3827
+ if (open && !again) return null;
3828
+ return {
3829
+ id: entry.id,
3830
+ next: earlierGrade(item, entry.item.response, {
3831
+ verdict,
3832
+ score: entry.evidence.score,
3833
+ grade: again
3834
+ })
3835
+ };
3836
+ }));
3837
+ let found = null;
3838
+ for (const { id, next } of graded.filter((entry) => entry?.next)) {
3839
+ const corrected = await regradeEvidence(course, id, {
3840
+ score: next.score,
3841
+ errorTag: next.errorTag
3842
+ });
3843
+ if (corrected) found = {
3844
+ count: (found?.count ?? 0) + 1,
3845
+ mastery: {
3846
+ ...found?.mastery,
3847
+ ...corrected.mastery
3848
+ },
3849
+ recommendation: corrected.recommendation
3850
+ };
3851
+ }
3852
+ return found;
3853
+ }
3854
+ function challengeItem({ source, response, shown = {}, evidenceId, note }) {
3855
+ const doubt = text(note, 800);
3856
+ return jobs.start("item/challenge", async (api) => {
3857
+ const place = await itemPlace(source);
3858
+ const answer = cleanResponse(place.item, response);
3859
+ const told = place.item.kind === "short" || place.item.kind === "work" ? [
3860
+ `A grader gave this answer a score of ${shown.score} (${shown.correct ? "correct" : shown.partial ? "partial" : "incorrect"}).`,
3861
+ shown.feedback ? `Feedback shown: ${shown.feedback}` : "",
3862
+ shown.wrongStep ? `First wrong line found: ${shown.wrongStep}` : "",
3863
+ shown.fix ? `Fix shown: ${shown.fix}` : ""
3864
+ ].filter(Boolean).join("\n") : `The answer key marked this answer ${shown.correct ? "correct" : shown.partial ? "partly correct" : "incorrect"}.`;
3865
+ api.preview({ phase: "checking" });
3866
+ const checked = await generateJson(llm, {
3867
+ purpose: "teach",
3868
+ effort: "low",
3869
+ prompt: verifyItemPrompt({
3870
+ scope: place.scope,
3871
+ language: place.language,
3872
+ item: itemForCheck(place.item),
3873
+ kind: place.item.kind,
3874
+ response: responseText(place.item, answer),
3875
+ grading: told,
3876
+ note: doubt
3877
+ }),
3878
+ maxTokens: 5e3,
3879
+ signal: api.signal,
3880
+ normalize: (raw) => normalizeItemVerdict(raw, place.item)
3881
+ });
3882
+ const item = checked.item ? await place.save((stored) => fixItem(stored, checked.item, { at: stamp() })) : place.item;
3883
+ const result = regrade(item, answer, checked);
3884
+ const changed = Boolean(result) && (result.correct !== Boolean(shown.correct) || Math.abs(result.score - (Number(shown.score) || 0)) > 1e-6);
3885
+ const corrected = changed && evidenceId && place.course ? await regradeEvidence(place.course, evidenceId, {
3886
+ score: result.score,
3887
+ ...result.correct ? { errorTag: "" } : result.errorTag ? { errorTag: result.errorTag } : {}
3888
+ }) : null;
3889
+ const earlier = checked.item && place.course ? await regradeEarlier({
3890
+ course: place.course,
3891
+ key: itemKey(source),
3892
+ item,
3893
+ verdict: checked.verdict,
3894
+ except: evidenceId,
3895
+ language: place.language,
3896
+ signal: api.signal
3897
+ }) : null;
3898
+ if (place.course) await logEvent(place.course.id, {
3899
+ type: "item-challenge",
3900
+ lessonId: place.lessonId,
3901
+ verdict: checked.verdict,
3902
+ changed,
3903
+ earlier: earlier?.count ?? 0
3904
+ });
3905
+ const mastery = corrected || earlier ? {
3906
+ ...corrected?.mastery,
3907
+ ...earlier?.mastery
3908
+ } : null;
3909
+ return {
3910
+ verdict: checked.verdict,
3911
+ summary: checked.summary,
3912
+ explanation: checked.explanation,
3913
+ item: checked.item ? item : null,
3914
+ result: changed ? result : null,
3915
+ earlier: earlier?.count ?? 0,
3916
+ mastery,
3917
+ recommendation: earlier?.recommendation ?? corrected?.recommendation ?? null
3918
+ };
3919
+ });
3920
+ }
3921
+ async function grade({ item, response, language = "zh-CN", signal }) {
2825
3922
  if (!item?.prompt) throw new TutorError("invalid", "Missing question");
2826
3923
  const work = item.kind === "work";
2827
3924
  const answer = text(response, 4e3);
@@ -2850,6 +3947,7 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
2850
3947
  language
2851
3948
  }),
2852
3949
  maxTokens: work ? 1800 : 1200,
3950
+ signal,
2853
3951
  normalize: (raw) => ({
2854
3952
  score: Math.min(1, Math.max(0, Number(raw?.score) || 0)),
2855
3953
  verdict: [
@@ -3029,6 +4127,44 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
3029
4127
  if (!exists && !jobs.running(`practice:${course.id}:${row.lecture.id}`)) lecturePractice(course, row);
3030
4128
  }).catch(() => void 0);
3031
4129
  }
4130
+ async function handout({ courseId, lessonId, force = false }) {
4131
+ const { course, row } = await context(courseId, lessonId);
4132
+ if (!force) {
4133
+ const existing = await store.readJson(store.paths.handout(courseId, lessonId), null);
4134
+ if (existing) return { handout: existing };
4135
+ }
4136
+ const board = await store.readJson(store.paths.board(courseId, lessonId), null);
4137
+ if (!board?.steps?.length) throw new TutorError("no-board", "The board is not written yet. Try again once its first step is up.");
4138
+ return { job: jobs.start("lesson/handout", async (api) => {
4139
+ const made = await generateJson(llm, {
4140
+ purpose: "teach",
4141
+ effort: "lowest",
4142
+ prompt: handoutPrompt({
4143
+ course,
4144
+ lesson: row.lesson,
4145
+ language: course.language,
4146
+ board: boardToPlainText(board, { maxChars: 12e3 })
4147
+ }),
4148
+ maxTokens: 5e3,
4149
+ signal: api.signal,
4150
+ normalize: normalizeHandout
4151
+ });
4152
+ const saved = {
4153
+ ...made,
4154
+ title: made.title || row.lesson.title,
4155
+ lessonId,
4156
+ createdAt: stamp()
4157
+ };
4158
+ await store.writeJson(store.paths.handout(courseId, lessonId), saved);
4159
+ return { handout: saved };
4160
+ }, {
4161
+ key: `handout:${courseId}:${lessonId}`,
4162
+ meta: {
4163
+ courseId,
4164
+ lessonId
4165
+ }
4166
+ }) };
4167
+ }
3032
4168
  async function unitSheet({ courseId, unitId, force = false }) {
3033
4169
  const course = await courses.readCourse(courseId);
3034
4170
  const unit = course.units?.find((item) => item.id === assertId(unitId, "unt"));
@@ -3183,6 +4319,9 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
3183
4319
  prepare,
3184
4320
  ask,
3185
4321
  reexplain,
4322
+ challenge,
4323
+ challengeItem,
4324
+ handout,
3186
4325
  record,
3187
4326
  grade,
3188
4327
  correct,
@@ -3525,6 +4664,7 @@ function createStore({ root }) {
3525
4664
  practice: (id, lessonId) => join(paths.courseDir(id), "practice", `${assertId(lessonId, "les")}.json`),
3526
4665
  asides: (id, lessonId) => join(paths.courseDir(id), "asides", `${assertId(lessonId, "les")}.json`),
3527
4666
  sheet: (id, unitId) => join(paths.courseDir(id), "sheets", `${assertId(unitId, "unt")}.json`),
4667
+ handout: (id, lessonId) => join(paths.courseDir(id), "handouts", `${assertId(lessonId, "les")}.json`),
3528
4668
  unitQuiz: (id, unitId) => join(paths.courseDir(id), "quizzes", `${assertId(unitId, "unt")}.json`),
3529
4669
  materials: () => join(root, "materials"),
3530
4670
  materialDir: (id) => join(root, "materials", assertId(id, "mat")),
@@ -4251,7 +5391,9 @@ const IDS = {
4251
5391
  chs: ID("chs"),
4252
5392
  dck: ID("dck"),
4253
5393
  exm: ID("exm"),
4254
- nte: ID("nte")
5394
+ nte: ID("nte"),
5395
+ asd: ID("asd"),
5396
+ evd: ID("evd")
4255
5397
  };
4256
5398
  function need(payload, key, prefix) {
4257
5399
  const value = payload?.[key];
@@ -4287,6 +5429,78 @@ function scopeOf(payload) {
4287
5429
  };
4288
5430
  throw new TutorError("invalid", "Invalid board scope");
4289
5431
  }
5432
+ function challengeTarget(payload) {
5433
+ const target = payload?.target && typeof payload.target === "object" ? payload.target : {};
5434
+ if (target.step && typeof target.step === "object") return { step: target.step };
5435
+ if (typeof target.stepId !== "string" || !/^s\d{1,4}$/u.test(target.stepId)) throw new TutorError("invalid", "Invalid step");
5436
+ return {
5437
+ stepId: target.stepId,
5438
+ ...target.asideId === void 0 ? {} : { asideId: need(target, "asideId", "asd") }
5439
+ };
5440
+ }
5441
+ function itemSource(payload) {
5442
+ const source = payload?.source && typeof payload.source === "object" ? payload.source : {};
5443
+ const itemId = () => {
5444
+ if (typeof source.itemId !== "string" || !/^[a-z0-9_-]{1,24}$/iu.test(source.itemId)) throw new TutorError("invalid", "Invalid question");
5445
+ return source.itemId;
5446
+ };
5447
+ if (source.kind === "practice") return {
5448
+ kind: "practice",
5449
+ courseId: need(source, "courseId", "crs"),
5450
+ lessonId: need(source, "lessonId", "les"),
5451
+ itemId: itemId()
5452
+ };
5453
+ if (source.kind === "quiz") return {
5454
+ kind: "quiz",
5455
+ courseId: need(source, "courseId", "crs"),
5456
+ unitId: need(source, "unitId", "unt"),
5457
+ itemId: itemId()
5458
+ };
5459
+ if (source.kind === "deck") return {
5460
+ kind: "deck",
5461
+ deckId: need(source, "deckId", "dck"),
5462
+ itemId: itemId()
5463
+ };
5464
+ if (source.kind === "check") {
5465
+ if (typeof source.stepId !== "string" || !/^s\d{1,4}$/u.test(source.stepId)) throw new TutorError("invalid", "Invalid step");
5466
+ return {
5467
+ kind: "check",
5468
+ courseId: need(source, "courseId", "crs"),
5469
+ lessonId: need(source, "lessonId", "les"),
5470
+ stepId: source.stepId
5471
+ };
5472
+ }
5473
+ throw new TutorError("invalid", "Invalid question");
5474
+ }
5475
+ function answerOf(value) {
5476
+ if (typeof value === "string") return value.slice(0, 4e3);
5477
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
5478
+ return Array.isArray(value) ? value.filter((id) => typeof id === "string" && /^[a-z]$/u.test(id)).slice(0, 12) : "";
5479
+ }
5480
+ function answeredItem(payload, courseId) {
5481
+ if (!payload?.source || payload.source.kind === "deck") return null;
5482
+ try {
5483
+ const source = itemSource(payload);
5484
+ return source.courseId === courseId ? {
5485
+ source,
5486
+ response: answerOf(payload.response)
5487
+ } : null;
5488
+ } catch {
5489
+ return null;
5490
+ }
5491
+ }
5492
+ function shownGrade(value) {
5493
+ const grade = value && typeof value === "object" ? value : {};
5494
+ const words = (key, max) => typeof grade[key] === "string" ? grade[key].slice(0, max) : "";
5495
+ return {
5496
+ score: Math.min(1, Math.max(0, Number(grade.score) || 0)),
5497
+ correct: grade.correct === true,
5498
+ partial: grade.partial === true,
5499
+ feedback: words("feedback", 1200),
5500
+ wrongStep: words("wrongStep", 400),
5501
+ fix: words("fix", 400)
5502
+ };
5503
+ }
4290
5504
  const ITEM_KINDS = {
4291
5505
  sheets: "chs",
4292
5506
  decks: "dck",
@@ -4341,6 +5555,11 @@ function createRoutes({ courses, lessons, learner, studio, projects, jobs, setti
4341
5555
  courseId: need(payload, "courseId", "crs"),
4342
5556
  instruction: payload.instruction
4343
5557
  }) }),
5558
+ "course/add-materials": (payload) => ({ job: courses.addMaterials({
5559
+ courseId: need(payload, "courseId", "crs"),
5560
+ materialIds: idList(payload.materialIds, "mat", 12),
5561
+ instruction: payload.instruction
5562
+ }) }),
4344
5563
  "course/save-blueprint": (payload) => courses.saveBlueprint({
4345
5564
  courseId: need(payload, "courseId", "crs"),
4346
5565
  blueprint: payload.blueprint
@@ -4396,9 +5615,17 @@ function createRoutes({ courses, lessons, learner, studio, projects, jobs, setti
4396
5615
  style: STYLES.includes(payload.style) ? payload.style : "example",
4397
5616
  anchorStepId: payload.anchorStepId
4398
5617
  }) }),
5618
+ "lesson/challenge": (payload) => ({ job: lessons.challenge({
5619
+ scope: scopeOf(payload),
5620
+ stepIndex: payload.stepIndex,
5621
+ anchorStepId: payload.anchorStepId,
5622
+ target: challengeTarget(payload),
5623
+ note: typeof payload.note === "string" ? payload.note : ""
5624
+ }) }),
4399
5625
  "lesson/record": (payload) => lessons.record({
4400
5626
  courseId: need(payload, "courseId", "crs"),
4401
5627
  lessonId: optional(payload, "lessonId", "les"),
5628
+ item: answeredItem(payload, need(payload, "courseId", "crs")),
4402
5629
  conceptIds: (Array.isArray(payload.conceptIds) ? payload.conceptIds : []).filter((id) => /^cpt_[a-z0-9]{6,40}$/u.test(String(id))).slice(0, 8),
4403
5630
  evidence: {
4404
5631
  kind: payload.evidence?.kind,
@@ -4416,6 +5643,13 @@ function createRoutes({ courses, lessons, learner, studio, projects, jobs, setti
4416
5643
  response: payload.response,
4417
5644
  language: typeof payload.language === "string" ? payload.language.slice(0, 12) : void 0
4418
5645
  }),
5646
+ "item/challenge": (payload) => ({ job: lessons.challengeItem({
5647
+ source: itemSource(payload),
5648
+ response: answerOf(payload.response),
5649
+ shown: shownGrade(payload.shown),
5650
+ evidenceId: optional(payload, "evidenceId", "evd"),
5651
+ note: typeof payload.note === "string" ? payload.note : ""
5652
+ }) }),
4419
5653
  "lesson/correct": (payload) => ({ job: lessons.correct({
4420
5654
  scope: scopeOf(payload),
4421
5655
  stepIndex: payload.stepIndex,
@@ -4434,6 +5668,11 @@ function createRoutes({ courses, lessons, learner, studio, projects, jobs, setti
4434
5668
  lessonId: need(payload, "lessonId", "les"),
4435
5669
  selfRating: Number(payload.selfRating) || void 0
4436
5670
  }),
5671
+ "lesson/handout": (payload) => lessons.handout({
5672
+ courseId: need(payload, "courseId", "crs"),
5673
+ lessonId: need(payload, "lessonId", "les"),
5674
+ force: payload.force === true
5675
+ }),
4437
5676
  "lesson/practice": (payload) => lessons.practice({
4438
5677
  courseId: need(payload, "courseId", "crs"),
4439
5678
  lessonId: need(payload, "lessonId", "les")
@@ -4455,6 +5694,7 @@ function createRoutes({ courses, lessons, learner, studio, projects, jobs, setti
4455
5694
  }),
4456
5695
  "profile/get": () => learner.getProfile(),
4457
5696
  "profile/save": (payload) => learner.saveProfile({
5697
+ name: payload.name,
4458
5698
  background: payload.background,
4459
5699
  goals: payload.goals,
4460
5700
  strengths: payload.strengths,
@@ -4492,6 +5732,7 @@ function createRoutes({ courses, lessons, learner, studio, projects, jobs, setti
4492
5732
  }) }),
4493
5733
  "material/url": (payload) => ({ job: studio.addUrl({ url: payload.url }) }),
4494
5734
  "material/list": () => studio.listMaterials(),
5735
+ "material/search": (payload) => studio.searchMaterials({ query: typeof payload.query === "string" ? payload.query : "" }),
4495
5736
  "material/get": (payload) => studio.getMaterial({ id: need(payload, "id", "mat") }),
4496
5737
  "material/rename": (payload) => studio.renameMaterial({
4497
5738
  id: need(payload, "id", "mat"),
@@ -4686,8 +5927,12 @@ const TTS_PROVIDERS = Object.freeze([
4686
5927
  "minimax",
4687
5928
  "azure"
4688
5929
  ]);
4689
- const REQUEST_TIMEOUT_MS = 45e3;
5930
+ const TTS_LIMITS = Object.freeze({
5931
+ totalMs: 45e3,
5932
+ quietMs: 1e4
5933
+ });
4690
5934
  const MAX_AUDIO_BYTES = 12 * 1024 * 1024;
5935
+ const MAX_JSON_BYTES = 3 * MAX_AUDIO_BYTES;
4691
5936
  const CACHE_MAX_BYTES = 300 * 1024 * 1024;
4692
5937
  var TtsError = class extends Error {
4693
5938
  constructor(code, message, status) {
@@ -4826,28 +6071,75 @@ function splitForTts(content, maxUnits) {
4826
6071
  if (current) chunks.push(current);
4827
6072
  return chunks;
4828
6073
  }
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;
6074
+ function firstByteMs(text, limits = TTS_LIMITS) {
6075
+ return Math.min(limits.totalMs, limits.quietMs + Math.round(estimateSpeechMs(text) / 10));
6076
+ }
6077
+ async function boundedFetch(fetchImpl, url, init, { text, signal, limits, maxBytes }) {
6078
+ const limit = new AbortController();
6079
+ let reason = null;
6080
+ const stop = (code) => {
6081
+ reason ??= code;
6082
+ limit.abort();
6083
+ };
6084
+ const total = setTimeout(() => stop("timeout"), limits.totalMs);
6085
+ let quiet = setTimeout(() => stop("stalled"), firstByteMs(text, limits));
6086
+ const combined = signal ? AbortSignal.any([signal, limit.signal]) : limit.signal;
6087
+ const failure = (error) => {
6088
+ if (signal?.aborted) return signal.reason ?? error;
6089
+ if (reason === "stalled") return new TtsError("stalled", "The TTS service is not responding");
6090
+ if (reason === "timeout") return new TtsError("timeout", "TTS request timed out");
6091
+ return null;
6092
+ };
4833
6093
  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");
6094
+ let response;
6095
+ try {
6096
+ response = await fetchImpl(url, {
6097
+ ...init,
6098
+ redirect: "error",
6099
+ signal: combined
6100
+ });
6101
+ } catch (error) {
6102
+ throw failure(error) ?? new TtsError("network", "Could not reach the TTS service");
6103
+ }
6104
+ if (!response.ok) {
6105
+ response.body?.cancel().catch(() => void 0);
6106
+ throw httpError(response.status);
6107
+ }
6108
+ const length = Number(response.headers.get("content-length"));
6109
+ if (Number.isFinite(length) && length > maxBytes) throw new TtsError("too-large", "TTS response is too large");
6110
+ const parts = [];
6111
+ let size = 0;
6112
+ const reader = response.body?.getReader();
6113
+ while (reader) {
6114
+ let chunk;
6115
+ try {
6116
+ chunk = await reader.read();
6117
+ } catch (error) {
6118
+ throw failure(error) ?? error;
6119
+ }
6120
+ if (chunk.done) break;
6121
+ if (!chunk.value?.byteLength) continue;
6122
+ clearTimeout(quiet);
6123
+ quiet = setTimeout(() => stop("stalled"), limits.quietMs);
6124
+ size += chunk.value.byteLength;
6125
+ if (size > maxBytes) {
6126
+ reader.cancel().catch(() => void 0);
6127
+ throw new TtsError("too-large", "TTS response is too large");
6128
+ }
6129
+ parts.push(chunk.value);
6130
+ }
6131
+ return Buffer.concat(parts);
6132
+ } finally {
6133
+ clearTimeout(total);
6134
+ clearTimeout(quiet);
4842
6135
  }
4843
- return response;
4844
6136
  }
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;
6137
+ function parseJson(body, service) {
6138
+ try {
6139
+ return JSON.parse(body.toString("utf8"));
6140
+ } catch {
6141
+ throw new TtsError("response", `${service} returned an unreadable response`);
6142
+ }
4851
6143
  }
4852
6144
  function httpError(status) {
4853
6145
  if (status === 401 || status === 403) return new TtsError("auth", "The TTS service rejected the key", status);
@@ -4869,10 +6161,10 @@ function xmlEscape(value) {
4869
6161
  })[char]);
4870
6162
  }
4871
6163
  const synthesizers = {
4872
- async openai({ text, config, secret, rate, fetchImpl, signal }) {
6164
+ async openai({ text, config, secret, rate, send }) {
4873
6165
  const base = checkEndpoint(config.baseUrl || "https://api.openai.com/v1");
4874
6166
  const path = base.pathname.replace(/\/+$/u, "");
4875
- const response = await boundedFetch(fetchImpl, new URL(`${path}/audio/speech`, base), {
6167
+ return send(new URL(`${path}/audio/speech`, base), {
4876
6168
  method: "POST",
4877
6169
  headers: {
4878
6170
  Authorization: `Bearer ${secret}`,
@@ -4886,13 +6178,11 @@ const synthesizers = {
4886
6178
  response_format: "mp3",
4887
6179
  speed: rateNumber(rate)
4888
6180
  })
4889
- }, signal);
4890
- if (!response.ok) throw httpError(response.status);
4891
- return readBounded(response);
6181
+ }, MAX_AUDIO_BYTES);
4892
6182
  },
4893
- async volcengine({ text, config, secret, rate, fetchImpl, signal }) {
6183
+ async volcengine({ text, config, secret, rate, send }) {
4894
6184
  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", {
6185
+ const body = parseJson(await send("https://openspeech.bytedance.com/api/v1/tts", {
4896
6186
  method: "POST",
4897
6187
  headers: {
4898
6188
  Authorization: `Bearer;${secret}`,
@@ -4916,24 +6206,17 @@ const synthesizers = {
4916
6206
  operation: "query"
4917
6207
  }
4918
6208
  })
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
- }
6209
+ }, MAX_JSON_BYTES), "Volcengine");
4927
6210
  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
6211
  const audio = Buffer.from(body.data, "base64");
4929
6212
  if (audio.length > MAX_AUDIO_BYTES) throw new TtsError("too-large", "TTS response is too large");
4930
6213
  return audio;
4931
6214
  },
4932
- async minimax({ text, config, secret, rate, fetchImpl, signal }) {
6215
+ async minimax({ text, config, secret, rate, send }) {
4933
6216
  const base = checkEndpoint(config.baseUrl || "https://api.minimaxi.com");
4934
6217
  const url = new URL("/v1/t2a_v2", base);
4935
6218
  if (config.groupId) url.searchParams.set("GroupId", config.groupId);
4936
- const response = await boundedFetch(fetchImpl, url, {
6219
+ const body = parseJson(await send(url, {
4937
6220
  method: "POST",
4938
6221
  headers: {
4939
6222
  Authorization: `Bearer ${secret}`,
@@ -4956,21 +6239,14 @@ const synthesizers = {
4956
6239
  channel: 1
4957
6240
  }
4958
6241
  })
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
- }
6242
+ }, MAX_JSON_BYTES), "MiniMax");
4967
6243
  const status = body?.base_resp?.status_code;
4968
6244
  if (status !== 0 || typeof body?.data?.audio !== "string") throw new TtsError(status === 1004 || status === 2049 ? "auth" : "request", `MiniMax TTS failed (${status ?? "unknown"})`);
4969
6245
  const audio = Buffer.from(body.data.audio, "hex");
4970
6246
  if (audio.length > MAX_AUDIO_BYTES) throw new TtsError("too-large", "TTS response is too large");
4971
6247
  return audio;
4972
6248
  },
4973
- async azure({ text, config, secret, rate, fetchImpl, signal }) {
6249
+ async azure({ text, config, secret, rate, send }) {
4974
6250
  const region = String(config.region || "").trim().toLowerCase();
4975
6251
  if (!/^[a-z0-9]{2,30}$/u.test(region)) throw new TtsError("azure", "Azure region is invalid");
4976
6252
  const voice = String(config.voice || "zh-CN-XiaoxiaoNeural");
@@ -4978,7 +6254,7 @@ const synthesizers = {
4978
6254
  const lang = voice.split("-").slice(0, 2).join("-");
4979
6255
  const percent = Math.round((rateNumber(rate) - 1) * 100);
4980
6256
  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`, {
6257
+ return send(`https://${region}.tts.speech.microsoft.com/cognitiveservices/v1`, {
4982
6258
  method: "POST",
4983
6259
  headers: {
4984
6260
  "Ocp-Apim-Subscription-Key": secret,
@@ -4987,9 +6263,7 @@ const synthesizers = {
4987
6263
  "User-Agent": "dsh-aha-tutor"
4988
6264
  },
4989
6265
  body: ssml
4990
- }, signal);
4991
- if (!response.ok) throw httpError(response.status);
4992
- return readBounded(response);
6266
+ }, MAX_AUDIO_BYTES);
4993
6267
  }
4994
6268
  };
4995
6269
  const CHUNK_UNITS = Object.freeze({
@@ -4998,7 +6272,7 @@ const CHUNK_UNITS = Object.freeze({
4998
6272
  minimax: 9e3,
4999
6273
  azure: 6e3
5000
6274
  });
5001
- function createTts({ cacheDir, fetchImpl = globalThis.fetch, getSecret }) {
6275
+ function createTts({ cacheDir, fetchImpl = globalThis.fetch, getSecret, limits = TTS_LIMITS }) {
5002
6276
  if (typeof getSecret !== "function") throw new TypeError("createTts needs getSecret");
5003
6277
  let pruning;
5004
6278
  const cacheKey = (provider, config, rate, text) => createHash("sha256").update(JSON.stringify([
@@ -5032,18 +6306,25 @@ function createTts({ cacheDir, fetchImpl = globalThis.fetch, getSecret }) {
5032
6306
  const secret = await getSecret(provider);
5033
6307
  if (!secret) throw new TtsError("no-key", "No key saved for this TTS provider");
5034
6308
  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
- }));
6309
+ for (const chunk of splitForTts(content, CHUNK_UNITS[provider])) {
6310
+ const send = (url, init, maxBytes) => boundedFetch(fetchImpl, url, init, {
6311
+ text: chunk,
6312
+ signal,
6313
+ limits,
6314
+ maxBytes
6315
+ });
6316
+ parts.push(await synthesizers[provider]({
6317
+ text: chunk,
6318
+ config: merged,
6319
+ secret,
6320
+ rate,
6321
+ send
6322
+ }));
6323
+ }
5043
6324
  const audio = Buffer.concat(parts);
5044
6325
  if (!audio.length) throw new TtsError("response", "The TTS service returned no audio");
5045
6326
  if (file) {
5046
- await writeFileAtomic(file, audio).catch(() => void 0);
6327
+ await mkdir(cacheDir, { recursive: true }).then(() => writeFileAtomic(file, audio)).catch(() => void 0);
5047
6328
  pruning ??= prune().finally(() => {
5048
6329
  pruning = void 0;
5049
6330
  });
@@ -5316,6 +6597,37 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
5316
6597
  const ids = await store.listIds(store.paths.materials(), "mat");
5317
6598
  return (await Promise.all(ids.map((id) => store.readJson(store.paths.material(id), null)))).filter(Boolean).sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
5318
6599
  }
6600
+ let libraryIndex = null;
6601
+ const SEARCH_CHUNKS = 3e4;
6602
+ async function searchMaterials({ query }) {
6603
+ const wanted = text(query, 200);
6604
+ if (!wanted) return { results: [] };
6605
+ const metas = (await listMaterials()).filter((meta) => meta.kind !== "image");
6606
+ const key = metas.map((meta) => `${meta.id}:${meta.title}:${meta.chars ?? meta.pages ?? ""}`).join("|");
6607
+ if (libraryIndex?.key !== key) {
6608
+ const chunks = [];
6609
+ for (const meta of metas) {
6610
+ if (chunks.length >= SEARCH_CHUNKS) break;
6611
+ const own = await store.readJson(store.paths.materialChunks(meta.id), []) ?? [];
6612
+ for (const chunk of own.slice(0, SEARCH_CHUNKS - chunks.length)) chunks.push({
6613
+ ...chunk,
6614
+ materialId: meta.id,
6615
+ materialTitle: meta.title
6616
+ });
6617
+ }
6618
+ libraryIndex = {
6619
+ key,
6620
+ index: buildIndex(chunks)
6621
+ };
6622
+ }
6623
+ return { results: search(libraryIndex.index, wanted, { limit: 12 }).map(({ chunk }) => ({
6624
+ materialId: chunk.materialId,
6625
+ title: chunk.materialTitle,
6626
+ page: chunk.page ?? null,
6627
+ slide: chunk.slide ?? null,
6628
+ snippet: snippetOf(chunk.text, wanted)
6629
+ })) };
6630
+ }
5319
6631
  async function getMaterial({ id }) {
5320
6632
  const meta = await store.readJson(store.paths.material(assertId(id, "mat")), null);
5321
6633
  if (!meta) throw new TutorError("not-found", "Material not found");
@@ -5756,6 +7068,7 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
5756
7068
  upload,
5757
7069
  addUrl,
5758
7070
  listMaterials,
7071
+ searchMaterials,
5759
7072
  getMaterial,
5760
7073
  renameMaterial,
5761
7074
  removeMaterial,