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