dsh-aha-tutor 1.2.0 → 1.4.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 +3 -1
- package/CHANGELOG.md +51 -0
- package/README.en.md +6 -6
- package/README.md +6 -6
- package/docs/guide.zh-CN.md +35 -12
- package/lib/agent.js +1 -1
- package/lib/client.js +140 -39
- package/lib/index.js +782 -243
- package/lib/{runtime-ywaTyu56.js → runtime-DqCjJ0yL.js} +312 -173
- package/package.json +2 -2
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as
|
|
1
|
+
import { $ as conceptIndex, A as generateText, B as readProgress, C as revisePrompt, D as TutorError, E as summaryPrompt, F as materialImages, G as applyEvidence, H as formatPassages, I as materialsSummary, J as recommendNext, K as createConceptState, L as passagesFor, M as EMPTY_PROFILE, N as emptyProgress, O as generateJson, P as masteryFor, Q as blueprintOutline, R as readMaterials, S as researchQueriesPrompt, T as shortPrompt, U as passageRange, V as documentParts, W as spreadPassages, X as boardToPlainText, Y as extractJson, Z as normalizeBoard, _ as projectReviewPrompt, _t as assertId, a as blueprintPrompt, at as lessonAfter, b as quizPrompt, c as detectLanguage, ct as normalizeCards, d as flashcardsPrompt, dt as normalizePractice, et as courseProgress, f as gradePrompt, ft as normalizeProject, g as profilePrompt, gt as text, h as practicePrompt, ht as safeRelativePath, i as assistPrompt, it as intakeSummary, j as streamCounter, k as generateSteps, l as digestPartPrompt, lt as normalizeCheatsheet, m as materialBriefPrompt, mt as outlineForPrompt, n as publishRuntime, nt as flattenLessons, o as cheatsheetPrompt, ot as nextLesson, p as lessonPrompt, pt as normalizeQuestionnaire, q as masteryPercent, r as askPrompt, rt as insertLesson, s as correctionPrompt, st as normalizeBlueprint, tt as findLesson, u as digestPrompt, ut as normalizeLesson, v as projectsPrompt, vt as newId, w as reworkPrompt, x as reexplainPrompt, y as questionnairePrompt, z as readProfile } from "./runtime-DqCjJ0yL.js";
|
|
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";
|
|
@@ -46,17 +46,20 @@ function exportCoursePack({ course, progress, boards = {}, practice = {}, sheets
|
|
|
46
46
|
function parseCoursePack(input) {
|
|
47
47
|
let pack = input;
|
|
48
48
|
if (typeof input === "string") {
|
|
49
|
-
if (input.length > 8388608) throw
|
|
49
|
+
if (input.length > 8388608) throw packError("pack-too-large", "course pack is too large");
|
|
50
50
|
try {
|
|
51
51
|
pack = JSON.parse(input);
|
|
52
52
|
} catch {
|
|
53
|
-
throw
|
|
53
|
+
throw packError("bad-pack", "course pack is not valid JSON");
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
|
-
if (pack?.format !== "aha-tutor.coursepack") throw
|
|
57
|
-
if (!Number.isInteger(pack.version) || pack.version > 1) throw new
|
|
56
|
+
if (pack?.format !== "aha-tutor.coursepack") throw packError("bad-pack", "not a course pack");
|
|
57
|
+
if (!Number.isInteger(pack.version) || pack.version > 1) throw packError("pack-too-new", "course pack was made by a newer version of the plugin");
|
|
58
58
|
return pack;
|
|
59
59
|
}
|
|
60
|
+
function packError(code, message) {
|
|
61
|
+
return Object.assign(new Error(message), { code });
|
|
62
|
+
}
|
|
60
63
|
function importCoursePack(input, { now = /* @__PURE__ */ new Date() } = {}) {
|
|
61
64
|
const pack = parseCoursePack(input);
|
|
62
65
|
const blueprint = normalizeBlueprint(stripIds(pack.course), { language: pack.course?.language });
|
|
@@ -217,7 +220,26 @@ const TIMELINES = [
|
|
|
217
220
|
"balanced",
|
|
218
221
|
"relaxed"
|
|
219
222
|
];
|
|
220
|
-
|
|
223
|
+
const SIZES = [
|
|
224
|
+
"auto",
|
|
225
|
+
"short",
|
|
226
|
+
"standard",
|
|
227
|
+
"full"
|
|
228
|
+
];
|
|
229
|
+
function outlineStream(api) {
|
|
230
|
+
let soFar = "";
|
|
231
|
+
const counter = streamCounter("\"estMinutes\"", (count) => {
|
|
232
|
+
api.progress(count, 0, "blueprint");
|
|
233
|
+
try {
|
|
234
|
+
api.preview(blueprintOutline(extractJson(soFar)));
|
|
235
|
+
} catch {}
|
|
236
|
+
});
|
|
237
|
+
return (delta) => {
|
|
238
|
+
soFar += String(delta ?? "");
|
|
239
|
+
counter(delta);
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
function createCourses({ store, llm, jobs, research, now = () => /* @__PURE__ */ new Date() }) {
|
|
221
243
|
const stamp = () => now().toISOString();
|
|
222
244
|
async function readCourse(courseId) {
|
|
223
245
|
const course = await store.readJson(store.paths.course(assertId(courseId, "crs")), null);
|
|
@@ -265,7 +287,11 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
|
|
|
265
287
|
}
|
|
266
288
|
async function get({ courseId }) {
|
|
267
289
|
const course = await readCourse(courseId);
|
|
268
|
-
const [progress, intake] = await Promise.all([
|
|
290
|
+
const [progress, intake, proposal] = await Promise.all([
|
|
291
|
+
readProgress(store, courseId),
|
|
292
|
+
store.readJson(store.paths.intake(courseId), null),
|
|
293
|
+
store.readJson(store.paths.proposal(courseId), null)
|
|
294
|
+
]);
|
|
269
295
|
const boards = new Set(await store.listIds(store.paths.boardsDir(courseId), "les"));
|
|
270
296
|
const practice = new Set(await store.listIds(store.paths.practiceDir(courseId), "les"));
|
|
271
297
|
const sheets = new Set(await store.listIds(store.paths.sheetsDir(courseId), "unt"));
|
|
@@ -273,6 +299,7 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
|
|
|
273
299
|
course,
|
|
274
300
|
progress,
|
|
275
301
|
intake,
|
|
302
|
+
proposal,
|
|
276
303
|
jobs: {
|
|
277
304
|
questions: jobs.running(`questions:${courseId}`),
|
|
278
305
|
blueprint: jobs.running(`blueprint:${courseId}`),
|
|
@@ -285,9 +312,9 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
|
|
|
285
312
|
}
|
|
286
313
|
};
|
|
287
314
|
}
|
|
288
|
-
function startCraft({ goal, materialIds = [], quick = false, language }) {
|
|
315
|
+
function startCraft({ goal, materialIds = [], quick = false, language, web = false }) {
|
|
289
316
|
const cleanGoal = text(goal, 2e3);
|
|
290
|
-
if (!cleanGoal && !materialIds.length) throw new TutorError("
|
|
317
|
+
if (!cleanGoal && !materialIds.length) throw new TutorError("empty-goal", "Tell me what you want to learn");
|
|
291
318
|
const ids = materialIds.filter((id) => /^mat_[a-z0-9]+$/u.test(id)).slice(0, 8);
|
|
292
319
|
const courseId = newId("crs");
|
|
293
320
|
return jobs.start("craft/start", async (api) => {
|
|
@@ -303,10 +330,29 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
|
|
|
303
330
|
createdAt: stamp()
|
|
304
331
|
};
|
|
305
332
|
await saveCourse(draft);
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
333
|
+
let found = null;
|
|
334
|
+
if (web && research) {
|
|
335
|
+
api.progress(0, quick ? 1 : 2, "research");
|
|
336
|
+
found = await research.run({
|
|
337
|
+
goal: draft.goal,
|
|
338
|
+
language: draft.language,
|
|
339
|
+
signal: api.signal
|
|
340
|
+
});
|
|
341
|
+
api.progress(0, quick ? 1 : 2, "questions");
|
|
342
|
+
}
|
|
343
|
+
if (quick) {
|
|
344
|
+
if (found) await store.writeJson(store.paths.intake(courseId), {
|
|
345
|
+
questionnaire: null,
|
|
346
|
+
answers: {},
|
|
347
|
+
research: found,
|
|
348
|
+
at: stamp()
|
|
349
|
+
});
|
|
350
|
+
return {
|
|
351
|
+
courseId,
|
|
352
|
+
questionnaire: null,
|
|
353
|
+
research: found
|
|
354
|
+
};
|
|
355
|
+
}
|
|
310
356
|
const profile = await readProfile(store);
|
|
311
357
|
const questionnaire = await generateJson(llm, {
|
|
312
358
|
purpose: "teach",
|
|
@@ -314,7 +360,8 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
|
|
|
314
360
|
goal: draft.goal,
|
|
315
361
|
language: draft.language,
|
|
316
362
|
materialsSummary: materialsSummary(materials),
|
|
317
|
-
profile
|
|
363
|
+
profile,
|
|
364
|
+
sources: found?.sources
|
|
318
365
|
}),
|
|
319
366
|
maxTokens: 3e3,
|
|
320
367
|
signal: api.signal,
|
|
@@ -327,19 +374,21 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
|
|
|
327
374
|
await store.writeJson(store.paths.intake(courseId), {
|
|
328
375
|
questionnaire,
|
|
329
376
|
answers: {},
|
|
377
|
+
...found ? { research: found } : {},
|
|
330
378
|
at: stamp()
|
|
331
379
|
});
|
|
332
380
|
api.progress(1, 2, "questions");
|
|
333
381
|
return {
|
|
334
382
|
courseId,
|
|
335
|
-
questionnaire
|
|
383
|
+
questionnaire,
|
|
384
|
+
research: found
|
|
336
385
|
};
|
|
337
386
|
}, {
|
|
338
387
|
key: `questions:${courseId}`,
|
|
339
388
|
meta: { courseId }
|
|
340
389
|
});
|
|
341
390
|
}
|
|
342
|
-
function submitIntake({ courseId, answers = {}, timeline = "balanced", weeklyMinutes, deadline }) {
|
|
391
|
+
function submitIntake({ courseId, answers = {}, timeline = "balanced", size = "auto", weeklyMinutes, deadline }) {
|
|
343
392
|
assertId(courseId, "crs");
|
|
344
393
|
return jobs.start("craft/blueprint", async (api) => {
|
|
345
394
|
const course = await readCourse(courseId);
|
|
@@ -353,10 +402,12 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
|
|
|
353
402
|
answers: cleanAnswers,
|
|
354
403
|
at: stamp()
|
|
355
404
|
});
|
|
405
|
+
if (course.materialIds?.length) api.progress(0, 0, "materials");
|
|
356
406
|
const materials = await readMaterials(store, course.materialIds ?? []);
|
|
357
407
|
const profile = await readProfile(store);
|
|
358
408
|
const settings = {
|
|
359
409
|
timeline: TIMELINES.includes(timeline) ? timeline : "balanced",
|
|
410
|
+
size: SIZES.includes(size) ? size : "auto",
|
|
360
411
|
weeklyMinutes: clampMinutes(weeklyMinutes),
|
|
361
412
|
deadline: /^\d{4}-\d{2}-\d{2}$/u.test(String(deadline ?? "")) ? deadline : null
|
|
362
413
|
};
|
|
@@ -370,20 +421,24 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
|
|
|
370
421
|
materialsSummary: materialsSummary(materials),
|
|
371
422
|
profile,
|
|
372
423
|
timeline: settings.timeline,
|
|
373
|
-
|
|
424
|
+
size: settings.size,
|
|
425
|
+
weeklyMinutes: settings.weeklyMinutes,
|
|
426
|
+
sources: intake.research?.sources
|
|
374
427
|
}),
|
|
375
428
|
images: llm.supportsImages?.("teach") ? await materialImages(store, materials) : void 0,
|
|
376
429
|
maxTokens: 16e3,
|
|
377
430
|
signal: api.signal,
|
|
378
|
-
onText:
|
|
431
|
+
onText: outlineStream(api),
|
|
379
432
|
normalize: (raw) => attachProjectUnits(raw, normalizeBlueprint(raw, { language: course.language }))
|
|
380
433
|
});
|
|
434
|
+
const sources = intake.research?.sources ?? [];
|
|
381
435
|
const next = {
|
|
382
436
|
...course,
|
|
383
437
|
...blueprint,
|
|
384
438
|
language: course.language,
|
|
385
439
|
status: "blueprint",
|
|
386
|
-
settings
|
|
440
|
+
settings,
|
|
441
|
+
...sources.length ? { sources } : {}
|
|
387
442
|
};
|
|
388
443
|
await saveCourse(next);
|
|
389
444
|
return { course: next };
|
|
@@ -394,10 +449,10 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
|
|
|
394
449
|
}
|
|
395
450
|
function revise({ courseId, instruction }) {
|
|
396
451
|
const request = text(instruction, 1500);
|
|
397
|
-
if (!request) throw new TutorError("
|
|
452
|
+
if (!request) throw new TutorError("empty-change", "Describe the change you want");
|
|
398
453
|
return jobs.start("craft/revise", async (api) => {
|
|
399
454
|
const course = await readCourse(courseId);
|
|
400
|
-
if (!course.units) throw new TutorError("
|
|
455
|
+
if (!course.units) throw new TutorError("no-blueprint", "There is no blueprint to revise yet");
|
|
401
456
|
const current = {
|
|
402
457
|
title: course.title,
|
|
403
458
|
subtitle: course.subtitle,
|
|
@@ -420,19 +475,78 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
|
|
|
420
475
|
onText: streamCounter("\"estMinutes\"", (count) => api.progress(count, 0, "blueprint")),
|
|
421
476
|
normalize: (raw) => attachProjectUnits(raw, normalizeBlueprint(raw, { language: course.language }))
|
|
422
477
|
});
|
|
423
|
-
const
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
478
|
+
const proposal = {
|
|
479
|
+
instruction: request,
|
|
480
|
+
at: stamp(),
|
|
481
|
+
blueprint: revised
|
|
482
|
+
};
|
|
483
|
+
await store.writeJson(store.paths.proposal(courseId), proposal);
|
|
484
|
+
return {
|
|
485
|
+
course,
|
|
486
|
+
proposal
|
|
428
487
|
};
|
|
429
|
-
await saveCourse(next);
|
|
430
|
-
return { course: next };
|
|
431
488
|
}, {
|
|
432
489
|
key: `blueprint:${courseId}`,
|
|
433
490
|
meta: { courseId }
|
|
434
491
|
});
|
|
435
492
|
}
|
|
493
|
+
async function applyProposal({ courseId }) {
|
|
494
|
+
const course = await readCourse(courseId);
|
|
495
|
+
const proposal = await store.readJson(store.paths.proposal(courseId), null);
|
|
496
|
+
if (!proposal?.blueprint?.units) throw new TutorError("no-proposal", "There is no proposed change");
|
|
497
|
+
const next = {
|
|
498
|
+
...course,
|
|
499
|
+
...proposal.blueprint,
|
|
500
|
+
language: course.language,
|
|
501
|
+
status: course.status === "ready" ? "ready" : "blueprint"
|
|
502
|
+
};
|
|
503
|
+
await saveCourse(next);
|
|
504
|
+
await store.removeDir(store.paths.proposal(courseId));
|
|
505
|
+
return { course: next };
|
|
506
|
+
}
|
|
507
|
+
async function discardProposal({ courseId }) {
|
|
508
|
+
assertId(courseId, "crs");
|
|
509
|
+
await store.removeDir(store.paths.proposal(courseId));
|
|
510
|
+
return { ok: true };
|
|
511
|
+
}
|
|
512
|
+
function reworkLesson({ courseId, lesson: raw, unitTitle, lectureTitle, mode }) {
|
|
513
|
+
const split = mode === "split";
|
|
514
|
+
const lesson = normalizeLesson(raw);
|
|
515
|
+
if (!lesson) throw new TutorError("invalid", "Unknown lesson");
|
|
516
|
+
return jobs.start("craft/rework", async (api) => {
|
|
517
|
+
const course = await readCourse(courseId);
|
|
518
|
+
return { lessons: await generateJson(llm, {
|
|
519
|
+
purpose: "teach",
|
|
520
|
+
prompt: reworkPrompt({
|
|
521
|
+
outline: outlineForPrompt(course, { maxChars: 4e3 }),
|
|
522
|
+
unitTitle: text(unitTitle, 120),
|
|
523
|
+
lectureTitle: text(lectureTitle, 120),
|
|
524
|
+
lesson: {
|
|
525
|
+
title: lesson.title,
|
|
526
|
+
summary: lesson.summary,
|
|
527
|
+
objectives: lesson.objectives,
|
|
528
|
+
concepts: lesson.concepts.map((concept) => concept.name),
|
|
529
|
+
estMinutes: lesson.estMinutes
|
|
530
|
+
},
|
|
531
|
+
mode: split ? "split" : "deepen",
|
|
532
|
+
language: course.language
|
|
533
|
+
}),
|
|
534
|
+
maxTokens: 3e3,
|
|
535
|
+
signal: api.signal,
|
|
536
|
+
normalize: (value) => {
|
|
537
|
+
const list = (Array.isArray(value?.lessons) ? value.lessons : [value]).map((entry) => normalizeLesson({
|
|
538
|
+
...entry,
|
|
539
|
+
id: void 0
|
|
540
|
+
})).filter(Boolean).slice(0, split ? 3 : 1);
|
|
541
|
+
if (split ? list.length < 2 : !list.length) throw new Error("no usable lessons");
|
|
542
|
+
return list;
|
|
543
|
+
}
|
|
544
|
+
}) };
|
|
545
|
+
}, {
|
|
546
|
+
key: `rework:${courseId}:${lesson.id}`,
|
|
547
|
+
meta: { courseId }
|
|
548
|
+
});
|
|
549
|
+
}
|
|
436
550
|
async function saveBlueprint({ courseId, blueprint }) {
|
|
437
551
|
const course = await readCourse(courseId);
|
|
438
552
|
const normalized = normalizeBlueprint({
|
|
@@ -463,7 +577,7 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
|
|
|
463
577
|
language: course.language,
|
|
464
578
|
passages: passagesFor(sources, query, 5e3)
|
|
465
579
|
}),
|
|
466
|
-
maxTokens: Math.min(16e3, 3e3 + lessons.length *
|
|
580
|
+
maxTokens: Math.min(16e3, 3e3 + lessons.length * 2600),
|
|
467
581
|
signal,
|
|
468
582
|
normalize: (raw) => raw?.lessons ?? raw
|
|
469
583
|
});
|
|
@@ -483,7 +597,7 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
|
|
|
483
597
|
function confirm({ courseId }) {
|
|
484
598
|
return jobs.start("craft/confirm", async (api) => {
|
|
485
599
|
const course = await readCourse(courseId);
|
|
486
|
-
if (!course.units) throw new TutorError("
|
|
600
|
+
if (!course.units) throw new TutorError("no-blueprint", "Generate a blueprint first");
|
|
487
601
|
const lectures = course.units.slice(0, 1).flatMap((unit) => unit.lectures.map((lecture) => ({
|
|
488
602
|
unit,
|
|
489
603
|
lecture
|
|
@@ -613,7 +727,17 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
|
|
|
613
727
|
};
|
|
614
728
|
}
|
|
615
729
|
async function importPack({ json }) {
|
|
616
|
-
|
|
730
|
+
let imported;
|
|
731
|
+
try {
|
|
732
|
+
imported = importCoursePack(json);
|
|
733
|
+
} catch (error) {
|
|
734
|
+
const known = [
|
|
735
|
+
"bad-pack",
|
|
736
|
+
"pack-too-large",
|
|
737
|
+
"pack-too-new"
|
|
738
|
+
].includes(error?.code);
|
|
739
|
+
throw new TutorError(known ? error.code : "bad-pack", known ? error.message : "not a course pack");
|
|
740
|
+
}
|
|
617
741
|
const { course } = imported;
|
|
618
742
|
await saveCourse(course);
|
|
619
743
|
for (const [lessonId, board] of Object.entries(imported.boards)) await store.writeJson(store.paths.board(course.id, lessonId), board);
|
|
@@ -642,6 +766,9 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
|
|
|
642
766
|
startCraft,
|
|
643
767
|
submitIntake,
|
|
644
768
|
revise,
|
|
769
|
+
applyProposal,
|
|
770
|
+
discardProposal,
|
|
771
|
+
reworkLesson,
|
|
645
772
|
saveBlueprint,
|
|
646
773
|
writeLecturePractice,
|
|
647
774
|
confirm,
|
|
@@ -741,6 +868,150 @@ const BOARD_FONT_FILES = Object.freeze([
|
|
|
741
868
|
})
|
|
742
869
|
]);
|
|
743
870
|
//#endregion
|
|
871
|
+
//#region src/host/jobs.js
|
|
872
|
+
const KEEP_FINISHED_MS = 10 * 6e4;
|
|
873
|
+
const MAX_JOBS = 200;
|
|
874
|
+
function createJobs({ logger, now = Date.now } = {}) {
|
|
875
|
+
const jobs = /* @__PURE__ */ new Map();
|
|
876
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
877
|
+
function sweep() {
|
|
878
|
+
const cutoff = now() - KEEP_FINISHED_MS;
|
|
879
|
+
for (const [id, job] of jobs) if (job.finishedAt && job.finishedAt < cutoff) {
|
|
880
|
+
jobs.delete(id);
|
|
881
|
+
if (job.key && byKey.get(job.key) === id) byKey.delete(job.key);
|
|
882
|
+
}
|
|
883
|
+
if (jobs.size > MAX_JOBS) {
|
|
884
|
+
const finished = [...jobs.values()].filter((job) => job.finishedAt).sort((a, b) => a.finishedAt - b.finishedAt);
|
|
885
|
+
for (const job of finished.slice(0, jobs.size - MAX_JOBS)) jobs.delete(job.id);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
function start(kind, run, { key, meta } = {}) {
|
|
889
|
+
sweep();
|
|
890
|
+
if (key && byKey.has(key)) {
|
|
891
|
+
const existing = jobs.get(byKey.get(key));
|
|
892
|
+
if (existing && !existing.finishedAt && !existing.controller.signal.aborted) return snapshot(existing);
|
|
893
|
+
}
|
|
894
|
+
const controller = new AbortController();
|
|
895
|
+
const job = {
|
|
896
|
+
id: newId("job"),
|
|
897
|
+
kind,
|
|
898
|
+
key: key ?? null,
|
|
899
|
+
meta: meta ?? {},
|
|
900
|
+
status: "running",
|
|
901
|
+
progress: {
|
|
902
|
+
done: 0,
|
|
903
|
+
total: 0,
|
|
904
|
+
label: ""
|
|
905
|
+
},
|
|
906
|
+
partial: [],
|
|
907
|
+
preview: null,
|
|
908
|
+
result: void 0,
|
|
909
|
+
error: void 0,
|
|
910
|
+
startedAt: now(),
|
|
911
|
+
finishedAt: null,
|
|
912
|
+
controller
|
|
913
|
+
};
|
|
914
|
+
jobs.set(job.id, job);
|
|
915
|
+
if (key) byKey.set(key, job.id);
|
|
916
|
+
const api = {
|
|
917
|
+
signal: controller.signal,
|
|
918
|
+
progress(done, total, label = job.progress.label) {
|
|
919
|
+
job.progress = {
|
|
920
|
+
done,
|
|
921
|
+
total,
|
|
922
|
+
label
|
|
923
|
+
};
|
|
924
|
+
},
|
|
925
|
+
push(item) {
|
|
926
|
+
job.partial.push(item);
|
|
927
|
+
},
|
|
928
|
+
preview(value) {
|
|
929
|
+
job.preview = value ?? null;
|
|
930
|
+
}
|
|
931
|
+
};
|
|
932
|
+
const aborted = new Promise((_, reject) => {
|
|
933
|
+
controller.signal.addEventListener("abort", () => reject(controller.signal.reason ?? /* @__PURE__ */ new Error("cancelled")), { once: true });
|
|
934
|
+
});
|
|
935
|
+
Promise.race([Promise.resolve().then(() => run(api)), aborted]).then((result) => {
|
|
936
|
+
job.status = "done";
|
|
937
|
+
job.result = result;
|
|
938
|
+
}).catch((error) => {
|
|
939
|
+
job.status = controller.signal.aborted ? "cancelled" : "failed";
|
|
940
|
+
job.error = controller.signal.aborted ? {
|
|
941
|
+
code: "cancelled",
|
|
942
|
+
message: "Cancelled"
|
|
943
|
+
} : {
|
|
944
|
+
code: publicCode(error),
|
|
945
|
+
message: publicMessage(error)
|
|
946
|
+
};
|
|
947
|
+
if (job.status === "failed") logger?.warn?.("aha-tutor job %s failed: %s", kind, error?.message);
|
|
948
|
+
}).finally(() => {
|
|
949
|
+
job.finishedAt = now();
|
|
950
|
+
});
|
|
951
|
+
return snapshot(job);
|
|
952
|
+
}
|
|
953
|
+
function snapshot(job, { since = 0 } = {}) {
|
|
954
|
+
return {
|
|
955
|
+
id: job.id,
|
|
956
|
+
kind: job.kind,
|
|
957
|
+
meta: job.meta,
|
|
958
|
+
status: job.status,
|
|
959
|
+
progress: job.progress,
|
|
960
|
+
partial: job.partial.slice(since),
|
|
961
|
+
partialCount: job.partial.length,
|
|
962
|
+
preview: job.preview ?? void 0,
|
|
963
|
+
elapsedMs: (job.finishedAt ?? now()) - job.startedAt,
|
|
964
|
+
result: job.status === "done" ? job.result : void 0,
|
|
965
|
+
error: job.error
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
function get(id, options) {
|
|
969
|
+
const job = jobs.get(id);
|
|
970
|
+
return job ? snapshot(job, options) : null;
|
|
971
|
+
}
|
|
972
|
+
function cancel(id) {
|
|
973
|
+
const job = jobs.get(id);
|
|
974
|
+
if (job && !job.finishedAt) job.controller.abort(/* @__PURE__ */ new Error("cancelled"));
|
|
975
|
+
return job ? snapshot(job) : null;
|
|
976
|
+
}
|
|
977
|
+
function running(key) {
|
|
978
|
+
const id = byKey.get(key);
|
|
979
|
+
const job = id ? jobs.get(id) : void 0;
|
|
980
|
+
return job && !job.finishedAt && !job.controller.signal.aborted ? snapshot(job) : null;
|
|
981
|
+
}
|
|
982
|
+
function cancelKey(key) {
|
|
983
|
+
const job = running(key);
|
|
984
|
+
return job ? cancel(job.id) : null;
|
|
985
|
+
}
|
|
986
|
+
function dispose() {
|
|
987
|
+
for (const job of jobs.values()) if (!job.finishedAt) job.controller.abort(/* @__PURE__ */ new Error("plugin stopped"));
|
|
988
|
+
jobs.clear();
|
|
989
|
+
byKey.clear();
|
|
990
|
+
}
|
|
991
|
+
return Object.freeze({
|
|
992
|
+
start,
|
|
993
|
+
get,
|
|
994
|
+
cancel,
|
|
995
|
+
cancelKey,
|
|
996
|
+
running,
|
|
997
|
+
dispose
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
function publicMessage(error) {
|
|
1001
|
+
if (error?.userMessage) return String(error.userMessage);
|
|
1002
|
+
if (USER_ERRORS$1.includes(error?.name)) return String(error.message);
|
|
1003
|
+
return "Generation failed";
|
|
1004
|
+
}
|
|
1005
|
+
function publicCode(error) {
|
|
1006
|
+
if (!USER_ERRORS$1.includes(error?.name)) return "failed";
|
|
1007
|
+
return error.name === "TtsError" ? `tts-${error.code ?? "failed"}` : error.code ?? "failed";
|
|
1008
|
+
}
|
|
1009
|
+
const USER_ERRORS$1 = [
|
|
1010
|
+
"MaterialError",
|
|
1011
|
+
"TtsError",
|
|
1012
|
+
"TutorError"
|
|
1013
|
+
];
|
|
1014
|
+
//#endregion
|
|
744
1015
|
//#region src/host/web-fetch.js
|
|
745
1016
|
const LOCAL_ADDRESS_MESSAGE = "Local network addresses are not supported";
|
|
746
1017
|
var AddressError = class extends Error {
|
|
@@ -1007,7 +1278,7 @@ function detectKind(name, bytes) {
|
|
|
1007
1278
|
if (head[0] === 137 && head[1] === 80) return "image";
|
|
1008
1279
|
if (head[0] === 255 && head[1] === 216) return "image";
|
|
1009
1280
|
if (head.subarray(0, 2).toString("latin1") === "PK") {
|
|
1010
|
-
if (lower.endsWith(".doc") || lower.endsWith(".ppt")) throw new MaterialError("
|
|
1281
|
+
if (lower.endsWith(".doc") || lower.endsWith(".ppt")) throw new MaterialError("old-office", "Old .doc/.ppt files are not supported; save as .docx/.pptx");
|
|
1011
1282
|
}
|
|
1012
1283
|
throw new MaterialError("unsupported", "This file type is not supported");
|
|
1013
1284
|
}
|
|
@@ -1261,7 +1532,7 @@ function extractPlain(bytes, kind) {
|
|
|
1261
1532
|
chunks.push(...chunkText(part, heading ? { section: heading.slice(0, 80) } : {}));
|
|
1262
1533
|
}
|
|
1263
1534
|
} else chunks.push(...chunkText(value, {}));
|
|
1264
|
-
if (!chunks.length) throw new MaterialError("
|
|
1535
|
+
if (!chunks.length) throw new MaterialError("empty", "This file is empty");
|
|
1265
1536
|
return {
|
|
1266
1537
|
title: kind === "markdown" ? /^#\s+(.+)$/mu.exec(value)?.[1]?.trim() ?? "" : "",
|
|
1267
1538
|
chunks
|
|
@@ -1273,7 +1544,7 @@ async function ingestFile({ name, bytes, signal }) {
|
|
|
1273
1544
|
const kind = detectKind(name, bytes);
|
|
1274
1545
|
const baseTitle = String(name ?? "").replace(/\.[^.]+$/u, "").slice(0, 120) || "Untitled";
|
|
1275
1546
|
if (kind === "image") {
|
|
1276
|
-
if (bytes.length > 10485760) throw new MaterialError("too-large", "Images must be smaller than 10 MB");
|
|
1547
|
+
if (bytes.length > 10485760) throw new MaterialError("image-too-large", "Images must be smaller than 10 MB");
|
|
1277
1548
|
return {
|
|
1278
1549
|
kind,
|
|
1279
1550
|
title: baseTitle,
|
|
@@ -1328,7 +1599,7 @@ async function ingestUrl(address, { fetchImpl = pageFetch, signal } = {}) {
|
|
|
1328
1599
|
} catch (error) {
|
|
1329
1600
|
if (signal?.aborted) throw signal.reason ?? error;
|
|
1330
1601
|
if (error?.name === "AddressError") throw new MaterialError("bad-url", error.message);
|
|
1331
|
-
if (error?.code === "ETOOLARGE") throw new MaterialError("too-large", "The page is too large");
|
|
1602
|
+
if (error?.code === "ETOOLARGE") throw new MaterialError("page-too-large", "The page is too large");
|
|
1332
1603
|
throw new MaterialError("network", timeout.aborted ? "The page took too long to load" : "The page could not be loaded");
|
|
1333
1604
|
}
|
|
1334
1605
|
if (response.status >= 300 && response.status < 400 && response.headers.get("location")) {
|
|
@@ -1340,7 +1611,7 @@ async function ingestUrl(address, { fetchImpl = pageFetch, signal } = {}) {
|
|
|
1340
1611
|
if (!response?.ok) throw new MaterialError("http", `The page returned an error (${response?.status ?? "redirect loop"})`);
|
|
1341
1612
|
const type = String(response.headers.get("content-type") ?? "").toLowerCase();
|
|
1342
1613
|
const bytes = Buffer.from(await response.arrayBuffer());
|
|
1343
|
-
if (bytes.length > MAX_PAGE_BYTES && !type.includes("pdf")) throw new MaterialError("too-large", "The page is too large");
|
|
1614
|
+
if (bytes.length > MAX_PAGE_BYTES && !type.includes("pdf")) throw new MaterialError("page-too-large", "The page is too large");
|
|
1344
1615
|
if (type.includes("application/pdf") || bytes.subarray(0, 5).toString("latin1") === "%PDF-") return {
|
|
1345
1616
|
...await ingestFile({
|
|
1346
1617
|
name: `${url.pathname.split("/").pop() || "page"}.pdf`,
|
|
@@ -1366,7 +1637,7 @@ async function ingestUrl(address, { fetchImpl = pageFetch, signal } = {}) {
|
|
|
1366
1637
|
}
|
|
1367
1638
|
const title = String(article?.title || document.querySelector("title")?.textContent || url.hostname).trim().slice(0, 160);
|
|
1368
1639
|
const chunks = chunkText(article?.textContent || document.body?.textContent || "", { section: url.hostname });
|
|
1369
|
-
if (!chunks.length) throw new MaterialError("no-text", "No readable text on this page");
|
|
1640
|
+
if (!chunks.length) throw new MaterialError("page-no-text", "No readable text on this page");
|
|
1370
1641
|
return finish("url", title, chunks, { url: url.href });
|
|
1371
1642
|
}
|
|
1372
1643
|
//#endregion
|
|
@@ -1421,7 +1692,7 @@ function uploadRoute({ studio }) {
|
|
|
1421
1692
|
}) }
|
|
1422
1693
|
});
|
|
1423
1694
|
} catch (error) {
|
|
1424
|
-
return failure$1(error
|
|
1695
|
+
return failure$1(publicCode(error), error?.name === "TutorError" ? error.message : "Upload failed", 400);
|
|
1425
1696
|
}
|
|
1426
1697
|
}
|
|
1427
1698
|
};
|
|
@@ -1452,7 +1723,7 @@ function ttsRoute({ speak }) {
|
|
|
1452
1723
|
});
|
|
1453
1724
|
} catch (error) {
|
|
1454
1725
|
const known = error?.name === "TtsError" || error?.name === "TutorError";
|
|
1455
|
-
return failure$1(error
|
|
1726
|
+
return failure$1(known ? publicCode(error) : "tts-failed", known ? error.message : "Speech synthesis failed", ["auth", "no-key"].includes(error?.code) ? 401 : 502);
|
|
1456
1727
|
}
|
|
1457
1728
|
}
|
|
1458
1729
|
};
|
|
@@ -1486,139 +1757,6 @@ function fontRoute({ dir = fontsDir() } = {}) {
|
|
|
1486
1757
|
};
|
|
1487
1758
|
}
|
|
1488
1759
|
//#endregion
|
|
1489
|
-
//#region src/host/jobs.js
|
|
1490
|
-
const KEEP_FINISHED_MS = 10 * 6e4;
|
|
1491
|
-
const MAX_JOBS = 200;
|
|
1492
|
-
function createJobs({ logger, now = Date.now } = {}) {
|
|
1493
|
-
const jobs = /* @__PURE__ */ new Map();
|
|
1494
|
-
const byKey = /* @__PURE__ */ new Map();
|
|
1495
|
-
function sweep() {
|
|
1496
|
-
const cutoff = now() - KEEP_FINISHED_MS;
|
|
1497
|
-
for (const [id, job] of jobs) if (job.finishedAt && job.finishedAt < cutoff) {
|
|
1498
|
-
jobs.delete(id);
|
|
1499
|
-
if (job.key && byKey.get(job.key) === id) byKey.delete(job.key);
|
|
1500
|
-
}
|
|
1501
|
-
if (jobs.size > MAX_JOBS) {
|
|
1502
|
-
const finished = [...jobs.values()].filter((job) => job.finishedAt).sort((a, b) => a.finishedAt - b.finishedAt);
|
|
1503
|
-
for (const job of finished.slice(0, jobs.size - MAX_JOBS)) jobs.delete(job.id);
|
|
1504
|
-
}
|
|
1505
|
-
}
|
|
1506
|
-
function start(kind, run, { key, meta } = {}) {
|
|
1507
|
-
sweep();
|
|
1508
|
-
if (key && byKey.has(key)) {
|
|
1509
|
-
const existing = jobs.get(byKey.get(key));
|
|
1510
|
-
if (existing && !existing.finishedAt && !existing.controller.signal.aborted) return snapshot(existing);
|
|
1511
|
-
}
|
|
1512
|
-
const controller = new AbortController();
|
|
1513
|
-
const job = {
|
|
1514
|
-
id: newId("job"),
|
|
1515
|
-
kind,
|
|
1516
|
-
key: key ?? null,
|
|
1517
|
-
meta: meta ?? {},
|
|
1518
|
-
status: "running",
|
|
1519
|
-
progress: {
|
|
1520
|
-
done: 0,
|
|
1521
|
-
total: 0,
|
|
1522
|
-
label: ""
|
|
1523
|
-
},
|
|
1524
|
-
partial: [],
|
|
1525
|
-
result: void 0,
|
|
1526
|
-
error: void 0,
|
|
1527
|
-
startedAt: now(),
|
|
1528
|
-
finishedAt: null,
|
|
1529
|
-
controller
|
|
1530
|
-
};
|
|
1531
|
-
jobs.set(job.id, job);
|
|
1532
|
-
if (key) byKey.set(key, job.id);
|
|
1533
|
-
const api = {
|
|
1534
|
-
signal: controller.signal,
|
|
1535
|
-
progress(done, total, label = job.progress.label) {
|
|
1536
|
-
job.progress = {
|
|
1537
|
-
done,
|
|
1538
|
-
total,
|
|
1539
|
-
label
|
|
1540
|
-
};
|
|
1541
|
-
},
|
|
1542
|
-
push(item) {
|
|
1543
|
-
job.partial.push(item);
|
|
1544
|
-
}
|
|
1545
|
-
};
|
|
1546
|
-
const aborted = new Promise((_, reject) => {
|
|
1547
|
-
controller.signal.addEventListener("abort", () => reject(controller.signal.reason ?? /* @__PURE__ */ new Error("cancelled")), { once: true });
|
|
1548
|
-
});
|
|
1549
|
-
Promise.race([Promise.resolve().then(() => run(api)), aborted]).then((result) => {
|
|
1550
|
-
job.status = "done";
|
|
1551
|
-
job.result = result;
|
|
1552
|
-
}).catch((error) => {
|
|
1553
|
-
job.status = controller.signal.aborted ? "cancelled" : "failed";
|
|
1554
|
-
job.error = controller.signal.aborted ? {
|
|
1555
|
-
code: "cancelled",
|
|
1556
|
-
message: "Cancelled"
|
|
1557
|
-
} : {
|
|
1558
|
-
code: error?.code ?? "failed",
|
|
1559
|
-
message: publicMessage(error)
|
|
1560
|
-
};
|
|
1561
|
-
if (job.status === "failed") logger?.warn?.("aha-tutor job %s failed: %s", kind, error?.message);
|
|
1562
|
-
}).finally(() => {
|
|
1563
|
-
job.finishedAt = now();
|
|
1564
|
-
});
|
|
1565
|
-
return snapshot(job);
|
|
1566
|
-
}
|
|
1567
|
-
function snapshot(job, { since = 0 } = {}) {
|
|
1568
|
-
return {
|
|
1569
|
-
id: job.id,
|
|
1570
|
-
kind: job.kind,
|
|
1571
|
-
meta: job.meta,
|
|
1572
|
-
status: job.status,
|
|
1573
|
-
progress: job.progress,
|
|
1574
|
-
partial: job.partial.slice(since),
|
|
1575
|
-
partialCount: job.partial.length,
|
|
1576
|
-
result: job.status === "done" ? job.result : void 0,
|
|
1577
|
-
error: job.error
|
|
1578
|
-
};
|
|
1579
|
-
}
|
|
1580
|
-
function get(id, options) {
|
|
1581
|
-
const job = jobs.get(id);
|
|
1582
|
-
return job ? snapshot(job, options) : null;
|
|
1583
|
-
}
|
|
1584
|
-
function cancel(id) {
|
|
1585
|
-
const job = jobs.get(id);
|
|
1586
|
-
if (job && !job.finishedAt) job.controller.abort(/* @__PURE__ */ new Error("cancelled"));
|
|
1587
|
-
return job ? snapshot(job) : null;
|
|
1588
|
-
}
|
|
1589
|
-
function running(key) {
|
|
1590
|
-
const id = byKey.get(key);
|
|
1591
|
-
const job = id ? jobs.get(id) : void 0;
|
|
1592
|
-
return job && !job.finishedAt && !job.controller.signal.aborted ? snapshot(job) : null;
|
|
1593
|
-
}
|
|
1594
|
-
function cancelKey(key) {
|
|
1595
|
-
const job = running(key);
|
|
1596
|
-
return job ? cancel(job.id) : null;
|
|
1597
|
-
}
|
|
1598
|
-
function dispose() {
|
|
1599
|
-
for (const job of jobs.values()) if (!job.finishedAt) job.controller.abort(/* @__PURE__ */ new Error("plugin stopped"));
|
|
1600
|
-
jobs.clear();
|
|
1601
|
-
byKey.clear();
|
|
1602
|
-
}
|
|
1603
|
-
return Object.freeze({
|
|
1604
|
-
start,
|
|
1605
|
-
get,
|
|
1606
|
-
cancel,
|
|
1607
|
-
cancelKey,
|
|
1608
|
-
running,
|
|
1609
|
-
dispose
|
|
1610
|
-
});
|
|
1611
|
-
}
|
|
1612
|
-
function publicMessage(error) {
|
|
1613
|
-
if (error?.userMessage) return String(error.userMessage);
|
|
1614
|
-
if ([
|
|
1615
|
-
"MaterialError",
|
|
1616
|
-
"TtsError",
|
|
1617
|
-
"TutorError"
|
|
1618
|
-
].includes(error?.name)) return String(error.message);
|
|
1619
|
-
return "Generation failed";
|
|
1620
|
-
}
|
|
1621
|
-
//#endregion
|
|
1622
1760
|
//#region src/core/planner.js
|
|
1623
1761
|
const DAY_MS$1 = 1440 * 60 * 1e3;
|
|
1624
1762
|
const HORIZON_DAYS = 400;
|
|
@@ -1662,12 +1800,13 @@ function clampNumber(value, min, max, fallback) {
|
|
|
1662
1800
|
if (!Number.isFinite(number)) return fallback;
|
|
1663
1801
|
return Math.min(max, Math.max(min, number));
|
|
1664
1802
|
}
|
|
1665
|
-
function buildPlan({ today, settings, courses = [], reviews = [], exams = [] }) {
|
|
1803
|
+
function buildPlan({ today, settings, courses = [], reviews = [], exams = [], spentToday = 0 }) {
|
|
1666
1804
|
const start = today ?? toDay(/* @__PURE__ */ new Date());
|
|
1667
1805
|
const { studyDays, weeklyMinutes, dailyMinutes } = normalizeSettings(settings);
|
|
1668
1806
|
const queues = courses.map((course) => ({
|
|
1669
1807
|
id: course.id,
|
|
1670
1808
|
title: course.title,
|
|
1809
|
+
emoji: course.emoji || "",
|
|
1671
1810
|
deadline: course.deadline || null,
|
|
1672
1811
|
priority: Number(course.priority) || 0,
|
|
1673
1812
|
remaining: (course.lessons || []).filter((lesson) => !lesson.done).map((lesson) => ({
|
|
@@ -1695,8 +1834,9 @@ function buildPlan({ today, settings, courses = [], reviews = [], exams = [] })
|
|
|
1695
1834
|
minutes: 0
|
|
1696
1835
|
}));
|
|
1697
1836
|
if (studyDays.includes(weekday)) {
|
|
1698
|
-
|
|
1699
|
-
|
|
1837
|
+
const spent = offset === 0 ? Math.max(0, Number(spentToday) || 0) : 0;
|
|
1838
|
+
let budget = dailyMinutes - spent;
|
|
1839
|
+
const reviewBudget = Math.min(Math.max(0, budget), Math.round(dailyMinutes * REVIEW_SHARE));
|
|
1700
1840
|
let reviewUsed = 0;
|
|
1701
1841
|
while (pendingReviews.length && pendingReviews[0].dueDay <= day && reviewUsed + pendingReviews[0].minutes <= reviewBudget) {
|
|
1702
1842
|
const review = pendingReviews.shift();
|
|
@@ -1706,6 +1846,8 @@ function buildPlan({ today, settings, courses = [], reviews = [], exams = [] })
|
|
|
1706
1846
|
refId: review.id,
|
|
1707
1847
|
courseId: review.courseId ?? null,
|
|
1708
1848
|
title: review.title,
|
|
1849
|
+
courseTitle: review.courseTitle ?? "",
|
|
1850
|
+
courseEmoji: review.courseEmoji ?? "",
|
|
1709
1851
|
minutes: review.minutes
|
|
1710
1852
|
});
|
|
1711
1853
|
}
|
|
@@ -1718,7 +1860,7 @@ function buildPlan({ today, settings, courses = [], reviews = [], exams = [] })
|
|
|
1718
1860
|
for (const queue of ordered) {
|
|
1719
1861
|
const lesson = queue.remaining[0];
|
|
1720
1862
|
if (!lesson) continue;
|
|
1721
|
-
const dayIsEmpty = !items.some((item) => item.kind === "lesson");
|
|
1863
|
+
const dayIsEmpty = !spent && !items.some((item) => item.kind === "lesson");
|
|
1722
1864
|
if (lesson.minutes <= budget || dayIsEmpty) {
|
|
1723
1865
|
queue.remaining.shift();
|
|
1724
1866
|
budget -= lesson.minutes;
|
|
@@ -1728,6 +1870,7 @@ function buildPlan({ today, settings, courses = [], reviews = [], exams = [] })
|
|
|
1728
1870
|
courseId: queue.id,
|
|
1729
1871
|
title: lesson.title,
|
|
1730
1872
|
courseTitle: queue.title,
|
|
1873
|
+
courseEmoji: queue.emoji,
|
|
1731
1874
|
minutes: lesson.minutes
|
|
1732
1875
|
});
|
|
1733
1876
|
if (queue.remaining.length === 0) queue.finishDay = day;
|
|
@@ -1803,9 +1946,61 @@ function neededWeeklyMinutes(course, start, deadline, studyDays) {
|
|
|
1803
1946
|
const perDay = remaining / studyDayCount / (1 - REVIEW_SHARE);
|
|
1804
1947
|
return Math.ceil(perDay * studyDays.length / 10) * 10;
|
|
1805
1948
|
}
|
|
1949
|
+
const itemKey = (item) => `${item.kind}:${item.refId}`;
|
|
1950
|
+
function mergeAgenda(saved = [], planned = []) {
|
|
1951
|
+
const seen = new Set(saved.map(itemKey));
|
|
1952
|
+
return [...saved, ...planned.filter((item) => !seen.has(itemKey(item)))];
|
|
1953
|
+
}
|
|
1954
|
+
function planCalendar({ today, agenda = {}, plan, done = {}, checks = [], ahead = 120 }) {
|
|
1955
|
+
const lessonsDone = done.lessons ?? /* @__PURE__ */ new Map();
|
|
1956
|
+
const reviewsDone = done.reviews ?? /* @__PURE__ */ new Map();
|
|
1957
|
+
const last = addDays(today, ahead);
|
|
1958
|
+
const days = /* @__PURE__ */ new Map();
|
|
1959
|
+
const put = (day, item) => {
|
|
1960
|
+
const list = days.get(day) ?? [];
|
|
1961
|
+
if (!list.some((entry) => itemKey(entry) === itemKey(item))) list.push(item);
|
|
1962
|
+
days.set(day, list);
|
|
1963
|
+
};
|
|
1964
|
+
for (const [day, items] of Object.entries(agenda)) if (day <= today) for (const item of items) put(day, item);
|
|
1965
|
+
for (const entry of plan?.days ?? []) if (entry.day >= today && entry.day <= last) for (const item of entry.items) put(entry.day, item);
|
|
1966
|
+
for (const { day, item } of lessonsDone.values()) if (day <= today) put(day, item);
|
|
1967
|
+
for (const check of checks) if (check.day <= today && check.item) put(check.day, check.item);
|
|
1968
|
+
const status = (item, day) => {
|
|
1969
|
+
if (day > today || item.kind === "exam") return false;
|
|
1970
|
+
if (checks.some((check) => check.refId === item.refId && check.day === day)) return "manual";
|
|
1971
|
+
if (item.kind === "lesson" && lessonsDone.get(item.refId)?.day === day) return "auto";
|
|
1972
|
+
if (item.kind === "review" && reviewsDone.get(item.refId) === day) return "auto";
|
|
1973
|
+
return false;
|
|
1974
|
+
};
|
|
1975
|
+
const out = [...days.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([day, items]) => ({
|
|
1976
|
+
day,
|
|
1977
|
+
items: items.map((item) => ({
|
|
1978
|
+
...item,
|
|
1979
|
+
checked: status(item, day)
|
|
1980
|
+
}))
|
|
1981
|
+
}));
|
|
1982
|
+
return {
|
|
1983
|
+
today: out.find((entry) => entry.day === today)?.items ?? [],
|
|
1984
|
+
days: out
|
|
1985
|
+
};
|
|
1986
|
+
}
|
|
1806
1987
|
//#endregion
|
|
1807
1988
|
//#region src/host/learner.js
|
|
1808
1989
|
const PROFILE_REFRESH_MS = 10 * 6e4;
|
|
1990
|
+
function agendaItem(raw) {
|
|
1991
|
+
const kind = raw?.kind === "lesson" || raw?.kind === "review" ? raw.kind : null;
|
|
1992
|
+
const refId = text(raw?.refId, 80);
|
|
1993
|
+
if (!kind || !refId) return null;
|
|
1994
|
+
return {
|
|
1995
|
+
kind,
|
|
1996
|
+
refId,
|
|
1997
|
+
courseId: /^crs_[a-z0-9]{6,40}$/u.test(String(raw.courseId)) ? raw.courseId : null,
|
|
1998
|
+
title: text(raw.title, 160),
|
|
1999
|
+
courseTitle: text(raw.courseTitle, 160),
|
|
2000
|
+
courseEmoji: text(raw.courseEmoji, 8),
|
|
2001
|
+
minutes: Math.min(240, Math.max(0, Math.round(Number(raw.minutes) || 0)))
|
|
2002
|
+
};
|
|
2003
|
+
}
|
|
1809
2004
|
function createLearner({ store, llm, now = () => /* @__PURE__ */ new Date(), logger }) {
|
|
1810
2005
|
let lastRefresh = 0;
|
|
1811
2006
|
let refreshing = null;
|
|
@@ -1895,9 +2090,12 @@ function createLearner({ store, llm, now = () => /* @__PURE__ */ new Date(), log
|
|
|
1895
2090
|
0
|
|
1896
2091
|
]
|
|
1897
2092
|
},
|
|
1898
|
-
exams: Array.isArray(plan.exams) ? plan.exams : []
|
|
2093
|
+
exams: Array.isArray(plan.exams) ? plan.exams : [],
|
|
2094
|
+
checks: Array.isArray(plan.checks) ? plan.checks : [],
|
|
2095
|
+
agenda: plan.agenda && typeof plan.agenda === "object" ? plan.agenda : {}
|
|
1899
2096
|
};
|
|
1900
2097
|
}
|
|
2098
|
+
const validDay = (value) => /^\d{4}-\d{2}-\d{2}$/u.test(String(value ?? "")) ? value : null;
|
|
1901
2099
|
async function activeCourses() {
|
|
1902
2100
|
const ids = await store.listIds(store.paths.courses(), "crs");
|
|
1903
2101
|
const result = [];
|
|
@@ -1913,35 +2111,67 @@ function createLearner({ store, llm, now = () => /* @__PURE__ */ new Date(), log
|
|
|
1913
2111
|
}
|
|
1914
2112
|
async function plan({ today } = {}) {
|
|
1915
2113
|
const file = await readPlanFile();
|
|
1916
|
-
const day =
|
|
2114
|
+
const day = validDay(today) ?? toDay(now());
|
|
1917
2115
|
const active = await activeCourses();
|
|
2116
|
+
const tickedLessons = new Set(file.checks.filter((check) => check.kind === "lesson" && check.day <= day).map((check) => check.refId));
|
|
2117
|
+
const tickedToday = new Set(file.checks.filter((check) => check.day === day).map((check) => check.refId));
|
|
1918
2118
|
const reviews = [];
|
|
2119
|
+
const done = {
|
|
2120
|
+
lessons: /* @__PURE__ */ new Map(),
|
|
2121
|
+
reviews: /* @__PURE__ */ new Map()
|
|
2122
|
+
};
|
|
1919
2123
|
for (const { course, progress } of active) {
|
|
2124
|
+
const label = {
|
|
2125
|
+
courseId: course.id,
|
|
2126
|
+
courseTitle: course.title,
|
|
2127
|
+
courseEmoji: course.emoji ?? ""
|
|
2128
|
+
};
|
|
1920
2129
|
const index = conceptIndex(course);
|
|
1921
2130
|
for (const [conceptId, card] of Object.entries(progress.reviews ?? {})) {
|
|
1922
2131
|
const concept = index.get(conceptId);
|
|
1923
2132
|
if (!concept) continue;
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
2133
|
+
const id = `${course.id}:${conceptId}`;
|
|
2134
|
+
if (card.lastReviewedAt) done.reviews.set(id, toDay(new Date(card.lastReviewedAt)));
|
|
2135
|
+
if (!tickedToday.has(id)) reviews.push({
|
|
2136
|
+
id,
|
|
2137
|
+
...label,
|
|
1927
2138
|
title: concept.name,
|
|
1928
2139
|
dueDay: toDay(new Date(card.dueAt)),
|
|
1929
2140
|
minutes: 5
|
|
1930
2141
|
});
|
|
1931
2142
|
}
|
|
2143
|
+
for (const { lesson } of flattenLessons(course)) {
|
|
2144
|
+
const record = progress.lessons?.[lesson.id];
|
|
2145
|
+
if (record?.status === "done" && record.completedAt) done.lessons.set(lesson.id, {
|
|
2146
|
+
day: toDay(new Date(record.completedAt)),
|
|
2147
|
+
item: {
|
|
2148
|
+
kind: "lesson",
|
|
2149
|
+
refId: lesson.id,
|
|
2150
|
+
title: lesson.title,
|
|
2151
|
+
minutes: lesson.estMinutes,
|
|
2152
|
+
...label
|
|
2153
|
+
}
|
|
2154
|
+
});
|
|
2155
|
+
}
|
|
1932
2156
|
}
|
|
2157
|
+
const spent = /* @__PURE__ */ new Map();
|
|
2158
|
+
for (const { day: doneDay, item } of done.lessons.values()) if (doneDay === day) spent.set(item.refId, item.minutes);
|
|
2159
|
+
for (const item of file.agenda[day] ?? []) if (item.kind === "review" && (done.reviews.get(item.refId) === day || tickedToday.has(item.refId))) spent.set(item.refId, item.minutes);
|
|
2160
|
+
for (const check of file.checks) if (check.day === day && check.item) spent.set(check.refId, check.item.minutes);
|
|
1933
2161
|
const built = buildPlan({
|
|
1934
2162
|
today: day,
|
|
2163
|
+
spentToday: [...spent.values()].reduce((sum, minutes) => sum + (Number(minutes) || 0), 0),
|
|
1935
2164
|
settings: file.settings,
|
|
1936
2165
|
courses: active.map(({ course, progress }) => ({
|
|
1937
2166
|
id: course.id,
|
|
1938
2167
|
title: course.title,
|
|
2168
|
+
emoji: course.emoji ?? "",
|
|
1939
2169
|
deadline: course.settings?.deadline ?? null,
|
|
1940
2170
|
lessons: flattenLessons(course).map(({ lesson }) => ({
|
|
1941
2171
|
id: lesson.id,
|
|
1942
2172
|
title: lesson.title,
|
|
1943
2173
|
minutes: lesson.estMinutes,
|
|
1944
|
-
done: progress.lessons?.[lesson.id]?.status === "done"
|
|
2174
|
+
done: progress.lessons?.[lesson.id]?.status === "done" || tickedLessons.has(lesson.id)
|
|
1945
2175
|
}))
|
|
1946
2176
|
})),
|
|
1947
2177
|
reviews,
|
|
@@ -1952,13 +2182,57 @@ function createLearner({ store, llm, now = () => /* @__PURE__ */ new Date(), log
|
|
|
1952
2182
|
courseId: exam.courseId ?? null
|
|
1953
2183
|
}))
|
|
1954
2184
|
});
|
|
2185
|
+
const planned = (built.days.find((entry) => entry.day === day)?.items ?? []).filter((item) => item.kind !== "exam").map(agendaItem);
|
|
2186
|
+
const oldest = addDays(day, -180);
|
|
2187
|
+
const merged = mergeAgenda(file.agenda[day] ?? [], planned).slice(0, 40);
|
|
2188
|
+
let agenda = file.agenda;
|
|
2189
|
+
if (JSON.stringify(merged) !== JSON.stringify(file.agenda[day] ?? []) || Object.keys(file.agenda).some((key) => key < oldest)) agenda = (await store.updateJson(store.paths.plan(), {}, (current) => {
|
|
2190
|
+
const kept = Object.fromEntries(Object.entries(current.agenda ?? {}).filter(([key]) => validDay(key) && key >= oldest));
|
|
2191
|
+
kept[day] = mergeAgenda(kept[day] ?? [], planned).slice(0, 40);
|
|
2192
|
+
return {
|
|
2193
|
+
...current,
|
|
2194
|
+
agenda: kept
|
|
2195
|
+
};
|
|
2196
|
+
})).agenda;
|
|
2197
|
+
const calendar = planCalendar({
|
|
2198
|
+
today: day,
|
|
2199
|
+
agenda,
|
|
2200
|
+
plan: built,
|
|
2201
|
+
done,
|
|
2202
|
+
checks: file.checks
|
|
2203
|
+
});
|
|
1955
2204
|
return {
|
|
1956
2205
|
settings: file.settings,
|
|
1957
2206
|
exams: file.exams,
|
|
1958
2207
|
plan: built,
|
|
1959
|
-
today:
|
|
2208
|
+
today: calendar.today,
|
|
2209
|
+
calendar: calendar.days
|
|
1960
2210
|
};
|
|
1961
2211
|
}
|
|
2212
|
+
async function check({ today, day, item }) {
|
|
2213
|
+
const current = validDay(today) ?? toDay(now());
|
|
2214
|
+
const when = validDay(day);
|
|
2215
|
+
const entry = agendaItem(item ?? {});
|
|
2216
|
+
if (!when || !entry || when > current) throw new TutorError("cannot-tick", "Only today or an earlier day can be ticked");
|
|
2217
|
+
await store.updateJson(store.paths.plan(), {}, (current) => ({
|
|
2218
|
+
...current,
|
|
2219
|
+
checks: [...(current.checks ?? []).filter((existing) => !(existing.refId === entry.refId && existing.day === when)), {
|
|
2220
|
+
refId: entry.refId,
|
|
2221
|
+
kind: entry.kind,
|
|
2222
|
+
day: when,
|
|
2223
|
+
item: entry,
|
|
2224
|
+
at: now().toISOString()
|
|
2225
|
+
}].slice(-1e3)
|
|
2226
|
+
}));
|
|
2227
|
+
return plan({ today });
|
|
2228
|
+
}
|
|
2229
|
+
async function uncheck({ today, day, refId }) {
|
|
2230
|
+
await store.updateJson(store.paths.plan(), {}, (current) => ({
|
|
2231
|
+
...current,
|
|
2232
|
+
checks: (current.checks ?? []).filter((existing) => !(existing.refId === refId && existing.day === day))
|
|
2233
|
+
}));
|
|
2234
|
+
return plan({ today });
|
|
2235
|
+
}
|
|
1962
2236
|
async function savePlanSettings({ weeklyMinutes, studyDays }) {
|
|
1963
2237
|
await store.updateJson(store.paths.plan(), {}, (current) => ({
|
|
1964
2238
|
...current,
|
|
@@ -1971,7 +2245,7 @@ function createLearner({ store, llm, now = () => /* @__PURE__ */ new Date(), log
|
|
|
1971
2245
|
}
|
|
1972
2246
|
async function addExam({ title, day, courseId }) {
|
|
1973
2247
|
const name = text(title, 120);
|
|
1974
|
-
if (!name || !/^\d{4}-\d{2}-\d{2}$/u.test(String(day))) throw new
|
|
2248
|
+
if (!name || !/^\d{4}-\d{2}-\d{2}$/u.test(String(day))) throw new TutorError("exam-incomplete", "Exam needs a title and a date");
|
|
1975
2249
|
await store.updateJson(store.paths.plan(), {}, (current) => ({
|
|
1976
2250
|
...current,
|
|
1977
2251
|
exams: [...current.exams ?? [], {
|
|
@@ -1990,10 +2264,11 @@ function createLearner({ store, llm, now = () => /* @__PURE__ */ new Date(), log
|
|
|
1990
2264
|
}));
|
|
1991
2265
|
return plan();
|
|
1992
2266
|
}
|
|
1993
|
-
async function reviewSession({ limit = 8 } = {}) {
|
|
2267
|
+
async function reviewSession({ limit = 8, courseId } = {}) {
|
|
1994
2268
|
const at = now().getTime();
|
|
1995
2269
|
const items = [];
|
|
1996
2270
|
for (const { course, progress } of await activeCourses()) {
|
|
2271
|
+
if (courseId && course.id !== courseId) continue;
|
|
1997
2272
|
const index = conceptIndex(course);
|
|
1998
2273
|
const due = Object.entries(progress.reviews ?? {}).filter(([id, card]) => index.has(id) && Date.parse(card.dueAt) <= at).sort((a, b) => Date.parse(a[1].dueAt) - Date.parse(b[1].dueAt));
|
|
1999
2274
|
for (const [conceptId] of due) {
|
|
@@ -2002,7 +2277,7 @@ function createLearner({ store, llm, now = () => /* @__PURE__ */ new Date(), log
|
|
|
2002
2277
|
const lesson = findLesson(course, concept.lessonId)?.lesson;
|
|
2003
2278
|
const set = await store.readJson(store.paths.practice(course.id, concept.lessonId), null);
|
|
2004
2279
|
const board = await store.readJson(store.paths.board(course.id, concept.lessonId), null);
|
|
2005
|
-
const candidates = [...set?.items ?? [], ...(board?.steps ?? []).map((step) => step.check).filter(Boolean)].filter((item) => item.conceptIds?.includes(conceptId) && item.kind !== "short");
|
|
2280
|
+
const candidates = [...set?.items ?? [], ...(board?.steps ?? []).map((step) => step.check).filter(Boolean)].filter((item) => item.conceptIds?.includes(conceptId) && item.kind !== "short" && item.kind !== "work");
|
|
2006
2281
|
const item = candidates[Math.floor(Math.random() * candidates.length)];
|
|
2007
2282
|
if (item) items.push({
|
|
2008
2283
|
courseId: course.id,
|
|
@@ -2024,6 +2299,8 @@ function createLearner({ store, llm, now = () => /* @__PURE__ */ new Date(), log
|
|
|
2024
2299
|
removeNote,
|
|
2025
2300
|
noteLesson,
|
|
2026
2301
|
plan,
|
|
2302
|
+
check,
|
|
2303
|
+
uncheck,
|
|
2027
2304
|
savePlanSettings,
|
|
2028
2305
|
addExam,
|
|
2029
2306
|
removeExam,
|
|
@@ -2122,6 +2399,7 @@ function round2(value) {
|
|
|
2122
2399
|
//#region src/host/lessons.js
|
|
2123
2400
|
const EVENTS_KEPT = 200;
|
|
2124
2401
|
const STEP_ID = /^s\d{1,4}$/u;
|
|
2402
|
+
const BOARD_JOBS = ["lesson/board", "lesson/prefetch"];
|
|
2125
2403
|
function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__PURE__ */ new Date() }) {
|
|
2126
2404
|
const stamp = () => now().toISOString();
|
|
2127
2405
|
async function context(courseId, lessonId) {
|
|
@@ -2165,7 +2443,7 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
|
|
|
2165
2443
|
mastery: Object.fromEntries(row.lesson.concepts.map((concept) => [concept.id, masteryPercent(progress.concepts?.[concept.id] ?? createConceptState(concept.id, concept.name))]))
|
|
2166
2444
|
};
|
|
2167
2445
|
}
|
|
2168
|
-
async function buildBoard({ course, row, signal, onStep, directive, style }) {
|
|
2446
|
+
async function buildBoard({ course, row, signal, onStep, onProgress, directive, style }) {
|
|
2169
2447
|
const progress = await readProgress(store, course.id);
|
|
2170
2448
|
const profile = await readProfile(store);
|
|
2171
2449
|
const materials = await readMaterials(store, course.materialIds ?? []);
|
|
@@ -2177,6 +2455,7 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
|
|
|
2177
2455
|
const kindDirective = row.lesson.kind === "remedial" ? "remediate" : row.lesson.kind === "review" ? "review" : row.lesson.kind === "challenge" ? "challenge" : void 0;
|
|
2178
2456
|
return generateSteps(llm, {
|
|
2179
2457
|
purpose: "teach",
|
|
2458
|
+
effort: "lowest",
|
|
2180
2459
|
prompt: lessonPrompt({
|
|
2181
2460
|
course,
|
|
2182
2461
|
unit: row.unit,
|
|
@@ -2196,6 +2475,7 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
|
|
|
2196
2475
|
signal,
|
|
2197
2476
|
conceptIds: row.lesson.concepts.map((concept) => concept.id),
|
|
2198
2477
|
onStep,
|
|
2478
|
+
onProgress,
|
|
2199
2479
|
minSteps: 3
|
|
2200
2480
|
});
|
|
2201
2481
|
}
|
|
@@ -2220,6 +2500,7 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
|
|
|
2220
2500
|
row,
|
|
2221
2501
|
signal: api.signal,
|
|
2222
2502
|
onStep: (step) => api.push(step),
|
|
2503
|
+
onProgress: (info) => api.preview(info),
|
|
2223
2504
|
style: text(style, 300) || void 0
|
|
2224
2505
|
});
|
|
2225
2506
|
const saved = {
|
|
@@ -2252,7 +2533,8 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
|
|
|
2252
2533
|
course,
|
|
2253
2534
|
row: next,
|
|
2254
2535
|
signal: api.signal,
|
|
2255
|
-
onStep: (step) => api.push(step)
|
|
2536
|
+
onStep: (step) => api.push(step),
|
|
2537
|
+
onProgress: (info) => api.preview(info)
|
|
2256
2538
|
});
|
|
2257
2539
|
const saved = {
|
|
2258
2540
|
...board,
|
|
@@ -2272,10 +2554,18 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
|
|
|
2272
2554
|
});
|
|
2273
2555
|
}).catch(() => void 0);
|
|
2274
2556
|
}
|
|
2557
|
+
function followedJob(scope, kinds, meta) {
|
|
2558
|
+
const job = scope?.jobId ? jobs.get(scope.jobId) : null;
|
|
2559
|
+
return job && kinds.includes(job.kind) && Object.entries(meta).every(([key, value]) => job.meta?.[key] === value) ? job : null;
|
|
2560
|
+
}
|
|
2275
2561
|
async function scopeBoard(scope) {
|
|
2276
2562
|
if (scope?.kind === "lesson") {
|
|
2277
2563
|
const { course, row } = await context(scope.courseId, scope.lessonId);
|
|
2278
|
-
const
|
|
2564
|
+
const writing = followedJob(scope, BOARD_JOBS, {
|
|
2565
|
+
courseId: course.id,
|
|
2566
|
+
lessonId: row.lesson.id
|
|
2567
|
+
});
|
|
2568
|
+
const board = writing ? { steps: writing.partial } : await store.readJson(store.paths.board(course.id, row.lesson.id), null);
|
|
2279
2569
|
return {
|
|
2280
2570
|
course,
|
|
2281
2571
|
lesson: row.lesson,
|
|
@@ -2284,18 +2574,20 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
|
|
|
2284
2574
|
};
|
|
2285
2575
|
}
|
|
2286
2576
|
if (scope?.kind === "thread" || scope?.kind === "short") {
|
|
2287
|
-
const
|
|
2288
|
-
const
|
|
2289
|
-
|
|
2290
|
-
|
|
2577
|
+
const thread = scope.kind === "thread";
|
|
2578
|
+
const writing = thread ? followedJob(scope, ["assist/turn"], { threadId: scope.id }) : followedJob(scope, ["short/make"], { id: scope.id });
|
|
2579
|
+
const item = await store.readJson(store.paths.item(thread ? "assist" : "shorts", scope.id), null);
|
|
2580
|
+
if (!item && !writing) throw new TutorError("not-found", "Not found");
|
|
2581
|
+
const board = writing ? { steps: writing.partial } : thread ? item.turns?.[Math.max(0, Math.min(item.turns.length - 1, Number(scope.turn) || 0))]?.board : item.board;
|
|
2582
|
+
const language = item?.language ?? detectLanguage((board?.steps ?? []).map((step) => step.say).join(" "));
|
|
2291
2583
|
return {
|
|
2292
2584
|
course: null,
|
|
2293
2585
|
lesson: {
|
|
2294
|
-
title: item.title,
|
|
2586
|
+
title: item?.title ?? writing.meta?.title ?? "",
|
|
2295
2587
|
objectives: []
|
|
2296
2588
|
},
|
|
2297
2589
|
board,
|
|
2298
|
-
language
|
|
2590
|
+
language
|
|
2299
2591
|
};
|
|
2300
2592
|
}
|
|
2301
2593
|
throw new TutorError("invalid", "Unknown board");
|
|
@@ -2313,7 +2605,7 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
|
|
|
2313
2605
|
const anchorFor = (board, stepIndex, anchorStepId) => STEP_ID.test(String(anchorStepId)) ? anchorStepId : board?.steps?.[Math.max(0, Number(stepIndex) || 0)]?.id ?? "s1";
|
|
2314
2606
|
function ask({ scope, stepIndex = 0, question, anchorStepId }) {
|
|
2315
2607
|
const request = text(question, 1500);
|
|
2316
|
-
if (!request) throw new TutorError("
|
|
2608
|
+
if (!request) throw new TutorError("empty-question", "Type your question");
|
|
2317
2609
|
return jobs.start("lesson/ask", async (api) => {
|
|
2318
2610
|
const { course, lesson, board, language } = await scopeBoard(scope);
|
|
2319
2611
|
const profile = await readProfile(store);
|
|
@@ -2355,10 +2647,11 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
|
|
|
2355
2647
|
function reexplain({ scope, stepIndex = 0, style = "example", anchorStepId }) {
|
|
2356
2648
|
return jobs.start("lesson/reexplain", async (api) => {
|
|
2357
2649
|
const { course, lesson, board, language } = await scopeBoard(scope);
|
|
2358
|
-
if (!board?.steps?.length) throw new TutorError("
|
|
2650
|
+
if (!board?.steps?.length) throw new TutorError("no-board", "The board is not written yet. Try again once its first step is up.");
|
|
2359
2651
|
const index = Math.min(board.steps.length - 1, Math.max(0, Number(stepIndex) || 0));
|
|
2360
2652
|
const answer = await generateSteps(llm, {
|
|
2361
2653
|
purpose: "teach",
|
|
2654
|
+
effort: "lowest",
|
|
2362
2655
|
prompt: reexplainPrompt({
|
|
2363
2656
|
lesson,
|
|
2364
2657
|
language,
|
|
@@ -2439,18 +2732,25 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
|
|
|
2439
2732
|
}
|
|
2440
2733
|
async function grade({ item, response, language = "zh-CN" }) {
|
|
2441
2734
|
if (!item?.prompt) throw new TutorError("invalid", "Missing question");
|
|
2735
|
+
const work = item.kind === "work";
|
|
2442
2736
|
const answer = text(response, 4e3);
|
|
2443
2737
|
if (!answer) return {
|
|
2444
2738
|
score: 0,
|
|
2445
2739
|
verdict: "incorrect",
|
|
2446
2740
|
feedback: "",
|
|
2447
2741
|
errorTag: "",
|
|
2448
|
-
misconception: false
|
|
2742
|
+
misconception: false,
|
|
2743
|
+
...work ? {
|
|
2744
|
+
wrongStep: "",
|
|
2745
|
+
why: "",
|
|
2746
|
+
fix: ""
|
|
2747
|
+
} : {}
|
|
2449
2748
|
};
|
|
2450
2749
|
return await generateJson(llm, {
|
|
2451
2750
|
purpose: "quick",
|
|
2452
2751
|
prompt: gradePrompt({
|
|
2453
2752
|
item: {
|
|
2753
|
+
kind: work ? "work" : "short",
|
|
2454
2754
|
prompt: text(item.prompt, 2e3),
|
|
2455
2755
|
answer: text(item.answer, 3e3),
|
|
2456
2756
|
rubric: Array.isArray(item.rubric) ? item.rubric.map((entry) => text(entry, 300)) : []
|
|
@@ -2458,7 +2758,7 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
|
|
|
2458
2758
|
response: answer,
|
|
2459
2759
|
language
|
|
2460
2760
|
}),
|
|
2461
|
-
maxTokens: 1200,
|
|
2761
|
+
maxTokens: work ? 1800 : 1200,
|
|
2462
2762
|
normalize: (raw) => ({
|
|
2463
2763
|
score: Math.min(1, Math.max(0, Number(raw?.score) || 0)),
|
|
2464
2764
|
verdict: [
|
|
@@ -2468,10 +2768,60 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
|
|
|
2468
2768
|
].includes(raw?.verdict) ? raw.verdict : Number(raw?.score) >= .8 ? "correct" : Number(raw?.score) >= .4 ? "partial" : "incorrect",
|
|
2469
2769
|
feedback: text(raw?.feedback, 1200),
|
|
2470
2770
|
errorTag: text(raw?.errorTag, 80),
|
|
2471
|
-
misconception: raw?.misconception === true
|
|
2771
|
+
misconception: raw?.misconception === true,
|
|
2772
|
+
...work ? {
|
|
2773
|
+
wrongStep: text(raw?.wrongStep, 400),
|
|
2774
|
+
why: text(raw?.why, 400),
|
|
2775
|
+
fix: text(raw?.fix, 400)
|
|
2776
|
+
} : {}
|
|
2472
2777
|
})
|
|
2473
2778
|
});
|
|
2474
2779
|
}
|
|
2780
|
+
function correct({ scope, stepIndex = 0, anchorStepId, item, response, grade: graded }) {
|
|
2781
|
+
const working = text(response, 4e3);
|
|
2782
|
+
if (!item?.prompt || !working) throw new TutorError("invalid", "Nothing to correct");
|
|
2783
|
+
const notes = graded && typeof graded === "object" ? graded : {};
|
|
2784
|
+
return jobs.start("lesson/correct", async (api) => {
|
|
2785
|
+
const { course, lesson, board, language } = await scopeBoard(scope);
|
|
2786
|
+
const answer = await generateSteps(llm, {
|
|
2787
|
+
purpose: "quick",
|
|
2788
|
+
prompt: correctionPrompt({
|
|
2789
|
+
course,
|
|
2790
|
+
lesson,
|
|
2791
|
+
language,
|
|
2792
|
+
boardSoFar: board ? boardToPlainText(board, {
|
|
2793
|
+
uptoStep: Number(stepIndex) || 0,
|
|
2794
|
+
maxChars: 5e3
|
|
2795
|
+
}) : "",
|
|
2796
|
+
item: {
|
|
2797
|
+
prompt: text(item.prompt, 2e3),
|
|
2798
|
+
answer: text(item.answer, 3e3)
|
|
2799
|
+
},
|
|
2800
|
+
response: working,
|
|
2801
|
+
grade: {
|
|
2802
|
+
feedback: text(notes.feedback, 1200),
|
|
2803
|
+
wrongStep: text(notes.wrongStep, 400),
|
|
2804
|
+
why: text(notes.why, 400),
|
|
2805
|
+
fix: text(notes.fix, 400)
|
|
2806
|
+
}
|
|
2807
|
+
}),
|
|
2808
|
+
maxTokens: 4e3,
|
|
2809
|
+
signal: api.signal,
|
|
2810
|
+
conceptIds: lesson?.concepts?.map((concept) => concept.id) ?? [],
|
|
2811
|
+
onStep: (step) => api.push(step)
|
|
2812
|
+
});
|
|
2813
|
+
const aside = await keepAside(scope, {
|
|
2814
|
+
kind: "correction",
|
|
2815
|
+
errorTag: text(notes.errorTag, 80),
|
|
2816
|
+
anchorStepId: anchorFor(board, stepIndex, anchorStepId),
|
|
2817
|
+
steps: answer.steps
|
|
2818
|
+
});
|
|
2819
|
+
return {
|
|
2820
|
+
steps: answer.steps,
|
|
2821
|
+
aside
|
|
2822
|
+
};
|
|
2823
|
+
});
|
|
2824
|
+
}
|
|
2475
2825
|
async function savePosition({ courseId, lessonId, stepIndex = 0 }) {
|
|
2476
2826
|
assertId(lessonId, "les");
|
|
2477
2827
|
await store.updateJson(store.paths.progress(assertId(courseId, "crs")), null, (progress) => {
|
|
@@ -2744,6 +3094,7 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
|
|
|
2744
3094
|
reexplain,
|
|
2745
3095
|
record,
|
|
2746
3096
|
grade,
|
|
3097
|
+
correct,
|
|
2747
3098
|
savePosition,
|
|
2748
3099
|
complete,
|
|
2749
3100
|
practice,
|
|
@@ -2781,6 +3132,27 @@ const RETRYABLE$1 = /* @__PURE__ */ new Set([
|
|
|
2781
3132
|
"TIMEOUT",
|
|
2782
3133
|
"TRANSPORT"
|
|
2783
3134
|
]);
|
|
3135
|
+
const REASONING_ROOM = Object.freeze({
|
|
3136
|
+
off: 0,
|
|
3137
|
+
none: 0,
|
|
3138
|
+
minimal: 4e3,
|
|
3139
|
+
low: 16e3,
|
|
3140
|
+
medium: 16e3,
|
|
3141
|
+
high: 24e3,
|
|
3142
|
+
xhigh: 48e3,
|
|
3143
|
+
max: 48e3
|
|
3144
|
+
});
|
|
3145
|
+
const EFFORT_ORDER = [
|
|
3146
|
+
"off",
|
|
3147
|
+
"none",
|
|
3148
|
+
"minimal",
|
|
3149
|
+
"low",
|
|
3150
|
+
"medium",
|
|
3151
|
+
"high",
|
|
3152
|
+
"xhigh",
|
|
3153
|
+
"max"
|
|
3154
|
+
];
|
|
3155
|
+
const STOPPED_AT_LIMIT = /* @__PURE__ */ new Set(["max-tokens", "length"]);
|
|
2784
3156
|
const CALL_TIMEOUT_MS = 6 * 6e4;
|
|
2785
3157
|
const MAX_RETRIES = 2;
|
|
2786
3158
|
const USER_ERRORS = Object.freeze({
|
|
@@ -2794,6 +3166,7 @@ const USER_ERRORS = Object.freeze({
|
|
|
2794
3166
|
CONTEXT_WINDOW_EXCEEDED: ["llm-context", "The material is too long for this model. Pick fewer materials or a model with a larger context."],
|
|
2795
3167
|
NO_ADAPTER: ["llm-unavailable", "The selected model is not available. Pick another model in Aha settings."],
|
|
2796
3168
|
TIMEOUT: ["llm-timeout", "The model took too long to respond. Try again."],
|
|
3169
|
+
EMPTY_RESPONSE: ["llm-empty", "The model returned nothing this time. Try again."],
|
|
2797
3170
|
ABORTED: ["cancelled", "Cancelled"]
|
|
2798
3171
|
});
|
|
2799
3172
|
function toTutorError(code, fallback) {
|
|
@@ -2801,6 +3174,20 @@ function toTutorError(code, fallback) {
|
|
|
2801
3174
|
if (known) return new TutorError(known[0], known[1]);
|
|
2802
3175
|
return new TutorError("llm-failed", `The model call failed${code ? ` (${code})` : ""}${fallback ? `: ${fallback}` : ""}`);
|
|
2803
3176
|
}
|
|
3177
|
+
function pickEffort(efforts, wanted) {
|
|
3178
|
+
if (!efforts?.length || !wanted) return void 0;
|
|
3179
|
+
if (wanted === "lowest") {
|
|
3180
|
+
const rank = (effort) => EFFORT_ORDER.includes(effort.id) ? EFFORT_ORDER.indexOf(effort.id) : EFFORT_ORDER.length;
|
|
3181
|
+
return efforts.reduce((lightest, effort) => rank(effort) < rank(lightest) ? effort : lightest).id;
|
|
3182
|
+
}
|
|
3183
|
+
return (efforts.find((effort) => effort.id === wanted) ?? efforts.find((effort) => effort.id !== "off" && effort.id !== "none") ?? efforts[0]).id;
|
|
3184
|
+
}
|
|
3185
|
+
function outputBudget(maxTokens, effort, model) {
|
|
3186
|
+
const cap = Number(model?.defaultMaxTokens) || 0;
|
|
3187
|
+
if (!cap || !model?.reasoning) return maxTokens;
|
|
3188
|
+
const room = REASONING_ROOM[effort ?? model.reasoning.defaultEffort] ?? REASONING_ROOM.high;
|
|
3189
|
+
return Math.max(Math.min(maxTokens, cap), Math.min(maxTokens + room, cap));
|
|
3190
|
+
}
|
|
2804
3191
|
const sleep$1 = (ms, signal) => new Promise((resolve, reject) => {
|
|
2805
3192
|
const timer = setTimeout(resolve, ms);
|
|
2806
3193
|
signal?.addEventListener("abort", () => {
|
|
@@ -2826,7 +3213,7 @@ function createLlmPort({ getLlm, getDefault, getAttachments, prefs, logger }) {
|
|
|
2826
3213
|
provider: selection.provider,
|
|
2827
3214
|
model: selection.model
|
|
2828
3215
|
};
|
|
2829
|
-
throw new TutorError("llm-
|
|
3216
|
+
throw new TutorError("llm-missing", "No model is configured in DSH yet.");
|
|
2830
3217
|
}
|
|
2831
3218
|
async function modalities(purpose, signal) {
|
|
2832
3219
|
const llm = getLlm();
|
|
@@ -2860,9 +3247,9 @@ function createLlmPort({ getLlm, getDefault, getAttachments, prefs, logger }) {
|
|
|
2860
3247
|
}
|
|
2861
3248
|
return blocks;
|
|
2862
3249
|
}
|
|
2863
|
-
async function attempt({ purpose, system, user, images, maxTokens, signal, onText }) {
|
|
3250
|
+
async function attempt({ purpose, effort, system, user, images, maxTokens, signal, onText, onReasoning }) {
|
|
2864
3251
|
const llm = getLlm();
|
|
2865
|
-
if (!llm) throw new TutorError("llm-
|
|
3252
|
+
if (!llm) throw new TutorError("llm-missing", "DSH has no model service in this profile.");
|
|
2866
3253
|
const target = route(purpose);
|
|
2867
3254
|
const timeout = AbortSignal.timeout(CALL_TIMEOUT_MS);
|
|
2868
3255
|
const fused = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
@@ -2871,13 +3258,14 @@ function createLlmPort({ getLlm, getDefault, getAttachments, prefs, logger }) {
|
|
|
2871
3258
|
prepared = await llm.prepareCall({
|
|
2872
3259
|
...target,
|
|
2873
3260
|
maxTokens
|
|
2874
|
-
}, fused,
|
|
2875
|
-
const
|
|
2876
|
-
return
|
|
3261
|
+
}, fused, (controls, model) => {
|
|
3262
|
+
const chosen = pickEffort(model?.reasoning?.efforts, purpose === "quick" ? "lowest" : effort);
|
|
3263
|
+
return {
|
|
2877
3264
|
...controls,
|
|
2878
|
-
reasoningEffort:
|
|
2879
|
-
|
|
2880
|
-
|
|
3265
|
+
...chosen ? { reasoningEffort: chosen } : {},
|
|
3266
|
+
maxTokens: outputBudget(maxTokens, chosen, model)
|
|
3267
|
+
};
|
|
3268
|
+
});
|
|
2881
3269
|
} catch (error) {
|
|
2882
3270
|
if (signal?.aborted) throw signal.reason ?? error;
|
|
2883
3271
|
throw Object.assign(toTutorError(error?.code, error?.message), { retryable: false });
|
|
@@ -2897,7 +3285,8 @@ function createLlmPort({ getLlm, getDefault, getAttachments, prefs, logger }) {
|
|
|
2897
3285
|
})) if (chunk.type === "text-delta" && typeof chunk.text === "string") {
|
|
2898
3286
|
textValue += chunk.text;
|
|
2899
3287
|
onText?.(chunk.text);
|
|
2900
|
-
} else if (chunk.type === "
|
|
3288
|
+
} else if (chunk.type === "reasoning-delta" && typeof chunk.text === "string") onReasoning?.(chunk.text);
|
|
3289
|
+
else if (chunk.type === "finish") finish = chunk.reason;
|
|
2901
3290
|
} catch (error) {
|
|
2902
3291
|
if (signal?.aborted) throw signal.reason ?? error;
|
|
2903
3292
|
throw Object.assign(toTutorError(timeout.aborted ? "TIMEOUT" : error?.code, error?.message), {
|
|
@@ -2916,25 +3305,32 @@ function createLlmPort({ getLlm, getDefault, getAttachments, prefs, logger }) {
|
|
|
2916
3305
|
}
|
|
2917
3306
|
if (!textValue.trim()) throw Object.assign(toTutorError("EMPTY_RESPONSE"), {
|
|
2918
3307
|
retryable: true,
|
|
2919
|
-
streamed: false
|
|
3308
|
+
streamed: false,
|
|
3309
|
+
grow: STOPPED_AT_LIMIT.has(finish?.kind)
|
|
2920
3310
|
});
|
|
2921
3311
|
return textValue;
|
|
2922
3312
|
}
|
|
2923
3313
|
async function call(options) {
|
|
2924
3314
|
let lastError;
|
|
3315
|
+
let current = options;
|
|
2925
3316
|
for (let tries = 0; tries <= MAX_RETRIES; tries += 1) try {
|
|
2926
|
-
return await attempt(
|
|
3317
|
+
return await attempt(current);
|
|
2927
3318
|
} catch (error) {
|
|
2928
3319
|
lastError = error;
|
|
2929
3320
|
if (options.signal?.aborted || !error?.retryable || error?.streamed || tries === MAX_RETRIES) throw error;
|
|
2930
|
-
|
|
3321
|
+
if (error.grow) current = {
|
|
3322
|
+
...current,
|
|
3323
|
+
maxTokens: current.maxTokens * 2
|
|
3324
|
+
};
|
|
3325
|
+
else await sleep$1(Math.min(2e4, Number(error.retryAfterMs) || 1500 * (tries + 1)), options.signal);
|
|
2931
3326
|
}
|
|
2932
3327
|
throw lastError;
|
|
2933
3328
|
}
|
|
2934
3329
|
return Object.freeze({
|
|
2935
3330
|
complete: (options) => call({
|
|
2936
3331
|
...options,
|
|
2937
|
-
onText: void 0
|
|
3332
|
+
onText: void 0,
|
|
3333
|
+
onReasoning: void 0
|
|
2938
3334
|
}),
|
|
2939
3335
|
stream: (options) => call(options),
|
|
2940
3336
|
route,
|
|
@@ -3029,6 +3425,7 @@ function createStore({ root }) {
|
|
|
3029
3425
|
course: (id) => join(paths.courseDir(id), "course.json"),
|
|
3030
3426
|
progress: (id) => join(paths.courseDir(id), "progress.json"),
|
|
3031
3427
|
intake: (id) => join(paths.courseDir(id), "intake.json"),
|
|
3428
|
+
proposal: (id) => join(paths.courseDir(id), "proposal.json"),
|
|
3032
3429
|
cards: (id) => join(paths.courseDir(id), "cards.json"),
|
|
3033
3430
|
boardsDir: (id) => join(paths.courseDir(id), "boards"),
|
|
3034
3431
|
practiceDir: (id) => join(paths.courseDir(id), "practice"),
|
|
@@ -3593,7 +3990,7 @@ function createProjects({ store, llm, jobs, courses, projectsRoot = resolveProje
|
|
|
3593
3990
|
const content = await readWorkspace(folderOf(course, project));
|
|
3594
3991
|
if (content) parts.push(`Files in the project folder:\n${content}`);
|
|
3595
3992
|
}
|
|
3596
|
-
if (!parts.length) throw new TutorError("
|
|
3993
|
+
if (!parts.length) throw new TutorError("nothing-to-review", "Nothing to review yet: write a note or work in the project folder first");
|
|
3597
3994
|
const result = await generateJson(llm, {
|
|
3598
3995
|
purpose: "teach",
|
|
3599
3996
|
prompt: projectReviewPrompt({
|
|
@@ -3655,6 +4052,100 @@ function createProjects({ store, llm, jobs, courses, projectsRoot = resolveProje
|
|
|
3655
4052
|
});
|
|
3656
4053
|
}
|
|
3657
4054
|
//#endregion
|
|
4055
|
+
//#region src/host/research.js
|
|
4056
|
+
const MAX_QUERIES = 3;
|
|
4057
|
+
const RESULTS_PER_QUERY = 6;
|
|
4058
|
+
const MAX_SOURCES = 10;
|
|
4059
|
+
const UNAVAILABLE = /* @__PURE__ */ new Set([
|
|
4060
|
+
"WEB_PROVIDER_UNAVAILABLE",
|
|
4061
|
+
"WEB_PROVIDER_CONFIGURED_MISSING",
|
|
4062
|
+
"WEB_PROVIDER_CONFIGURED_UNAVAILABLE",
|
|
4063
|
+
"WEB_PROVIDER_AMBIGUOUS"
|
|
4064
|
+
]);
|
|
4065
|
+
function cleanQueries(raw, goal) {
|
|
4066
|
+
const queries = [...new Set((Array.isArray(raw) ? raw : []).map((query) => text(query, 120)).filter(Boolean))].slice(0, MAX_QUERIES);
|
|
4067
|
+
return queries.length ? queries : [text(goal, 120)].filter(Boolean);
|
|
4068
|
+
}
|
|
4069
|
+
function mergeSources(lists) {
|
|
4070
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4071
|
+
const out = [];
|
|
4072
|
+
const longest = Math.max(0, ...lists.map((list) => list.length));
|
|
4073
|
+
for (let rank = 0; rank < longest && out.length < MAX_SOURCES; rank += 1) for (const list of lists) {
|
|
4074
|
+
const source = list[rank];
|
|
4075
|
+
const url = safeUrl(source?.url);
|
|
4076
|
+
if (!url || seen.has(url) || out.length >= MAX_SOURCES) continue;
|
|
4077
|
+
seen.add(url);
|
|
4078
|
+
out.push({
|
|
4079
|
+
url,
|
|
4080
|
+
title: text(source.title, 200) || new URL(url).hostname,
|
|
4081
|
+
snippet: text(source.snippet ?? source.text ?? source.excerpt, 400),
|
|
4082
|
+
publishedAt: text(source.publishedAt ?? source.date, 40)
|
|
4083
|
+
});
|
|
4084
|
+
}
|
|
4085
|
+
return out;
|
|
4086
|
+
}
|
|
4087
|
+
function safeUrl(value) {
|
|
4088
|
+
const raw = text(value, 500);
|
|
4089
|
+
if (!raw) return "";
|
|
4090
|
+
try {
|
|
4091
|
+
const url = new URL(raw);
|
|
4092
|
+
return url.protocol === "https:" || url.protocol === "http:" ? url.href : "";
|
|
4093
|
+
} catch {
|
|
4094
|
+
return "";
|
|
4095
|
+
}
|
|
4096
|
+
}
|
|
4097
|
+
function skipReason(error) {
|
|
4098
|
+
const code = String(error?.code ?? "");
|
|
4099
|
+
if (code === "WEB_PROVIDER_CREDENTIAL_MISSING") return "no-credential";
|
|
4100
|
+
if (UNAVAILABLE.has(code)) return "unavailable";
|
|
4101
|
+
return error ? "failed" : "empty";
|
|
4102
|
+
}
|
|
4103
|
+
function createResearch({ getWeb, llm, logger }) {
|
|
4104
|
+
async function run({ goal, language, signal, onSearching }) {
|
|
4105
|
+
const web = getWeb?.();
|
|
4106
|
+
if (typeof web?.search !== "function") return {
|
|
4107
|
+
skipped: "unavailable",
|
|
4108
|
+
sources: []
|
|
4109
|
+
};
|
|
4110
|
+
let queries;
|
|
4111
|
+
try {
|
|
4112
|
+
queries = await generateJson(llm, {
|
|
4113
|
+
purpose: "quick",
|
|
4114
|
+
prompt: researchQueriesPrompt({
|
|
4115
|
+
goal,
|
|
4116
|
+
language
|
|
4117
|
+
}),
|
|
4118
|
+
maxTokens: 400,
|
|
4119
|
+
signal,
|
|
4120
|
+
normalize: (raw) => cleanQueries(raw?.queries, goal)
|
|
4121
|
+
});
|
|
4122
|
+
} catch (error) {
|
|
4123
|
+
if (signal?.aborted) throw error;
|
|
4124
|
+
queries = cleanQueries([], goal);
|
|
4125
|
+
}
|
|
4126
|
+
onSearching?.(queries);
|
|
4127
|
+
const settled = await Promise.allSettled(queries.map((query) => web.search({
|
|
4128
|
+
query,
|
|
4129
|
+
maxResults: RESULTS_PER_QUERY
|
|
4130
|
+
}, signal)));
|
|
4131
|
+
signal?.throwIfAborted?.();
|
|
4132
|
+
const failure = settled.find((result) => result.status === "rejected")?.reason;
|
|
4133
|
+
if (failure) logger?.debug?.("aha-tutor web research: %s", failure?.code ?? failure?.message);
|
|
4134
|
+
const sources = mergeSources(settled.filter((result) => result.status === "fulfilled").map((result) => result.value?.sources ?? []));
|
|
4135
|
+
if (!sources.length) return {
|
|
4136
|
+
skipped: skipReason(failure),
|
|
4137
|
+
queries,
|
|
4138
|
+
sources: []
|
|
4139
|
+
};
|
|
4140
|
+
return {
|
|
4141
|
+
queries,
|
|
4142
|
+
sources,
|
|
4143
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
4144
|
+
};
|
|
4145
|
+
}
|
|
4146
|
+
return Object.freeze({ run });
|
|
4147
|
+
}
|
|
4148
|
+
//#endregion
|
|
3658
4149
|
//#region src/host/rpc.js
|
|
3659
4150
|
const ID = (prefix) => new RegExp(`^${prefix}_[a-z0-9]{6,40}$`, "u");
|
|
3660
4151
|
const IDS = {
|
|
@@ -3684,19 +4175,24 @@ function idList(value, prefix, max = 8) {
|
|
|
3684
4175
|
}
|
|
3685
4176
|
function scopeOf(payload) {
|
|
3686
4177
|
const scope = payload?.scope ?? {};
|
|
4178
|
+
const jobId = optional(scope, "jobId", "job");
|
|
4179
|
+
const writing = jobId ? { jobId } : {};
|
|
3687
4180
|
if (scope.kind === "lesson") return {
|
|
3688
4181
|
kind: "lesson",
|
|
3689
4182
|
courseId: need(scope, "courseId", "crs"),
|
|
3690
|
-
lessonId: need(scope, "lessonId", "les")
|
|
4183
|
+
lessonId: need(scope, "lessonId", "les"),
|
|
4184
|
+
...writing
|
|
3691
4185
|
};
|
|
3692
4186
|
if (scope.kind === "thread") return {
|
|
3693
4187
|
kind: "thread",
|
|
3694
4188
|
id: need(scope, "id", "thd"),
|
|
3695
|
-
turn: Number(scope.turn) || 0
|
|
4189
|
+
turn: Number(scope.turn) || 0,
|
|
4190
|
+
...writing
|
|
3696
4191
|
};
|
|
3697
4192
|
if (scope.kind === "short") return {
|
|
3698
4193
|
kind: "short",
|
|
3699
|
-
id: need(scope, "id", "sht")
|
|
4194
|
+
id: need(scope, "id", "sht"),
|
|
4195
|
+
...writing
|
|
3700
4196
|
};
|
|
3701
4197
|
throw new TutorError("invalid", "Invalid board scope");
|
|
3702
4198
|
}
|
|
@@ -3730,12 +4226,23 @@ function createRoutes({ courses, lessons, learner, studio, projects, jobs, setti
|
|
|
3730
4226
|
goal: payload.goal,
|
|
3731
4227
|
materialIds: idList(payload.materialIds, "mat"),
|
|
3732
4228
|
quick: payload.quick === true,
|
|
4229
|
+
web: payload.web === true,
|
|
3733
4230
|
language: typeof payload.language === "string" ? payload.language.slice(0, 12) : void 0
|
|
3734
4231
|
}) }),
|
|
4232
|
+
"course/proposal-apply": (payload) => courses.applyProposal({ courseId: need(payload, "courseId", "crs") }),
|
|
4233
|
+
"course/proposal-discard": (payload) => courses.discardProposal({ courseId: need(payload, "courseId", "crs") }),
|
|
4234
|
+
"course/rework-lesson": (payload) => ({ job: courses.reworkLesson({
|
|
4235
|
+
courseId: need(payload, "courseId", "crs"),
|
|
4236
|
+
lesson: payload.lesson,
|
|
4237
|
+
unitTitle: payload.unitTitle,
|
|
4238
|
+
lectureTitle: payload.lectureTitle,
|
|
4239
|
+
mode: payload.mode === "split" ? "split" : "deepen"
|
|
4240
|
+
}) }),
|
|
3735
4241
|
"course/intake": (payload) => ({ job: courses.submitIntake({
|
|
3736
4242
|
courseId: need(payload, "courseId", "crs"),
|
|
3737
4243
|
answers: payload.answers,
|
|
3738
4244
|
timeline: payload.timeline,
|
|
4245
|
+
size: payload.size,
|
|
3739
4246
|
weeklyMinutes: payload.weeklyMinutes,
|
|
3740
4247
|
deadline: payload.deadline
|
|
3741
4248
|
}) }),
|
|
@@ -3818,6 +4325,14 @@ function createRoutes({ courses, lessons, learner, studio, projects, jobs, setti
|
|
|
3818
4325
|
response: payload.response,
|
|
3819
4326
|
language: typeof payload.language === "string" ? payload.language.slice(0, 12) : void 0
|
|
3820
4327
|
}),
|
|
4328
|
+
"lesson/correct": (payload) => ({ job: lessons.correct({
|
|
4329
|
+
scope: scopeOf(payload),
|
|
4330
|
+
stepIndex: payload.stepIndex,
|
|
4331
|
+
anchorStepId: payload.anchorStepId,
|
|
4332
|
+
item: payload.item,
|
|
4333
|
+
response: payload.response,
|
|
4334
|
+
grade: payload.grade
|
|
4335
|
+
}) }),
|
|
3821
4336
|
"lesson/position": (payload) => lessons.savePosition({
|
|
3822
4337
|
courseId: need(payload, "courseId", "crs"),
|
|
3823
4338
|
lessonId: need(payload, "lessonId", "les"),
|
|
@@ -3859,6 +4374,16 @@ function createRoutes({ courses, lessons, learner, studio, projects, jobs, setti
|
|
|
3859
4374
|
"profile/clear": () => learner.clearProfile(),
|
|
3860
4375
|
"profile/remove-note": (payload) => learner.removeNote({ id: need(payload, "id", "nte") }),
|
|
3861
4376
|
"plan/get": (payload) => learner.plan({ today: payload.today }),
|
|
4377
|
+
"plan/check": (payload) => learner.check({
|
|
4378
|
+
today: payload.today,
|
|
4379
|
+
day: payload.day,
|
|
4380
|
+
item: payload.item
|
|
4381
|
+
}),
|
|
4382
|
+
"plan/uncheck": (payload) => learner.uncheck({
|
|
4383
|
+
today: payload.today,
|
|
4384
|
+
day: String(payload.day ?? "").slice(0, 10),
|
|
4385
|
+
refId: String(payload.refId ?? "").slice(0, 80)
|
|
4386
|
+
}),
|
|
3862
4387
|
"plan/settings": (payload) => learner.savePlanSettings({
|
|
3863
4388
|
weeklyMinutes: payload.weeklyMinutes,
|
|
3864
4389
|
studyDays: payload.studyDays
|
|
@@ -3869,7 +4394,7 @@ function createRoutes({ courses, lessons, learner, studio, projects, jobs, setti
|
|
|
3869
4394
|
courseId: payload.courseId
|
|
3870
4395
|
}),
|
|
3871
4396
|
"plan/remove-exam": (payload) => learner.removeExam({ id: need(payload, "id", "exm") }),
|
|
3872
|
-
"review/session": () => learner.reviewSession(),
|
|
4397
|
+
"review/session": (payload) => learner.reviewSession({ courseId: optional(payload, "courseId", "crs") }),
|
|
3873
4398
|
"material/upload": (payload) => ({ job: studio.upload({
|
|
3874
4399
|
name: payload.name,
|
|
3875
4400
|
base64: payload.base64
|
|
@@ -3991,13 +4516,12 @@ function createRpcHandler(routes, { logger, checkPeer = isOwner } = {}) {
|
|
|
3991
4516
|
};
|
|
3992
4517
|
} catch (error) {
|
|
3993
4518
|
if (signal?.aborted) throw error;
|
|
3994
|
-
|
|
4519
|
+
if (![
|
|
3995
4520
|
"TutorError",
|
|
3996
4521
|
"MaterialError",
|
|
3997
4522
|
"TtsError"
|
|
3998
|
-
].includes(error?.name);
|
|
3999
|
-
|
|
4000
|
-
return failure(known ? error.code ?? "failed" : "failed", known ? error.message : publicMessage(error));
|
|
4523
|
+
].includes(error?.name)) logger?.warn?.("aha-tutor %s failed: %s", endpoint, error?.message);
|
|
4524
|
+
return failure(publicCode(error), publicMessage(error));
|
|
4001
4525
|
}
|
|
4002
4526
|
};
|
|
4003
4527
|
}
|
|
@@ -4157,15 +4681,15 @@ function checkEndpoint(value) {
|
|
|
4157
4681
|
try {
|
|
4158
4682
|
url = new URL(String(value));
|
|
4159
4683
|
} catch {
|
|
4160
|
-
throw new TtsError("
|
|
4684
|
+
throw new TtsError("address", "TTS address is not a valid URL");
|
|
4161
4685
|
}
|
|
4162
4686
|
const local = [
|
|
4163
4687
|
"localhost",
|
|
4164
4688
|
"127.0.0.1",
|
|
4165
4689
|
"[::1]"
|
|
4166
4690
|
].includes(url.hostname);
|
|
4167
|
-
if (url.protocol !== "https:" && !(url.protocol === "http:" && local)) throw new TtsError("
|
|
4168
|
-
if (url.username || url.password) throw new TtsError("
|
|
4691
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && local)) throw new TtsError("address", "TTS address must use https (http only for localhost)");
|
|
4692
|
+
if (url.username || url.password) throw new TtsError("address", "TTS address must not contain credentials");
|
|
4169
4693
|
return url;
|
|
4170
4694
|
}
|
|
4171
4695
|
const SENTENCE_END = /([。!?!?;;…]+|\.(?=\s)|\n+)/u;
|
|
@@ -4268,7 +4792,7 @@ const synthesizers = {
|
|
|
4268
4792
|
return readBounded(response);
|
|
4269
4793
|
},
|
|
4270
4794
|
async volcengine({ text, config, secret, rate, fetchImpl, signal }) {
|
|
4271
|
-
if (!config.appId) throw new TtsError("
|
|
4795
|
+
if (!config.appId) throw new TtsError("app-id", "Volcengine App ID is required");
|
|
4272
4796
|
const response = await boundedFetch(fetchImpl, "https://openspeech.bytedance.com/api/v1/tts", {
|
|
4273
4797
|
method: "POST",
|
|
4274
4798
|
headers: {
|
|
@@ -4349,9 +4873,9 @@ const synthesizers = {
|
|
|
4349
4873
|
},
|
|
4350
4874
|
async azure({ text, config, secret, rate, fetchImpl, signal }) {
|
|
4351
4875
|
const region = String(config.region || "").trim().toLowerCase();
|
|
4352
|
-
if (!/^[a-z0-9]{2,30}$/u.test(region)) throw new TtsError("
|
|
4876
|
+
if (!/^[a-z0-9]{2,30}$/u.test(region)) throw new TtsError("azure", "Azure region is invalid");
|
|
4353
4877
|
const voice = String(config.voice || "zh-CN-XiaoxiaoNeural");
|
|
4354
|
-
if (!/^[A-Za-z]{2,3}-[A-Za-z]{2,4}-[A-Za-z0-9:]+$/u.test(voice)) throw new TtsError("
|
|
4878
|
+
if (!/^[A-Za-z]{2,3}-[A-Za-z]{2,4}-[A-Za-z0-9:]+$/u.test(voice)) throw new TtsError("azure", "Azure voice name is invalid");
|
|
4355
4879
|
const lang = voice.split("-").slice(0, 2).join("-");
|
|
4356
4880
|
const percent = Math.round((rateNumber(rate) - 1) * 100);
|
|
4357
4881
|
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>`;
|
|
@@ -4391,7 +4915,7 @@ function createTts({ cacheDir, fetchImpl = globalThis.fetch, getSecret }) {
|
|
|
4391
4915
|
async function synthesize({ provider, config = {}, text, rate = 1, signal }) {
|
|
4392
4916
|
if (!TTS_PROVIDERS.includes(provider)) throw new TtsError("config", "Unknown TTS provider");
|
|
4393
4917
|
const content = String(text ?? "").trim().slice(0, 4e3);
|
|
4394
|
-
if (!content) throw new TtsError("
|
|
4918
|
+
if (!content) throw new TtsError("empty", "Nothing to speak");
|
|
4395
4919
|
const merged = {
|
|
4396
4920
|
...providerDefaults(provider),
|
|
4397
4921
|
...config
|
|
@@ -4407,7 +4931,7 @@ function createTts({ cacheDir, fetchImpl = globalThis.fetch, getSecret }) {
|
|
|
4407
4931
|
};
|
|
4408
4932
|
} catch {}
|
|
4409
4933
|
const secret = await getSecret(provider);
|
|
4410
|
-
if (!secret) throw new TtsError("
|
|
4934
|
+
if (!secret) throw new TtsError("no-key", "No key saved for this TTS provider");
|
|
4411
4935
|
const parts = [];
|
|
4412
4936
|
for (const chunk of splitForTts(content, CHUNK_UNITS[provider])) parts.push(await synthesizers[provider]({
|
|
4413
4937
|
text: chunk,
|
|
@@ -4540,7 +5064,7 @@ function createKeyStore({ getCredentials, credentialKey }) {
|
|
|
4540
5064
|
};
|
|
4541
5065
|
const service = () => {
|
|
4542
5066
|
const credentials = getCredentials();
|
|
4543
|
-
if (!credentials) throw new TutorError("
|
|
5067
|
+
if (!credentials) throw new TutorError("no-credentials", "DSH credentials are not available in this profile");
|
|
4544
5068
|
return credentials;
|
|
4545
5069
|
};
|
|
4546
5070
|
return {
|
|
@@ -4559,7 +5083,7 @@ function createKeyStore({ getCredentials, credentialKey }) {
|
|
|
4559
5083
|
},
|
|
4560
5084
|
async write(provider, value) {
|
|
4561
5085
|
const key = String(value ?? "").trim();
|
|
4562
|
-
if (!/^[\x21-\x7E]{8,1024}$/u.test(key)) throw new TutorError("
|
|
5086
|
+
if (!/^[\x21-\x7E]{8,1024}$/u.test(key)) throw new TutorError("bad-key", "The key looks wrong (8–1024 printable characters, no spaces)");
|
|
4563
5087
|
await service().modifyRecord(keyOf(provider), async () => ({
|
|
4564
5088
|
kind: "api-key",
|
|
4565
5089
|
key
|
|
@@ -4721,7 +5245,7 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
|
|
|
4721
5245
|
function digest({ id }) {
|
|
4722
5246
|
return jobs.start("material/digest", async (api) => {
|
|
4723
5247
|
const [material] = await readMaterials(store, [assertId(id, "mat")]);
|
|
4724
|
-
if (!material?.chunks.length) throw new TutorError("
|
|
5248
|
+
if (!material?.chunks.length) throw new TutorError("material-no-text", "This material has no text to digest");
|
|
4725
5249
|
const title = material.meta.title;
|
|
4726
5250
|
const language = detectLanguage(material.chunks[0].text, uiLanguage());
|
|
4727
5251
|
const size = material.chunks.reduce((sum, chunk) => sum + chunk.text.length, 0);
|
|
@@ -4804,9 +5328,9 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
|
|
|
4804
5328
|
}
|
|
4805
5329
|
async function scopeOf(materialIds) {
|
|
4806
5330
|
const ids = (Array.isArray(materialIds) ? materialIds : []).filter((id) => /^mat_[a-z0-9]+$/u.test(String(id))).slice(0, 8);
|
|
4807
|
-
if (!ids.length) throw new TutorError("
|
|
5331
|
+
if (!ids.length) throw new TutorError("no-material", "Pick at least one material");
|
|
4808
5332
|
const materials = await readMaterials(store, ids);
|
|
4809
|
-
if (!materials.some((item) => item.chunks.length)) throw new TutorError("
|
|
5333
|
+
if (!materials.some((item) => item.chunks.length)) throw new TutorError("material-no-text", "These materials have no text");
|
|
4810
5334
|
const title = materials.map((item) => item.meta.title).join(" + ");
|
|
4811
5335
|
return {
|
|
4812
5336
|
ids,
|
|
@@ -5021,8 +5545,9 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
|
|
|
5021
5545
|
}
|
|
5022
5546
|
function assist({ mode = "explain", request, materialIds = [], threadId, newThreadId }) {
|
|
5023
5547
|
const ask = text(request, 4e3);
|
|
5024
|
-
if (!ask) throw new TutorError("
|
|
5548
|
+
if (!ask) throw new TutorError("empty-question", "Type your question");
|
|
5025
5549
|
const chosen = ASSIST_MODES.includes(mode) ? mode : "explain";
|
|
5550
|
+
const fresh = /^thd_[a-z0-9]{6,40}$/u.test(String(newThreadId)) ? newThreadId : null;
|
|
5026
5551
|
return jobs.start("assist/turn", async (api) => {
|
|
5027
5552
|
let thread = threadId ? await store.readJson(store.paths.item("assist", assertId(threadId, "thd")), null) : null;
|
|
5028
5553
|
const ids = [.../* @__PURE__ */ new Set([...thread?.materialIds ?? [], ...Array.isArray(materialIds) ? materialIds : []])].filter((id) => /^mat_[a-z0-9]+$/u.test(String(id))).slice(0, 8);
|
|
@@ -5033,6 +5558,7 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
|
|
|
5033
5558
|
const images = await assistImages(ids);
|
|
5034
5559
|
const board = await generateSteps(llm, {
|
|
5035
5560
|
purpose: "teach",
|
|
5561
|
+
effort: "lowest",
|
|
5036
5562
|
prompt: assistPrompt({
|
|
5037
5563
|
mode: chosen,
|
|
5038
5564
|
request: ask,
|
|
@@ -5053,7 +5579,7 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
|
|
|
5053
5579
|
board,
|
|
5054
5580
|
at: stamp()
|
|
5055
5581
|
};
|
|
5056
|
-
const id = thread?.id ??
|
|
5582
|
+
const id = thread?.id ?? fresh ?? newId("thd");
|
|
5057
5583
|
const saved = await store.updateJson(store.paths.item("assist", id), null, (current) => {
|
|
5058
5584
|
const next = current ?? {
|
|
5059
5585
|
id,
|
|
@@ -5073,14 +5599,18 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
|
|
|
5073
5599
|
turn: saved.turns.length - 1,
|
|
5074
5600
|
board
|
|
5075
5601
|
};
|
|
5076
|
-
}, { meta: {
|
|
5602
|
+
}, { meta: {
|
|
5603
|
+
threadId: threadId ?? fresh,
|
|
5604
|
+
title: ask.slice(0, 60)
|
|
5605
|
+
} });
|
|
5077
5606
|
}
|
|
5078
5607
|
async function assistImages(ids) {
|
|
5079
5608
|
return llm.supportsImages?.("teach") ? materialImages(store, await readMaterials(store, ids)) : [];
|
|
5080
5609
|
}
|
|
5081
5610
|
function makeShort({ topic, url, newId: presetId }) {
|
|
5082
5611
|
const subject = text(topic, 300);
|
|
5083
|
-
if (!subject && !url) throw new TutorError("
|
|
5612
|
+
if (!subject && !url) throw new TutorError("empty-topic", "Give a topic or a web address");
|
|
5613
|
+
const id = /^sht_[a-z0-9]{6,40}$/u.test(String(presetId)) ? presetId : newId("sht");
|
|
5084
5614
|
return jobs.start("short/make", async (api) => {
|
|
5085
5615
|
let passages = "";
|
|
5086
5616
|
let sourceTitle = "";
|
|
@@ -5094,6 +5624,7 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
|
|
|
5094
5624
|
const language = detectLanguage(subject || passages, uiLanguage());
|
|
5095
5625
|
const board = await generateSteps(llm, {
|
|
5096
5626
|
purpose: "teach",
|
|
5627
|
+
effort: "lowest",
|
|
5097
5628
|
prompt: shortPrompt({
|
|
5098
5629
|
topic: subject || sourceTitle,
|
|
5099
5630
|
passages,
|
|
@@ -5105,7 +5636,7 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
|
|
|
5105
5636
|
minSteps: 3
|
|
5106
5637
|
});
|
|
5107
5638
|
const item = {
|
|
5108
|
-
id
|
|
5639
|
+
id,
|
|
5109
5640
|
title: board.title || subject || sourceTitle,
|
|
5110
5641
|
topic: subject,
|
|
5111
5642
|
sourceUrl: url ? String(url) : null,
|
|
@@ -5115,7 +5646,10 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
|
|
|
5115
5646
|
};
|
|
5116
5647
|
await store.writeJson(store.paths.item("shorts", item.id), item);
|
|
5117
5648
|
return { item };
|
|
5118
|
-
}
|
|
5649
|
+
}, { meta: {
|
|
5650
|
+
id,
|
|
5651
|
+
title: subject
|
|
5652
|
+
} });
|
|
5119
5653
|
}
|
|
5120
5654
|
return Object.freeze({
|
|
5121
5655
|
upload,
|
|
@@ -5228,7 +5762,12 @@ function apply(ctx) {
|
|
|
5228
5762
|
const courses = createCourses({
|
|
5229
5763
|
store,
|
|
5230
5764
|
llm,
|
|
5231
|
-
jobs
|
|
5765
|
+
jobs,
|
|
5766
|
+
research: createResearch({
|
|
5767
|
+
getWeb: () => ctx.get?.("web"),
|
|
5768
|
+
llm,
|
|
5769
|
+
logger
|
|
5770
|
+
})
|
|
5232
5771
|
});
|
|
5233
5772
|
const lessons = createLessons({
|
|
5234
5773
|
store,
|