dsh-aha-tutor 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,
@@ -1640,11 +1647,74 @@ async function ingestUrl(address, { fetchImpl = pageFetch, signal } = {}) {
1640
1647
  if (!chunks.length) throw new MaterialError("page-no-text", "No readable text on this page");
1641
1648
  return finish("url", title, chunks, { url: url.href });
1642
1649
  }
1650
+ const BYTES_PER_SECOND = 32e3;
1651
+ const MAX_SPEECH_BYTES = 3840044;
1652
+ const MAX_TEXT = 2e3;
1653
+ const USABLE = /* @__PURE__ */ new Set(["ready", "standby"]);
1654
+ const PREPARING = /* @__PURE__ */ new Set([
1655
+ "checking",
1656
+ "downloading",
1657
+ "cancelling"
1658
+ ]);
1659
+ function wavSeconds(bytes) {
1660
+ const data = bytes instanceof Uint8Array ? bytes : null;
1661
+ if (!data || data.length < 46) return null;
1662
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
1663
+ const tag = (at) => String.fromCharCode(...data.subarray(at, at + 4));
1664
+ return tag(0) === "RIFF" && tag(8) === "WAVE" && tag(12) === "fmt " && tag(36) === "data" && view.getUint32(4, true) === data.length - 8 && view.getUint32(16, true) === 16 && view.getUint16(20, true) === 1 && view.getUint16(22, true) === 1 && view.getUint32(24, true) === 16e3 && view.getUint32(28, true) === BYTES_PER_SECOND && view.getUint16(32, true) === 2 && view.getUint16(34, true) === 16 && view.getUint32(40, true) === data.length - 44 && (data.length - 44) % 2 === 0 ? (data.length - 44) / BYTES_PER_SECOND : null;
1665
+ }
1666
+ function createSpeechInput({ getSpeech, logger }) {
1667
+ function status() {
1668
+ const speech = getSpeech();
1669
+ let snapshot = null;
1670
+ try {
1671
+ snapshot = speech?.snapshot?.() ?? null;
1672
+ } catch {
1673
+ snapshot = null;
1674
+ }
1675
+ const chosen = snapshot?.selection?.providerId;
1676
+ const provider = snapshot?.providers?.find((entry) => entry.id === chosen);
1677
+ if (!provider) return {
1678
+ state: "off",
1679
+ maxSeconds: 120
1680
+ };
1681
+ const phase = provider.preparation?.phase ?? "ready";
1682
+ return {
1683
+ state: USABLE.has(phase) ? "ready" : PREPARING.has(phase) ? "preparing" : phase === "failed" ? "failed" : "unprepared",
1684
+ maxSeconds: 120
1685
+ };
1686
+ }
1687
+ async function transcribe({ audio, signal }) {
1688
+ const { state } = status();
1689
+ if (state === "off") throw new TutorError("speech-off", "Voice input is not enabled in DSH");
1690
+ if (state !== "ready") throw new TutorError("speech-unprepared", "The speech recognizer is not ready yet");
1691
+ const seconds = wavSeconds(audio);
1692
+ if (seconds === null) throw new TutorError("speech-audio", "The recording could not be read");
1693
+ if (seconds > 120) throw new TutorError("speech-too-long", `Recordings are limited to 120 seconds`);
1694
+ const speech = getSpeech();
1695
+ let result;
1696
+ try {
1697
+ result = await speech.transcribe(speech.resolve({ audio: Buffer.from(audio.buffer, audio.byteOffset, audio.byteLength) }), signal ?? new AbortController().signal);
1698
+ } catch (error) {
1699
+ if (signal?.aborted) throw error;
1700
+ logger?.debug?.("aha-tutor speech recognition failed: %s", error?.message);
1701
+ throw new TutorError("speech-failed", "Speech recognition failed");
1702
+ }
1703
+ const text = String(typeof result === "string" ? result : result?.text ?? "").replace(/\s+/gu, " ").trim().slice(0, MAX_TEXT);
1704
+ if (!text) throw new TutorError("speech-empty", "No speech was heard");
1705
+ return { text };
1706
+ }
1707
+ return Object.freeze({
1708
+ status,
1709
+ transcribe
1710
+ });
1711
+ }
1643
1712
  //#endregion
