dsh-aha-tutor 1.6.0 → 1.8.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/CHANGELOG.md +34 -0
- package/README.en.md +5 -4
- package/README.md +5 -4
- package/compatibility.json +5 -3
- package/cordis.patch.yml +4 -0
- package/docs/guide.zh-CN.md +24 -11
- package/lib/agent.js +1 -1
- package/lib/client.js +114 -41
- package/lib/index.js +1036 -55
- package/lib/{runtime-B6zUpJjA.js → runtime-C3yJfJTT.js} +287 -159
- package/package.json +1 -1
- package/samples/bayes-thinking.aha-course.json +62 -56
- package/samples/harmonic-motion.aha-course.json +70 -63
package/lib/index.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import { $ as
|
|
1
|
+
import { $ as documentParts, A as shortPrompt, At as safeRelativePath, B as emptyProgress, C as questionnairePrompt, Ct as normalizeHandout, D as researchQueriesPrompt, Dt as normalizeProject, E as reexplainPrompt, Et as normalizePractice, F as generateJson, G as readMaterials, H as materialImages, I as generateSteps, J as applyEvidence, K as readProfile, L as generateText, M as verifyItemPrompt, Mt as plotToText, N as verifyStepPrompt, Nt as assertId, O as revisePrompt, Ot as normalizeQuestionnaire, P as TutorError, Pt as newId, Q as buildIndex, R as streamCounter, S as projectsPrompt, St as normalizeCheatsheet, T as recognizePrompt, Tt as normalizeLesson, U as materialsSummary, V as masteryFor, W as passagesFor, X as masteryPercent, Y as createConceptState, Z as recommendNext, _ as materialBriefPrompt, _t as intakeSummary, a as assistPrompt, at as extractJson, b as profilePrompt, bt as normalizeBlueprint, c as cheatsheetPrompt, ct as estimateSpeechMs, d as digestPartPrompt, dt as blueprintOutline, et as formatPassages, f as digestPrompt, ft as conceptIndex, g as lessonPrompt, gt as insertLesson, h as handoutPrompt, ht as flattenLessons, i as askPrompt, it as spreadPassages, j as summaryPrompt, jt as text, k as reworkPrompt, kt as outlineForPrompt, l as correctionPrompt, lt as normalizeBoard, m as gradePrompt, mt as findLesson, n as publishRuntime, nt as search, o as blueprintPrompt, ot as BOARD_LIMITS, p as flashcardsPrompt, pt as courseProgress, q as readProgress, r as RECOGNIZE_EMPTY, rt as snippetOf, s as challengePrompt, st as boardToPlainText, tt as passageRange, u as detectLanguage, ut as normalizeOp, v as materialsRevisePrompt, vt as lessonAfter, w as quizPrompt, wt as normalizeItem, x as projectReviewPrompt, xt as normalizeCards, y as practicePrompt, yt as nextLesson, z as EMPTY_PROFILE } from "./runtime-C3yJfJTT.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";
|
|
5
5
|
import { lstat, mkdir, readFile, readdir, realpath, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
6
|
+
import zlib, { deflateSync } from "node:zlib";
|
|
6
7
|
import { lookup } from "node:dns";
|
|
7
8
|
import http from "node:http";
|
|
8
9
|
import https from "node:https";
|
|
9
10
|
import { isIP } from "node:net";
|
|
10
11
|
import { pipeline } from "node:stream";
|
|
11
|
-
import zlib from "node:zlib";
|
|
12
12
|
import { spawn } from "node:child_process";
|
|
13
13
|
import { existsSync } from "node:fs";
|
|
14
14
|
import { fileURLToPath } from "node:url";
|
|
@@ -490,15 +490,76 @@ function createCourses({ store, llm, jobs, research, now = () => /* @__PURE__ */
|
|
|
490
490
|
meta: { courseId }
|
|
491
491
|
});
|
|
492
492
|
}
|
|
493
|
+
function addMaterials({ courseId, materialIds = [], instruction }) {
|
|
494
|
+
const wanted = [...new Set(materialIds)].filter((id) => /^mat_[a-z0-9]+$/u.test(id)).slice(0, 12);
|
|
495
|
+
if (!wanted.length) throw new TutorError("no-materials", "Choose at least one material");
|
|
496
|
+
const request = text(instruction, 600);
|
|
497
|
+
return jobs.start("craft/revise", async (api) => {
|
|
498
|
+
const course = await readCourse(courseId);
|
|
499
|
+
if (!course.units) throw new TutorError("no-blueprint", "There is no blueprint to revise yet");
|
|
500
|
+
const known = course.materialIds ?? [];
|
|
501
|
+
const added = (await readMaterials(store, wanted.filter((id) => !known.includes(id)))).slice(0, Math.max(0, 12 - known.length));
|
|
502
|
+
if (!added.length) throw new TutorError(wanted.every((id) => known.includes(id)) ? "materials-known" : known.length >= 12 ? "materials-full" : "not-found", "These materials cannot be added");
|
|
503
|
+
const current = {
|
|
504
|
+
title: course.title,
|
|
505
|
+
subtitle: course.subtitle,
|
|
506
|
+
emoji: course.emoji,
|
|
507
|
+
summary: course.summary,
|
|
508
|
+
level: course.level,
|
|
509
|
+
units: course.units,
|
|
510
|
+
projects: course.projects
|
|
511
|
+
};
|
|
512
|
+
const share = Math.floor(9e3 / added.length);
|
|
513
|
+
const passages = added.filter((item) => item.chunks.length).map((item) => formatPassages(spreadPassages(item.chunks, {
|
|
514
|
+
maxChars: share,
|
|
515
|
+
samples: 8
|
|
516
|
+
}), item.meta.title)).join("\n\n");
|
|
517
|
+
api.progress(0, 0, "blueprint");
|
|
518
|
+
const revised = await generateJson(llm, {
|
|
519
|
+
purpose: "teach",
|
|
520
|
+
prompt: materialsRevisePrompt({
|
|
521
|
+
blueprint: current,
|
|
522
|
+
materials: materialsSummary(added),
|
|
523
|
+
passages,
|
|
524
|
+
instruction: request,
|
|
525
|
+
language: course.language
|
|
526
|
+
}),
|
|
527
|
+
images: llm.supportsImages?.("teach") ? await materialImages(store, added) : void 0,
|
|
528
|
+
maxTokens: 16e3,
|
|
529
|
+
signal: api.signal,
|
|
530
|
+
onText: streamCounter("\"estMinutes\"", (count) => api.progress(count, 0, "blueprint")),
|
|
531
|
+
normalize: (raw) => attachProjectUnits(raw, normalizeBlueprint(raw, { language: course.language }))
|
|
532
|
+
});
|
|
533
|
+
const proposal = {
|
|
534
|
+
instruction: request,
|
|
535
|
+
materials: added.map((item) => ({
|
|
536
|
+
id: item.meta.id,
|
|
537
|
+
title: item.meta.title
|
|
538
|
+
})),
|
|
539
|
+
at: stamp(),
|
|
540
|
+
blueprint: revised
|
|
541
|
+
};
|
|
542
|
+
await store.writeJson(store.paths.proposal(courseId), proposal);
|
|
543
|
+
return {
|
|
544
|
+
course,
|
|
545
|
+
proposal
|
|
546
|
+
};
|
|
547
|
+
}, {
|
|
548
|
+
key: `blueprint:${courseId}`,
|
|
549
|
+
meta: { courseId }
|
|
550
|
+
});
|
|
551
|
+
}
|
|
493
552
|
async function applyProposal({ courseId }) {
|
|
494
553
|
const course = await readCourse(courseId);
|
|
495
554
|
const proposal = await store.readJson(store.paths.proposal(courseId), null);
|
|
496
555
|
if (!proposal?.blueprint?.units) throw new TutorError("no-proposal", "There is no proposed change");
|
|
556
|
+
const materialIds = [.../* @__PURE__ */ new Set([...course.materialIds ?? [], ...(proposal.materials ?? []).map((item) => item.id)])].slice(0, 12);
|
|
497
557
|
const next = {
|
|
498
558
|
...course,
|
|
499
559
|
...proposal.blueprint,
|
|
500
560
|
language: course.language,
|
|
501
|
-
status: course.status === "ready" ? "ready" : "blueprint"
|
|
561
|
+
status: course.status === "ready" ? "ready" : "blueprint",
|
|
562
|
+
...materialIds.length ? { materialIds } : {}
|
|
502
563
|
};
|
|
503
564
|
await saveCourse(next);
|
|
504
565
|
await store.removeDir(store.paths.proposal(courseId));
|
|
@@ -766,6 +827,7 @@ function createCourses({ store, llm, jobs, research, now = () => /* @__PURE__ */
|
|
|
766
827
|
startCraft,
|
|
767
828
|
submitIntake,
|
|
768
829
|
revise,
|
|
830
|
+
addMaterials,
|
|
769
831
|
applyProposal,
|
|
770
832
|
discardProposal,
|
|
771
833
|
reworkLesson,
|
|
@@ -1019,6 +1081,484 @@ const USER_ERRORS$1 = [
|
|
|
1019
1081
|
"TutorError"
|
|
1020
1082
|
];
|
|
1021
1083
|
//#endregion
|
|
1084
|
+
//#region src/host/page-image.js
|
|
1085
|
+
const MAX_PIXELS = 4e6;
|
|
1086
|
+
const MAX_EDGE = 2400;
|
|
1087
|
+
const OBJECT_WAIT_MS = 2e4;
|
|
1088
|
+
const IDENTITY = [
|
|
1089
|
+
1,
|
|
1090
|
+
0,
|
|
1091
|
+
0,
|
|
1092
|
+
1,
|
|
1093
|
+
0,
|
|
1094
|
+
0
|
|
1095
|
+
];
|
|
1096
|
+
function multiply(m, n) {
|
|
1097
|
+
return [
|
|
1098
|
+
m[0] * n[0] + m[2] * n[1],
|
|
1099
|
+
m[1] * n[0] + m[3] * n[1],
|
|
1100
|
+
m[0] * n[2] + m[2] * n[3],
|
|
1101
|
+
m[1] * n[2] + m[3] * n[3],
|
|
1102
|
+
m[0] * n[4] + m[2] * n[5] + m[4],
|
|
1103
|
+
m[1] * n[4] + m[3] * n[5] + m[5]
|
|
1104
|
+
];
|
|
1105
|
+
}
|
|
1106
|
+
function invert(m) {
|
|
1107
|
+
const det = m[0] * m[3] - m[1] * m[2];
|
|
1108
|
+
if (!det) return null;
|
|
1109
|
+
return [
|
|
1110
|
+
m[3] / det,
|
|
1111
|
+
-m[1] / det,
|
|
1112
|
+
-m[2] / det,
|
|
1113
|
+
m[0] / det,
|
|
1114
|
+
(m[2] * m[5] - m[3] * m[4]) / det,
|
|
1115
|
+
(m[1] * m[4] - m[0] * m[5]) / det
|
|
1116
|
+
];
|
|
1117
|
+
}
|
|
1118
|
+
const apply$1 = (m, x, y) => [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]];
|
|
1119
|
+
function grayOf(color) {
|
|
1120
|
+
const hex = /^#([0-9a-f]{6})$/iu.exec(String(color ?? ""))?.[1];
|
|
1121
|
+
if (!hex) return 0;
|
|
1122
|
+
const value = Number.parseInt(hex, 16);
|
|
1123
|
+
return Math.round(((value >> 16) * 299 + (value >> 8 & 255) * 587 + (value & 255) * 114) / 1e3);
|
|
1124
|
+
}
|
|
1125
|
+
function placements(OPS, operatorList) {
|
|
1126
|
+
const draws = [];
|
|
1127
|
+
let ctm = IDENTITY;
|
|
1128
|
+
let fill = 0;
|
|
1129
|
+
const stack = [];
|
|
1130
|
+
const push = () => stack.push({
|
|
1131
|
+
ctm,
|
|
1132
|
+
fill
|
|
1133
|
+
});
|
|
1134
|
+
const pop = () => {
|
|
1135
|
+
const saved = stack.pop();
|
|
1136
|
+
if (saved) ({ctm, fill} = saved);
|
|
1137
|
+
};
|
|
1138
|
+
const { fnArray, argsArray } = operatorList;
|
|
1139
|
+
for (let index = 0; index < fnArray.length; index += 1) {
|
|
1140
|
+
const args = argsArray[index] ?? [];
|
|
1141
|
+
switch (fnArray[index]) {
|
|
1142
|
+
case OPS.save:
|
|
1143
|
+
push();
|
|
1144
|
+
break;
|
|
1145
|
+
case OPS.restore:
|
|
1146
|
+
case OPS.paintFormXObjectEnd:
|
|
1147
|
+
case OPS.endGroup:
|
|
1148
|
+
pop();
|
|
1149
|
+
break;
|
|
1150
|
+
case OPS.transform:
|
|
1151
|
+
ctm = multiply(ctm, args);
|
|
1152
|
+
break;
|
|
1153
|
+
case OPS.paintFormXObjectBegin:
|
|
1154
|
+
push();
|
|
1155
|
+
if (Array.isArray(args[0])) ctm = multiply(ctm, args[0]);
|
|
1156
|
+
break;
|
|
1157
|
+
case OPS.beginGroup:
|
|
1158
|
+
push();
|
|
1159
|
+
if (Array.isArray(args[0]?.matrix)) ctm = multiply(ctm, args[0].matrix);
|
|
1160
|
+
break;
|
|
1161
|
+
case OPS.setFillRGBColor:
|
|
1162
|
+
fill = grayOf(args[0]);
|
|
1163
|
+
break;
|
|
1164
|
+
case OPS.paintImageXObject:
|
|
1165
|
+
draws.push({
|
|
1166
|
+
matrix: ctm,
|
|
1167
|
+
image: args[0]
|
|
1168
|
+
});
|
|
1169
|
+
break;
|
|
1170
|
+
case OPS.paintInlineImageXObject:
|
|
1171
|
+
draws.push({
|
|
1172
|
+
matrix: ctm,
|
|
1173
|
+
image: args[0]
|
|
1174
|
+
});
|
|
1175
|
+
break;
|
|
1176
|
+
case OPS.paintImageXObjectRepeat: {
|
|
1177
|
+
const [image, scaleX, scaleY, positions = []] = args;
|
|
1178
|
+
for (let at = 0; at + 1 < positions.length; at += 2) draws.push({
|
|
1179
|
+
matrix: multiply(ctm, [
|
|
1180
|
+
scaleX,
|
|
1181
|
+
0,
|
|
1182
|
+
0,
|
|
1183
|
+
scaleY,
|
|
1184
|
+
positions[at],
|
|
1185
|
+
positions[at + 1]
|
|
1186
|
+
]),
|
|
1187
|
+
image
|
|
1188
|
+
});
|
|
1189
|
+
break;
|
|
1190
|
+
}
|
|
1191
|
+
case OPS.paintInlineImageXObjectGroup: {
|
|
1192
|
+
const [image, map = []] = args;
|
|
1193
|
+
for (const entry of map) draws.push({
|
|
1194
|
+
matrix: multiply(ctm, entry.transform),
|
|
1195
|
+
image,
|
|
1196
|
+
region: {
|
|
1197
|
+
x: entry.x,
|
|
1198
|
+
y: entry.y,
|
|
1199
|
+
w: entry.w,
|
|
1200
|
+
h: entry.h
|
|
1201
|
+
}
|
|
1202
|
+
});
|
|
1203
|
+
break;
|
|
1204
|
+
}
|
|
1205
|
+
case OPS.paintImageMaskXObject:
|
|
1206
|
+
draws.push({
|
|
1207
|
+
matrix: ctm,
|
|
1208
|
+
mask: args[0],
|
|
1209
|
+
fill
|
|
1210
|
+
});
|
|
1211
|
+
break;
|
|
1212
|
+
case OPS.paintImageMaskXObjectGroup:
|
|
1213
|
+
for (const mask of args[0] ?? []) draws.push({
|
|
1214
|
+
matrix: multiply(ctm, mask.transform),
|
|
1215
|
+
mask,
|
|
1216
|
+
fill
|
|
1217
|
+
});
|
|
1218
|
+
break;
|
|
1219
|
+
case OPS.paintImageMaskXObjectRepeat: {
|
|
1220
|
+
const [mask, scaleX, skewX = 0, skewY = 0, scaleY, positions = []] = args;
|
|
1221
|
+
for (let at = 0; at + 1 < positions.length; at += 2) draws.push({
|
|
1222
|
+
matrix: multiply(ctm, [
|
|
1223
|
+
scaleX,
|
|
1224
|
+
skewX,
|
|
1225
|
+
skewY,
|
|
1226
|
+
scaleY,
|
|
1227
|
+
positions[at],
|
|
1228
|
+
positions[at + 1]
|
|
1229
|
+
]),
|
|
1230
|
+
mask,
|
|
1231
|
+
fill
|
|
1232
|
+
});
|
|
1233
|
+
break;
|
|
1234
|
+
}
|
|
1235
|
+
case OPS.paintSolidColorImageMask:
|
|
1236
|
+
draws.push({
|
|
1237
|
+
matrix: ctm,
|
|
1238
|
+
solid: fill
|
|
1239
|
+
});
|
|
1240
|
+
break;
|
|
1241
|
+
default: break;
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
return draws;
|
|
1245
|
+
}
|
|
1246
|
+
async function resolve(page, value) {
|
|
1247
|
+
if (typeof value !== "string") return value ?? null;
|
|
1248
|
+
const objects = value.startsWith("g_") ? page.commonObjs : page.objs;
|
|
1249
|
+
let timer;
|
|
1250
|
+
try {
|
|
1251
|
+
return await Promise.race([new Promise((done) => {
|
|
1252
|
+
objects.get(value, done);
|
|
1253
|
+
}), new Promise((done) => {
|
|
1254
|
+
timer = setTimeout(() => done(null), OBJECT_WAIT_MS);
|
|
1255
|
+
})]);
|
|
1256
|
+
} finally {
|
|
1257
|
+
clearTimeout(timer);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
function shrink(image, { factor = 1, mask = false, region } = {}) {
|
|
1261
|
+
const { width, height, kind, data } = image;
|
|
1262
|
+
const x0 = region?.x ?? 0;
|
|
1263
|
+
const y0 = region?.y ?? 0;
|
|
1264
|
+
const w = Math.max(1, Math.min(region?.w ?? width, width - x0));
|
|
1265
|
+
const h = Math.max(1, Math.min(region?.h ?? height, height - y0));
|
|
1266
|
+
const outW = Math.max(1, Math.ceil(w / factor));
|
|
1267
|
+
const outH = Math.max(1, Math.ceil(h / factor));
|
|
1268
|
+
const gray = new Uint8Array(outW * outH);
|
|
1269
|
+
const alpha = new Uint8Array(outW * outH);
|
|
1270
|
+
let opaque = true;
|
|
1271
|
+
const bits = mask || kind === 1;
|
|
1272
|
+
const stride = bits ? width + 7 >> 3 : width * (kind === 3 ? 4 : 3);
|
|
1273
|
+
for (let oy = 0; oy < outH; oy += 1) {
|
|
1274
|
+
const yStart = y0 + oy * factor;
|
|
1275
|
+
const yEnd = Math.min(y0 + h, yStart + factor);
|
|
1276
|
+
for (let ox = 0; ox < outW; ox += 1) {
|
|
1277
|
+
const xStart = x0 + ox * factor;
|
|
1278
|
+
const xEnd = Math.min(x0 + w, xStart + factor);
|
|
1279
|
+
let level = 0;
|
|
1280
|
+
let cover = 0;
|
|
1281
|
+
let count = 0;
|
|
1282
|
+
for (let y = yStart; y < yEnd; y += 1) {
|
|
1283
|
+
const row = y * stride;
|
|
1284
|
+
for (let x = xStart; x < xEnd; x += 1) {
|
|
1285
|
+
count += 1;
|
|
1286
|
+
if (bits) {
|
|
1287
|
+
const set = data[row + (x >> 3)] >> 7 - (x & 7) & 1;
|
|
1288
|
+
if (mask) cover += set ? 0 : 255;
|
|
1289
|
+
else {
|
|
1290
|
+
level += set ? 255 : 0;
|
|
1291
|
+
cover += 255;
|
|
1292
|
+
}
|
|
1293
|
+
} else {
|
|
1294
|
+
const at = row + x * (kind === 3 ? 4 : 3);
|
|
1295
|
+
const value = (data[at] * 299 + data[at + 1] * 587 + data[at + 2] * 114) / 1e3;
|
|
1296
|
+
const opacity = kind === 3 ? data[at + 3] : 255;
|
|
1297
|
+
level += value * opacity;
|
|
1298
|
+
cover += opacity;
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
const at = oy * outW + ox;
|
|
1303
|
+
alpha[at] = count ? Math.round(cover / count) : 0;
|
|
1304
|
+
if (alpha[at] < 255) opaque = false;
|
|
1305
|
+
gray[at] = bits ? mask ? 0 : Math.round(level / count) : cover ? Math.round(level / cover) : 255;
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
return {
|
|
1309
|
+
width: outW,
|
|
1310
|
+
height: outH,
|
|
1311
|
+
gray,
|
|
1312
|
+
alpha,
|
|
1313
|
+
opaque
|
|
1314
|
+
};
|
|
1315
|
+
}
|
|
1316
|
+
function paint(page, full, picture, { fill = null } = {}) {
|
|
1317
|
+
const inverse = invert(full);
|
|
1318
|
+
if (!inverse) return;
|
|
1319
|
+
const corners = [
|
|
1320
|
+
[0, 0],
|
|
1321
|
+
[1, 0],
|
|
1322
|
+
[0, 1],
|
|
1323
|
+
[1, 1]
|
|
1324
|
+
].map(([x, y]) => apply$1(full, x, y));
|
|
1325
|
+
const left = Math.max(0, Math.floor(Math.min(...corners.map((point) => point[0]))));
|
|
1326
|
+
const right = Math.min(page.width, Math.ceil(Math.max(...corners.map((point) => point[0]))));
|
|
1327
|
+
const top = Math.max(0, Math.floor(Math.min(...corners.map((point) => point[1]))));
|
|
1328
|
+
const bottom = Math.min(page.height, Math.ceil(Math.max(...corners.map((point) => point[1]))));
|
|
1329
|
+
const [ia, ib, ic, id, ie, iff] = inverse;
|
|
1330
|
+
if (picture && Math.abs(ib) < 1e-9 && Math.abs(ic) < 1e-9) {
|
|
1331
|
+
paintAligned(page, picture, {
|
|
1332
|
+
left,
|
|
1333
|
+
right,
|
|
1334
|
+
top,
|
|
1335
|
+
bottom,
|
|
1336
|
+
ia,
|
|
1337
|
+
id,
|
|
1338
|
+
ie,
|
|
1339
|
+
iff,
|
|
1340
|
+
fill
|
|
1341
|
+
});
|
|
1342
|
+
return;
|
|
1343
|
+
}
|
|
1344
|
+
const { width, height, gray, alpha } = picture ?? {
|
|
1345
|
+
width: 1,
|
|
1346
|
+
height: 1
|
|
1347
|
+
};
|
|
1348
|
+
for (let y = top; y < bottom; y += 1) {
|
|
1349
|
+
const py = y + .5;
|
|
1350
|
+
for (let x = left; x < right; x += 1) {
|
|
1351
|
+
const px = x + .5;
|
|
1352
|
+
const u = ia * px + ic * py + ie;
|
|
1353
|
+
const v = ib * px + id * py + iff;
|
|
1354
|
+
if (u < 0 || u >= 1 || v < 0 || v >= 1) continue;
|
|
1355
|
+
const at = y * page.width + x;
|
|
1356
|
+
if (!picture) {
|
|
1357
|
+
page.gray[at] = fill;
|
|
1358
|
+
continue;
|
|
1359
|
+
}
|
|
1360
|
+
const fx = Math.min(width - 1, Math.max(0, u * width - .5));
|
|
1361
|
+
const fy = Math.min(height - 1, Math.max(0, (1 - v) * height - .5));
|
|
1362
|
+
const x1 = Math.floor(fx);
|
|
1363
|
+
const y1 = Math.floor(fy);
|
|
1364
|
+
const x2 = Math.min(width - 1, x1 + 1);
|
|
1365
|
+
const y2 = Math.min(height - 1, y1 + 1);
|
|
1366
|
+
const tx = fx - x1;
|
|
1367
|
+
const ty = fy - y1;
|
|
1368
|
+
const w1 = (1 - tx) * (1 - ty);
|
|
1369
|
+
const w2 = tx * (1 - ty);
|
|
1370
|
+
const w3 = (1 - tx) * ty;
|
|
1371
|
+
const w4 = tx * ty;
|
|
1372
|
+
const p1 = y1 * width + x1;
|
|
1373
|
+
const p2 = y1 * width + x2;
|
|
1374
|
+
const p3 = y2 * width + x1;
|
|
1375
|
+
const p4 = y2 * width + x2;
|
|
1376
|
+
const cover = alpha[p1] * w1 + alpha[p2] * w2 + alpha[p3] * w3 + alpha[p4] * w4;
|
|
1377
|
+
if (!cover) continue;
|
|
1378
|
+
const ink = fill ?? (gray[p1] * alpha[p1] * w1 + gray[p2] * alpha[p2] * w2 + gray[p3] * alpha[p3] * w3 + gray[p4] * alpha[p4] * w4) / cover;
|
|
1379
|
+
const share = cover / 255;
|
|
1380
|
+
page.gray[at] = Math.round(page.gray[at] * (1 - share) + ink * share);
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
function axisSamples(from, to, scale, offset, size, flip) {
|
|
1385
|
+
const first = new Int32Array(to - from);
|
|
1386
|
+
const second = new Int32Array(to - from);
|
|
1387
|
+
const weight = new Float32Array(to - from);
|
|
1388
|
+
for (let index = 0; index < to - from; index += 1) {
|
|
1389
|
+
const unit = scale * (from + index + .5) + offset;
|
|
1390
|
+
if (unit < 0 || unit >= 1) {
|
|
1391
|
+
first[index] = -1;
|
|
1392
|
+
continue;
|
|
1393
|
+
}
|
|
1394
|
+
const position = Math.min(size - 1, Math.max(0, (flip ? 1 - unit : unit) * size - .5));
|
|
1395
|
+
first[index] = Math.floor(position);
|
|
1396
|
+
second[index] = Math.min(size - 1, first[index] + 1);
|
|
1397
|
+
weight[index] = position - first[index];
|
|
1398
|
+
}
|
|
1399
|
+
return {
|
|
1400
|
+
first,
|
|
1401
|
+
second,
|
|
1402
|
+
weight
|
|
1403
|
+
};
|
|
1404
|
+
}
|
|
1405
|
+
function paintAligned(page, picture, { left, right, top, bottom, ia, id, ie, iff, fill }) {
|
|
1406
|
+
const { width, height, gray, alpha, opaque } = picture;
|
|
1407
|
+
const columns = axisSamples(left, right, ia, ie, width, false);
|
|
1408
|
+
const rows = axisSamples(top, bottom, id, iff, height, true);
|
|
1409
|
+
for (let y = top; y < bottom; y += 1) {
|
|
1410
|
+
const row = y - top;
|
|
1411
|
+
if (rows.first[row] < 0) continue;
|
|
1412
|
+
const r1 = rows.first[row] * width;
|
|
1413
|
+
const r2 = rows.second[row] * width;
|
|
1414
|
+
const ty = rows.weight[row];
|
|
1415
|
+
const out = y * page.width;
|
|
1416
|
+
for (let x = left; x < right; x += 1) {
|
|
1417
|
+
const column = x - left;
|
|
1418
|
+
const c1 = columns.first[column];
|
|
1419
|
+
if (c1 < 0) continue;
|
|
1420
|
+
const c2 = columns.second[column];
|
|
1421
|
+
const tx = columns.weight[column];
|
|
1422
|
+
const p1 = r1 + c1;
|
|
1423
|
+
const p2 = r1 + c2;
|
|
1424
|
+
const p3 = r2 + c1;
|
|
1425
|
+
const p4 = r2 + c2;
|
|
1426
|
+
if (opaque && fill === null) {
|
|
1427
|
+
const above = gray[p1] + (gray[p2] - gray[p1]) * tx;
|
|
1428
|
+
const below = gray[p3] + (gray[p4] - gray[p3]) * tx;
|
|
1429
|
+
page.gray[out + x] = Math.round(above + (below - above) * ty);
|
|
1430
|
+
continue;
|
|
1431
|
+
}
|
|
1432
|
+
const w1 = (1 - tx) * (1 - ty);
|
|
1433
|
+
const w2 = tx * (1 - ty);
|
|
1434
|
+
const w3 = (1 - tx) * ty;
|
|
1435
|
+
const w4 = tx * ty;
|
|
1436
|
+
const cover = alpha[p1] * w1 + alpha[p2] * w2 + alpha[p3] * w3 + alpha[p4] * w4;
|
|
1437
|
+
if (!cover) continue;
|
|
1438
|
+
const ink = fill ?? (gray[p1] * alpha[p1] * w1 + gray[p2] * alpha[p2] * w2 + gray[p3] * alpha[p3] * w3 + gray[p4] * alpha[p4] * w4) / cover;
|
|
1439
|
+
const share = cover / 255;
|
|
1440
|
+
page.gray[out + x] = Math.round(page.gray[out + x] * (1 - share) + ink * share);
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
function extent(full) {
|
|
1445
|
+
const [x0, y0] = apply$1(full, 0, 0);
|
|
1446
|
+
const [x1, y1] = apply$1(full, 1, 0);
|
|
1447
|
+
const [x2, y2] = apply$1(full, 0, 1);
|
|
1448
|
+
return {
|
|
1449
|
+
across: Math.hypot(x1 - x0, y1 - y0),
|
|
1450
|
+
down: Math.hypot(x2 - x0, y2 - y0)
|
|
1451
|
+
};
|
|
1452
|
+
}
|
|
1453
|
+
async function pageImage(pdfjs, page) {
|
|
1454
|
+
const operatorList = await page.getOperatorList({ annotationMode: pdfjs.AnnotationMode?.DISABLE });
|
|
1455
|
+
const draws = placements(pdfjs.OPS, operatorList);
|
|
1456
|
+
if (!draws.length) return null;
|
|
1457
|
+
const base = page.getViewport({ scale: 1 });
|
|
1458
|
+
const pictures = await Promise.all(draws.map((draw) => draw.solid !== void 0 ? null : resolve(page, draw.image ?? (typeof draw.mask?.data === "string" ? draw.mask.data : draw.mask))));
|
|
1459
|
+
let density = 1;
|
|
1460
|
+
for (const [index, draw] of draws.entries()) {
|
|
1461
|
+
const decoded = pictures[index];
|
|
1462
|
+
if (!decoded?.width) continue;
|
|
1463
|
+
const size = extent(multiply(base.transform, draw.matrix));
|
|
1464
|
+
if (size.across * size.down < base.width * base.height * .05) continue;
|
|
1465
|
+
density = Math.max(density, Math.min((draw.region?.w ?? decoded.width) / Math.max(1, size.across), (draw.region?.h ?? decoded.height) / Math.max(1, size.down)));
|
|
1466
|
+
}
|
|
1467
|
+
const scale = Math.min(density, Math.sqrt(MAX_PIXELS / (base.width * base.height)), MAX_EDGE / Math.max(base.width, base.height));
|
|
1468
|
+
const viewport = page.getViewport({ scale });
|
|
1469
|
+
const bitmap = {
|
|
1470
|
+
width: Math.max(1, Math.ceil(viewport.width)),
|
|
1471
|
+
height: Math.max(1, Math.ceil(viewport.height))
|
|
1472
|
+
};
|
|
1473
|
+
bitmap.gray = new Uint8Array(bitmap.width * bitmap.height).fill(255);
|
|
1474
|
+
let painted = false;
|
|
1475
|
+
for (const [index, draw] of draws.entries()) {
|
|
1476
|
+
const full = multiply(viewport.transform, draw.matrix);
|
|
1477
|
+
if (draw.solid !== void 0) {
|
|
1478
|
+
paint(bitmap, full, null, { fill: draw.solid });
|
|
1479
|
+
painted = true;
|
|
1480
|
+
continue;
|
|
1481
|
+
}
|
|
1482
|
+
const decoded = pictures[index];
|
|
1483
|
+
const mask = Boolean(draw.mask);
|
|
1484
|
+
const image = mask ? {
|
|
1485
|
+
...draw.mask,
|
|
1486
|
+
...decoded ?? {},
|
|
1487
|
+
kind: 1
|
|
1488
|
+
} : decoded;
|
|
1489
|
+
if (!image?.data || !image.width || !image.height) continue;
|
|
1490
|
+
if (!mask && ![
|
|
1491
|
+
1,
|
|
1492
|
+
2,
|
|
1493
|
+
3
|
|
1494
|
+
].includes(image.kind)) continue;
|
|
1495
|
+
const size = extent(full);
|
|
1496
|
+
paint(bitmap, full, shrink(image, {
|
|
1497
|
+
factor: Math.max(1, Math.floor(Math.min((draw.region?.w ?? image.width) / Math.max(1, size.across), (draw.region?.h ?? image.height) / Math.max(1, size.down)))),
|
|
1498
|
+
mask,
|
|
1499
|
+
region: draw.region
|
|
1500
|
+
}), { fill: mask ? draw.fill : null });
|
|
1501
|
+
painted = true;
|
|
1502
|
+
}
|
|
1503
|
+
if (!painted) return null;
|
|
1504
|
+
return {
|
|
1505
|
+
mime: "image/png",
|
|
1506
|
+
data: encodeGrayPng(bitmap.width, bitmap.height, bitmap.gray),
|
|
1507
|
+
width: bitmap.width,
|
|
1508
|
+
height: bitmap.height
|
|
1509
|
+
};
|
|
1510
|
+
}
|
|
1511
|
+
const CRC_TABLE = (/* @__PURE__ */ new Uint32Array(256)).map((_, index) => {
|
|
1512
|
+
let value = index;
|
|
1513
|
+
for (let bit = 0; bit < 8; bit += 1) value = value & 1 ? 3988292384 ^ value >>> 1 : value >>> 1;
|
|
1514
|
+
return value >>> 0;
|
|
1515
|
+
});
|
|
1516
|
+
function crc32(bytes) {
|
|
1517
|
+
let value = 4294967295;
|
|
1518
|
+
for (const byte of bytes) value = CRC_TABLE[(value ^ byte) & 255] ^ value >>> 8;
|
|
1519
|
+
return (value ^ 4294967295) >>> 0;
|
|
1520
|
+
}
|
|
1521
|
+
function chunk(type, body) {
|
|
1522
|
+
const head = Buffer.alloc(8);
|
|
1523
|
+
head.writeUInt32BE(body.length, 0);
|
|
1524
|
+
head.write(type, 4, "latin1");
|
|
1525
|
+
const tail = Buffer.alloc(4);
|
|
1526
|
+
tail.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), body])), 0);
|
|
1527
|
+
return Buffer.concat([
|
|
1528
|
+
head,
|
|
1529
|
+
body,
|
|
1530
|
+
tail
|
|
1531
|
+
]);
|
|
1532
|
+
}
|
|
1533
|
+
function encodeGrayPng(width, height, gray) {
|
|
1534
|
+
const raw = Buffer.alloc((width + 1) * height);
|
|
1535
|
+
for (let y = 0; y < height; y += 1) {
|
|
1536
|
+
const out = y * (width + 1);
|
|
1537
|
+
raw[out] = 2;
|
|
1538
|
+
for (let x = 0; x < width; x += 1) raw[out + 1 + x] = gray[y * width + x] - (y ? gray[(y - 1) * width + x] : 0) & 255;
|
|
1539
|
+
}
|
|
1540
|
+
const header = Buffer.alloc(13);
|
|
1541
|
+
header.writeUInt32BE(width, 0);
|
|
1542
|
+
header.writeUInt32BE(height, 4);
|
|
1543
|
+
header[8] = 8;
|
|
1544
|
+
header[9] = 0;
|
|
1545
|
+
return Buffer.concat([
|
|
1546
|
+
Buffer.from([
|
|
1547
|
+
137,
|
|
1548
|
+
80,
|
|
1549
|
+
78,
|
|
1550
|
+
71,
|
|
1551
|
+
13,
|
|
1552
|
+
10,
|
|
1553
|
+
26,
|
|
1554
|
+
10
|
|
1555
|
+
]),
|
|
1556
|
+
chunk("IHDR", header),
|
|
1557
|
+
chunk("IDAT", deflateSync(raw)),
|
|
1558
|
+
chunk("IEND", Buffer.alloc(0))
|
|
1559
|
+
]);
|
|
1560
|
+
}
|
|
1561
|
+
//#endregion
|
|
1022
1562
|
//#region src/host/web-fetch.js
|
|
1023
1563
|
const LOCAL_ADDRESS_MESSAGE = "Local network addresses are not supported";
|
|
1024
1564
|
var AddressError = class extends Error {
|
|
@@ -1257,6 +1797,16 @@ const MAX_TEXT_CHARS = 2e6;
|
|
|
1257
1797
|
const CHUNK_CHARS = 1600;
|
|
1258
1798
|
const FETCH_TIMEOUT_MS = 2e4;
|
|
1259
1799
|
const MAX_PAGE_BYTES = 6 * 1024 * 1024;
|
|
1800
|
+
const MIN_PAGE_TEXT = 30;
|
|
1801
|
+
const TEXT_PER_PICTURE = 150;
|
|
1802
|
+
const MIN_PICTURE_BYTES = 2048;
|
|
1803
|
+
const PICTURE_TYPES = Object.freeze({
|
|
1804
|
+
png: "image/png",
|
|
1805
|
+
jpg: "image/jpeg",
|
|
1806
|
+
jpeg: "image/jpeg",
|
|
1807
|
+
gif: "image/gif",
|
|
1808
|
+
webp: "image/webp"
|
|
1809
|
+
});
|
|
1260
1810
|
const pageFetch = createGuardedFetch({ maxBytes: MAX_UPLOAD_BYTES });
|
|
1261
1811
|
var MaterialError = class extends Error {
|
|
1262
1812
|
constructor(code, message) {
|
|
@@ -1397,6 +1947,71 @@ function finish(kind, title, chunks, extra = {}) {
|
|
|
1397
1947
|
...extra
|
|
1398
1948
|
};
|
|
1399
1949
|
}
|
|
1950
|
+
const visibleLength = (value) => String(value ?? "").replace(/\s+/gu, "").length;
|
|
1951
|
+
async function readPictures(recognize, sources) {
|
|
1952
|
+
if (!sources.length) return {
|
|
1953
|
+
texts: [],
|
|
1954
|
+
note: null,
|
|
1955
|
+
available: true,
|
|
1956
|
+
error: null
|
|
1957
|
+
};
|
|
1958
|
+
const tried = sources.slice(0, 200);
|
|
1959
|
+
const results = recognize ? await recognize(tried) : null;
|
|
1960
|
+
if (!results) return {
|
|
1961
|
+
texts: [],
|
|
1962
|
+
note: { unread: sources.length },
|
|
1963
|
+
available: false,
|
|
1964
|
+
error: null
|
|
1965
|
+
};
|
|
1966
|
+
const error = results.find((result) => result?.error)?.error ?? null;
|
|
1967
|
+
const failed = tried.filter((source, index) => results[index]?.error).map((source) => source.where);
|
|
1968
|
+
return {
|
|
1969
|
+
texts: tried.map((source, index) => results[index]?.text ?? ""),
|
|
1970
|
+
note: {
|
|
1971
|
+
read: results.filter((result) => result && !result.error).length,
|
|
1972
|
+
failed,
|
|
1973
|
+
reason: error?.code ?? null,
|
|
1974
|
+
left: sources.length - tried.length
|
|
1975
|
+
},
|
|
1976
|
+
available: true,
|
|
1977
|
+
error
|
|
1978
|
+
};
|
|
1979
|
+
}
|
|
1980
|
+
function resolvePart(base, target) {
|
|
1981
|
+
const parts = target.startsWith("/") ? [] : base.split("/").filter(Boolean);
|
|
1982
|
+
for (const piece of target.split("/")) if (piece === "..") parts.pop();
|
|
1983
|
+
else if (piece && piece !== ".") parts.push(piece);
|
|
1984
|
+
return parts.join("/");
|
|
1985
|
+
}
|
|
1986
|
+
async function partPictures(zip, part) {
|
|
1987
|
+
const slash = part.lastIndexOf("/");
|
|
1988
|
+
const folder = part.slice(0, slash);
|
|
1989
|
+
const xml = await zip.file(part)?.async("string") ?? "";
|
|
1990
|
+
const rels = await zip.file(`${folder}/_rels/${part.slice(slash + 1)}.rels`)?.async("string") ?? "";
|
|
1991
|
+
const targets = /* @__PURE__ */ new Map();
|
|
1992
|
+
for (const [, attributes] of rels.matchAll(/<Relationship\b([^>]*)>/gu)) {
|
|
1993
|
+
const id = /\bId="([^"]+)"/u.exec(attributes)?.[1];
|
|
1994
|
+
const target = /\bTarget="([^"]+)"/u.exec(attributes)?.[1];
|
|
1995
|
+
if (!id || !target || !/\bType="[^"]*\/image"/u.test(attributes) || /\bTargetMode="External"/u.test(attributes)) continue;
|
|
1996
|
+
targets.set(id, resolvePart(folder, decodeXml(target)));
|
|
1997
|
+
}
|
|
1998
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1999
|
+
const pictures = [];
|
|
2000
|
+
for (const [, id] of xml.matchAll(/\br:(?:embed|id)="([^"]+)"/gu)) {
|
|
2001
|
+
const path = targets.get(id);
|
|
2002
|
+
if (!path || seen.has(path)) continue;
|
|
2003
|
+
seen.add(path);
|
|
2004
|
+
const mime = PICTURE_TYPES[path.split(".").pop().toLowerCase()];
|
|
2005
|
+
const file = zip.file(path);
|
|
2006
|
+
if (!mime || !file) continue;
|
|
2007
|
+
const data = await file.async("nodebuffer");
|
|
2008
|
+
if (data.length >= MIN_PICTURE_BYTES && data.length <= 10485760) pictures.push({
|
|
2009
|
+
mime,
|
|
2010
|
+
data
|
|
2011
|
+
});
|
|
2012
|
+
}
|
|
2013
|
+
return pictures;
|
|
2014
|
+
}
|
|
1400
2015
|
let pdfjsModule;
|
|
1401
2016
|
async function loadPdfjs() {
|
|
1402
2017
|
pdfjsModule ??= import("pdfjs-dist/legacy/build/pdf.mjs");
|
|
@@ -1405,20 +2020,23 @@ async function loadPdfjs() {
|
|
|
1405
2020
|
function pdfjsAssetUrls(root, { join: joinPath = join } = {}) {
|
|
1406
2021
|
return {
|
|
1407
2022
|
cMapUrl: `${joinPath(root, "cmaps")}/`,
|
|
1408
|
-
standardFontDataUrl: `${joinPath(root, "standard_fonts")}
|
|
2023
|
+
standardFontDataUrl: `${joinPath(root, "standard_fonts")}/`,
|
|
2024
|
+
wasmUrl: `${joinPath(root, "wasm")}/`
|
|
1409
2025
|
};
|
|
1410
2026
|
}
|
|
1411
2027
|
function pdfjsAssets() {
|
|
1412
2028
|
return pdfjsAssetUrls(dirname(createRequire(import.meta.url).resolve("pdfjs-dist/package.json")));
|
|
1413
2029
|
}
|
|
1414
|
-
async function extractPdf(bytes, { signal } = {}) {
|
|
2030
|
+
async function extractPdf(bytes, { signal, recognize } = {}) {
|
|
1415
2031
|
const pdfjs = await loadPdfjs();
|
|
1416
|
-
const { cMapUrl, standardFontDataUrl } = pdfjsAssets();
|
|
2032
|
+
const { cMapUrl, standardFontDataUrl, wasmUrl } = pdfjsAssets();
|
|
1417
2033
|
const task = pdfjs.getDocument({
|
|
1418
2034
|
data: new Uint8Array(bytes),
|
|
1419
2035
|
cMapUrl,
|
|
1420
2036
|
cMapPacked: true,
|
|
1421
2037
|
standardFontDataUrl,
|
|
2038
|
+
wasmUrl,
|
|
2039
|
+
maxImageSize: 1e8,
|
|
1422
2040
|
isEvalSupported: false,
|
|
1423
2041
|
disableFontFace: true,
|
|
1424
2042
|
useSystemFonts: false,
|
|
@@ -1432,12 +2050,12 @@ async function extractPdf(bytes, { signal } = {}) {
|
|
|
1432
2050
|
throw new MaterialError("unreadable", "This PDF could not be read");
|
|
1433
2051
|
}
|
|
1434
2052
|
try {
|
|
1435
|
-
const chunks = [];
|
|
1436
2053
|
let title = "";
|
|
1437
2054
|
try {
|
|
1438
2055
|
const meta = await doc.getMetadata();
|
|
1439
2056
|
title = String(meta?.info?.Title ?? "").trim();
|
|
1440
2057
|
} catch {}
|
|
2058
|
+
const texts = [];
|
|
1441
2059
|
for (let pageNumber = 1; pageNumber <= doc.numPages; pageNumber += 1) {
|
|
1442
2060
|
signal?.throwIfAborted();
|
|
1443
2061
|
const page = await doc.getPage(pageNumber);
|
|
@@ -1449,19 +2067,39 @@ async function extractPdf(bytes, { signal } = {}) {
|
|
|
1449
2067
|
pageText += item.hasEOL ? "\n" : "";
|
|
1450
2068
|
}
|
|
1451
2069
|
page.cleanup();
|
|
1452
|
-
|
|
2070
|
+
texts.push(pageText);
|
|
1453
2071
|
}
|
|
1454
|
-
|
|
2072
|
+
const pictured = texts.flatMap((pageText, index) => visibleLength(pageText) < MIN_PAGE_TEXT ? [index + 1] : []);
|
|
2073
|
+
const read = await readPictures(recognize, pictured.map((pageNumber) => ({
|
|
2074
|
+
where: { page: pageNumber },
|
|
2075
|
+
image: async () => {
|
|
2076
|
+
signal?.throwIfAborted();
|
|
2077
|
+
const page = await doc.getPage(pageNumber);
|
|
2078
|
+
try {
|
|
2079
|
+
return await pageImage(pdfjs, page);
|
|
2080
|
+
} finally {
|
|
2081
|
+
page.cleanup();
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
})));
|
|
2085
|
+
if (pictured.length === doc.numPages && !read.note?.read) {
|
|
2086
|
+
if (!read.available) throw new MaterialError("scan-needs-vision", "This PDF is a scan; reading it needs a model that can see images");
|
|
2087
|
+
throw read.error ?? new MaterialError("no-text", "No text found in this PDF");
|
|
2088
|
+
}
|
|
2089
|
+
const recognized = new Map(pictured.slice(0, read.texts.length).map((pageNumber, index) => [pageNumber, read.texts[index]]));
|
|
2090
|
+
const chunks = texts.flatMap((pageText, index) => chunkText([pageText, recognized.get(index + 1)].filter(Boolean).join("\n\n"), { page: index + 1 }));
|
|
2091
|
+
if (!chunks.length) throw read.error ?? new MaterialError("no-text", "No text found in this PDF");
|
|
1455
2092
|
return {
|
|
1456
2093
|
title,
|
|
1457
2094
|
pages: doc.numPages,
|
|
1458
|
-
chunks
|
|
2095
|
+
chunks,
|
|
2096
|
+
ocr: read.note
|
|
1459
2097
|
};
|
|
1460
2098
|
} finally {
|
|
1461
2099
|
await Promise.resolve(task.destroy?.()).catch(() => void 0);
|
|
1462
2100
|
}
|
|
1463
2101
|
}
|
|
1464
|
-
async function extractDocx(bytes) {
|
|
2102
|
+
async function extractDocx(bytes, { recognize } = {}) {
|
|
1465
2103
|
const mammoth = await import("mammoth");
|
|
1466
2104
|
const extract = mammoth.extractRawText ?? mammoth.default?.extractRawText;
|
|
1467
2105
|
let result;
|
|
@@ -1470,7 +2108,8 @@ async function extractDocx(bytes) {
|
|
|
1470
2108
|
} catch {
|
|
1471
2109
|
throw new MaterialError("unreadable", "This Word document could not be read");
|
|
1472
2110
|
}
|
|
1473
|
-
const
|
|
2111
|
+
const own = String(result?.value ?? "");
|
|
2112
|
+
const sections = own.split(/\n{2,}/u);
|
|
1474
2113
|
const chunks = [];
|
|
1475
2114
|
let buffer = "";
|
|
1476
2115
|
let section = 1;
|
|
@@ -1482,9 +2121,31 @@ async function extractDocx(bytes) {
|
|
|
1482
2121
|
}
|
|
1483
2122
|
buffer += `${paragraph}\n\n`;
|
|
1484
2123
|
}
|
|
1485
|
-
if (buffer.trim())
|
|
2124
|
+
if (buffer.trim()) {
|
|
2125
|
+
chunks.push(...chunkText(buffer, { section: `§${section}` }));
|
|
2126
|
+
section += 1;
|
|
2127
|
+
}
|
|
2128
|
+
const JSZip = (await import("jszip")).default;
|
|
2129
|
+
const pictures = await partPictures(await JSZip.loadAsync(bytes), "word/document.xml");
|
|
2130
|
+
let note = null;
|
|
2131
|
+
if (pictures.length && visibleLength(own) < TEXT_PER_PICTURE * pictures.length) {
|
|
2132
|
+
const read = await readPictures(recognize, pictures.map((picture, index) => ({
|
|
2133
|
+
where: { picture: index + 1 },
|
|
2134
|
+
image: async () => picture
|
|
2135
|
+
})));
|
|
2136
|
+
if (!read.note?.read && visibleLength(own) < MIN_PAGE_TEXT) {
|
|
2137
|
+
if (!read.available) throw new MaterialError("pictures-need-vision", "This document is pictures; reading them needs a model that can see images");
|
|
2138
|
+
throw read.error ?? new MaterialError("no-text", "No text found in this document");
|
|
2139
|
+
}
|
|
2140
|
+
for (const [index, pictureText] of read.texts.entries()) chunks.push(...chunkText(pictureText, { section: `§${section + index}` }));
|
|
2141
|
+
if (!chunks.length) throw read.error ?? new MaterialError("no-text", "No text found in this document");
|
|
2142
|
+
note = read.note;
|
|
2143
|
+
}
|
|
1486
2144
|
if (!chunks.length) throw new MaterialError("no-text", "No text found in this document");
|
|
1487
|
-
return {
|
|
2145
|
+
return {
|
|
2146
|
+
chunks,
|
|
2147
|
+
ocr: note
|
|
2148
|
+
};
|
|
1488
2149
|
}
|
|
1489
2150
|
const XML_ENTITIES = {
|
|
1490
2151
|
amp: "&",
|
|
@@ -1504,7 +2165,7 @@ function slideText(xml) {
|
|
|
1504
2165
|
return [...paragraph.matchAll(/<a:t(?:\s[^>]*)?>([\s\S]*?)<\/a:t>/gu)].map((match) => decodeXml(match[1])).join("");
|
|
1505
2166
|
}).filter((line) => line.trim()).join("\n");
|
|
1506
2167
|
}
|
|
1507
|
-
async function extractPptx(bytes) {
|
|
2168
|
+
async function extractPptx(bytes, { recognize } = {}) {
|
|
1508
2169
|
const JSZip = (await import("jszip")).default;
|
|
1509
2170
|
let zip;
|
|
1510
2171
|
try {
|
|
@@ -1514,19 +2175,40 @@ async function extractPptx(bytes) {
|
|
|
1514
2175
|
}
|
|
1515
2176
|
const slideNumber = (name) => Number(/(\d+)\.xml$/u.exec(name)?.[1] ?? 0);
|
|
1516
2177
|
const slides = Object.keys(zip.files).filter((name) => /^ppt\/slides\/slide\d+\.xml$/u.test(name)).sort((a, b) => slideNumber(a) - slideNumber(b));
|
|
1517
|
-
const
|
|
2178
|
+
const texts = [];
|
|
2179
|
+
const sources = [];
|
|
2180
|
+
let written = 0;
|
|
1518
2181
|
for (const name of slides) {
|
|
1519
2182
|
const number = slideNumber(name);
|
|
1520
2183
|
const body = slideText(await zip.file(name).async("string"));
|
|
1521
2184
|
const notesFile = zip.file(`ppt/notesSlides/notesSlide${number}.xml`);
|
|
1522
2185
|
const notes = notesFile ? slideText(await notesFile.async("string")) : "";
|
|
1523
|
-
|
|
1524
|
-
|
|
2186
|
+
texts.push({
|
|
2187
|
+
number,
|
|
2188
|
+
text: notes ? `${body}\n[notes] ${notes}` : body
|
|
2189
|
+
});
|
|
2190
|
+
if (visibleLength(body) < MIN_PAGE_TEXT) for (const picture of await partPictures(zip, name)) sources.push({
|
|
2191
|
+
where: { slide: number },
|
|
2192
|
+
image: async () => picture
|
|
2193
|
+
});
|
|
2194
|
+
else written += 1;
|
|
2195
|
+
}
|
|
2196
|
+
const read = await readPictures(recognize, sources);
|
|
2197
|
+
if (sources.length && !written && !read.note?.read) {
|
|
2198
|
+
if (!read.available) throw new MaterialError("pictures-need-vision", "These slides are pictures; reading them needs a model that can see images");
|
|
2199
|
+
throw read.error ?? new MaterialError("no-text", "No text found in these slides");
|
|
2200
|
+
}
|
|
2201
|
+
const pictured = /* @__PURE__ */ new Map();
|
|
2202
|
+
for (const [index, pictureText] of read.texts.entries()) {
|
|
2203
|
+
const { slide } = sources[index].where;
|
|
2204
|
+
pictured.set(slide, [pictured.get(slide), pictureText].filter(Boolean).join("\n\n"));
|
|
1525
2205
|
}
|
|
1526
|
-
|
|
2206
|
+
const chunks = texts.flatMap(({ number, text }) => chunkText([text, pictured.get(number)].filter(Boolean).join("\n\n"), { slide: number }));
|
|
2207
|
+
if (!chunks.length) throw read.error ?? new MaterialError("no-text", "No text found in these slides");
|
|
1527
2208
|
return {
|
|
1528
2209
|
slides: slides.length,
|
|
1529
|
-
chunks
|
|
2210
|
+
chunks,
|
|
2211
|
+
ocr: read.note
|
|
1530
2212
|
};
|
|
1531
2213
|
}
|
|
1532
2214
|
function extractPlain(bytes, kind) {
|
|
@@ -1545,30 +2227,46 @@ function extractPlain(bytes, kind) {
|
|
|
1545
2227
|
chunks
|
|
1546
2228
|
};
|
|
1547
2229
|
}
|
|
1548
|
-
async function ingestFile({ name, bytes, signal }) {
|
|
2230
|
+
async function ingestFile({ name, bytes, signal, recognize }) {
|
|
1549
2231
|
if (!bytes?.length) throw new MaterialError("empty", "The file is empty");
|
|
1550
2232
|
if (bytes.length > 62914560) throw new MaterialError("too-large", "The file is larger than 60 MB");
|
|
1551
2233
|
const kind = detectKind(name, bytes);
|
|
1552
2234
|
const baseTitle = String(name ?? "").replace(/\.[^.]+$/u, "").slice(0, 120) || "Untitled";
|
|
1553
2235
|
if (kind === "image") {
|
|
1554
2236
|
if (bytes.length > 10485760) throw new MaterialError("image-too-large", "Images must be smaller than 10 MB");
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
};
|
|
2237
|
+
const mime = imageMime(name, bytes);
|
|
2238
|
+
const read = await readPictures(recognize, [{
|
|
2239
|
+
where: { picture: 1 },
|
|
2240
|
+
image: async () => ({
|
|
2241
|
+
mime,
|
|
2242
|
+
data: Buffer.from(bytes)
|
|
2243
|
+
})
|
|
2244
|
+
}]);
|
|
2245
|
+
return finish(kind, baseTitle, chunkText(read.texts[0] ?? "", {}), {
|
|
2246
|
+
mime,
|
|
2247
|
+
...read.available ? { ocr: read.note } : {}
|
|
2248
|
+
});
|
|
1563
2249
|
}
|
|
1564
2250
|
if (kind === "pdf") {
|
|
1565
|
-
const result = await extractPdf(bytes, {
|
|
1566
|
-
|
|
2251
|
+
const result = await extractPdf(bytes, {
|
|
2252
|
+
signal,
|
|
2253
|
+
recognize
|
|
2254
|
+
});
|
|
2255
|
+
return finish(kind, result.title || baseTitle, result.chunks, {
|
|
2256
|
+
pages: result.pages,
|
|
2257
|
+
ocr: result.ocr
|
|
2258
|
+
});
|
|
2259
|
+
}
|
|
2260
|
+
if (kind === "docx") {
|
|
2261
|
+
const result = await extractDocx(bytes, { recognize });
|
|
2262
|
+
return finish(kind, baseTitle, result.chunks, { ocr: result.ocr });
|
|
1567
2263
|
}
|
|
1568
|
-
if (kind === "docx") return finish(kind, baseTitle, (await extractDocx(bytes)).chunks);
|
|
1569
2264
|
if (kind === "pptx") {
|
|
1570
|
-
const result = await extractPptx(bytes);
|
|
1571
|
-
return finish(kind, baseTitle, result.chunks, {
|
|
2265
|
+
const result = await extractPptx(bytes, { recognize });
|
|
2266
|
+
return finish(kind, baseTitle, result.chunks, {
|
|
2267
|
+
slides: result.slides,
|
|
2268
|
+
ocr: result.ocr
|
|
2269
|
+
});
|
|
1572
2270
|
}
|
|
1573
2271
|
const result = extractPlain(bytes, kind);
|
|
1574
2272
|
return finish(kind, result.title || baseTitle, result.chunks);
|
|
@@ -1589,7 +2287,7 @@ function checkPublicUrl(value) {
|
|
|
1589
2287
|
}
|
|
1590
2288
|
return url;
|
|
1591
2289
|
}
|
|
1592
|
-
async function ingestUrl(address, { fetchImpl = pageFetch, signal } = {}) {
|
|
2290
|
+
async function ingestUrl(address, { fetchImpl = pageFetch, signal, recognize } = {}) {
|
|
1593
2291
|
let url = checkPublicUrl(address);
|
|
1594
2292
|
let response;
|
|
1595
2293
|
for (let hop = 0; hop < 6; hop += 1) {
|
|
@@ -1623,7 +2321,8 @@ async function ingestUrl(address, { fetchImpl = pageFetch, signal } = {}) {
|
|
|
1623
2321
|
...await ingestFile({
|
|
1624
2322
|
name: `${url.pathname.split("/").pop() || "page"}.pdf`,
|
|
1625
2323
|
bytes,
|
|
1626
|
-
signal
|
|
2324
|
+
signal,
|
|
2325
|
+
recognize
|
|
1627
2326
|
}),
|
|
1628
2327
|
kind: "url",
|
|
1629
2328
|
url: url.href
|
|
@@ -2044,10 +2743,10 @@ function neededWeeklyMinutes(course, start, deadline, studyDays) {
|
|
|
2044
2743
|
const perDay = remaining / studyDayCount / (1 - REVIEW_SHARE);
|
|
2045
2744
|
return Math.ceil(perDay * studyDays.length / 10) * 10;
|
|
2046
2745
|
}
|
|
2047
|
-
const itemKey = (item) => `${item.kind}:${item.refId}`;
|
|
2746
|
+
const itemKey$1 = (item) => `${item.kind}:${item.refId}`;
|
|
2048
2747
|
function mergeAgenda(saved = [], planned = []) {
|
|
2049
|
-
const seen = new Set(saved.map(itemKey));
|
|
2050
|
-
return [...saved, ...planned.filter((item) => !seen.has(itemKey(item)))];
|
|
2748
|
+
const seen = new Set(saved.map(itemKey$1));
|
|
2749
|
+
return [...saved, ...planned.filter((item) => !seen.has(itemKey$1(item)))];
|
|
2051
2750
|
}
|
|
2052
2751
|
function planCalendar({ today, agenda = {}, plan, done = {}, checks = [], ahead = 120 }) {
|
|
2053
2752
|
const lessonsDone = done.lessons ?? /* @__PURE__ */ new Map();
|
|
@@ -2056,7 +2755,7 @@ function planCalendar({ today, agenda = {}, plan, done = {}, checks = [], ahead
|
|
|
2056
2755
|
const days = /* @__PURE__ */ new Map();
|
|
2057
2756
|
const put = (day, item) => {
|
|
2058
2757
|
const list = days.get(day) ?? [];
|
|
2059
|
-
if (!list.some((entry) => itemKey(entry) === itemKey(item))) list.push(item);
|
|
2758
|
+
if (!list.some((entry) => itemKey$1(entry) === itemKey$1(item))) list.push(item);
|
|
2060
2759
|
days.set(day, list);
|
|
2061
2760
|
};
|
|
2062
2761
|
for (const [day, items] of Object.entries(agenda)) if (day <= today) for (const item of items) put(day, item);
|
|
@@ -2106,9 +2805,10 @@ function createLearner({ store, llm, now = () => /* @__PURE__ */ new Date(), log
|
|
|
2106
2805
|
return readProfile(store);
|
|
2107
2806
|
}
|
|
2108
2807
|
async function saveProfile(patch = {}) {
|
|
2808
|
+
const given = Object.fromEntries(Object.entries(patch).filter(([, value]) => value !== void 0));
|
|
2109
2809
|
return store.updateJson(store.paths.profile(), { ...EMPTY_PROFILE }, (current) => cleanProfile({
|
|
2110
2810
|
...current,
|
|
2111
|
-
...
|
|
2811
|
+
...given,
|
|
2112
2812
|
updatedAt: now().toISOString()
|
|
2113
2813
|
}));
|
|
2114
2814
|
}
|
|
@@ -2428,6 +3128,7 @@ function list(value) {
|
|
|
2428
3128
|
}
|
|
2429
3129
|
function cleanProfile(profile) {
|
|
2430
3130
|
return {
|
|
3131
|
+
name: text(profile.name, 20).replace(/[\r\n]+/gu, " "),
|
|
2431
3132
|
background: text(profile.background, 600),
|
|
2432
3133
|
goals: list(profile.goals) ?? [],
|
|
2433
3134
|
strengths: list(profile.strengths) ?? [],
|
|
@@ -2751,6 +3452,16 @@ function stateBefore(state, card) {
|
|
|
2751
3452
|
function logEvidence(progress, entry) {
|
|
2752
3453
|
progress.evidenceLog = [...Array.isArray(progress.evidenceLog) ? progress.evidenceLog : [], entry].slice(-50);
|
|
2753
3454
|
}
|
|
3455
|
+
function itemKey(source) {
|
|
3456
|
+
if (source?.kind === "practice") return `practice:${source.lessonId}:${source.itemId}`;
|
|
3457
|
+
if (source?.kind === "quiz") return `quiz:${source.unitId}:${source.itemId}`;
|
|
3458
|
+
if (source?.kind === "check") return `check:${source.lessonId}:${source.stepId}`;
|
|
3459
|
+
return null;
|
|
3460
|
+
}
|
|
3461
|
+
function answersTo(progress, key, except = null) {
|
|
3462
|
+
if (!key) return [];
|
|
3463
|
+
return (Array.isArray(progress?.evidenceLog) ? progress.evidenceLog : []).filter((entry) => entry.item?.key === key && entry.id !== except && scored(entry.evidence));
|
|
3464
|
+
}
|
|
2754
3465
|
function correctEvidence(progress, id, patch, { known = (conceptId) => conceptId, at = (/* @__PURE__ */ new Date()).toISOString() } = {}) {
|
|
2755
3466
|
const log = Array.isArray(progress.evidenceLog) ? progress.evidenceLog : [];
|
|
2756
3467
|
const index = log.findIndex((entry) => entry.id === id);
|
|
@@ -2917,6 +3628,11 @@ function gradeObjective(item, response) {
|
|
|
2917
3628
|
gradable: true
|
|
2918
3629
|
};
|
|
2919
3630
|
}
|
|
3631
|
+
function wrongLabel(item, response) {
|
|
3632
|
+
if (item.kind === "choice") return `chose ${String(response).toUpperCase()}: ${(item.options?.find((option) => option.id === response)?.text ?? "").slice(0, 50)}`;
|
|
3633
|
+
if (item.kind === "number" || item.kind === "blank") return `answered ${String(response).slice(0, 40)}`;
|
|
3634
|
+
return "wrong";
|
|
3635
|
+
}
|
|
2920
3636
|
function round2(value) {
|
|
2921
3637
|
return Math.round(value * 100) / 100;
|
|
2922
3638
|
}
|
|
@@ -2992,7 +3708,7 @@ function correctedItem(old, raw) {
|
|
|
2992
3708
|
...raw,
|
|
2993
3709
|
kind: old.kind
|
|
2994
3710
|
}, 0);
|
|
2995
|
-
if (!next || (old.options?.length ?? 0) !== (next.options?.length ?? 0)) return null;
|
|
3711
|
+
if (!next || next.kind !== old.kind || (old.options?.length ?? 0) !== (next.options?.length ?? 0)) return null;
|
|
2996
3712
|
const redrawn = [
|
|
2997
3713
|
"svg",
|
|
2998
3714
|
"figure",
|
|
@@ -3088,6 +3804,32 @@ function regrade(item, response, checked) {
|
|
|
3088
3804
|
partial: verdict === "partial"
|
|
3089
3805
|
};
|
|
3090
3806
|
}
|
|
3807
|
+
function earlierGrade(item, response, { verdict, score, grade = null }) {
|
|
3808
|
+
if (verdict !== "wrong" && verdict !== "unclear") return null;
|
|
3809
|
+
let next;
|
|
3810
|
+
if (OBJECTIVE_KINDS.includes(item.kind)) {
|
|
3811
|
+
const answer = cleanResponse(item, response);
|
|
3812
|
+
const graded = gradeObjective(item, answer);
|
|
3813
|
+
if (!graded.gradable) return null;
|
|
3814
|
+
next = {
|
|
3815
|
+
score: graded.score,
|
|
3816
|
+
correct: graded.correct,
|
|
3817
|
+
errorTag: graded.correct ? "" : wrongLabel(item, answer)
|
|
3818
|
+
};
|
|
3819
|
+
} else {
|
|
3820
|
+
if (!grade || !Number.isFinite(Number(grade.score))) return null;
|
|
3821
|
+
const value = Math.min(1, Math.max(0, Number(grade.score)));
|
|
3822
|
+
const correct = grade.verdict ? grade.verdict === "correct" : value >= .8;
|
|
3823
|
+
next = {
|
|
3824
|
+
score: value,
|
|
3825
|
+
correct,
|
|
3826
|
+
errorTag: correct ? "" : text(grade.errorTag, 80)
|
|
3827
|
+
};
|
|
3828
|
+
}
|
|
3829
|
+
const before = Math.min(1, Math.max(0, Number(score) || 0));
|
|
3830
|
+
if (verdict === "unclear" && next.score <= before || Math.abs(next.score - before) < 1e-6) return null;
|
|
3831
|
+
return next;
|
|
3832
|
+
}
|
|
3091
3833
|
//#endregion
|
|
3092
3834
|
//#region src/host/lessons.js
|
|
3093
3835
|
const EVENTS_KEPT = 200;
|
|
@@ -3537,12 +4279,13 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
|
|
|
3537
4279
|
};
|
|
3538
4280
|
});
|
|
3539
4281
|
}
|
|
3540
|
-
async function record({ courseId, lessonId, conceptIds = [], evidence = {} }) {
|
|
4282
|
+
async function record({ courseId, lessonId, conceptIds = [], evidence = {}, item = null }) {
|
|
3541
4283
|
const course = await courses.readCourse(courseId);
|
|
3542
4284
|
const index = conceptIndex(course);
|
|
3543
4285
|
const ids = (Array.isArray(conceptIds) && conceptIds.length ? conceptIds : findLesson(course, lessonId)?.lesson.concepts.map((concept) => concept.id) ?? []).filter((id) => index.has(id)).slice(0, 8);
|
|
3544
4286
|
const at = now().getTime();
|
|
3545
4287
|
const evidenceId = newId("evd");
|
|
4288
|
+
const key = item?.source?.courseId === courseId ? itemKey(item.source) : null;
|
|
3546
4289
|
const next = await store.updateJson(store.paths.progress(courseId), null, (progress) => {
|
|
3547
4290
|
const value = progress ?? {
|
|
3548
4291
|
courseId,
|
|
@@ -3568,7 +4311,11 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
|
|
|
3568
4311
|
lessonId: lessonId ?? null,
|
|
3569
4312
|
conceptIds: ids,
|
|
3570
4313
|
evidence: JSON.parse(JSON.stringify(evidence)),
|
|
3571
|
-
before
|
|
4314
|
+
before,
|
|
4315
|
+
...key ? { item: {
|
|
4316
|
+
key,
|
|
4317
|
+
response: item.response ?? ""
|
|
4318
|
+
} } : {}
|
|
3572
4319
|
});
|
|
3573
4320
|
pushEvent(value, {
|
|
3574
4321
|
type: "evidence",
|
|
@@ -3701,6 +4448,46 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
|
|
|
3701
4448
|
save: rewrite(path, (value) => value?.items)
|
|
3702
4449
|
};
|
|
3703
4450
|
}
|
|
4451
|
+
async function regradeEarlier({ course, key, item, verdict, except, language, signal }) {
|
|
4452
|
+
const answers = answersTo(await store.readJson(store.paths.progress(course.id), null), key, except);
|
|
4453
|
+
const open = !OBJECTIVE_KINDS.includes(item.kind);
|
|
4454
|
+
const graded = await Promise.all((open ? answers.slice(-3) : answers).map(async (entry) => {
|
|
4455
|
+
const again = open ? await grade({
|
|
4456
|
+
item,
|
|
4457
|
+
response: entry.item.response,
|
|
4458
|
+
language,
|
|
4459
|
+
signal
|
|
4460
|
+
}).catch((error) => {
|
|
4461
|
+
if (signal?.aborted) throw error;
|
|
4462
|
+
return null;
|
|
4463
|
+
}) : null;
|
|
4464
|
+
if (open && !again) return null;
|
|
4465
|
+
return {
|
|
4466
|
+
id: entry.id,
|
|
4467
|
+
next: earlierGrade(item, entry.item.response, {
|
|
4468
|
+
verdict,
|
|
4469
|
+
score: entry.evidence.score,
|
|
4470
|
+
grade: again
|
|
4471
|
+
})
|
|
4472
|
+
};
|
|
4473
|
+
}));
|
|
4474
|
+
let found = null;
|
|
4475
|
+
for (const { id, next } of graded.filter((entry) => entry?.next)) {
|
|
4476
|
+
const corrected = await regradeEvidence(course, id, {
|
|
4477
|
+
score: next.score,
|
|
4478
|
+
errorTag: next.errorTag
|
|
4479
|
+
});
|
|
4480
|
+
if (corrected) found = {
|
|
4481
|
+
count: (found?.count ?? 0) + 1,
|
|
4482
|
+
mastery: {
|
|
4483
|
+
...found?.mastery,
|
|
4484
|
+
...corrected.mastery
|
|
4485
|
+
},
|
|
4486
|
+
recommendation: corrected.recommendation
|
|
4487
|
+
};
|
|
4488
|
+
}
|
|
4489
|
+
return found;
|
|
4490
|
+
}
|
|
3704
4491
|
function challengeItem({ source, response, shown = {}, evidenceId, note }) {
|
|
3705
4492
|
const doubt = text(note, 800);
|
|
3706
4493
|
return jobs.start("item/challenge", async (api) => {
|
|
@@ -3736,24 +4523,39 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
|
|
|
3736
4523
|
score: result.score,
|
|
3737
4524
|
...result.correct ? { errorTag: "" } : result.errorTag ? { errorTag: result.errorTag } : {}
|
|
3738
4525
|
}) : null;
|
|
4526
|
+
const earlier = checked.item && place.course ? await regradeEarlier({
|
|
4527
|
+
course: place.course,
|
|
4528
|
+
key: itemKey(source),
|
|
4529
|
+
item,
|
|
4530
|
+
verdict: checked.verdict,
|
|
4531
|
+
except: evidenceId,
|
|
4532
|
+
language: place.language,
|
|
4533
|
+
signal: api.signal
|
|
4534
|
+
}) : null;
|
|
3739
4535
|
if (place.course) await logEvent(place.course.id, {
|
|
3740
4536
|
type: "item-challenge",
|
|
3741
4537
|
lessonId: place.lessonId,
|
|
3742
4538
|
verdict: checked.verdict,
|
|
3743
|
-
changed
|
|
4539
|
+
changed,
|
|
4540
|
+
earlier: earlier?.count ?? 0
|
|
3744
4541
|
});
|
|
4542
|
+
const mastery = corrected || earlier ? {
|
|
4543
|
+
...corrected?.mastery,
|
|
4544
|
+
...earlier?.mastery
|
|
4545
|
+
} : null;
|
|
3745
4546
|
return {
|
|
3746
4547
|
verdict: checked.verdict,
|
|
3747
4548
|
summary: checked.summary,
|
|
3748
4549
|
explanation: checked.explanation,
|
|
3749
4550
|
item: checked.item ? item : null,
|
|
3750
4551
|
result: changed ? result : null,
|
|
3751
|
-
|
|
3752
|
-
|
|
4552
|
+
earlier: earlier?.count ?? 0,
|
|
4553
|
+
mastery,
|
|
4554
|
+
recommendation: earlier?.recommendation ?? corrected?.recommendation ?? null
|
|
3753
4555
|
};
|
|
3754
4556
|
});
|
|
3755
4557
|
}
|
|
3756
|
-
async function grade({ item, response, language = "zh-CN" }) {
|
|
4558
|
+
async function grade({ item, response, language = "zh-CN", signal }) {
|
|
3757
4559
|
if (!item?.prompt) throw new TutorError("invalid", "Missing question");
|
|
3758
4560
|
const work = item.kind === "work";
|
|
3759
4561
|
const answer = text(response, 4e3);
|
|
@@ -3782,6 +4584,7 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
|
|
|
3782
4584
|
language
|
|
3783
4585
|
}),
|
|
3784
4586
|
maxTokens: work ? 1800 : 1200,
|
|
4587
|
+
signal,
|
|
3785
4588
|
normalize: (raw) => ({
|
|
3786
4589
|
score: Math.min(1, Math.max(0, Number(raw?.score) || 0)),
|
|
3787
4590
|
verdict: [
|
|
@@ -3961,6 +4764,44 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
|
|
|
3961
4764
|
if (!exists && !jobs.running(`practice:${course.id}:${row.lecture.id}`)) lecturePractice(course, row);
|
|
3962
4765
|
}).catch(() => void 0);
|
|
3963
4766
|
}
|
|
4767
|
+
async function handout({ courseId, lessonId, force = false }) {
|
|
4768
|
+
const { course, row } = await context(courseId, lessonId);
|
|
4769
|
+
if (!force) {
|
|
4770
|
+
const existing = await store.readJson(store.paths.handout(courseId, lessonId), null);
|
|
4771
|
+
if (existing) return { handout: existing };
|
|
4772
|
+
}
|
|
4773
|
+
const board = await store.readJson(store.paths.board(courseId, lessonId), null);
|
|
4774
|
+
if (!board?.steps?.length) throw new TutorError("no-board", "The board is not written yet. Try again once its first step is up.");
|
|
4775
|
+
return { job: jobs.start("lesson/handout", async (api) => {
|
|
4776
|
+
const made = await generateJson(llm, {
|
|
4777
|
+
purpose: "teach",
|
|
4778
|
+
effort: "lowest",
|
|
4779
|
+
prompt: handoutPrompt({
|
|
4780
|
+
course,
|
|
4781
|
+
lesson: row.lesson,
|
|
4782
|
+
language: course.language,
|
|
4783
|
+
board: boardToPlainText(board, { maxChars: 12e3 })
|
|
4784
|
+
}),
|
|
4785
|
+
maxTokens: 5e3,
|
|
4786
|
+
signal: api.signal,
|
|
4787
|
+
normalize: normalizeHandout
|
|
4788
|
+
});
|
|
4789
|
+
const saved = {
|
|
4790
|
+
...made,
|
|
4791
|
+
title: made.title || row.lesson.title,
|
|
4792
|
+
lessonId,
|
|
4793
|
+
createdAt: stamp()
|
|
4794
|
+
};
|
|
4795
|
+
await store.writeJson(store.paths.handout(courseId, lessonId), saved);
|
|
4796
|
+
return { handout: saved };
|
|
4797
|
+
}, {
|
|
4798
|
+
key: `handout:${courseId}:${lessonId}`,
|
|
4799
|
+
meta: {
|
|
4800
|
+
courseId,
|
|
4801
|
+
lessonId
|
|
4802
|
+
}
|
|
4803
|
+
}) };
|
|
4804
|
+
}
|
|
3964
4805
|
async function unitSheet({ courseId, unitId, force = false }) {
|
|
3965
4806
|
const course = await courses.readCourse(courseId);
|
|
3966
4807
|
const unit = course.units?.find((item) => item.id === assertId(unitId, "unt"));
|
|
@@ -4117,6 +4958,7 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
|
|
|
4117
4958
|
reexplain,
|
|
4118
4959
|
challenge,
|
|
4119
4960
|
challengeItem,
|
|
4961
|
+
handout,
|
|
4120
4962
|
record,
|
|
4121
4963
|
grade,
|
|
4122
4964
|
correct,
|
|
@@ -4249,14 +5091,17 @@ function createLlmPort({ getLlm, getDefault, getAttachments, prefs, logger }) {
|
|
|
4249
5091
|
return null;
|
|
4250
5092
|
}
|
|
4251
5093
|
}
|
|
4252
|
-
async function content(user, images, prepared) {
|
|
5094
|
+
async function content(user, images, prepared, required) {
|
|
4253
5095
|
const blocks = [{
|
|
4254
5096
|
type: "text",
|
|
4255
5097
|
text: String(user ?? "")
|
|
4256
5098
|
}];
|
|
4257
|
-
if (!images?.length
|
|
5099
|
+
if (!images?.length) return blocks;
|
|
4258
5100
|
const attachments = getAttachments();
|
|
4259
|
-
if (typeof attachments?.saveImage !== "function")
|
|
5101
|
+
if (prepared.inputModalities?.includes("image") !== true || typeof attachments?.saveImage !== "function") {
|
|
5102
|
+
if (required) throw new TutorError("llm-no-vision", "The selected model cannot see images. Pick one that can in Aha settings.");
|
|
5103
|
+
return blocks;
|
|
5104
|
+
}
|
|
4260
5105
|
for (const [index, image] of images.slice(0, 4).entries()) try {
|
|
4261
5106
|
const ref = await attachments.saveImage({
|
|
4262
5107
|
data: Buffer.from(image.base64, "base64"),
|
|
@@ -4268,11 +5113,12 @@ function createLlmPort({ getLlm, getDefault, getAttachments, prefs, logger }) {
|
|
|
4268
5113
|
attachment: ref
|
|
4269
5114
|
});
|
|
4270
5115
|
} catch (error) {
|
|
5116
|
+
if (required) throw new TutorError("image-unusable", "This picture could not be given to the model.");
|
|
4271
5117
|
logger?.debug?.("aha-tutor could not attach an image: %s", error?.message);
|
|
4272
5118
|
}
|
|
4273
5119
|
return blocks;
|
|
4274
5120
|
}
|
|
4275
|
-
async function attempt({ purpose, effort, system, user, images, maxTokens, signal, onText, onReasoning }) {
|
|
5121
|
+
async function attempt({ purpose, effort, system, user, images, requireImages = false, maxTokens, signal, onText, onReasoning }) {
|
|
4276
5122
|
const llm = getLlm();
|
|
4277
5123
|
if (!llm) throw new TutorError("llm-missing", "DSH has no model service in this profile.");
|
|
4278
5124
|
const target = route(purpose);
|
|
@@ -4299,7 +5145,7 @@ function createLlmPort({ getLlm, getDefault, getAttachments, prefs, logger }) {
|
|
|
4299
5145
|
let finish = null;
|
|
4300
5146
|
const messages = [{
|
|
4301
5147
|
role: "user",
|
|
4302
|
-
content: await content(user, images, prepared)
|
|
5148
|
+
content: await content(user, images, prepared, requireImages)
|
|
4303
5149
|
}];
|
|
4304
5150
|
try {
|
|
4305
5151
|
for await (const chunk of prepared.stream({
|
|
@@ -4459,6 +5305,7 @@ function createStore({ root }) {
|
|
|
4459
5305
|
practice: (id, lessonId) => join(paths.courseDir(id), "practice", `${assertId(lessonId, "les")}.json`),
|
|
4460
5306
|
asides: (id, lessonId) => join(paths.courseDir(id), "asides", `${assertId(lessonId, "les")}.json`),
|
|
4461
5307
|
sheet: (id, unitId) => join(paths.courseDir(id), "sheets", `${assertId(unitId, "unt")}.json`),
|
|
5308
|
+
handout: (id, lessonId) => join(paths.courseDir(id), "handouts", `${assertId(lessonId, "les")}.json`),
|
|
4462
5309
|
unitQuiz: (id, unitId) => join(paths.courseDir(id), "quizzes", `${assertId(unitId, "unt")}.json`),
|
|
4463
5310
|
materials: () => join(root, "materials"),
|
|
4464
5311
|
materialDir: (id) => join(root, "materials", assertId(id, "mat")),
|
|
@@ -5271,6 +6118,18 @@ function answerOf(value) {
|
|
|
5271
6118
|
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
5272
6119
|
return Array.isArray(value) ? value.filter((id) => typeof id === "string" && /^[a-z]$/u.test(id)).slice(0, 12) : "";
|
|
5273
6120
|
}
|
|
6121
|
+
function answeredItem(payload, courseId) {
|
|
6122
|
+
if (!payload?.source || payload.source.kind === "deck") return null;
|
|
6123
|
+
try {
|
|
6124
|
+
const source = itemSource(payload);
|
|
6125
|
+
return source.courseId === courseId ? {
|
|
6126
|
+
source,
|
|
6127
|
+
response: answerOf(payload.response)
|
|
6128
|
+
} : null;
|
|
6129
|
+
} catch {
|
|
6130
|
+
return null;
|
|
6131
|
+
}
|
|
6132
|
+
}
|
|
5274
6133
|
function shownGrade(value) {
|
|
5275
6134
|
const grade = value && typeof value === "object" ? value : {};
|
|
5276
6135
|
const words = (key, max) => typeof grade[key] === "string" ? grade[key].slice(0, max) : "";
|
|
@@ -5337,6 +6196,11 @@ function createRoutes({ courses, lessons, learner, studio, projects, jobs, setti
|
|
|
5337
6196
|
courseId: need(payload, "courseId", "crs"),
|
|
5338
6197
|
instruction: payload.instruction
|
|
5339
6198
|
}) }),
|
|
6199
|
+
"course/add-materials": (payload) => ({ job: courses.addMaterials({
|
|
6200
|
+
courseId: need(payload, "courseId", "crs"),
|
|
6201
|
+
materialIds: idList(payload.materialIds, "mat", 12),
|
|
6202
|
+
instruction: payload.instruction
|
|
6203
|
+
}) }),
|
|
5340
6204
|
"course/save-blueprint": (payload) => courses.saveBlueprint({
|
|
5341
6205
|
courseId: need(payload, "courseId", "crs"),
|
|
5342
6206
|
blueprint: payload.blueprint
|
|
@@ -5402,6 +6266,7 @@ function createRoutes({ courses, lessons, learner, studio, projects, jobs, setti
|
|
|
5402
6266
|
"lesson/record": (payload) => lessons.record({
|
|
5403
6267
|
courseId: need(payload, "courseId", "crs"),
|
|
5404
6268
|
lessonId: optional(payload, "lessonId", "les"),
|
|
6269
|
+
item: answeredItem(payload, need(payload, "courseId", "crs")),
|
|
5405
6270
|
conceptIds: (Array.isArray(payload.conceptIds) ? payload.conceptIds : []).filter((id) => /^cpt_[a-z0-9]{6,40}$/u.test(String(id))).slice(0, 8),
|
|
5406
6271
|
evidence: {
|
|
5407
6272
|
kind: payload.evidence?.kind,
|
|
@@ -5444,6 +6309,11 @@ function createRoutes({ courses, lessons, learner, studio, projects, jobs, setti
|
|
|
5444
6309
|
lessonId: need(payload, "lessonId", "les"),
|
|
5445
6310
|
selfRating: Number(payload.selfRating) || void 0
|
|
5446
6311
|
}),
|
|
6312
|
+
"lesson/handout": (payload) => lessons.handout({
|
|
6313
|
+
courseId: need(payload, "courseId", "crs"),
|
|
6314
|
+
lessonId: need(payload, "lessonId", "les"),
|
|
6315
|
+
force: payload.force === true
|
|
6316
|
+
}),
|
|
5447
6317
|
"lesson/practice": (payload) => lessons.practice({
|
|
5448
6318
|
courseId: need(payload, "courseId", "crs"),
|
|
5449
6319
|
lessonId: need(payload, "lessonId", "les")
|
|
@@ -5465,6 +6335,7 @@ function createRoutes({ courses, lessons, learner, studio, projects, jobs, setti
|
|
|
5465
6335
|
}),
|
|
5466
6336
|
"profile/get": () => learner.getProfile(),
|
|
5467
6337
|
"profile/save": (payload) => learner.saveProfile({
|
|
6338
|
+
name: payload.name,
|
|
5468
6339
|
background: payload.background,
|
|
5469
6340
|
goals: payload.goals,
|
|
5470
6341
|
strengths: payload.strengths,
|
|
@@ -5502,6 +6373,7 @@ function createRoutes({ courses, lessons, learner, studio, projects, jobs, setti
|
|
|
5502
6373
|
}) }),
|
|
5503
6374
|
"material/url": (payload) => ({ job: studio.addUrl({ url: payload.url }) }),
|
|
5504
6375
|
"material/list": () => studio.listMaterials(),
|
|
6376
|
+
"material/search": (payload) => studio.searchMaterials({ query: typeof payload.query === "string" ? payload.query : "" }),
|
|
5505
6377
|
"material/get": (payload) => studio.getMaterial({ id: need(payload, "id", "mat") }),
|
|
5506
6378
|
"material/rename": (payload) => studio.renameMaterial({
|
|
5507
6379
|
id: need(payload, "id", "mat"),
|
|
@@ -6282,6 +7154,73 @@ function createSettingsService({ prefs, keys, llmPort }) {
|
|
|
6282
7154
|
};
|
|
6283
7155
|
}
|
|
6284
7156
|
//#endregion
|
|
7157
|
+
//#region src/host/recognize.js
|
|
7158
|
+
const AT_ONCE = 3;
|
|
7159
|
+
const STOPS = /* @__PURE__ */ new Set([
|
|
7160
|
+
"llm-auth",
|
|
7161
|
+
"llm-quota",
|
|
7162
|
+
"llm-missing",
|
|
7163
|
+
"llm-unavailable",
|
|
7164
|
+
"llm-no-vision"
|
|
7165
|
+
]);
|
|
7166
|
+
const MAX_TOKENS = 6e3;
|
|
7167
|
+
async function visionPurpose(llm, signal) {
|
|
7168
|
+
await llm.refreshCapabilities?.(signal);
|
|
7169
|
+
return ["quick", "teach"].find((purpose) => llm.supportsImages?.(purpose)) ?? null;
|
|
7170
|
+
}
|
|
7171
|
+
function cleanTranscript(value) {
|
|
7172
|
+
return String(value ?? "").trim().replace(/^```[\w-]*\n([\s\S]*?)\n```$/u, "$1").replaceAll(RECOGNIZE_EMPTY, "").trim();
|
|
7173
|
+
}
|
|
7174
|
+
function createRecognizer({ llm, signal, onProgress, atOnce = AT_ONCE }) {
|
|
7175
|
+
let route = null;
|
|
7176
|
+
async function read(purpose, image) {
|
|
7177
|
+
const prompt = recognizePrompt();
|
|
7178
|
+
return cleanTranscript(await llm.complete({
|
|
7179
|
+
purpose,
|
|
7180
|
+
effort: "lowest",
|
|
7181
|
+
system: prompt.system,
|
|
7182
|
+
user: prompt.user,
|
|
7183
|
+
images: [{
|
|
7184
|
+
mime: image.mime,
|
|
7185
|
+
base64: Buffer.from(image.data).toString("base64")
|
|
7186
|
+
}],
|
|
7187
|
+
requireImages: true,
|
|
7188
|
+
maxTokens: MAX_TOKENS,
|
|
7189
|
+
signal
|
|
7190
|
+
}));
|
|
7191
|
+
}
|
|
7192
|
+
return async (sources) => {
|
|
7193
|
+
route ??= visionPurpose(llm, signal);
|
|
7194
|
+
const purpose = await route;
|
|
7195
|
+
if (!purpose) return null;
|
|
7196
|
+
const results = new Array(sources.length).fill(null);
|
|
7197
|
+
let next = 0;
|
|
7198
|
+
let done = 0;
|
|
7199
|
+
let stopped = null;
|
|
7200
|
+
onProgress?.(0, sources.length);
|
|
7201
|
+
const worker = async () => {
|
|
7202
|
+
while (next < sources.length) {
|
|
7203
|
+
const index = next;
|
|
7204
|
+
next += 1;
|
|
7205
|
+
signal?.throwIfAborted();
|
|
7206
|
+
try {
|
|
7207
|
+
if (stopped) throw stopped;
|
|
7208
|
+
const image = await sources[index].image();
|
|
7209
|
+
results[index] = { text: image ? await read(purpose, image) : "" };
|
|
7210
|
+
} catch (error) {
|
|
7211
|
+
if (signal?.aborted) throw signal.reason ?? error;
|
|
7212
|
+
if (STOPS.has(error?.code)) stopped ??= error;
|
|
7213
|
+
results[index] = { error };
|
|
7214
|
+
}
|
|
7215
|
+
done += 1;
|
|
7216
|
+
onProgress?.(done, sources.length);
|
|
7217
|
+
}
|
|
7218
|
+
};
|
|
7219
|
+
await Promise.all(Array.from({ length: Math.max(1, Math.min(atOnce, sources.length)) }, worker));
|
|
7220
|
+
return results;
|
|
7221
|
+
};
|
|
7222
|
+
}
|
|
7223
|
+
//#endregion
|
|
6285
7224
|
//#region src/host/studio.js
|
|
6286
7225
|
const ASSIST_MODES = [
|
|
6287
7226
|
"explain",
|
|
@@ -6304,6 +7243,7 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
|
|
|
6304
7243
|
truncated: result.truncated === true,
|
|
6305
7244
|
mime: result.mime ?? null,
|
|
6306
7245
|
url: result.url ?? null,
|
|
7246
|
+
ocr: result.ocr ?? null,
|
|
6307
7247
|
createdAt: stamp(),
|
|
6308
7248
|
brief: null,
|
|
6309
7249
|
...extra
|
|
@@ -6334,6 +7274,11 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
|
|
|
6334
7274
|
await store.writeJson(store.paths.material(id), meta);
|
|
6335
7275
|
return meta;
|
|
6336
7276
|
}
|
|
7277
|
+
const recognizer = (api) => createRecognizer({
|
|
7278
|
+
llm,
|
|
7279
|
+
signal: api.signal,
|
|
7280
|
+
onProgress: (done, total) => api.progress(done, total, "recognizing")
|
|
7281
|
+
});
|
|
6337
7282
|
function upload({ name, base64, bytes: raw }) {
|
|
6338
7283
|
const fileName = text(name, 200) || "file";
|
|
6339
7284
|
if (!raw && (typeof base64 !== "string" || !base64)) throw new TutorError("invalid", "No file data");
|
|
@@ -6344,7 +7289,8 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
|
|
|
6344
7289
|
const result = await ingestFile({
|
|
6345
7290
|
name: fileName,
|
|
6346
7291
|
bytes,
|
|
6347
|
-
signal: api.signal
|
|
7292
|
+
signal: api.signal,
|
|
7293
|
+
recognize: recognizer(api)
|
|
6348
7294
|
});
|
|
6349
7295
|
api.progress(1, 2, "summarizing");
|
|
6350
7296
|
return { material: await saveMaterial(result, {
|
|
@@ -6357,7 +7303,10 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
|
|
|
6357
7303
|
function addUrl({ url }) {
|
|
6358
7304
|
return jobs.start("material/url", async (api) => {
|
|
6359
7305
|
api.progress(0, 2, "fetching");
|
|
6360
|
-
const result = await ingestUrl(url, {
|
|
7306
|
+
const result = await ingestUrl(url, {
|
|
7307
|
+
signal: api.signal,
|
|
7308
|
+
recognize: recognizer(api)
|
|
7309
|
+
});
|
|
6361
7310
|
api.progress(1, 2, "summarizing");
|
|
6362
7311
|
return { material: await saveMaterial(result, { signal: api.signal }) };
|
|
6363
7312
|
});
|
|
@@ -6366,6 +7315,37 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
|
|
|
6366
7315
|
const ids = await store.listIds(store.paths.materials(), "mat");
|
|
6367
7316
|
return (await Promise.all(ids.map((id) => store.readJson(store.paths.material(id), null)))).filter(Boolean).sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
|
|
6368
7317
|
}
|
|
7318
|
+
let libraryIndex = null;
|
|
7319
|
+
const SEARCH_CHUNKS = 3e4;
|
|
7320
|
+
async function searchMaterials({ query }) {
|
|
7321
|
+
const wanted = text(query, 200);
|
|
7322
|
+
if (!wanted) return { results: [] };
|
|
7323
|
+
const metas = (await listMaterials()).filter((meta) => meta.kind !== "image" || meta.chars > 0);
|
|
7324
|
+
const key = metas.map((meta) => `${meta.id}:${meta.title}:${meta.chars ?? meta.pages ?? ""}`).join("|");
|
|
7325
|
+
if (libraryIndex?.key !== key) {
|
|
7326
|
+
const chunks = [];
|
|
7327
|
+
for (const meta of metas) {
|
|
7328
|
+
if (chunks.length >= SEARCH_CHUNKS) break;
|
|
7329
|
+
const own = await store.readJson(store.paths.materialChunks(meta.id), []) ?? [];
|
|
7330
|
+
for (const chunk of own.slice(0, SEARCH_CHUNKS - chunks.length)) chunks.push({
|
|
7331
|
+
...chunk,
|
|
7332
|
+
materialId: meta.id,
|
|
7333
|
+
materialTitle: meta.title
|
|
7334
|
+
});
|
|
7335
|
+
}
|
|
7336
|
+
libraryIndex = {
|
|
7337
|
+
key,
|
|
7338
|
+
index: buildIndex(chunks)
|
|
7339
|
+
};
|
|
7340
|
+
}
|
|
7341
|
+
return { results: search(libraryIndex.index, wanted, { limit: 12 }).map(({ chunk }) => ({
|
|
7342
|
+
materialId: chunk.materialId,
|
|
7343
|
+
title: chunk.materialTitle,
|
|
7344
|
+
page: chunk.page ?? null,
|
|
7345
|
+
slide: chunk.slide ?? null,
|
|
7346
|
+
snippet: snippetOf(chunk.text, wanted)
|
|
7347
|
+
})) };
|
|
7348
|
+
}
|
|
6369
7349
|
async function getMaterial({ id }) {
|
|
6370
7350
|
const meta = await store.readJson(store.paths.material(assertId(id, "mat")), null);
|
|
6371
7351
|
if (!meta) throw new TutorError("not-found", "Material not found");
|
|
@@ -6806,6 +7786,7 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
|
|
|
6806
7786
|
upload,
|
|
6807
7787
|
addUrl,
|
|
6808
7788
|
listMaterials,
|
|
7789
|
+
searchMaterials,
|
|
6809
7790
|
getMaterial,
|
|
6810
7791
|
renameMaterial,
|
|
6811
7792
|
removeMaterial,
|