dsh-aha-tutor 1.1.0 → 1.3.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.
@@ -240,12 +240,34 @@ function normalizeBlueprint(raw, { language } = {}) {
240
240
  projects: list(raw?.projects, LIMITS.projects, normalizeProject)
241
241
  };
242
242
  }
243
+ function blueprintOutline(raw) {
244
+ const units = list(raw?.units, LIMITS.units, (unit) => {
245
+ const title = text(unit?.title, 120);
246
+ if (!title) return null;
247
+ return {
248
+ title,
249
+ lectures: list(unit?.lectures, LIMITS.lecturesPerUnit, (lecture) => {
250
+ const lessons = strings(lecture?.lessons, LIMITS.lessonsPerLecture, 120);
251
+ const name = text(lecture?.title, 120);
252
+ return name || lessons.length ? {
253
+ title: name,
254
+ lessons
255
+ } : null;
256
+ })
257
+ };
258
+ });
259
+ return {
260
+ title: text(raw?.title, 120),
261
+ units
262
+ };
263
+ }
243
264
  const ITEM_KINDS = [
244
265
  "choice",
245
266
  "multi",
246
267
  "blank",
247
268
  "number",
248
269
  "short",
270
+ "work",
249
271
  "order"
250
272
  ];
251
273
  function normalizeItem(raw, index = 0, conceptIds = []) {
@@ -261,6 +283,8 @@ function normalizeItem(raw, index = 0, conceptIds = []) {
261
283
  difficulty: Math.min(3, Math.max(1, Math.round(Number(raw?.difficulty) || 2))),
262
284
  conceptIds: (Array.isArray(raw?.conceptIds) ? raw.conceptIds : []).filter((id) => conceptIds.includes(id)).slice(0, 4)
263
285
  };
286
+ const figure = text(raw?.svg ?? raw?.figure, 16e3);
287
+ if (/^<svg[\s>]/iu.test(figure)) item.svg = figure;
264
288
  if (kind === "choice" || kind === "multi" || kind === "order") {
265
289
  item.options = list(raw?.options, LIMITS.options4, (option, optionIndex) => {
266
290
  const label = text(typeof option === "string" ? option : option?.text ?? option?.label, 400);
@@ -667,14 +691,28 @@ function normalizeOp(raw, index) {
667
691
  default: return null;
668
692
  }
669
693
  }
694
+ function syncOps(ops, rawAts) {
695
+ if (!rawAts.some((value) => value !== null)) return;
696
+ let current = 0;
697
+ ops.forEach((op, index) => {
698
+ current = Math.max(current, rawAts[index] ?? current);
699
+ op.at = current;
700
+ });
701
+ }
702
+ function sentenceIndex(value) {
703
+ const number = Number(value);
704
+ return value !== null && value !== "" && Number.isInteger(number) && number >= 0 ? Math.min(19, number) : null;
705
+ }
670
706
  function normalizeBoard(raw, { conceptIds = [] } = {}) {
671
707
  const sourceSteps = Array.isArray(raw) ? raw : Array.isArray(raw?.steps) ? raw.steps : [];
672
708
  const steps = [];
673
709
  let opCounter = 0;
710
+ let worked = false;
674
711
  const idMap = /* @__PURE__ */ new Map();
675
712
  for (const rawStep of sourceSteps.slice(0, BOARD_LIMITS.steps)) {
676
713
  const say = text(rawStep?.say ?? rawStep?.narration ?? rawStep?.speech, BOARD_LIMITS.say);
677
714
  const ops = [];
715
+ const ats = [];
678
716
  for (const rawOp of (Array.isArray(rawStep?.ops ?? rawStep?.board) ? rawStep.ops ?? rawStep.board : []).slice(0, BOARD_LIMITS.opsPerStep)) {
679
717
  const op = normalizeOp(rawOp, opCounter);
680
718
  if (!op) continue;
@@ -682,13 +720,22 @@ function normalizeBoard(raw, { conceptIds = [] } = {}) {
682
720
  op.id = `o${++opCounter}`;
683
721
  if (originalId) idMap.set(originalId, op.id);
684
722
  ops.push(op);
723
+ ats.push(sentenceIndex(rawOp?.at));
685
724
  }
686
- const check = rawStep?.check ? normalizeItem(rawStep.check, steps.length, conceptIds) : null;
725
+ syncOps(ops, ats);
726
+ let check = rawStep?.check ? normalizeItem(rawStep.check, steps.length, conceptIds) : null;
727
+ if (check?.kind === "work") if (worked) check = {
728
+ ...check,
729
+ kind: "short"
730
+ };
731
+ else worked = true;
687
732
  if (!say && !ops.length && !check) continue;
733
+ const goal = sentenceIndex(rawStep?.goal);
688
734
  steps.push({
689
735
  id: `s${steps.length + 1}`,
690
736
  say,
691
737
  ops,
738
+ ...goal !== null && goal < 12 ? { goal } : {},
692
739
  ...check && check.kind !== "order" ? { check: {
693
740
  ...check,
694
741
  id: `k${steps.length + 1}`
@@ -723,6 +770,164 @@ function boardToPlainText(board, { maxChars = 8e3, uptoStep } = {}) {
723
770
  return output.length > maxChars ? `${output.slice(0, maxChars)}\n…` : output;
724
771
  }
725
772
  //#endregion
773
+ //#region src/core/json-extract.js
774
+ var JsonExtractError = class extends Error {
775
+ constructor(message, { cause, text } = {}) {
776
+ super(message, cause ? { cause } : void 0);
777
+ this.name = "JsonExtractError";
778
+ this.preview = typeof text === "string" ? text.slice(0, 200) : void 0;
779
+ }
780
+ };
781
+ const FENCE = /```(?:json|jsonl|javascript|js)?\s*\n?([\s\S]*?)```/giu;
782
+ function stripTrailingCommas(text) {
783
+ let out = "";
784
+ let inString = false;
785
+ let escaped = false;
786
+ for (let index = 0; index < text.length; index += 1) {
787
+ const char = text[index];
788
+ if (inString) {
789
+ out += char;
790
+ if (escaped) escaped = false;
791
+ else if (char === "\\") escaped = true;
792
+ else if (char === "\"") inString = false;
793
+ continue;
794
+ }
795
+ if (char === "\"") {
796
+ inString = true;
797
+ out += char;
798
+ continue;
799
+ }
800
+ if (char === ",") {
801
+ let next = index + 1;
802
+ while (next < text.length && /\s/u.test(text[next])) next += 1;
803
+ if (text[next] === "}" || text[next] === "]") continue;
804
+ }
805
+ out += char;
806
+ }
807
+ return out;
808
+ }
809
+ function balancedSlice(text, from = 0) {
810
+ const start = text.slice(from).search(/[{[]/u);
811
+ if (start < 0) return void 0;
812
+ const begin = from + start;
813
+ const stack = [];
814
+ let inString = false;
815
+ let escaped = false;
816
+ for (let index = begin; index < text.length; index += 1) {
817
+ const char = text[index];
818
+ if (inString) {
819
+ if (escaped) escaped = false;
820
+ else if (char === "\\") escaped = true;
821
+ else if (char === "\"") inString = false;
822
+ continue;
823
+ }
824
+ if (char === "\"") inString = true;
825
+ else if (char === "{" || char === "[") stack.push(char);
826
+ else if (char === "}" || char === "]") {
827
+ const open = stack.pop();
828
+ if (char === "}" && open !== "{" || char === "]" && open !== "[") return void 0;
829
+ if (stack.length === 0) return {
830
+ begin,
831
+ end: index + 1,
832
+ text: text.slice(begin, index + 1)
833
+ };
834
+ }
835
+ }
836
+ return {
837
+ begin,
838
+ end: text.length,
839
+ text: text.slice(begin),
840
+ truncated: true,
841
+ stack
842
+ };
843
+ }
844
+ function repairTruncated(text) {
845
+ let body = text;
846
+ for (let attempt = 0; attempt < 40; attempt += 1) {
847
+ const closed = closeOpen(body);
848
+ if (closed !== void 0) try {
849
+ return JSON.parse(stripTrailingCommas(closed));
850
+ } catch {}
851
+ const cut = Math.max(body.lastIndexOf(","), body.lastIndexOf("{"), body.lastIndexOf("["));
852
+ if (cut <= 0) break;
853
+ body = body.slice(0, body[cut] === "," ? cut : cut + 1);
854
+ }
855
+ throw new JsonExtractError("model output was truncated beyond repair", { text });
856
+ }
857
+ function closeOpen(text) {
858
+ const stack = [];
859
+ let inString = false;
860
+ let escaped = false;
861
+ for (const char of text) {
862
+ if (inString) {
863
+ if (escaped) escaped = false;
864
+ else if (char === "\\") escaped = true;
865
+ else if (char === "\"") inString = false;
866
+ continue;
867
+ }
868
+ if (char === "\"") inString = true;
869
+ else if (char === "{" || char === "[") stack.push(char);
870
+ else if (char === "}" || char === "]") stack.pop();
871
+ }
872
+ if (inString) return void 0;
873
+ return text + stack.reverse().map((open) => open === "{" ? "}" : "]").join("");
874
+ }
875
+ function extractJson(raw, { allowTruncated = true } = {}) {
876
+ if (raw !== null && typeof raw === "object") return raw;
877
+ const text = String(raw ?? "").replace(/^/u, "").trim();
878
+ if (!text) throw new JsonExtractError("model returned an empty response", { text });
879
+ const candidates = [];
880
+ for (const match of text.matchAll(FENCE)) candidates.push(match[1].trim());
881
+ candidates.push(text);
882
+ let lastError;
883
+ for (const candidate of candidates) {
884
+ try {
885
+ return JSON.parse(candidate);
886
+ } catch (error) {
887
+ lastError = error;
888
+ }
889
+ const slice = balancedSlice(candidate);
890
+ if (slice === void 0) continue;
891
+ if (!slice.truncated) try {
892
+ return JSON.parse(stripTrailingCommas(slice.text));
893
+ } catch (error) {
894
+ lastError = error;
895
+ }
896
+ else if (allowTruncated) try {
897
+ return repairTruncated(slice.text);
898
+ } catch (error) {
899
+ lastError = error;
900
+ }
901
+ }
902
+ throw new JsonExtractError("model output did not contain valid JSON", {
903
+ cause: lastError,
904
+ text
905
+ });
906
+ }
907
+ function parseJsonLines(raw, { final = true } = {}) {
908
+ const lines = String(raw ?? "").replace(/```(?:jsonl|json)?/giu, "").split(/\r?\n/u);
909
+ const rest = final ? "" : lines.pop() ?? "";
910
+ const items = [];
911
+ const errors = [];
912
+ for (const line of lines) {
913
+ const trimmed = line.trim();
914
+ if (!trimmed || !/^[{[]/u.test(trimmed)) continue;
915
+ try {
916
+ items.push(JSON.parse(stripTrailingCommas(trimmed)));
917
+ } catch (error) {
918
+ errors.push({
919
+ line: trimmed.slice(0, 120),
920
+ message: error.message
921
+ });
922
+ }
923
+ }
924
+ return {
925
+ items,
926
+ errors,
927
+ rest
928
+ };
929
+ }
930
+ //#endregion
726
931
  //#region src/core/mastery.js
727
932
  const BKT_DEFAULTS = Object.freeze({
728
933
  pInit: .2,
@@ -868,6 +1073,13 @@ function pushHistory(state, entry) {
868
1073
  function masteryPercent(state) {
869
1074
  return Math.round(clamp(Number(state?.pKnown) || 0, 0, 1) * 100);
870
1075
  }
1076
+ Object.freeze([
1077
+ "none",
1078
+ "weak",
1079
+ "learning",
1080
+ "proficient",
1081
+ "mastered"
1082
+ ]);
871
1083
  function masteryLevel(state) {
872
1084
  const p = clamp(Number(state?.pKnown) || 0, 0, 1);
873
1085
  let level = MASTERY_LEVELS[0].id;
@@ -1014,10 +1226,53 @@ function selectPassages(chunks, query, { maxChars = 12e3, limit = 12 } = {}) {
1014
1226
  }
1015
1227
  return result;
1016
1228
  }
1229
+ function spreadPassages(chunks, { maxChars = 12e3, samples = 16 } = {}) {
1230
+ if (!chunks?.length) return [];
1231
+ if (chunks.reduce((sum, chunk) => sum + chunk.text.length, 0) <= maxChars) return chunks.slice();
1232
+ const count = Math.min(chunks.length, Math.max(2, samples));
1233
+ const share = Math.max(200, Math.floor(maxChars / count));
1234
+ const picked = [];
1235
+ for (let index = 0; index < count; index += 1) {
1236
+ const at = Math.round(index * (chunks.length - 1) / (count - 1));
1237
+ if (picked.at(-1) !== at) picked.push(at);
1238
+ }
1239
+ return picked.map((at) => {
1240
+ const chunk = chunks[at];
1241
+ return chunk.text.length > share ? {
1242
+ ...chunk,
1243
+ text: `${chunk.text.slice(0, share)}…`
1244
+ } : chunk;
1245
+ });
1246
+ }
1247
+ function documentParts(chunks, { partChars = 2e4, maxParts = 24, maxPartChars = 4e4 } = {}) {
1248
+ if (!chunks?.length) return [];
1249
+ const total = Math.max(1, chunks.reduce((sum, chunk) => sum + chunk.text.length, 0));
1250
+ const count = Math.min(maxParts, Math.max(1, Math.ceil(total / partChars)));
1251
+ const parts = Array.from({ length: count }, () => []);
1252
+ let seen = 0;
1253
+ for (const chunk of chunks) {
1254
+ parts[Math.min(count - 1, Math.floor(seen / total * count))].push(chunk);
1255
+ seen += chunk.text.length;
1256
+ }
1257
+ return parts.filter((part) => part.length).map((part) => {
1258
+ return part.reduce((sum, chunk) => sum + chunk.text.length, 0) > maxPartChars ? spreadPassages(part, {
1259
+ maxChars: maxPartChars,
1260
+ samples: 24
1261
+ }) : part;
1262
+ });
1263
+ }
1264
+ function locationOf(passage) {
1265
+ return passage?.page ? `p.${passage.page}` : passage?.slide ? `slide ${passage.slide}` : passage?.section ?? "";
1266
+ }
1267
+ function passageRange(passages) {
1268
+ const first = locationOf(passages?.[0]);
1269
+ const last = locationOf(passages?.at(-1));
1270
+ return first && last && first !== last ? `${first}–${last}` : first || last;
1271
+ }
1017
1272
  function formatPassages(passages, sourceTitle) {
1018
1273
  return passages.map((passage) => {
1019
- const where = passage.page ? `p.${passage.page}` : passage.slide ? `slide ${passage.slide}` : passage.section ?? "";
1020
- return `<<${sourceTitle ?? passage.source ?? "source"}${where ? ` · ${where}` : ""}>>\n${passage.text}`;
1274
+ const where = locationOf(passage);
1275
+ return `<<${[sourceTitle ?? passage.source ?? "source", where].filter(Boolean).join(" · ") || "excerpt"}>>\n${passage.text}`;
1021
1276
  }).join("\n\n");
1022
1277
  }
1023
1278
  //#endregion
@@ -1117,164 +1372,6 @@ async function materialImages(store, materials) {
1117
1372
  return images;
1118
1373
  }
1119
1374
  //#endregion
1120
- //#region src/core/json-extract.js
1121
- var JsonExtractError = class extends Error {
1122
- constructor(message, { cause, text } = {}) {
1123
- super(message, cause ? { cause } : void 0);
1124
- this.name = "JsonExtractError";
1125
- this.preview = typeof text === "string" ? text.slice(0, 200) : void 0;
1126
- }
1127
- };
1128
- const FENCE = /```(?:json|jsonl|javascript|js)?\s*\n?([\s\S]*?)```/giu;
1129
- function stripTrailingCommas(text) {
1130
- let out = "";
1131
- let inString = false;
1132
- let escaped = false;
1133
- for (let index = 0; index < text.length; index += 1) {
1134
- const char = text[index];
1135
- if (inString) {
1136
- out += char;
1137
- if (escaped) escaped = false;
1138
- else if (char === "\\") escaped = true;
1139
- else if (char === "\"") inString = false;
1140
- continue;
1141
- }
1142
- if (char === "\"") {
1143
- inString = true;
1144
- out += char;
1145
- continue;
1146
- }
1147
- if (char === ",") {
1148
- let next = index + 1;
1149
- while (next < text.length && /\s/u.test(text[next])) next += 1;
1150
- if (text[next] === "}" || text[next] === "]") continue;
1151
- }
1152
- out += char;
1153
- }
1154
- return out;
1155
- }
1156
- function balancedSlice(text, from = 0) {
1157
- const start = text.slice(from).search(/[{[]/u);
1158
- if (start < 0) return void 0;
1159
- const begin = from + start;
1160
- const stack = [];
1161
- let inString = false;
1162
- let escaped = false;
1163
- for (let index = begin; index < text.length; index += 1) {
1164
- const char = text[index];
1165
- if (inString) {
1166
- if (escaped) escaped = false;
1167
- else if (char === "\\") escaped = true;
1168
- else if (char === "\"") inString = false;
1169
- continue;
1170
- }
1171
- if (char === "\"") inString = true;
1172
- else if (char === "{" || char === "[") stack.push(char);
1173
- else if (char === "}" || char === "]") {
1174
- const open = stack.pop();
1175
- if (char === "}" && open !== "{" || char === "]" && open !== "[") return void 0;
1176
- if (stack.length === 0) return {
1177
- begin,
1178
- end: index + 1,
1179
- text: text.slice(begin, index + 1)
1180
- };
1181
- }
1182
- }
1183
- return {
1184
- begin,
1185
- end: text.length,
1186
- text: text.slice(begin),
1187
- truncated: true,
1188
- stack
1189
- };
1190
- }
1191
- function repairTruncated(text) {
1192
- let body = text;
1193
- for (let attempt = 0; attempt < 40; attempt += 1) {
1194
- const closed = closeOpen(body);
1195
- if (closed !== void 0) try {
1196
- return JSON.parse(stripTrailingCommas(closed));
1197
- } catch {}
1198
- const cut = Math.max(body.lastIndexOf(","), body.lastIndexOf("{"), body.lastIndexOf("["));
1199
- if (cut <= 0) break;
1200
- body = body.slice(0, body[cut] === "," ? cut : cut + 1);
1201
- }
1202
- throw new JsonExtractError("model output was truncated beyond repair", { text });
1203
- }
1204
- function closeOpen(text) {
1205
- const stack = [];
1206
- let inString = false;
1207
- let escaped = false;
1208
- for (const char of text) {
1209
- if (inString) {
1210
- if (escaped) escaped = false;
1211
- else if (char === "\\") escaped = true;
1212
- else if (char === "\"") inString = false;
1213
- continue;
1214
- }
1215
- if (char === "\"") inString = true;
1216
- else if (char === "{" || char === "[") stack.push(char);
1217
- else if (char === "}" || char === "]") stack.pop();
1218
- }
1219
- if (inString) return void 0;
1220
- return text + stack.reverse().map((open) => open === "{" ? "}" : "]").join("");
1221
- }
1222
- function extractJson(raw, { allowTruncated = true } = {}) {
1223
- if (raw !== null && typeof raw === "object") return raw;
1224
- const text = String(raw ?? "").replace(/^/u, "").trim();
1225
- if (!text) throw new JsonExtractError("model returned an empty response", { text });
1226
- const candidates = [];
1227
- for (const match of text.matchAll(FENCE)) candidates.push(match[1].trim());
1228
- candidates.push(text);
1229
- let lastError;
1230
- for (const candidate of candidates) {
1231
- try {
1232
- return JSON.parse(candidate);
1233
- } catch (error) {
1234
- lastError = error;
1235
- }
1236
- const slice = balancedSlice(candidate);
1237
- if (slice === void 0) continue;
1238
- if (!slice.truncated) try {
1239
- return JSON.parse(stripTrailingCommas(slice.text));
1240
- } catch (error) {
1241
- lastError = error;
1242
- }
1243
- else if (allowTruncated) try {
1244
- return repairTruncated(slice.text);
1245
- } catch (error) {
1246
- lastError = error;
1247
- }
1248
- }
1249
- throw new JsonExtractError("model output did not contain valid JSON", {
1250
- cause: lastError,
1251
- text
1252
- });
1253
- }
1254
- function parseJsonLines(raw, { final = true } = {}) {
1255
- const lines = String(raw ?? "").replace(/```(?:jsonl|json)?/giu, "").split(/\r?\n/u);
1256
- const rest = final ? "" : lines.pop() ?? "";
1257
- const items = [];
1258
- const errors = [];
1259
- for (const line of lines) {
1260
- const trimmed = line.trim();
1261
- if (!trimmed || !/^[{[]/u.test(trimmed)) continue;
1262
- try {
1263
- items.push(JSON.parse(stripTrailingCommas(trimmed)));
1264
- } catch (error) {
1265
- errors.push({
1266
- line: trimmed.slice(0, 120),
1267
- message: error.message
1268
- });
1269
- }
1270
- }
1271
- return {
1272
- items,
1273
- errors,
1274
- rest
1275
- };
1276
- }
1277
- //#endregion
1278
1375
  //#region src/host/generate.js
1279
1376
  var TutorError = class extends Error {
1280
1377
  constructor(code, message) {
@@ -1284,15 +1381,19 @@ var TutorError = class extends Error {
1284
1381
  }
1285
1382
  };
1286
1383
  const REPAIR = "Your previous reply could not be parsed as JSON. Reply again with the complete answer as valid JSON only — no code fences, no commentary.";
1287
- async function generateJson(llm, { purpose = "teach", prompt, maxTokens = 8e3, images, signal, normalize = (value) => value }) {
1288
- const first = await llm.complete({
1384
+ async function generateJson(llm, { purpose = "teach", prompt, maxTokens = 8e3, images, signal, normalize = (value) => value, onText }) {
1385
+ const request = {
1289
1386
  purpose,
1290
1387
  system: prompt.system,
1291
1388
  user: prompt.user,
1292
1389
  images,
1293
1390
  maxTokens,
1294
1391
  signal
1295
- });
1392
+ };
1393
+ const first = onText ? await llm.stream({
1394
+ ...request,
1395
+ onText
1396
+ }) : await llm.complete(request);
1296
1397
  try {
1297
1398
  return normalize(extractJson(first));
1298
1399
  } catch (error) {
@@ -1313,7 +1414,7 @@ async function generateJson(llm, { purpose = "teach", prompt, maxTokens = 8e3, i
1313
1414
  }
1314
1415
  }
1315
1416
  }
1316
- async function generateSteps(llm, { purpose = "teach", prompt, maxTokens = 12e3, signal, conceptIds = [], onStep, minSteps = 1, images }) {
1417
+ async function generateSteps(llm, { purpose = "teach", effort, prompt, maxTokens = 12e3, signal, conceptIds = [], onStep, minSteps = 1, images }) {
1317
1418
  const raw = [];
1318
1419
  let buffer = "";
1319
1420
  let emitted = 0;
@@ -1340,6 +1441,7 @@ async function generateSteps(llm, { purpose = "teach", prompt, maxTokens = 12e3,
1340
1441
  };
1341
1442
  const full = await llm.stream({
1342
1443
  purpose,
1444
+ effort,
1343
1445
  system: prompt.system,
1344
1446
  user: prompt.user,
1345
1447
  images,
@@ -1369,6 +1471,20 @@ async function generateSteps(llm, { purpose = "teach", prompt, maxTokens = 12e3,
1369
1471
  }
1370
1472
  return board;
1371
1473
  }
1474
+ function streamCounter(marker, onCount) {
1475
+ let tail = "";
1476
+ let count = 0;
1477
+ return (delta) => {
1478
+ const textValue = tail + String(delta ?? "");
1479
+ let found = 0;
1480
+ for (let at = textValue.indexOf(marker); at !== -1; at = textValue.indexOf(marker, at + marker.length)) found += 1;
1481
+ tail = textValue.slice(-(marker.length - 1));
1482
+ if (found) {
1483
+ count += found;
1484
+ onCount(count);
1485
+ }
1486
+ };
1487
+ }
1372
1488
  async function generateText(llm, { purpose = "teach", prompt, maxTokens = 6e3, signal, onText }) {
1373
1489
  const textValue = onText ? await llm.stream({
1374
1490
  purpose,
@@ -1462,8 +1578,11 @@ Fill the left column, then {"t":"col"}, then the right column; start a new page
1462
1578
  const STEP_SPEC = `Each output line is ONE teaching step:
1463
1579
  {"say":"what you say while writing — natural spoken sentences, 1–4 sentences, no markdown","ops":[ …board ops written during this step… ],"check":{ …optional quick question… }}
1464
1580
 
1581
+ Write the board in time with your voice: give every op "at", the 0-based index of the sentence in "say" during which you write it, so each item appears while the sentence about it is spoken (sentences end at 。!?;.!?;). "at" never goes down within a step: {"say":"先看这个公式。它说明力与位移方向相反。","ops":[{"t":"math","tex":"F=-kx","at":0},{"t":"p","text":"负号 = 方向相反","at":1}]}
1582
+
1465
1583
  A check is a short question asked right after the step, to see whether the idea landed:
1466
- {"kind":"choice|multi|blank|number|short","prompt":"…","options":["…","…"],"answer":<option index (0-based) for choice, list of indexes for multi, accepted strings for blank, a number for number, a model answer for short>,"tolerance":<number, for number>,"explanation":"why the answer is right, in one or two sentences","conceptIds":["<ids from the concept list>"]}
1584
+ {"kind":"choice|multi|blank|number|short|work","prompt":"…","options":["…","…"],"answer":<option index (0-based) for choice, list of indexes for multi, accepted strings for blank, a number for number, a model answer for short, the full model working for work>,"tolerance":<number, for number>,"rubric":["…"],"explanation":"why the answer is right, in one or two sentences","conceptIds":["<ids from the concept list>"]}
1585
+ A "work" check has the learner write out a whole calculation or derivation: "answer" is the model working, one step per line with inline math $…$, and "rubric" lists the 2–4 steps a correct working must contain. Use "work" only where the instructions ask for it.
1467
1586
  Make wrong options plausible (common mistakes), never trick questions.`;
1468
1587
  function questionnairePrompt({ goal, language, materialsSummary, profile }) {
1469
1588
  return {
@@ -1473,14 +1592,33 @@ function questionnairePrompt({ goal, language, materialsSummary, profile }) {
1473
1592
  Return {"questions":[{"prompt":"…","topic":"purpose|level|timeline|style|focus|other","multi":false,"options":[{"label":"…","detail":"…"}]}]}`)
1474
1593
  };
1475
1594
  }
1476
- function sizeGuidance(timeline) {
1595
+ const SIZE_RANGES = Object.freeze({
1596
+ short: [
1597
+ 3,
1598
+ 8,
1599
+ "1–2"
1600
+ ],
1601
+ standard: [
1602
+ 10,
1603
+ 20,
1604
+ "2–4"
1605
+ ],
1606
+ full: [
1607
+ 25,
1608
+ 45,
1609
+ "4–8"
1610
+ ]
1611
+ });
1612
+ function sizeGuidance(timeline, size) {
1613
+ const range = SIZE_RANGES[size];
1614
+ if (range) return `The learner chose the course size: ${range[0]}–${range[1]} lessons in the whole course, in ${range[2]} units. Count every lesson across all units before you answer: never more than ${range[1]}; if the subject seems to need more, merge closely related lessons. The pace only spreads the lessons over the weeks. Never pad with filler lessons.`;
1477
1615
  return `Size the course to the goal first. When the goal is a single idea or names a time budget ("in 20 minutes", "one evening", "this weekend"), fit it: one unit with a single lecture of 1–4 lessons can be the whole course. Never pad with filler lessons. ${timeline === "intensive" ? "Intensive pace: for a broad subject, up to 4–6 units and 18–30 lessons, focused on what matters most for the goal." : timeline === "relaxed" ? "No fixed deadline: for a broad subject, up to 6–10 units and 35–70 lessons, with room for depth, connections and enrichment." : "Balanced pace: for a broad subject, up to 5–8 units and 26–45 lessons, covering foundations, practice and an applied project."}`;
1478
1616
  }
1479
- function blueprintPrompt({ goal, language, intake, materialsSummary, profile, timeline, weeklyMinutes }) {
1617
+ function blueprintPrompt({ goal, language, intake, materialsSummary, profile, timeline, size, weeklyMinutes }) {
1480
1618
  return {
1481
1619
  system: join(PERSONA, `You design personal courses with the care of an experienced curriculum designer: clear prerequisites first, one new idea per lesson, frequent practice, and applied projects that make the knowledge usable. ${languageRule(language)}`, JSON_ONLY),
1482
1620
  user: join(block("learning_goal", goal), block("intake_answers", intake), block("materials", materialsSummary), learnerBlock({ profile }), `Design the course blueprint.
1483
- - ${sizeGuidance(timeline)}${weeklyMinutes ? ` The learner can study about ${weeklyMinutes} minutes per week.` : ""}
1621
+ - ${sizeGuidance(timeline, size)}${weeklyMinutes ? ` The learner can study about ${weeklyMinutes} minutes per week.` : ""}
1484
1622
  - Structure: units → lectures (1–5 per unit) → lessons (1–5 per lecture). Each lesson takes 10–25 minutes and teaches one coherent idea.
1485
1623
  - Order lessons so every prerequisite comes first. Start from where the learner is, not from zero if they already know the basics.
1486
1624
  - If materials were provided, follow their scope and order where sensible.
@@ -1497,13 +1635,20 @@ function revisePrompt({ blueprint, instruction, language }) {
1497
1635
  user: join(block("current_blueprint", blueprint), block("change_request", instruction), "Apply the change request and return the complete revised blueprint in the same JSON shape. Keep the \"id\" field of every unit, lecture, lesson and concept you do not substantially change; new items get no id.")
1498
1636
  };
1499
1637
  }
1638
+ const ITEM_FIGURE = "When a figure makes a question clearer (a shape to measure, a graph to read, a circuit, a diagram to label), give the item \"svg\": a small diagram following the same rules as the board's svg op (viewBox at most 480×320). Most items need no figure.";
1639
+ function reworkPrompt({ outline, unitTitle, lectureTitle, lesson, mode, language }) {
1640
+ return {
1641
+ system: join(PERSONA, `You design personal courses with the care of an experienced curriculum designer: clear prerequisites first, one new idea per lesson. ${languageRule(language)}`, JSON_ONLY),
1642
+ user: join(block("course_outline", outline), block("position", `Unit: ${unitTitle}\nLecture: ${lectureTitle}`), block("lesson", lesson), mode === "split" ? "Split this lesson into two consecutive lessons that together cover everything it covers. Each teaches one coherent idea in 10–20 minutes, and the first prepares the second. Stay consistent with the rest of the course." : "Rewrite this lesson to go deeper. Keep its topic and its place in the course, but aim at why it works, its edge cases and its connections to the rest of the course; adjust the objectives, concepts and minutes (at most 25) to match.", "Return {\"lessons\":[{\"title\":\"…\",\"summary\":\"…\",\"objectives\":[\"…\"],\"concepts\":[\"…\"],\"estMinutes\":15}]}")
1643
+ };
1644
+ }
1500
1645
  function practicePrompt({ course, lecture, lessons, language, passages }) {
1501
1646
  const lessonLines = lessons.map((lesson) => `- [${lesson.id}] ${lesson.title}: ${lesson.objectives.join("; ")} | concepts: ${lesson.concepts.map((concept) => `${concept.id}=${concept.name}`).join(", ")}`).join("\n");
1502
1647
  return {
1503
1648
  system: join(PERSONA, `You write practice that builds real understanding: retrieval, application, and common-mistake traps, from easy to harder. ${languageRule(language)}`, JSON_ONLY),
1504
- user: join(block("course", `${course.title} — ${course.summary ?? ""}`), block("lecture", lecture.title), block("lessons", lessonLines), block("source_passages", passages), `For EACH lesson write 4–6 practice items. Mix kinds: "choice" (4 options), "multi", "blank" (short exact answer; list accepted spellings), "number" (with tolerance), "short" (a sentence or two, with a model answer and 2–3 rubric points). At least one item per lesson asks the learner to apply the idea to a new situation. Give each item 1–2 hints that nudge without giving the answer, an explanation, a difficulty 1–3, and the conceptIds it tests (use the ids given).
1649
+ user: join(block("course", `${course.title} — ${course.summary ?? ""}`), block("lecture", lecture.title), block("lessons", lessonLines), block("source_passages", passages), `For EACH lesson write 4–6 practice items. Mix kinds: "choice" (4 options), "multi", "blank" (short exact answer; list accepted spellings), "number" (with tolerance), "short" (a sentence or two, with a model answer and 2–3 rubric points). At least one item per lesson asks the learner to apply the idea to a new situation. Give each item 1–2 hints that nudge without giving the answer, an explanation, a difficulty 1–3, and the conceptIds it tests (use the ids given). ${ITEM_FIGURE}
1505
1650
 
1506
- Return {"lessons":{"<lesson id>":{"items":[{"kind":"choice","prompt":"…","options":["…"],"answer":0,"hints":["…"],"explanation":"…","difficulty":1,"conceptIds":["…"]}]}}}`)
1651
+ Return {"lessons":{"<lesson id>":{"items":[{"kind":"choice","prompt":"…","options":["…"],"answer":0,"hints":["…"],"explanation":"…","difficulty":1,"conceptIds":["…"],"svg":"<svg …>…</svg> (optional)"}]}}}`)
1507
1652
  };
1508
1653
  }
1509
1654
  function projectsPrompt({ course, projects, language }) {
@@ -1533,8 +1678,10 @@ function lessonPrompt({ course, unit, lecture, lesson, language, learner, passag
1533
1678
  - Build intuition before formalism; then make it precise; then work at least one example fully on the board.
1534
1679
  - Draw a diagram or chart whenever a picture makes the idea click.
1535
1680
  - Put 2–4 checks after the key ideas, plus one at the end; every check must name its conceptIds.
1681
+ - If the lesson works through a calculation or a derivation, make exactly one check a "work" check, right after the key worked example: a similar problem the learner solves alone, writing every step. A lesson without a calculation or derivation gets no "work" check.
1536
1682
  - If source passages are given, stay consistent with them and mention the page when you lean on one.
1537
1683
  - Close with a recap step: a "key" note with the 2–3 takeaways, and say what comes next.
1684
+ - Give each step that works towards one of the objectives "goal": its 0-based index in the objectives above (${lesson.objectives.map((objective, index) => `${index} = ${objective}`).join("; ") || "none"}); leave it out for the hook and the recap. Cover the objectives in order.
1538
1685
  Start with a step whose ops begin with {"t":"title","text":"${lesson.title.replace(/"/gu, "\\\"")}"}.`)
1539
1686
  };
1540
1687
  }
@@ -1557,10 +1704,24 @@ function reexplainPrompt({ lesson, language, step, boardSoFar, style }) {
1557
1704
  user: join(block("lesson", lesson ? lesson.title : ""), block("board_so_far", boardSoFar), block("step_to_reexplain", step), `The learner did not get this step. Explain the same idea again ${REEXPLAIN_STYLES[style] ?? REEXPLAIN_STYLES.example}, in 2–4 steps, starting with {"t":"page","title":"…"}. End with one quick check.`)
1558
1705
  };
1559
1706
  }
1707
+ const GRADE_FIELDS = "\"score\":<0 to 1>,\"verdict\":\"correct|partial|incorrect\",\"feedback\":\"2–3 encouraging sentences: what is right, what is missing, how to fix it\",\"errorTag\":\"<3–6 word label of the main mistake, or empty>\",\"misconception\":<true if the answer shows a confident wrong belief rather than a gap>";
1560
1708
  function gradePrompt({ item, response, language }) {
1709
+ const work = item.kind === "work";
1561
1710
  return {
1562
- system: join(PERSONA, `You grade open answers fairly: credit correct ideas expressed in the learner's own words, ignore spelling and phrasing, and pinpoint the exact gap. ${languageRule(language)}`, JSON_ONLY),
1563
- user: join(block("question", item.prompt), block("model_answer", item.answer), block("rubric", (item.rubric ?? []).join("\n")), block("learner_answer", response), "Return {\"score\":<0 to 1>,\"verdict\":\"correct|partial|incorrect\",\"feedback\":\"2–3 encouraging sentences: what is right, what is missing, how to fix it\",\"errorTag\":\"<3–6 word label of the main mistake, or empty>\",\"misconception\":<true if the answer shows a confident wrong belief rather than a gap>}")
1711
+ system: join(PERSONA, `You grade open answers fairly: credit correct ideas expressed in the learner's own words, ignore spelling and phrasing, and pinpoint the exact gap.${work ? " For a written working, follow it line by line and find the first line that goes wrong; a different but valid route is not a mistake, and a slip carried forward counts once." : ""} ${languageRule(language)}`, JSON_ONLY),
1712
+ user: join(block("question", item.prompt), block(work ? "model_working" : "model_answer", item.answer), block("rubric", (item.rubric ?? []).join("\n")), block(work ? "learner_working" : "learner_answer", response), work ? `Return {${GRADE_FIELDS},"wrongStep":"the first wrong line of the learner's working, copied exactly, or empty when every line is right","why":"one sentence: what is wrong in that line","fix":"that line written correctly, with inline math $…$"}` : `Return {${GRADE_FIELDS}}`)
1713
+ };
1714
+ }
1715
+ function correctionPrompt({ course, lesson, language, boardSoFar, item, response, grade }) {
1716
+ const notes = [
1717
+ grade.wrongStep ? `First wrong line: ${grade.wrongStep}` : "",
1718
+ grade.why ? `What is wrong: ${grade.why}` : "",
1719
+ grade.fix ? `Corrected line: ${grade.fix}` : "",
1720
+ grade.feedback ? `Feedback already shown: ${grade.feedback}` : ""
1721
+ ].filter(Boolean).join("\n");
1722
+ return {
1723
+ system: join(PERSONA, BOARD_SPEC, STEP_SPEC, languageRule(language), JSONL_ONLY),
1724
+ user: join(block("course", course ? course.title : ""), block("lesson", lesson ? `${lesson.title}: ${lesson.objectives?.join("; ") ?? ""}` : ""), block("board_so_far", boardSoFar), block("problem", item.prompt), block("model_working", item.answer), block("learner_working", response), block("grader_notes", notes), "The learner just wrote out their working for this problem and went wrong somewhere. Correct it at the board in 2–3 steps, continuing on the current board: (1) write the learner's wrong line as they wrote it, in an op with an \"id\", then {\"t\":\"mark\",\"ref\":\"<that id>\",\"style\":\"box\"}, and say kindly what went wrong there and why; (2) write the corrected line and carry the working on to the right answer; (3) end with a short {\"t\":\"note\",\"tone\":\"key\",\"text\":\"…\"} holding the rule to remember. Speak to the learner directly and keep it brief. Add no check, and do not continue the lesson itself.")
1564
1725
  };
1565
1726
  }
1566
1727
  function projectReviewPrompt({ project, submission, language }) {
@@ -1593,7 +1754,7 @@ Return {"cards":[{"front":"…","back":"…","tag":"<topic>"}]}`)
1593
1754
  function quizPrompt({ scopeTitle, outline, concepts, language, count = 10 }) {
1594
1755
  return {
1595
1756
  system: join(PERSONA, `You write fair, well-balanced quizzes. ${languageRule(language)}`, JSON_ONLY),
1596
- user: join(block("scope", scopeTitle), block("outline", outline), block("concepts", concepts), `Write a ${count}-question quiz across the whole scope: about 30% recall, 50% application, 20% harder transfer questions. Mix choice, multi, blank, number and short items, each with explanation, difficulty and conceptIds (use the ids given).
1757
+ user: join(block("scope", scopeTitle), block("outline", outline), block("concepts", concepts), `Write a ${count}-question quiz across the whole scope: about 30% recall, 50% application, 20% harder transfer questions. Mix choice, multi, blank, number and short items, each with explanation, difficulty and conceptIds (use the ids given). ${ITEM_FIGURE}
1597
1758
 
1598
1759
  Return {"items":[…same item shape as practice items…]}`)
1599
1760
  };
@@ -1604,10 +1765,16 @@ function summaryPrompt({ scopeTitle, passages, outline, language }) {
1604
1765
  user: join(block("scope", scopeTitle), block("outline", outline), block("source_passages", passages), "Write structured study notes in Markdown: a 3–5 sentence overview, then sections with headings, key points, formulas ($…$ / $$…$$), and short examples, then a \"Check yourself\" list of 5 questions. Cite pages as (p. N) where the source says so.")
1605
1766
  };
1606
1767
  }
1607
- function digestPrompt({ title, passages, language }) {
1768
+ function digestPrompt({ title, passages, notes, language }) {
1608
1769
  return {
1609
1770
  system: join(PERSONA, `You help learners digest long readings: you surface the structure and the argument, not just a summary. ${languageRule(language)}`, JSON_ONLY),
1610
- user: join(block("document", title), block("passages", passages), "Return {\"overview\":\"3–5 sentences\",\"structure\":[{\"heading\":\"…\",\"points\":[\"…\"],\"pages\":\"e.g. 3–7\"}],\"keyIdeas\":[\"…\"],\"arguments\":[{\"claim\":\"…\",\"support\":\"…\",\"page\":\"…\"}],\"glossary\":[{\"term\":\"…\",\"meaning\":\"…\"}],\"questions\":[\"5 questions worth discussing or testing\"]}")
1771
+ user: join(block("document", title), notes ? block("notes_on_each_part", notes) : block("passages", passages), notes ? "The notes above cover the whole document part by part, in order. Combine them into one digest of the whole document: follow its structure from beginning to end, merge points that repeat, and keep the page references from the notes." : "", "Return {\"overview\":\"3–5 sentences\",\"structure\":[{\"heading\":\"…\",\"points\":[\"…\"],\"pages\":\"e.g. 3–7\"}],\"keyIdeas\":[\"…\"],\"arguments\":[{\"claim\":\"…\",\"support\":\"…\",\"page\":\"…\"}],\"glossary\":[{\"term\":\"…\",\"meaning\":\"…\"}],\"questions\":[\"5 questions worth discussing or testing\"]}")
1772
+ };
1773
+ }
1774
+ function digestPartPrompt({ title, part, parts, range, passages, language }) {
1775
+ return {
1776
+ system: join(PERSONA, `You are reading a long document one part at a time for a learner. Take faithful notes on this part only; a later pass combines the notes of all parts. ${languageRule(language)}`, JSON_ONLY),
1777
+ user: join(block("document", title), block("part", `Part ${part} of ${parts}${range ? ` (${range})` : ""}`), block("passages", passages), "Take notes on this part only. Return {\"sections\":[{\"heading\":\"the chapter or section title as the text gives it\",\"pages\":\"e.g. 12–18\",\"points\":[\"…\"]}],\"keyIdeas\":[\"…\"],\"arguments\":[{\"claim\":\"…\",\"support\":\"…\",\"page\":\"…\"}],\"glossary\":[{\"term\":\"…\",\"meaning\":\"…\"}]}. Take page numbers from the passage labels; never invent them.")
1611
1778
  };
1612
1779
  }
1613
1780
  const ASSIST_MODES = Object.freeze({
@@ -1630,7 +1797,7 @@ function shortPrompt({ topic, passages, language }) {
1630
1797
  function materialBriefPrompt({ title, passages, language }) {
1631
1798
  return {
1632
1799
  system: join(PERSONA, languageRule(language), JSON_ONLY),
1633
- user: join(block("document", title), block("passages", passages), "Describe this material for a course designer. Return {\"summary\":\"3–4 sentences\",\"topics\":[\"main topics in order\"],\"level\":\"beginner|intermediate|advanced\",\"suggestedGoal\":\"one sentence learning goal a student might have for it\"}")
1800
+ user: join(block("document", title), block("passages", passages), "The passages are excerpts spread across the whole document, each labelled with where it comes from. Describe this material as a whole for a course designer, not just its opening. Return {\"summary\":\"3–4 sentences\",\"topics\":[\"main topics in the order the document covers them\"],\"level\":\"beginner|intermediate|advanced\",\"suggestedGoal\":\"one sentence learning goal a student might have for it\"}")
1634
1801
  };
1635
1802
  }
1636
1803
  function profilePrompt({ profile, events, language }) {
@@ -1654,4 +1821,4 @@ function getRuntime() {
1654
1821
  return runtime;
1655
1822
  }
1656
1823
  //#endregion
1657
- export { normalizePractice as $, materialImages as A, boardToPlainText as B, TutorError as C, EMPTY_PROFILE as D, generateText as E, readProgress as F, flattenLessons as G, conceptIndex as H, applyEvidence as I, lessonAfter as J, insertLesson as K, createConceptState as L, passagesFor as M, readMaterials as N, emptyProgress as O, readProfile as P, normalizeCheatsheet as Q, masteryPercent as R, summaryPrompt as S, generateSteps as T, courseProgress as U, normalizeBoard as V, findLesson as W, normalizeBlueprint as X, nextLesson as Y, normalizeCards as Z, questionnairePrompt as _, blueprintPrompt as a, assertId as at, revisePrompt as b, digestPrompt as c, lessonPrompt as d, normalizeProject as et, materialBriefPrompt as f, projectsPrompt as g, projectReviewPrompt as h, assistPrompt as i, text as it, materialsSummary as j, masteryFor as k, flashcardsPrompt as l, profilePrompt as m, publishRuntime as n, outlineForPrompt as nt, cheatsheetPrompt as o, newId as ot, practicePrompt as p, intakeSummary as q, askPrompt as r, safeRelativePath as rt, detectLanguage as s, getRuntime as t, normalizeQuestionnaire as tt, gradePrompt as u, quizPrompt as v, generateJson as w, shortPrompt as x, reexplainPrompt as y, recommendNext as z };
1824
+ export { courseProgress as $, streamCounter as A, documentParts as B, reworkPrompt as C, generateJson as D, TutorError as E, materialsSummary as F, createConceptState as G, passageRange as H, passagesFor as I, extractJson as J, masteryPercent as K, readMaterials as L, emptyProgress as M, masteryFor as N, generateSteps as O, materialImages as P, conceptIndex as Q, readProfile as R, revisePrompt as S, summaryPrompt as T, spreadPassages as U, formatPassages as V, applyEvidence as W, normalizeBoard as X, boardToPlainText as Y, blueprintOutline as Z, projectReviewPrompt as _, newId as _t, blueprintPrompt as a, nextLesson as at, quizPrompt as b, detectLanguage as c, normalizeCheatsheet as ct, flashcardsPrompt as d, normalizeProject as dt, findLesson as et, gradePrompt as f, normalizeQuestionnaire as ft, profilePrompt as g, assertId as gt, practicePrompt as h, text as ht, assistPrompt as i, lessonAfter as it, EMPTY_PROFILE as j, generateText as k, digestPartPrompt as l, normalizeLesson as lt, materialBriefPrompt as m, safeRelativePath as mt, publishRuntime as n, insertLesson as nt, cheatsheetPrompt as o, normalizeBlueprint as ot, lessonPrompt as p, outlineForPrompt as pt, recommendNext as q, askPrompt as r, intakeSummary as rt, correctionPrompt as s, normalizeCards as st, getRuntime as t, flattenLessons as tt, digestPrompt as u, normalizePractice as ut, projectsPrompt as v, shortPrompt as w, reexplainPrompt as x, questionnairePrompt as y, readProgress as z };