1644
1713
  //#region src/host/http.js
1645
1714
  const UPLOAD_PATH = "/api/aha-tutor/upload";
1646
1715
  const TTS_PATH = "/api/aha-tutor/tts";
1647
1716
  const FONT_PATH = "/api/aha-tutor/font";
1717
+ const SPEECH_PATH = "/api/aha-tutor/speech";
1648
1718
  async function readCapped(stream, max, signal) {
1649
1719
  const parts = [];
1650
1720
  let size = 0;
@@ -1697,6 +1767,34 @@ function uploadRoute({ studio }) {
1697
1767
  }
1698
1768
  };
1699
1769
  }
1770
+ function speechRoute({ speechInput }) {
1771
+ return {
1772
+ path: SPEECH_PATH,
1773
+ methods: ["POST"],
1774
+ requestBody: "streaming",
1775
+ fetch: async (request) => {
1776
+ let audio;
1777
+ try {
1778
+ audio = await readCapped(request.body, MAX_SPEECH_BYTES, request.signal);
1779
+ } catch {
1780
+ return failure$1("aborted", "Upload interrupted", 400);
1781
+ }
1782
+ if (audio === null) return failure$1("speech-too-long", "The recording is too long", 413);
1783
+ try {
1784
+ return json({
1785
+ ok: true,
1786
+ value: await speechInput.transcribe({
1787
+ audio: new Uint8Array(audio.buffer, audio.byteOffset, audio.byteLength),
1788
+ signal: request.signal
1789
+ })
1790
+ });
1791
+ } catch (error) {
1792
+ const known = error?.name === "TutorError";
1793
+ return failure$1(known ? publicCode(error) : "speech-failed", known ? error.message : "Speech recognition failed", known && error.code !== "speech-failed" ? 400 : 502);
1794
+ }
1795
+ }
1796
+ };
1797
+ }
1700
1798
  function ttsRoute({ speak }) {
1701
1799
  return {
1702
1800
  path: TTS_PATH,
@@ -2277,16 +2375,33 @@ function createLearner({ store, llm, now = () => /* @__PURE__ */ new Date(), log
2277
2375
  const lesson = findLesson(course, concept.lessonId)?.lesson;
2278
2376
  const set = await store.readJson(store.paths.practice(course.id, concept.lessonId), null);
2279
2377
  const board = await store.readJson(store.paths.board(course.id, concept.lessonId), null);
2280
- const candidates = [...set?.items ?? [], ...(board?.steps ?? []).map((step) => step.check).filter(Boolean)].filter((item) => item.conceptIds?.includes(conceptId) && item.kind !== "short" && item.kind !== "work");
2281
- const item = candidates[Math.floor(Math.random() * candidates.length)];
2282
- 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({
2283
2397
  courseId: course.id,
2284
2398
  courseTitle: course.title,
2285
2399
  lessonId: concept.lessonId,
2286
2400
  lessonTitle: lesson?.title ?? "",
2287
2401
  conceptId,
2288
2402
  conceptName: concept.name,
2289
- item
2403
+ item: pick.item,
2404
+ source: pick.source
2290
2405
  });
2291
2406
  }
2292
2407
  }
@@ -2335,6 +2450,223 @@ function cleanProfile(profile) {
2335
2450
  };
2336
2451
  }
2337
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
2338
2670
  //#region src/core/srs.js
2339
2671
  const GRADES$1 = Object.freeze([
2340
2672
  "again",
@@ -2366,7 +2698,7 @@ function review(card, grade, now = Date.now()) {
2366
2698
  if (!GRADES$1.includes(grade)) throw new TypeError(`grade must be one of ${GRADES$1.join(", ")}`);
2367
2699
  const quality = GRADE_QUALITY[grade];
2368
2700
  const next = { ...card };
2369
- 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)));
2370
2702
  if (grade === "again") {
2371
2703
  next.reps = 0;
2372
2704
  next.lapses = card.lapses + 1;
@@ -2392,10 +2724,371 @@ function gradeFromScore(score, confidence) {
2392
2724
  if (score >= .95 && confidence === "sure") return "easy";
2393
2725
  return "good";
2394
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
+ }
2395
2920
  function round2(value) {
2396
2921
  return Math.round(value * 100) / 100;
2397
2922
  }
2398
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
2399
3092
  //#region src/host/lessons.js
2400
3093
  const EVENTS_KEPT = 200;
2401
3094
  const STEP_ID = /^s\d{1,4}$/u;
@@ -2570,7 +3263,8 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
2570
3263
  course,
2571
3264
  lesson: row.lesson,
2572
3265
  board,
2573
- language: course.language
3266
+ language: course.language,
3267
+ writing
2574
3268
  };
2575
3269
  }
