dsh-aha-tutor 1.4.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -3
- package/CHANGELOG.md +27 -0
- package/README.en.md +4 -4
- package/README.md +4 -4
- package/docs/guide.zh-CN.md +37 -5
- package/lib/agent.js +1 -1
- package/lib/client.js +100 -45
- package/lib/index.js +1235 -78
- package/lib/{runtime-DqCjJ0yL.js → runtime-B6zUpJjA.js} +783 -16
- package/package.json +1 -1
- package/samples/harmonic-motion.aha-course.json +92 -388
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as
|
|
1
|
+
import { $ as BOARD_LIMITS, A as TutorError, B as passagesFor, C as researchQueriesPrompt, Ct as text, D as summaryPrompt, E as shortPrompt, Et as newId, F as EMPTY_PROFILE, G as formatPassages, H as readProfile, I as emptyProgress, J as applyEvidence, K as passageRange, L as masteryFor, M as generateSteps, N as generateText, O as verifyItemPrompt, P as streamCounter, Q as extractJson, R as materialImages, S as reexplainPrompt, St as safeRelativePath, T as reworkPrompt, Tt as assertId, U as readProgress, V as readMaterials, W as documentParts, X as masteryPercent, Y as createConceptState, Z as recommendNext, _ as profilePrompt, _t as normalizeLesson, a as blueprintPrompt, at as conceptIndex, b as questionnairePrompt, bt as normalizeQuestionnaire, c as correctionPrompt, ct as flattenLessons, d as digestPrompt, dt as lessonAfter, et as boardToPlainText, f as flashcardsPrompt, ft as nextLesson, g as practicePrompt, gt as normalizeItem, h as materialBriefPrompt, ht as normalizeCheatsheet, i as assistPrompt, it as blueprintOutline, j as generateJson, k as verifyStepPrompt, l as detectLanguage, lt as insertLesson, m as lessonPrompt, mt as normalizeCards, n as publishRuntime, nt as normalizeBoard, o as challengePrompt, ot as courseProgress, p as gradePrompt, pt as normalizeBlueprint, q as spreadPassages, r as askPrompt, rt as normalizeOp, s as cheatsheetPrompt, st as findLesson, tt as estimateSpeechMs, u as digestPartPrompt, ut as intakeSummary, v as projectReviewPrompt, vt as normalizePractice, w as revisePrompt, wt as plotToText, x as quizPrompt, xt as outlineForPrompt, y as projectsPrompt, yt as normalizeProject, z as materialsSummary } from "./runtime-B6zUpJjA.js";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import { dirname, join, relative, sep } from "node:path";
|
|
4
4
|
import { credentialKey } from "@deepseek-ai/dsh-credentials";
|
|
@@ -932,7 +932,7 @@ function createJobs({ logger, now = Date.now } = {}) {
|
|
|
932
932
|
const aborted = new Promise((_, reject) => {
|
|
933
933
|
controller.signal.addEventListener("abort", () => reject(controller.signal.reason ?? /* @__PURE__ */ new Error("cancelled")), { once: true });
|
|
934
934
|
});
|
|
935
|
-
Promise.race([Promise.resolve().then(() => run(api)), aborted]).then((result) => {
|
|
935
|
+
job.settled = Promise.race([Promise.resolve().then(() => run(api)), aborted]).then((result) => {
|
|
936
936
|
job.status = "done";
|
|
937
937
|
job.result = result;
|
|
938
938
|
}).catch((error) => {
|
|
@@ -979,6 +979,12 @@ function createJobs({ logger, now = Date.now } = {}) {
|
|
|
979
979
|
const job = id ? jobs.get(id) : void 0;
|
|
980
980
|
return job && !job.finishedAt && !job.controller.signal.aborted ? snapshot(job) : null;
|
|
981
981
|
}
|
|
982
|
+
async function wait(id) {
|
|
983
|
+
const job = jobs.get(id);
|
|
984
|
+
if (!job) return null;
|
|
985
|
+
await job.settled;
|
|
986
|
+
return snapshot(job);
|
|
987
|
+
}
|
|
982
988
|
function cancelKey(key) {
|
|
983
989
|
const job = running(key);
|
|
984
990
|
return job ? cancel(job.id) : null;
|
|
@@ -991,6 +997,7 @@ function createJobs({ logger, now = Date.now } = {}) {
|
|
|
991
997
|
return Object.freeze({
|
|
992
998
|
start,
|
|
993
999
|
get,
|
|
1000
|
+
wait,
|
|
994
1001
|
cancel,
|
|
995
1002
|
cancelKey,
|
|
996
1003
|
running,
|
|
@@ -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 ?? []
|
|
2281
|
-
|
|
2282
|
-
|
|
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
|
-
|
|
2710
|
-
value.concepts[id]
|
|
2711
|
-
|
|
2712
|
-
|
|
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
|
|
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
|
-
|
|
4731
|
-
|
|
4732
|
-
|
|
4733
|
-
|
|
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
|
|
4736
|
-
|
|
4737
|
-
|
|
4738
|
-
|
|
4739
|
-
|
|
4740
|
-
|
|
4741
|
-
|
|
4742
|
-
|
|
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
|
-
|
|
4747
|
-
|
|
4748
|
-
|
|
4749
|
-
|
|
4750
|
-
|
|
4751
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
},
|
|
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,
|
|
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
|
|
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
|
-
},
|
|
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,
|
|
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
|
|
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
|
-
},
|
|
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,
|
|
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
|
-
|
|
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
|
-
},
|
|
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]))
|
|
4937
|
-
|
|
4938
|
-
|
|
4939
|
-
|
|
4940
|
-
|
|
4941
|
-
|
|
4942
|
-
|
|
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({
|