2576
3270
  if (scope?.kind === "thread" || scope?.kind === "short") {
@@ -2587,7 +3281,8 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
2587
3281
  objectives: []
2588
3282
  },
2589
3283
  board,
2590
- language
3284
+ language,
3285
+ writing
2591
3286
  };
2592
3287
  }
2593
3288
  throw new TutorError("invalid", "Unknown board");
@@ -2688,11 +3383,166 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
2688
3383
  };
2689
3384
  });
2690
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
+ }
2691
3540
  async function record({ courseId, lessonId, conceptIds = [], evidence = {} }) {
2692
3541
  const course = await courses.readCourse(courseId);
2693
3542
  const index = conceptIndex(course);
2694
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);
2695
3544
  const at = now().getTime();
3545
+ const evidenceId = newId("evd");
2696
3546
  const next = await store.updateJson(store.paths.progress(courseId), null, (progress) => {
2697
3547
  const value = progress ?? {
2698
3548
  courseId,
@@ -2705,14 +3555,21 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
2705
3555
  };
2706
3556
  value.concepts ??= {};
2707
3557
  value.reviews ??= {};
3558
+ const before = {};
2708
3559
  for (const id of ids) {
2709
- const state = value.concepts[id] ?? createConceptState(id, index.get(id).name);
2710
- value.concepts[id] = applyEvidence(state, evidence, at);
2711
- if (evidence.kind !== "reexplain" && evidence.kind !== "self" && Number.isFinite(Number(evidence.score))) {
2712
- const card = value.reviews[id] ?? createCard(id, at);
2713
- value.reviews[id] = review(card, gradeFromScore(Number(evidence.score), evidence.confidence), at);
2714
- }
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;
2715
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
+ });
2716
3573
  pushEvent(value, {
2717
3574
  type: "evidence",
2718
3575
  lessonId: lessonId ?? null,
@@ -2725,11 +3582,177 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
2725
3582
  return value;
2726
3583
  });
2727
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);
2728
3617
  return {
2729
3618
  mastery: Object.fromEntries(states.map((state) => [state.id, masteryPercent(state)])),
2730
3619
  recommendation: recommendNext(states)
2731
3620
  };
2732
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
+ }
2733
3756
  async function grade({ item, response, language = "zh-CN" }) {
2734
3757
  if (!item?.prompt) throw new TutorError("invalid", "Missing question");
2735
3758
  const work = item.kind === "work";
@@ -3092,6 +4115,8 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
3092
4115
  prepare,
3093
4116
  ask,
3094
4117
  reexplain,
4118
+ challenge,
4119
+ challengeItem,
3095
4120
  record,
3096
4121
  grade,
3097
4122
  correct,
@@ -4160,7 +5185,9 @@ const IDS = {
4160
5185
  chs: ID("chs"),
4161
5186
  dck: ID("dck"),
4162
5187
  exm: ID("exm"),
4163
- nte: ID("nte")
5188
+ nte: ID("nte"),
5189
+ asd: ID("asd"),
5190
+ evd: ID("evd")
4164
5191
  };
4165
5192
  function need(payload, key, prefix) {
4166
5193
  const value = payload?.[key];
@@ -4196,6 +5223,66 @@ function scopeOf(payload) {
4196
5223
  };
4197
5224
  throw new TutorError("invalid", "Invalid board scope");
4198
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
+ }
4199
5286
  const ITEM_KINDS = {
4200
5287
  sheets: "chs",
4201
5288
  decks: "dck",
@@ -4215,7 +5302,7 @@ const STYLES = [
4215
5302
  "simpler",
4216
5303
  "deeper"
4217
5304
  ];
4218
- function createRoutes({ courses, lessons, learner, studio, projects, jobs, settings, tts, plugin, info }) {
5305
+ function createRoutes({ courses, lessons, learner, studio, projects, jobs, settings, tts, plugin, info, speechInput }) {
4219
5306
  return {
4220
5307
  "app/info": () => info(),
4221
5308
  "job/get": (payload) => jobs.get(need(payload, "id", "job"), { since: Math.max(0, Number(payload.since) || 0) }) ?? { status: "missing" },
@@ -4305,6 +5392,13 @@ function createRoutes({ courses, lessons, learner, studio, projects, jobs, setti
4305
5392
  style: STYLES.includes(payload.style) ? payload.style : "example",
4306
5393
  anchorStepId: payload.anchorStepId
4307
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
+ }) }),
4308
5402
  "lesson/record": (payload) => lessons.record({
4309
5403
  courseId: need(payload, "courseId", "crs"),
4310
5404
  lessonId: optional(payload, "lessonId", "les"),
@@ -4325,6 +5419,13 @@ function createRoutes({ courses, lessons, learner, studio, projects, jobs, setti
4325
5419
  response: payload.response,
4326
5420
  language: typeof payload.language === "string" ? payload.language.slice(0, 12) : void 0
4327
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
+ }) }),
4328
5429
  "lesson/correct": (payload) => ({ job: lessons.correct({
4329
5430
  scope: scopeOf(payload),
4330
5431
  stepIndex: payload.stepIndex,
@@ -4491,6 +5592,14 @@ function createRoutes({ courses, lessons, learner, studio, projects, jobs, setti
4491
5592
  cached: result.cached === true
4492
5593
  };
4493
5594
  },
5595
+ "speech/status": () => speechInput.status(),
5596
+ "speech/transcribe": async (payload) => {
5597
+ const encoded = typeof payload.base64 === "string" ? payload.base64 : "";
5598
+ if (encoded.length > Math.ceil(3840044 / 3) * 4) throw new TutorError("speech-too-long", "The recording is too long");
5599
+ const audio = Buffer.from(encoded, "base64");
5600
+ if (!encoded || audio.toString("base64") !== encoded) throw new TutorError("speech-audio", "The recording could not be read");
5601
+ return speechInput.transcribe({ audio: new Uint8Array(audio.buffer, audio.byteOffset, audio.byteLength) });
5602
+ },
4494
5603
  "settings/get": () => settings.get(),
4495
5604
  "settings/save": (payload) => settings.save(payload.settings ?? {}),
4496
5605
  "settings/set-key": (payload) => settings.setKey({
@@ -4587,8 +5696,12 @@ const TTS_PROVIDERS = Object.freeze([
4587
5696
  "minimax",
4588
5697
  "azure"
4589
5698
  ]);
4590
- const REQUEST_TIMEOUT_MS = 45e3;
5699
+ const TTS_LIMITS = Object.freeze({
5700
+ totalMs: 45e3,
5701
+ quietMs: 1e4
5702
+ });
4591
5703
  const MAX_AUDIO_BYTES = 12 * 1024 * 1024;
5704
+ const MAX_JSON_BYTES = 3 * MAX_AUDIO_BYTES;
4592
5705
  const CACHE_MAX_BYTES = 300 * 1024 * 1024;
4593
5706
  var TtsError = class extends Error {
4594
5707
  constructor(code, message, status) {
@@ -4727,28 +5840,75 @@ function splitForTts(content, maxUnits) {
4727
5840
  if (current) chunks.push(current);
4728
5841
  return chunks;
4729
5842
  }
4730
- async function boundedFetch(fetchImpl, url, init, signal) {
4731
- const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
4732
- const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
4733
- 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
+ };
4734
5862
  try {
4735
- response = await fetchImpl(url, {
4736
- ...init,
4737
- redirect: "error",
4738
- signal: combined
4739
- });
4740
- } catch (error) {
4741
- if (signal?.aborted) throw signal.reason ?? error;
4742
- 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);
4743
5904
  }
4744
- return response;
4745
5905
  }
4746
- async function readBounded(response) {
4747
- const length = Number(response.headers.get("content-length"));
4748
- if (Number.isFinite(length) && length > MAX_AUDIO_BYTES) throw new TtsError("too-large", "TTS response is too large");
4749
- const buffer = Buffer.from(await response.arrayBuffer());
4750
- if (buffer.length > MAX_AUDIO_BYTES) throw new TtsError("too-large", "TTS response is too large");
4751
- 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
+ }
4752
5912
  }
4753
5913
  function httpError(status) {
4754
5914
  if (status === 401 || status === 403) return new TtsError("auth", "The TTS service rejected the key", status);
@@ -4770,10 +5930,10 @@ function xmlEscape(value) {
4770
5930
  })[char]);
4771
5931
  }
4772
5932
  const synthesizers = {
4773
- async openai({ text, config, secret, rate, fetchImpl, signal }) {
5933
+ async openai({ text, config, secret, rate, send }) {
4774
5934
  const base = checkEndpoint(config.baseUrl || "https://api.openai.com/v1");
4775
5935
  const path = base.pathname.replace(/\/+$/u, "");
4776
- const response = await boundedFetch(fetchImpl, new URL(`${path}/audio/speech`, base), {
5936
+ return send(new URL(`${path}/audio/speech`, base), {
4777
5937
  method: "POST",
4778
5938
  headers: {
4779
5939
  Authorization: `Bearer ${secret}`,
@@ -4787,13 +5947,11 @@ const synthesizers = {
4787
5947
  response_format: "mp3",
4788
5948
  speed: rateNumber(rate)
4789
5949
  })
4790
- }, signal);
4791
- if (!response.ok) throw httpError(response.status);
4792
- return readBounded(response);
5950
+ }, MAX_AUDIO_BYTES);
4793
5951
  },
4794
- async volcengine({ text, config, secret, rate, fetchImpl, signal }) {
5952
+ async volcengine({ text, config, secret, rate, send }) {
4795
5953
  if (!config.appId) throw new TtsError("app-id", "Volcengine App ID is required");
4796
- 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", {
4797
5955
  method: "POST",
4798
5956
  headers: {
4799
5957
  Authorization: `Bearer;${secret}`,
@@ -4817,24 +5975,17 @@ const synthesizers = {
4817
5975
  operation: "query"
4818
5976
  }
4819
5977
  })
4820
- }, signal);
4821
- if (!response.ok) throw httpError(response.status);
4822
- let body;
4823
- try {
4824
- body = await response.json();
4825
- } catch {
4826
- throw new TtsError("response", "Volcengine returned an unreadable response");
4827
- }
5978
+ }, MAX_JSON_BYTES), "Volcengine");
4828
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"})`);
4829
5980
  const audio = Buffer.from(body.data, "base64");
4830
5981
  if (audio.length > MAX_AUDIO_BYTES) throw new TtsError("too-large", "TTS response is too large");
4831
5982
  return audio;
4832
5983
  },
4833
- async minimax({ text, config, secret, rate, fetchImpl, signal }) {
5984
+ async minimax({ text, config, secret, rate, send }) {
4834
5985
  const base = checkEndpoint(config.baseUrl || "https://api.minimaxi.com");
4835
5986
  const url = new URL("/v1/t2a_v2", base);
4836
5987
  if (config.groupId) url.searchParams.set("GroupId", config.groupId);
4837
- const response = await boundedFetch(fetchImpl, url, {
5988
+ const body = parseJson(await send(url, {
4838
5989
  method: "POST",
4839
5990
  headers: {
4840
5991
  Authorization: `Bearer ${secret}`,
@@ -4857,21 +6008,14 @@ const synthesizers = {
4857
6008
  channel: 1
4858
6009
  }
4859
6010
  })
4860
- }, signal);
4861
- if (!response.ok) throw httpError(response.status);
4862
- let body;
4863
- try {
4864
- body = await response.json();
4865
- } catch {
4866
- throw new TtsError("response", "MiniMax returned an unreadable response");
4867
- }
6011
+ }, MAX_JSON_BYTES), "MiniMax");
4868
6012
  const status = body?.base_resp?.status_code;
4869
6013
  if (status !== 0 || typeof body?.data?.audio !== "string") throw new TtsError(status === 1004 || status === 2049 ? "auth" : "request", `MiniMax TTS failed (${status ?? "unknown"})`);
4870
6014
  const audio = Buffer.from(body.data.audio, "hex");
4871
6015
  if (audio.length > MAX_AUDIO_BYTES) throw new TtsError("too-large", "TTS response is too large");
4872
6016
  return audio;
4873
6017
  },
4874
- async azure({ text, config, secret, rate, fetchImpl, signal }) {
6018
+ async azure({ text, config, secret, rate, send }) {
4875
6019
  const region = String(config.region || "").trim().toLowerCase();
4876
6020
  if (!/^[a-z0-9]{2,30}$/u.test(region)) throw new TtsError("azure", "Azure region is invalid");
4877
6021
  const voice = String(config.voice || "zh-CN-XiaoxiaoNeural");
@@ -4879,7 +6023,7 @@ const synthesizers = {
4879
6023
  const lang = voice.split("-").slice(0, 2).join("-");
4880
6024
  const percent = Math.round((rateNumber(rate) - 1) * 100);
4881
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>`;
4882
- const response = await boundedFetch(fetchImpl, `https://${region}.tts.speech.microsoft.com/cognitiveservices/v1`, {
6026
+ return send(`https://${region}.tts.speech.microsoft.com/cognitiveservices/v1`, {
4883
6027
  method: "POST",
4884
6028
  headers: {
4885
6029
  "Ocp-Apim-Subscription-Key": secret,
@@ -4888,9 +6032,7 @@ const synthesizers = {
4888
6032
  "User-Agent": "dsh-aha-tutor"
4889
6033
  },
4890
6034
  body: ssml
4891
- }, signal);
4892
- if (!response.ok) throw httpError(response.status);
4893
- return readBounded(response);
6035
+ }, MAX_AUDIO_BYTES);
4894
6036
  }
4895
6037
  };
4896
6038
  const CHUNK_UNITS = Object.freeze({
@@ -4899,7 +6041,7 @@ const CHUNK_UNITS = Object.freeze({
4899
6041
  minimax: 9e3,
4900
6042
  azure: 6e3
4901
6043
  });
4902
- function createTts({ cacheDir, fetchImpl = globalThis.fetch, getSecret }) {
6044
+ function createTts({ cacheDir, fetchImpl = globalThis.fetch, getSecret, limits = TTS_LIMITS }) {
4903
6045
  if (typeof getSecret !== "function") throw new TypeError("createTts needs getSecret");
4904
6046
  let pruning;
4905
6047
  const cacheKey = (provider, config, rate, text) => createHash("sha256").update(JSON.stringify([
@@ -4933,18 +6075,25 @@ function createTts({ cacheDir, fetchImpl = globalThis.fetch, getSecret }) {
4933
6075
  const secret = await getSecret(provider);
4934
6076
  if (!secret) throw new TtsError("no-key", "No key saved for this TTS provider");
4935
6077
  const parts = [];
4936
- for (const chunk of splitForTts(content, CHUNK_UNITS[provider])) parts.push(await synthesizers[provider]({
4937
- text: chunk,
4938
- config: merged,
4939
- secret,
4940
- rate,
4941
- fetchImpl,
4942
- signal
4943
- }));
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
+ }
4944
6093
  const audio = Buffer.concat(parts);
4945
6094
  if (!audio.length) throw new TtsError("response", "The TTS service returned no audio");
4946
6095
  if (file) {
4947
- await writeFileAtomic(file, audio).catch(() => void 0);
6096
+ await mkdir(cacheDir, { recursive: true }).then(() => writeFileAtomic(file, audio)).catch(() => void 0);
4948
6097
  pruning ??= prune().finally(() => {
4949
6098
  pruning = void 0;
4950
6099
  });
@@ -4998,6 +6147,7 @@ const PREF_DEFAULTS = Object.freeze({
4998
6147
  voiceRate: 1,
4999
6148
  boardTheme: "auto",
5000
6149
  boardFont: "hand",
6150
+ boardView: "canvas",
5001
6151
  ttsProvider: "openai",
5002
6152
  tts: Object.fromEntries(TTS_PROVIDERS.map((provider) => [provider, providerDefaults(provider)]))
5003
6153
  });
@@ -5028,6 +6178,7 @@ function cleanPrefs(input = {}, base = PREF_DEFAULTS) {
5028
6178
  "dark"
5029
6179
  ].includes(input.boardTheme)) next.boardTheme = input.boardTheme;
5030
6180
  if (["hand", "standard"].includes(input.boardFont)) next.boardFont = input.boardFont;
6181
+ if (["canvas", "pages"].includes(input.boardView)) next.boardView = input.boardView;
5031
6182
  if (TTS_PROVIDERS.includes(input.ttsProvider)) next.ttsProvider = input.ttsProvider;
5032
6183
  for (const provider of TTS_PROVIDERS) {
5033
6184
  const patch = input.tts?.[provider];
@@ -5802,6 +6953,10 @@ function apply(ctx) {
5802
6953
  keys,
5803
6954
  llmPort: llm
5804
6955
  });
6956
+ const speechInput = createSpeechInput({
6957
+ getSpeech: () => ctx.get?.("speechToText"),
6958
+ logger
6959
+ });
5805
6960
  async function speak({ text, provider, signal }) {
5806
6961
  await ready;
5807
6962
  const current = prefs.read();
@@ -5838,7 +6993,8 @@ function apply(ctx) {
5838
6993
  settings,
5839
6994
  tts: { speak },
5840
6995
  plugin,
5841
- info
6996
+ info,
6997
+ speechInput
5842
6998
  }),
5843
6999
  "app/info": (payload) => info(payload),
5844
7000
  "samples/list": () => samples.list(),
@@ -5853,6 +7009,7 @@ function apply(ctx) {
5853
7009
  if (typeof fetchRoutes?.register !== "function") return;
5854
7010
  connectionCtx.effect(() => fetchRoutes.register(uploadRoute({ studio })), "aha-tutor: upload route");
5855
7011
  connectionCtx.effect(() => fetchRoutes.register(ttsRoute({ speak })), "aha-tutor: tts route");
7012
+ connectionCtx.effect(() => fetchRoutes.register(speechRoute({ speechInput })), "aha-tutor: speech route");
5856
7013
  connectionCtx.effect(() => fetchRoutes.register(fontRoute()), "aha-tutor: font route");
5857
7014
  });
5858
7015
  ctx.effect(() => publishRuntime({