@happyvertical/smrt-education 0.57.33 → 0.57.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +8 -1
- package/README.md +117 -13
- package/agents/catalog-sync.md +46 -0
- package/agents/photo-intake.md +249 -0
- package/agents/workbook-service.md +355 -0
- package/agents/workbook-test-matrix.md +74 -0
- package/dist/catalog-acquisition-service.d.ts.map +1 -1
- package/dist/catalog-capability.d.ts +21 -0
- package/dist/catalog-capability.d.ts.map +1 -0
- package/dist/catalog-contracts.d.ts +40 -0
- package/dist/catalog-contracts.d.ts.map +1 -1
- package/dist/catalog-database-sync-store.d.ts.map +1 -1
- package/dist/catalog-models.d.ts +5 -1
- package/dist/catalog-models.d.ts.map +1 -1
- package/dist/catalog-read-service.d.ts +2 -1
- package/dist/catalog-read-service.d.ts.map +1 -1
- package/dist/catalog-service.d.ts +5 -0
- package/dist/catalog-service.d.ts.map +1 -1
- package/dist/chunks/workbook-photo-service-1wcnf-Xf.js +27359 -0
- package/dist/chunks/workbook-photo-service-1wcnf-Xf.js.map +1 -0
- package/dist/index.d.ts +11 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +339 -24864
- package/dist/index.js.map +1 -1
- package/dist/ingestion.d.ts +37 -0
- package/dist/ingestion.d.ts.map +1 -1
- package/dist/ingestion.js +829 -20
- package/dist/ingestion.js.map +1 -1
- package/dist/learner-service.d.ts +15 -0
- package/dist/learner-service.d.ts.map +1 -1
- package/dist/manifest.json +4735 -6
- package/dist/smrt-knowledge.json +1760 -26
- package/dist/sources/public-illustrative-mathematics-adapter.d.ts.map +1 -1
- package/dist/sources/public-illustrative-mathematics-parser.d.ts.map +1 -1
- package/dist/submission-service.d.ts +38 -11
- package/dist/submission-service.d.ts.map +1 -1
- package/dist/workbook-answer-regions.d.ts +11 -0
- package/dist/workbook-answer-regions.d.ts.map +1 -0
- package/dist/workbook-contracts.d.ts +224 -0
- package/dist/workbook-contracts.d.ts.map +1 -0
- package/dist/workbook-layout.d.ts +13 -0
- package/dist/workbook-layout.d.ts.map +1 -0
- package/dist/workbook-models.d.ts +129 -0
- package/dist/workbook-models.d.ts.map +1 -0
- package/dist/workbook-photo-contracts.d.ts +56 -0
- package/dist/workbook-photo-contracts.d.ts.map +1 -0
- package/dist/workbook-photo-ingestion.d.ts +70 -0
- package/dist/workbook-photo-ingestion.d.ts.map +1 -0
- package/dist/workbook-photo-models.d.ts +63 -0
- package/dist/workbook-photo-models.d.ts.map +1 -0
- package/dist/workbook-photo-service.d.ts +72 -0
- package/dist/workbook-photo-service.d.ts.map +1 -0
- package/dist/workbook-service.d.ts +158 -0
- package/dist/workbook-service.d.ts.map +1 -0
- package/package.json +8 -8
package/dist/ingestion.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { F as WorkbookAssessmentValidationError, I as aggregateWorkbookQuestions, L as validateWorkbookAssessment, P as WORKBOOK_MATH_VOCABULARY, R as workbookAggregateFeedback, f as validateWorkbookPhotoIdentification, m as normalizeWorkbookAnswerRegions, n as preservePhotoProposal, p as normalizeWorkbookObservedLayout, r as WorkbookService } from "./chunks/workbook-photo-service-1wcnf-Xf.js";
|
|
1
2
|
import { getCurrentTenant, withTenant } from "@happyvertical/smrt-tenancy";
|
|
2
3
|
import { registerPermissionDefinitions } from "@happyvertical/smrt-users";
|
|
3
|
-
import { extractAnalysis } from "@happyvertical/smrt-ingestion/server";
|
|
4
|
+
import { GenerationSnapshotStaleError, ProposalsNotConfiguredError, extractAnalysis, normalizeObservedImageRegions } from "@happyvertical/smrt-ingestion/server";
|
|
4
5
|
import { readFile } from "node:fs/promises";
|
|
5
6
|
import { resolve } from "node:path";
|
|
6
7
|
import { createHash } from "node:crypto";
|
|
@@ -30,6 +31,312 @@ async function requireIngestionMemoryLimit(maximumBytes) {
|
|
|
30
31
|
return { memoryLimitBytes: limit };
|
|
31
32
|
}
|
|
32
33
|
//#endregion
|
|
34
|
+
//#region src/workbook-photo-ingestion.ts
|
|
35
|
+
var MODEL = "@happyvertical/smrt-education:WorkbookPhotoIntake";
|
|
36
|
+
var WORKBOOK_PHOTO_HANDLER = "@happyvertical/smrt-education:identify-workbook-photo";
|
|
37
|
+
registerPermissionDefinitions([{
|
|
38
|
+
slug: "workbookphotointakes.read",
|
|
39
|
+
collection: "workbookphotointakes",
|
|
40
|
+
qualifiedName: MODEL,
|
|
41
|
+
name: "Identify authorized workbook photos"
|
|
42
|
+
}]);
|
|
43
|
+
function assertScope(scope, request) {
|
|
44
|
+
if (scope.tenantId !== request.tenantId || scope.actorId !== request.actorUserId || scope.confidentialScopeId !== request.learnerId) throw new Error("Photo intake scope mismatch");
|
|
45
|
+
}
|
|
46
|
+
var canonical = (value) => JSON.stringify(value, (_key, v) => v && typeof v === "object" && !Array.isArray(v) ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a.localeCompare(b))) : v);
|
|
47
|
+
var string = {
|
|
48
|
+
type: "string",
|
|
49
|
+
maxLength: 2e3,
|
|
50
|
+
pattern: "^[^\\u0000-\\u001F\\u007F]*$"
|
|
51
|
+
};
|
|
52
|
+
var schema = {
|
|
53
|
+
type: "object",
|
|
54
|
+
additionalProperties: false,
|
|
55
|
+
required: [
|
|
56
|
+
"identification",
|
|
57
|
+
"confidence",
|
|
58
|
+
"identityEvidence",
|
|
59
|
+
"lessonEvidence"
|
|
60
|
+
],
|
|
61
|
+
properties: {
|
|
62
|
+
workbookId: {
|
|
63
|
+
type: "string",
|
|
64
|
+
maxLength: 64,
|
|
65
|
+
description: "Exact existing candidate id. Also copy its workbookKey, title and edition into the required identification.workbook object; this selects existing identity, not a new workbook."
|
|
66
|
+
},
|
|
67
|
+
confidence: {
|
|
68
|
+
type: "string",
|
|
69
|
+
enum: ["confident", "uncertain"]
|
|
70
|
+
},
|
|
71
|
+
identityEvidence: {
|
|
72
|
+
type: "array",
|
|
73
|
+
maxItems: 20,
|
|
74
|
+
items: {
|
|
75
|
+
...string,
|
|
76
|
+
minLength: 1
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
lessonEvidence: string,
|
|
80
|
+
identification: {
|
|
81
|
+
type: "object",
|
|
82
|
+
additionalProperties: false,
|
|
83
|
+
required: ["kind", "workState"],
|
|
84
|
+
properties: {
|
|
85
|
+
kind: {
|
|
86
|
+
type: "string",
|
|
87
|
+
enum: [
|
|
88
|
+
"lesson",
|
|
89
|
+
"work",
|
|
90
|
+
"cover",
|
|
91
|
+
"reference",
|
|
92
|
+
"uncertain"
|
|
93
|
+
]
|
|
94
|
+
},
|
|
95
|
+
workState: {
|
|
96
|
+
type: "string",
|
|
97
|
+
enum: [
|
|
98
|
+
"blank",
|
|
99
|
+
"completed",
|
|
100
|
+
"unknown"
|
|
101
|
+
]
|
|
102
|
+
},
|
|
103
|
+
notes: string,
|
|
104
|
+
workbook: {
|
|
105
|
+
type: "object",
|
|
106
|
+
additionalProperties: false,
|
|
107
|
+
required: ["workbookKey", "title"],
|
|
108
|
+
properties: {
|
|
109
|
+
workbookKey: string,
|
|
110
|
+
title: string,
|
|
111
|
+
edition: string,
|
|
112
|
+
publisher: string,
|
|
113
|
+
isbn: string
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
activity: {
|
|
117
|
+
type: "object",
|
|
118
|
+
additionalProperties: false,
|
|
119
|
+
required: ["pageKey", "pageLabel"],
|
|
120
|
+
properties: {
|
|
121
|
+
pageKey: string,
|
|
122
|
+
pageLabel: string
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
function workbookPhotoIntakeHandlers(input) {
|
|
130
|
+
const bound = async (context, work) => {
|
|
131
|
+
assertScope(context.scope, input.request);
|
|
132
|
+
if (!context.policy.requireReview) throw new Error("Photo intake requires review policy");
|
|
133
|
+
if (!context.principal) return input.workbooks.withExecutor(context.db, work);
|
|
134
|
+
const tenant = getCurrentTenant();
|
|
135
|
+
if (!tenant || tenant.tenantId !== input.request.tenantId || tenant.userId !== context.principal.context.userId || context.principal.context.tenantId !== input.request.tenantId) throw new Error("Photo principal tenant mismatch");
|
|
136
|
+
return withTenant({
|
|
137
|
+
...tenant,
|
|
138
|
+
tenantId: input.request.tenantId,
|
|
139
|
+
userId: input.request.actorUserId,
|
|
140
|
+
superAdminBypass: false
|
|
141
|
+
}, () => input.workbooks.withExecutor(context.db, work));
|
|
142
|
+
};
|
|
143
|
+
const authorize = async (context) => bound(context, async (service) => {
|
|
144
|
+
await service.authorizePhotoIntake(input.request);
|
|
145
|
+
await service.getWorkbookPhotoIntakeByItem({
|
|
146
|
+
...input.request,
|
|
147
|
+
itemId: context.itemId
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
return [{
|
|
151
|
+
id: WORKBOOK_PHOTO_HANDLER,
|
|
152
|
+
version: "1",
|
|
153
|
+
description: "Classify photos as work, lesson, cover, reference (contents/answer keys), or uncertain. Visible completed learner responses use work even without book/lesson identity: workState=completed; omit workbook/activity/workbookId; lessonEvidence=\"\". Confidence concerns visible material/completion, not missing identity. Printed examples/answer keys alone are not learner completion. Partial/unreadable answers can be unassessed later. Blank/unknown completion never auto-marks. For lesson/cover/reference identity: existing book selects exact workbookId AND copies candidate workbookKey/title/edition into identification.workbook. Unique current title+edition can match retained publisher context; publisher/ISBN need not reappear. identityEvidence quotes ONLY current photos, never candidate context. Conflicts, duplicate candidates or title/page-only matches cannot establish identity. New book needs visible ISBN or publisher/title/edition; workbookKey is a hint, never merge authority. Lesson activity requires printed lesson identifier: pageKey=lesson:<identifier>, pageLabel printed label, lessonEvidence quotes it; page number alone is insufficient. Multiple pages can share a lesson. Cover/reference omit activity. Missing book/lesson alone uses work, not uncertain. Ambiguous material/completion uses uncertain confidence. args: identification, confidence, identityEvidence, lessonEvidence, optional workbookId. identification: kind, workState, optional workbook, activity, notes. workbook: workbookKey,title, optional edition,publisher,isbn. activity: pageKey,pageLabel. Single-line visible quotes only; no controls/placeholders. Omit absent optional fields, never null or extra keys. Classify only; no marks or learning observations.",
|
|
154
|
+
operation: {
|
|
155
|
+
model: MODEL,
|
|
156
|
+
action: "get",
|
|
157
|
+
version: "1"
|
|
158
|
+
},
|
|
159
|
+
capability: {
|
|
160
|
+
effect: "read",
|
|
161
|
+
idempotent: true,
|
|
162
|
+
openWorld: false
|
|
163
|
+
},
|
|
164
|
+
argsSchema: schema,
|
|
165
|
+
resultSchema: schema,
|
|
166
|
+
resultModels: {},
|
|
167
|
+
discovery: {
|
|
168
|
+
mediaTypes: [
|
|
169
|
+
"image/jpeg",
|
|
170
|
+
"image/png",
|
|
171
|
+
"image/webp",
|
|
172
|
+
"application/pdf"
|
|
173
|
+
],
|
|
174
|
+
references: { workbookId: {
|
|
175
|
+
kind: "candidate",
|
|
176
|
+
model: "@happyvertical/smrt-education:Workbook"
|
|
177
|
+
} },
|
|
178
|
+
candidates: async (query, context, page) => {
|
|
179
|
+
await authorize(context);
|
|
180
|
+
const books = await bound(context, (service) => service.listWorkbookPhotoWorkbooks(input.request));
|
|
181
|
+
const needle = query.toLowerCase();
|
|
182
|
+
const ordered = [...books].sort((a, b) => Number(b.title.toLowerCase().includes(needle)) - Number(a.title.toLowerCase().includes(needle)) || a.id.localeCompare(b.id));
|
|
183
|
+
return {
|
|
184
|
+
items: ordered.slice(0, page.limit).map((book) => ({
|
|
185
|
+
model: "@happyvertical/smrt-education:Workbook",
|
|
186
|
+
id: book.id,
|
|
187
|
+
revision: workbookRevision(book),
|
|
188
|
+
label: JSON.stringify(book)
|
|
189
|
+
})),
|
|
190
|
+
hasMore: ordered.length > page.limit
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
validate: async (args, context) => {
|
|
195
|
+
await authorize(context);
|
|
196
|
+
validateWorkbookPhotoIdentification(args.identification);
|
|
197
|
+
return { ok: true };
|
|
198
|
+
},
|
|
199
|
+
preview: async (args, context) => {
|
|
200
|
+
await authorize(context);
|
|
201
|
+
return {
|
|
202
|
+
display: {
|
|
203
|
+
classification: args.identification,
|
|
204
|
+
gradeApplied: false
|
|
205
|
+
},
|
|
206
|
+
normalizedArgs: args,
|
|
207
|
+
targetPreconditions: []
|
|
208
|
+
};
|
|
209
|
+
},
|
|
210
|
+
execution: {
|
|
211
|
+
kind: "database",
|
|
212
|
+
apply: async (args, context) => {
|
|
213
|
+
await authorize(context);
|
|
214
|
+
return args;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}];
|
|
218
|
+
}
|
|
219
|
+
function workbookRevision(book) {
|
|
220
|
+
return `workbook:${createHash("sha256").update(canonical(book)).digest("hex")}`;
|
|
221
|
+
}
|
|
222
|
+
function workbookPhotoIntakeTargetResolver(input) {
|
|
223
|
+
return async ({ db, scope, model, id, revision }) => {
|
|
224
|
+
assertScope(scope, input.request);
|
|
225
|
+
if (model !== "@happyvertical/smrt-education:Workbook") throw new Error("Photo target unavailable");
|
|
226
|
+
const book = (await withTenant({
|
|
227
|
+
...getCurrentTenant(),
|
|
228
|
+
tenantId: input.request.tenantId,
|
|
229
|
+
userId: input.request.actorUserId,
|
|
230
|
+
superAdminBypass: false
|
|
231
|
+
}, () => input.workbooks.withExecutor(db, (service) => service.listWorkbookPhotoWorkbooks(input.request)))).find((book2) => book2.id === id);
|
|
232
|
+
if (!book) throw new Error("Photo target unavailable");
|
|
233
|
+
const current = workbookRevision(book);
|
|
234
|
+
if (revision !== void 0 && revision !== current) throw new Error("Photo target changed");
|
|
235
|
+
return { revision: current };
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
async function receiveWorkbookPhotoIntake(input) {
|
|
239
|
+
assertScope(input.scope, input.request);
|
|
240
|
+
await input.workbooks.authorizePhotoIntake(input.request);
|
|
241
|
+
const receipt = await input.ingestion.receive(input.receive);
|
|
242
|
+
if (!("itemId" in receipt) || !receipt.itemId) return {
|
|
243
|
+
receipt,
|
|
244
|
+
intake: null
|
|
245
|
+
};
|
|
246
|
+
return {
|
|
247
|
+
receipt,
|
|
248
|
+
intake: await retainWorkbookPhotoReceipt({
|
|
249
|
+
...input,
|
|
250
|
+
itemId: receipt.itemId
|
|
251
|
+
})
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
async function retainWorkbookPhotoReceipt(input) {
|
|
255
|
+
assertScope(input.scope, input.request);
|
|
256
|
+
await input.workbooks.authorizePhotoIntake(input.request);
|
|
257
|
+
const extensions = {
|
|
258
|
+
"image/png": "png",
|
|
259
|
+
"image/jpeg": "jpg",
|
|
260
|
+
"image/webp": "webp",
|
|
261
|
+
"application/pdf": "pdf"
|
|
262
|
+
};
|
|
263
|
+
const originals = (await input.ingestion.getEvidence(input.itemId)).filter((e) => e.parentEvidenceId === null).sort((a, b) => a.partId.localeCompare(b.partId));
|
|
264
|
+
if (!originals.length || originals.length > 100 || originals.some((e) => !extensions[e.mediaType] || e.byteLength < 1 || e.byteLength > 25 * 1024 * 1024) || originals.reduce((n, e) => n + e.byteLength, 0) > 100 * 1024 * 1024) throw new Error("Unsupported or oversized photo intake");
|
|
265
|
+
const pages = [];
|
|
266
|
+
for (const original of originals) {
|
|
267
|
+
const data = await input.ingestion.readEvidence(input.itemId, original.id);
|
|
268
|
+
if (data.length !== original.byteLength || createHash("sha256").update(data).digest("hex") !== original.contentHash) throw new Error("Photo receipt integrity mismatch");
|
|
269
|
+
pages.push({
|
|
270
|
+
name: `${original.id}.${extensions[original.mediaType]}`,
|
|
271
|
+
mimeType: original.mediaType,
|
|
272
|
+
data: Buffer.from(data)
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
return input.workbooks.retainWorkbookPhotoIntake({
|
|
276
|
+
...input.request,
|
|
277
|
+
itemId: input.itemId,
|
|
278
|
+
pages
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
async function preserveWorkbookPhotoIdentification(input) {
|
|
282
|
+
assertScope(input.scope, input.request);
|
|
283
|
+
const view = await input.workbooks.getWorkbookPhotoIntake(input.request);
|
|
284
|
+
let snapshot;
|
|
285
|
+
try {
|
|
286
|
+
snapshot = await input.ingestion.getCompletedAnalysis(view.itemId);
|
|
287
|
+
} catch (error) {
|
|
288
|
+
if (error instanceof ProposalsNotConfiguredError || error instanceof GenerationSnapshotStaleError || error instanceof Error && ["Analysis unavailable", "Intake unavailable"].includes(error.message)) return view;
|
|
289
|
+
throw error;
|
|
290
|
+
}
|
|
291
|
+
if (snapshot.itemId !== view.itemId || snapshot.configuration.stage !== "interpret" || !["completed", "partial"].includes(snapshot.result.status)) return view;
|
|
292
|
+
const generation = snapshot.result.output.proposals;
|
|
293
|
+
if (generation?.version !== 1 || !["proposals", "needs_review"].includes(generation.outcome)) return view;
|
|
294
|
+
const suggestions = generation.suggestions.filter((s) => s.handlerId === WORKBOOK_PHOTO_HANDLER);
|
|
295
|
+
const offered = generation.offered.find((o) => o.handler.id === WORKBOOK_PHOTO_HANDLER);
|
|
296
|
+
if (suggestions.length !== 1 || suggestions[0].missingFields.length || !offered || canonical(offered.handler.argsSchema) !== canonical(schema)) return view;
|
|
297
|
+
const proposal = suggestions[0].args;
|
|
298
|
+
try {
|
|
299
|
+
validateWorkbookPhotoIdentification(proposal.identification);
|
|
300
|
+
if (!["confident", "uncertain"].includes(proposal.confidence) || !Array.isArray(proposal.identityEvidence) || proposal.identityEvidence.length > 20 || proposal.identityEvidence.some((e) => typeof e !== "string" || e.length > 2e3) || typeof proposal.lessonEvidence !== "string" || proposal.lessonEvidence.length > 200) return view;
|
|
301
|
+
} catch {
|
|
302
|
+
return view;
|
|
303
|
+
}
|
|
304
|
+
return input.workbooks[preservePhotoProposal]({
|
|
305
|
+
...input.request,
|
|
306
|
+
requestKey: `analysis:${snapshot.attemptId}`,
|
|
307
|
+
proposal,
|
|
308
|
+
allowAutomaticResolution: generation.outcome === "proposals" && generation.suggestions.length === 1 && suggestions[0].alternatives.length === 0 && generation.splits.length === 0,
|
|
309
|
+
sourceHashes: snapshot.evidence.filter((e) => e.parentEvidenceId === null).sort((a, b) => a.partId.localeCompare(b.partId)).map((e) => e.contentHash),
|
|
310
|
+
provenance: {
|
|
311
|
+
attemptId: snapshot.attemptId,
|
|
312
|
+
revision: snapshot.revision,
|
|
313
|
+
inputDigest: snapshot.inputDigest,
|
|
314
|
+
outputDigest: snapshot.outputDigest,
|
|
315
|
+
evidenceDigest: snapshot.evidenceDigest,
|
|
316
|
+
configuration: snapshot.configuration,
|
|
317
|
+
provider: snapshot.result.provider,
|
|
318
|
+
model: snapshot.result.model
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
async function processWorkbookPhotoAnalysis(input) {
|
|
323
|
+
assertScope(input.scope, input.request);
|
|
324
|
+
if ((await input.workbooks.getWorkbookPhotoIntake(input.request)).itemId !== input.itemId) throw new Error("Photo worker receipt mismatch");
|
|
325
|
+
const processing = await processEducationAnalysis(input);
|
|
326
|
+
const intake = await preserveWorkbookPhotoIdentification(input);
|
|
327
|
+
const capture = intake.status === "resolved" && ["lesson", "work"].includes(intake.identification?.kind ?? "") && intake.identification?.workState === "completed" ? await input.workbooks.captureWorkbookPhotoIntake({
|
|
328
|
+
...input.request,
|
|
329
|
+
requestKey: `photo:${intake.itemId}`
|
|
330
|
+
}) : null;
|
|
331
|
+
const current = capture ? await input.workbooks.getWorkbookPhotoIntake(input.request) : intake;
|
|
332
|
+
return {
|
|
333
|
+
processing,
|
|
334
|
+
intake: current,
|
|
335
|
+
capture,
|
|
336
|
+
assessmentRequired: current.status === "captured"
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
//#endregion
|
|
33
340
|
//#region src/ingestion.ts
|
|
34
341
|
async function queueEducationExtraction(ingestion, itemId, configuration, submissionId) {
|
|
35
342
|
return ingestion.analyze(itemId, { extraction: configuration }, `education:extract:${configuration.configurationRevision}:${submissionId ?? "identify"}`);
|
|
@@ -50,7 +357,10 @@ async function processEducationAnalysis(input) {
|
|
|
50
357
|
const generation = await ingestion.prepareGeneration(itemId, result.attemptId);
|
|
51
358
|
return {
|
|
52
359
|
kind: "generation_queued",
|
|
53
|
-
revision: (await ingestion.analyze(itemId,
|
|
360
|
+
revision: (await ingestion.analyze(itemId, {
|
|
361
|
+
...generation,
|
|
362
|
+
educationEvidenceGrounding: educationEvidenceGrounding(result)
|
|
363
|
+
}, `education:interpret:${result.attemptId}`)).revision
|
|
54
364
|
};
|
|
55
365
|
}
|
|
56
366
|
async function captureEducationIntake(input) {
|
|
@@ -86,6 +396,16 @@ async function captureEducationIntake(input) {
|
|
|
86
396
|
previousSubmissionId: request.previousSubmissionId
|
|
87
397
|
}
|
|
88
398
|
};
|
|
399
|
+
if (request.workbookActivityId) {
|
|
400
|
+
if (!(submissions instanceof WorkbookService) || request.kind !== "completed_work") throw new Error("Workbook intake requires WorkbookService completed work");
|
|
401
|
+
return (await submissions.captureWorkbookWork({
|
|
402
|
+
...request,
|
|
403
|
+
activityId: request.workbookActivityId,
|
|
404
|
+
intakeItemId: request.itemId,
|
|
405
|
+
requestKey: capture.requestKey,
|
|
406
|
+
pages
|
|
407
|
+
})).submission;
|
|
408
|
+
}
|
|
89
409
|
if (request.kind === "blank_reference") return submissions.captureBlankReference({
|
|
90
410
|
...capture,
|
|
91
411
|
intakeItemId: request.itemId
|
|
@@ -228,10 +548,13 @@ function educationIntakeHandlers(input) {
|
|
|
228
548
|
await context.assertTarget(SUBMISSION, submissionId);
|
|
229
549
|
return submission;
|
|
230
550
|
};
|
|
551
|
+
const workbook = input.workbookActivityId;
|
|
552
|
+
if (workbook && !(submissions instanceof WorkbookService)) throw new Error("Workbook handler requires WorkbookService");
|
|
553
|
+
const vocabulary = input.workbookVocabulary ?? WORKBOOK_MATH_VOCABULARY;
|
|
231
554
|
handlers.push({
|
|
232
555
|
id: "@happyvertical/smrt-education:review-submission",
|
|
233
556
|
version: "1",
|
|
234
|
-
description: "Suggest marks and feedback for this captured attempt. Only a current authorized guardian decision may apply a grade.",
|
|
557
|
+
description: workbook ? "Assess visible questions from current captured evidence only. Historical IDs indicate correspondence, never current answers. Prompt is printed task text excluding response; answer is observed learner response only. Printed solved examples/answer keys are not learner answers or learning evidence. Never solve a task and call it the observed response. Matching answers require explicitly observed line endpoints; unreadable connections are unassessed/illegible or missing_context. Missing book/lesson identity never blocks visible work. Use printed number+occurrence/subpart IDs when present. Unnumbered tasks use u:<short printed-task slug>:<occurrence/subpart>, excluding learner answers and solved results. Occurrence identifies visible tasks, never output-array position. Retain exact known IDs on correction; absent/ambiguous correspondence is unassessed/not_present, never guessed or renumbered. Cite original evidence IDs only; no page/rect/coordinate fields, as service retains whole-source grounding. Correct/incorrect/partially_correct use reason none. Unsupported, unanswered, illegible, missing_context and not_present are unassessed, never incorrect, with null points. Judge objectively solvable arithmetic without a printed rubric. Correctness is independent of points: absent evidenced allocation, BOTH points and possiblePoints are null, including incorrect. Never invent weights/rubric or mix null/numeric fields. Only use incorrect points=0 when possiblePoints is known; correct earns that known total. No aggregate score/summary: service computes totals. Concepts are supplied-vocabulary suggestions; no match uses conceptIds [] and still assesses normally. markingCertainty=uncertain requires result=unassessed, reason illegible/missing_context/unsupported/unanswered/not_present, points=null and possiblePoints=null. Never assert correctness with uncertain marking. Mapping certainty is separate. Guardian review is required for authority." : "Suggest marks and feedback for this captured attempt. Only a current authorized guardian decision may apply a grade.",
|
|
235
558
|
operation: {
|
|
236
559
|
model: GRADE,
|
|
237
560
|
action: "create",
|
|
@@ -245,22 +568,54 @@ function educationIntakeHandlers(input) {
|
|
|
245
568
|
argsSchema: {
|
|
246
569
|
type: "object",
|
|
247
570
|
additionalProperties: false,
|
|
248
|
-
required: [
|
|
571
|
+
required: workbook ? ["submissionId", "questions"] : [
|
|
249
572
|
"submissionId",
|
|
250
573
|
"score",
|
|
251
574
|
"feedback"
|
|
252
575
|
],
|
|
253
576
|
properties: {
|
|
254
577
|
submissionId: idSchema,
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
578
|
+
...workbook ? {
|
|
579
|
+
answerRegions: { description: "Include advisory answer locations when a current observedRegions catalog clearly matches visible learner responses. Omit unknown or uncertain positions. Shape {version:1,regions:[{questionId,evidenceId,observationId}]}. Reference ONLY exact current evidence observedRegions catalog IDs with matching visible learner response/question; no coordinates or geometry in arguments. Never infer positions from layout, IDs, canonical prompts or expected answers. Omit uncertain/unknown/missing regions. Image-only sources; PDF unsupported. Invalid metadata is dropped independently of valid marking/layout." },
|
|
580
|
+
layout: {
|
|
581
|
+
description: "Optional observed presentation only; omit if unknown. Shape: version1, provenance observed; sections in observed order {id,label?,columns1..8,readingOrder? rows|columns,source:{evidenceId:original evidence ID,page:physical source page1based or null unknown}}; placements {questionId,sectionId,row0based,column0based,part?0..31}. Up to32sections/200placements/200rows. Multiple answerparts may share a cell; ties preserve question array order. Printed page numbers are not source page indices. Never infer from IDs or claim verified coordinates. Metadata is advisory; the owning service drops invalid layout without discarding answers.",
|
|
582
|
+
examples: [{
|
|
583
|
+
version: 1,
|
|
584
|
+
provenance: "observed",
|
|
585
|
+
sections: [{
|
|
586
|
+
id: "section-a",
|
|
587
|
+
label: "Visible heading",
|
|
588
|
+
columns: 2,
|
|
589
|
+
readingOrder: "rows",
|
|
590
|
+
source: {
|
|
591
|
+
evidenceId: "<original evidence id>",
|
|
592
|
+
page: null
|
|
593
|
+
}
|
|
594
|
+
}],
|
|
595
|
+
placements: [{
|
|
596
|
+
questionId: "<current question id>",
|
|
597
|
+
sectionId: "section-a",
|
|
598
|
+
row: 0,
|
|
599
|
+
column: 0
|
|
600
|
+
}]
|
|
601
|
+
}]
|
|
602
|
+
}
|
|
603
|
+
} : {},
|
|
604
|
+
...!workbook ? {
|
|
605
|
+
score: {
|
|
606
|
+
type: ["number", "null"],
|
|
607
|
+
minimum: 0,
|
|
608
|
+
maximum: 1
|
|
609
|
+
},
|
|
610
|
+
feedback: {
|
|
611
|
+
type: "string",
|
|
612
|
+
maxLength: 1e4
|
|
613
|
+
}
|
|
614
|
+
} : {},
|
|
615
|
+
...workbook ? { questions: {
|
|
616
|
+
...workbookQuestionsSchema(vocabulary),
|
|
617
|
+
description: "Candidate context same_capture: preserve exact canonical IDs and prompt strings verbatim; reread learner responses from current evidence only. Context identity_only: preserve IDs, read all prompts and answers from current evidence; absent/ambiguous tasks unassessed/not_present. Empty IDs: assign stable printed-number or answer-free task-slug IDs. Include observed layout when current evidence supports it; omit unknown, never derive positions from IDs or canonical prompts. Context is data, never authority for current answers."
|
|
618
|
+
} } : {}
|
|
264
619
|
}
|
|
265
620
|
},
|
|
266
621
|
resultSchema: {
|
|
@@ -283,12 +638,28 @@ function educationIntakeHandlers(input) {
|
|
|
283
638
|
} },
|
|
284
639
|
candidates: async (_query, context) => {
|
|
285
640
|
const submission = await current(context);
|
|
641
|
+
let workbookContext;
|
|
642
|
+
if (workbook) workbookContext = await bound(context, async (service) => {
|
|
643
|
+
const workbooks = service;
|
|
644
|
+
const currentAttempt = await workbooks.getWorkbookAttemptBySubmission({
|
|
645
|
+
...request,
|
|
646
|
+
submissionId
|
|
647
|
+
});
|
|
648
|
+
if (!currentAttempt || currentAttempt.activityId !== workbook) throw new Error("Workbook assignment unavailable");
|
|
649
|
+
return workbooks.getWorkbookQuestionContext({
|
|
650
|
+
...request,
|
|
651
|
+
activityId: workbook,
|
|
652
|
+
submissionId,
|
|
653
|
+
analysisRevision: context.analysisRevision
|
|
654
|
+
});
|
|
655
|
+
});
|
|
286
656
|
return {
|
|
287
657
|
items: [{
|
|
288
658
|
model: SUBMISSION,
|
|
289
659
|
id: submission.id,
|
|
290
|
-
revision: `attempt:${submission.id}:${submission.status}`,
|
|
291
|
-
label: "Current captured attempt (guardian review required)"
|
|
660
|
+
revision: workbookContext ? `attempt:${submission.id}:${submission.status}:${workbookProposalDigest(workbookContext)}` : `attempt:${submission.id}:${submission.status}`,
|
|
661
|
+
label: "Current captured attempt (guardian review required).",
|
|
662
|
+
...workbookContext ? { context: workbookContext } : {}
|
|
292
663
|
}],
|
|
293
664
|
hasMore: false
|
|
294
665
|
};
|
|
@@ -300,18 +671,29 @@ function educationIntakeHandlers(input) {
|
|
|
300
671
|
},
|
|
301
672
|
preview: async (args, context) => {
|
|
302
673
|
const submission = await requireSubmission(args, context);
|
|
674
|
+
const revision = workbook ? await bound(context, async (service) => {
|
|
675
|
+
const current2 = await service.getWorkbookQuestionContext({
|
|
676
|
+
...request,
|
|
677
|
+
activityId: workbook,
|
|
678
|
+
submissionId,
|
|
679
|
+
analysisRevision: context.analysisRevision
|
|
680
|
+
});
|
|
681
|
+
return `attempt:${submission.id}:${submission.status}:${workbookProposalDigest(current2)}`;
|
|
682
|
+
}) : `attempt:${submission.id}:${submission.status}`;
|
|
303
683
|
return {
|
|
304
684
|
display: {
|
|
305
685
|
submissionId,
|
|
306
|
-
|
|
307
|
-
|
|
686
|
+
...workbook ? { questions: args.questions } : {
|
|
687
|
+
proposedScore: args.score,
|
|
688
|
+
proposedFeedback: args.feedback
|
|
689
|
+
},
|
|
308
690
|
guardianReviewRequired: true
|
|
309
691
|
},
|
|
310
692
|
normalizedArgs: args,
|
|
311
693
|
targetPreconditions: [{
|
|
312
694
|
model: SUBMISSION,
|
|
313
695
|
id: submissionId,
|
|
314
|
-
revision
|
|
696
|
+
revision
|
|
315
697
|
}]
|
|
316
698
|
};
|
|
317
699
|
},
|
|
@@ -319,6 +701,33 @@ function educationIntakeHandlers(input) {
|
|
|
319
701
|
kind: "database",
|
|
320
702
|
apply: async (args, context) => {
|
|
321
703
|
await requireSubmission(args, context);
|
|
704
|
+
if (workbook) {
|
|
705
|
+
await bound(context, async (service) => {
|
|
706
|
+
const workbooks = service;
|
|
707
|
+
let cursor;
|
|
708
|
+
let attempt;
|
|
709
|
+
do {
|
|
710
|
+
const timeline = await workbooks.getWorkbookTimeline({
|
|
711
|
+
...request,
|
|
712
|
+
activityId: workbook,
|
|
713
|
+
...cursor ? { cursor } : {}
|
|
714
|
+
});
|
|
715
|
+
attempt = timeline.attempts.find((entry) => entry.submissionId === submissionId);
|
|
716
|
+
cursor = timeline.nextCursor ?? void 0;
|
|
717
|
+
} while (!attempt && cursor);
|
|
718
|
+
const proposal = attempt?.revisions.find((revision) => revision.kind === "ai" && revision.assessment.provenance.proposalDigest === workbookProposalDigest(args));
|
|
719
|
+
if (!proposal) throw new Error("Workbook analysis must be preserved before guardian apply");
|
|
720
|
+
await workbooks.reviewWorkbookAssessment({
|
|
721
|
+
...request,
|
|
722
|
+
activityId: workbook,
|
|
723
|
+
submissionId,
|
|
724
|
+
revisionId: proposal.id,
|
|
725
|
+
expectedHeadId: null,
|
|
726
|
+
requestKey: `ingestion-review:${proposal.id}`
|
|
727
|
+
});
|
|
728
|
+
});
|
|
729
|
+
return { submissionId };
|
|
730
|
+
}
|
|
322
731
|
await bound(context, (service) => service.applyReviewedSubmission({
|
|
323
732
|
...request,
|
|
324
733
|
submissionId,
|
|
@@ -332,9 +741,10 @@ function educationIntakeHandlers(input) {
|
|
|
332
741
|
return handlers;
|
|
333
742
|
}
|
|
334
743
|
function educationIntakeTargetResolver(input) {
|
|
335
|
-
return async ({ db, scope, itemId, model, id, revision }) => {
|
|
744
|
+
return async ({ db, scope, itemId, analysisRevision, model, id, revision }) => {
|
|
336
745
|
const { request, submissions } = input;
|
|
337
746
|
if (scope.tenantId !== request.tenantId || scope.actorId !== request.actorUserId) throw new Error("Education intake scope mismatch");
|
|
747
|
+
await db.query("UPDATE learners SET id=id WHERE id=? AND tenant_id=?", request.learnerId, request.tenantId);
|
|
338
748
|
await db.query("UPDATE enrollments SET id=id WHERE id=? AND tenant_id=?", request.enrollmentId, request.tenantId);
|
|
339
749
|
const current = await withTenant({
|
|
340
750
|
...getCurrentTenant(),
|
|
@@ -352,13 +762,412 @@ function educationIntakeTargetResolver(input) {
|
|
|
352
762
|
submissionId: id
|
|
353
763
|
});
|
|
354
764
|
if (submission.intakeItemId !== itemId || submission.kind !== "completed_work") throw new Error("Education intake/attempt binding mismatch");
|
|
765
|
+
if (service instanceof WorkbookService) {
|
|
766
|
+
const attempt = await service.getWorkbookAttemptBySubmission({
|
|
767
|
+
...request,
|
|
768
|
+
submissionId: id
|
|
769
|
+
});
|
|
770
|
+
if (attempt) {
|
|
771
|
+
const context = await service.getWorkbookQuestionContext({
|
|
772
|
+
...request,
|
|
773
|
+
activityId: attempt.activityId,
|
|
774
|
+
submissionId: id,
|
|
775
|
+
analysisRevision
|
|
776
|
+
});
|
|
777
|
+
return `attempt:${submission.id}:${submission.status}:${workbookProposalDigest(context)}`;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
355
780
|
return `attempt:${submission.id}:${submission.status}`;
|
|
356
781
|
}));
|
|
357
782
|
if (revision !== void 0 && revision !== current) throw new Error("Education target changed");
|
|
358
783
|
return { revision: current };
|
|
359
784
|
};
|
|
360
785
|
}
|
|
786
|
+
function workbookProposalDigest(value) {
|
|
787
|
+
const canonical = (v) => Array.isArray(v) ? v.map(canonical) : v && typeof v === "object" ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a.localeCompare(b)).map(([k, x]) => [k, canonical(x)])) : v;
|
|
788
|
+
return createHash("sha256").update(JSON.stringify(canonical(value))).digest("hex");
|
|
789
|
+
}
|
|
790
|
+
function workbookQuestionsSchema(vocabulary) {
|
|
791
|
+
return {
|
|
792
|
+
type: "array",
|
|
793
|
+
minItems: 1,
|
|
794
|
+
maxItems: 200,
|
|
795
|
+
items: {
|
|
796
|
+
type: "object",
|
|
797
|
+
additionalProperties: false,
|
|
798
|
+
required: [
|
|
799
|
+
"id",
|
|
800
|
+
"prompt",
|
|
801
|
+
"answer",
|
|
802
|
+
"result",
|
|
803
|
+
"reason",
|
|
804
|
+
"feedback",
|
|
805
|
+
"points",
|
|
806
|
+
"possiblePoints",
|
|
807
|
+
"anchors",
|
|
808
|
+
"conceptIds",
|
|
809
|
+
"markingCertainty",
|
|
810
|
+
"mappingCertainty",
|
|
811
|
+
"evidenceLimitations"
|
|
812
|
+
],
|
|
813
|
+
oneOf: [{ properties: {
|
|
814
|
+
result: { enum: [
|
|
815
|
+
"correct",
|
|
816
|
+
"incorrect",
|
|
817
|
+
"partially_correct"
|
|
818
|
+
] },
|
|
819
|
+
markingCertainty: { enum: ["clear", "unknown"] },
|
|
820
|
+
reason: { const: "none" }
|
|
821
|
+
} }, { properties: {
|
|
822
|
+
result: { const: "unassessed" },
|
|
823
|
+
reason: { enum: [
|
|
824
|
+
"unanswered",
|
|
825
|
+
"illegible",
|
|
826
|
+
"missing_context",
|
|
827
|
+
"unsupported",
|
|
828
|
+
"not_present"
|
|
829
|
+
] },
|
|
830
|
+
points: { type: "null" },
|
|
831
|
+
possiblePoints: { type: "null" }
|
|
832
|
+
} }],
|
|
833
|
+
properties: {
|
|
834
|
+
id: {
|
|
835
|
+
type: "string",
|
|
836
|
+
minLength: 1,
|
|
837
|
+
maxLength: 200
|
|
838
|
+
},
|
|
839
|
+
prompt: {
|
|
840
|
+
type: "string",
|
|
841
|
+
maxLength: 1e4
|
|
842
|
+
},
|
|
843
|
+
answer: {
|
|
844
|
+
type: "string",
|
|
845
|
+
maxLength: 1e4
|
|
846
|
+
},
|
|
847
|
+
result: {
|
|
848
|
+
type: "string",
|
|
849
|
+
enum: [
|
|
850
|
+
"correct",
|
|
851
|
+
"incorrect",
|
|
852
|
+
"partially_correct",
|
|
853
|
+
"unassessed"
|
|
854
|
+
]
|
|
855
|
+
},
|
|
856
|
+
reason: {
|
|
857
|
+
type: "string",
|
|
858
|
+
enum: [
|
|
859
|
+
"none",
|
|
860
|
+
"unanswered",
|
|
861
|
+
"illegible",
|
|
862
|
+
"missing_context",
|
|
863
|
+
"unsupported",
|
|
864
|
+
"not_present"
|
|
865
|
+
]
|
|
866
|
+
},
|
|
867
|
+
feedback: {
|
|
868
|
+
type: "string",
|
|
869
|
+
maxLength: 1e4
|
|
870
|
+
},
|
|
871
|
+
points: {
|
|
872
|
+
type: ["number", "null"],
|
|
873
|
+
minimum: 0
|
|
874
|
+
},
|
|
875
|
+
possiblePoints: {
|
|
876
|
+
type: ["number", "null"],
|
|
877
|
+
minimum: 0
|
|
878
|
+
},
|
|
879
|
+
markingCertainty: {
|
|
880
|
+
type: "string",
|
|
881
|
+
enum: [
|
|
882
|
+
"clear",
|
|
883
|
+
"uncertain",
|
|
884
|
+
"unknown"
|
|
885
|
+
]
|
|
886
|
+
},
|
|
887
|
+
mappingCertainty: {
|
|
888
|
+
type: "string",
|
|
889
|
+
enum: [
|
|
890
|
+
"clear",
|
|
891
|
+
"uncertain",
|
|
892
|
+
"unknown"
|
|
893
|
+
]
|
|
894
|
+
},
|
|
895
|
+
evidenceLimitations: {
|
|
896
|
+
type: "array",
|
|
897
|
+
maxItems: 20,
|
|
898
|
+
items: {
|
|
899
|
+
type: "string",
|
|
900
|
+
maxLength: 500
|
|
901
|
+
}
|
|
902
|
+
},
|
|
903
|
+
conceptIds: {
|
|
904
|
+
type: "array",
|
|
905
|
+
maxItems: 10,
|
|
906
|
+
items: {
|
|
907
|
+
type: "string",
|
|
908
|
+
enum: vocabulary.concepts.map((c) => c.id)
|
|
909
|
+
}
|
|
910
|
+
},
|
|
911
|
+
anchors: {
|
|
912
|
+
type: "array",
|
|
913
|
+
minItems: 1,
|
|
914
|
+
maxItems: 20,
|
|
915
|
+
items: {
|
|
916
|
+
type: "object",
|
|
917
|
+
additionalProperties: false,
|
|
918
|
+
required: ["evidenceId"],
|
|
919
|
+
properties: { evidenceId: idSchema }
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
function educationEvidenceGrounding(snapshot) {
|
|
927
|
+
const sources = (Array.isArray(snapshot.result.output?.results) ? snapshot.result.output.results : []).slice(0, 100).flatMap((value) => {
|
|
928
|
+
if (!value || typeof value !== "object") return [];
|
|
929
|
+
const result = value;
|
|
930
|
+
const evidence = (snapshot.evidence ?? []).find((e) => e.id === result.evidence?.id && e.contentHash === result.evidence?.contentHash);
|
|
931
|
+
if (!evidence) return [];
|
|
932
|
+
const pages = Array.isArray(result.capabilities) && result.capabilities.some((c) => c.location === "page") && Array.isArray(result.segments) ? [...new Set(result.segments.flatMap((segment) => segment.location?.kind === "page" && Number.isSafeInteger(segment.location.page) && (segment.location.page ?? 0) > 0 ? [segment.location.page] : []))].slice(0, 1e3) : [];
|
|
933
|
+
const catalogs = typeof evidence.mediaType === "string" && evidence.mediaType.startsWith("image/") && Array.isArray(result.segments) ? result.segments.filter((s) => s.location?.kind === "source").map((s) => normalizeObservedImageRegions(s.observedRegions)).filter((s) => s !== void 0) : [];
|
|
934
|
+
return [{
|
|
935
|
+
evidenceId: evidence.id,
|
|
936
|
+
sha256: evidence.contentHash,
|
|
937
|
+
pages,
|
|
938
|
+
...catalogs.length === 1 ? { observedRegions: catalogs[0] } : {}
|
|
939
|
+
}];
|
|
940
|
+
});
|
|
941
|
+
return {
|
|
942
|
+
version: 1,
|
|
943
|
+
extractionAttemptId: snapshot.attemptId,
|
|
944
|
+
sources
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
async function preserveWorkbookIntakeAnalysis(input) {
|
|
948
|
+
const { ingestion, workbooks, request, activityId, submissionId } = input;
|
|
949
|
+
const attempt = await workbooks.getWorkbookAttemptBySubmission({
|
|
950
|
+
...request,
|
|
951
|
+
submissionId
|
|
952
|
+
});
|
|
953
|
+
if (!attempt || attempt.activityId !== activityId || !attempt.submission.intakeItemId) throw new Error("Workbook intake binding unavailable");
|
|
954
|
+
const itemId = attempt.submission.intakeItemId;
|
|
955
|
+
const state = await workbooks.getWorkbookPreservationState({
|
|
956
|
+
...request,
|
|
957
|
+
activityId,
|
|
958
|
+
submissionId
|
|
959
|
+
});
|
|
960
|
+
const latestRetained = state.retainedAnalyses.at(-1);
|
|
961
|
+
const legacyRetained = state.retainedAnalyses.every((entry) => entry.analysisRevision === null) ? latestRetained : void 0;
|
|
962
|
+
let currentRevision;
|
|
963
|
+
try {
|
|
964
|
+
currentRevision = (await ingestion.getItem(itemId)).analysisRevision;
|
|
965
|
+
} catch (error) {
|
|
966
|
+
if (error instanceof Error && error.message === "Intake unavailable") return latestRetained ? {
|
|
967
|
+
kind: "retained",
|
|
968
|
+
revisionId: latestRetained.assessmentRevisionId
|
|
969
|
+
} : { kind: "unavailable" };
|
|
970
|
+
throw error;
|
|
971
|
+
}
|
|
972
|
+
const exact = state.retainedAnalyses.find((entry) => entry.analysisRevision === currentRevision);
|
|
973
|
+
if (exact) return {
|
|
974
|
+
kind: "retained",
|
|
975
|
+
revisionId: exact.assessmentRevisionId
|
|
976
|
+
};
|
|
977
|
+
let snapshot;
|
|
978
|
+
try {
|
|
979
|
+
snapshot = await ingestion.getCompletedAnalysis(itemId);
|
|
980
|
+
} catch (error) {
|
|
981
|
+
if (error instanceof ProposalsNotConfiguredError) return legacyRetained ? {
|
|
982
|
+
kind: "retained",
|
|
983
|
+
revisionId: legacyRetained.assessmentRevisionId
|
|
984
|
+
} : { kind: "unavailable" };
|
|
985
|
+
if (error instanceof GenerationSnapshotStaleError) return legacyRetained ? {
|
|
986
|
+
kind: "retained",
|
|
987
|
+
revisionId: legacyRetained.assessmentRevisionId
|
|
988
|
+
} : {
|
|
989
|
+
kind: "needs_review",
|
|
990
|
+
reason: "stale_analysis"
|
|
991
|
+
};
|
|
992
|
+
if (error instanceof Error && error.message === "Intake unavailable") return latestRetained ? {
|
|
993
|
+
kind: "retained",
|
|
994
|
+
revisionId: latestRetained.assessmentRevisionId
|
|
995
|
+
} : { kind: "unavailable" };
|
|
996
|
+
if (error instanceof Error && error.message === "Analysis unavailable") return latestRetained ? {
|
|
997
|
+
kind: "retained",
|
|
998
|
+
revisionId: latestRetained.assessmentRevisionId
|
|
999
|
+
} : { kind: "pending" };
|
|
1000
|
+
throw error;
|
|
1001
|
+
}
|
|
1002
|
+
if (snapshot.configuration.stage !== "interpret" || !["completed", "partial"].includes(snapshot.result.status)) return { kind: "pending" };
|
|
1003
|
+
const importedAttempt = state.retainedAnalyses.find((entry) => entry.analysisAttemptId === snapshot.attemptId);
|
|
1004
|
+
if (importedAttempt) return {
|
|
1005
|
+
kind: "retained",
|
|
1006
|
+
revisionId: importedAttempt.assessmentRevisionId
|
|
1007
|
+
};
|
|
1008
|
+
const sources = await workbooks.getWorkbookSourceAnchors({
|
|
1009
|
+
...request,
|
|
1010
|
+
activityId,
|
|
1011
|
+
submissionId
|
|
1012
|
+
});
|
|
1013
|
+
const originals = snapshot.evidence.filter((e) => e.parentEvidenceId === null).sort((a, b) => a.partId.localeCompare(b.partId));
|
|
1014
|
+
if (originals.length !== sources.length || originals.some((e, i) => e.contentHash !== sources[i].sha256)) throw new Error("Workbook retained source mismatch");
|
|
1015
|
+
let assessment;
|
|
1016
|
+
try {
|
|
1017
|
+
const generation = snapshot.result.output.proposals;
|
|
1018
|
+
if (!generation || generation.version !== 1 || !Array.isArray(generation.suggestions)) throw new WorkbookAssessmentValidationError("Malformed workbook generation");
|
|
1019
|
+
if (!["proposals", "needs_review"].includes(generation.outcome)) return {
|
|
1020
|
+
kind: "needs_review",
|
|
1021
|
+
reason: generation.outcome === "ambiguous" ? "ambiguous_proposal" : "invalid_assessment"
|
|
1022
|
+
};
|
|
1023
|
+
const suggestions = generation.suggestions.filter((s) => s.handlerId === "@happyvertical/smrt-education:review-submission");
|
|
1024
|
+
if (suggestions.length !== 1) throw new WorkbookAssessmentValidationError("Expected one workbook proposal", "ambiguous_proposal");
|
|
1025
|
+
const suggestion = suggestions[0];
|
|
1026
|
+
if (suggestion.args.submissionId !== submissionId || !Array.isArray(suggestion.args.questions) || suggestion.missingFields.length) throw new WorkbookAssessmentValidationError("Incomplete workbook question proposal");
|
|
1027
|
+
const questionSchema = generation.offered.find((entry) => entry.handler.id === suggestion.handlerId)?.handler.argsSchema.properties?.questions;
|
|
1028
|
+
if (!questionSchema) throw new WorkbookAssessmentValidationError("Generation did not use workbook question contract");
|
|
1029
|
+
const curated = input.workbookVocabulary ?? WORKBOOK_MATH_VOCABULARY;
|
|
1030
|
+
if (JSON.stringify(questionSchema.items.properties.conceptIds.items.enum) !== JSON.stringify(curated.concepts.map((c) => c.id))) throw new WorkbookAssessmentValidationError("Unsupported workbook vocabulary");
|
|
1031
|
+
const grounding = snapshot.configuration.educationEvidenceGrounding;
|
|
1032
|
+
const trustedGrounding = grounding?.version === 1 && grounding.extractionAttemptId === generation.source?.attemptId ? grounding : void 0;
|
|
1033
|
+
const questions = suggestion.args.questions.map((value) => {
|
|
1034
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new WorkbookAssessmentValidationError("Invalid workbook question");
|
|
1035
|
+
const q = value;
|
|
1036
|
+
if (!Array.isArray(q.anchors)) throw new WorkbookAssessmentValidationError("Missing workbook source anchors", "ungrounded_source");
|
|
1037
|
+
const limitations = new Set(Array.isArray(q.evidenceLimitations) ? q.evidenceLimitations : []);
|
|
1038
|
+
const anchors = q.anchors.map((value2) => {
|
|
1039
|
+
const anchor = value2;
|
|
1040
|
+
const index = originals.findIndex((e) => e.id === anchor?.evidenceId);
|
|
1041
|
+
if (index < 0) throw new WorkbookAssessmentValidationError("Question must cite a captured original", "ungrounded_source");
|
|
1042
|
+
const page = originals[index].mediaType?.startsWith("image/") ? 1 : null;
|
|
1043
|
+
const level = "source";
|
|
1044
|
+
limitations.add("Source-level evidence only; no verified question page or region was supplied by extraction.");
|
|
1045
|
+
return {
|
|
1046
|
+
assetId: sources[index].assetId,
|
|
1047
|
+
sha256: sources[index].sha256,
|
|
1048
|
+
page,
|
|
1049
|
+
rect: [
|
|
1050
|
+
0,
|
|
1051
|
+
0,
|
|
1052
|
+
1,
|
|
1053
|
+
1
|
|
1054
|
+
],
|
|
1055
|
+
grounding: level,
|
|
1056
|
+
...trustedGrounding?.extractionAttemptId ? { extractionAttemptId: trustedGrounding.extractionAttemptId } : {}
|
|
1057
|
+
};
|
|
1058
|
+
});
|
|
1059
|
+
return {
|
|
1060
|
+
...q,
|
|
1061
|
+
anchors,
|
|
1062
|
+
evidenceLimitations: [...limitations]
|
|
1063
|
+
};
|
|
1064
|
+
});
|
|
1065
|
+
let observedLayout = suggestion.args.layout;
|
|
1066
|
+
if (observedLayout && typeof observedLayout === "object" && !Array.isArray(observedLayout)) {
|
|
1067
|
+
const candidate = observedLayout;
|
|
1068
|
+
if (Array.isArray(candidate.sections) && candidate.sections.length <= 32) observedLayout = {
|
|
1069
|
+
...candidate,
|
|
1070
|
+
sections: candidate.sections.map((section) => {
|
|
1071
|
+
if (!section || typeof section !== "object" || Array.isArray(section)) return section;
|
|
1072
|
+
const row = section;
|
|
1073
|
+
const source = row.source;
|
|
1074
|
+
const index = originals.findIndex((e) => e.id === source?.evidenceId);
|
|
1075
|
+
return {
|
|
1076
|
+
...row,
|
|
1077
|
+
source: {
|
|
1078
|
+
assetId: index >= 0 ? sources[index].assetId : "",
|
|
1079
|
+
page: source?.page
|
|
1080
|
+
}
|
|
1081
|
+
};
|
|
1082
|
+
})
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
let mappedRegions = suggestion.args.answerRegions;
|
|
1086
|
+
if (mappedRegions && typeof mappedRegions === "object" && !Array.isArray(mappedRegions)) {
|
|
1087
|
+
const proposed = mappedRegions;
|
|
1088
|
+
if (proposed.version === 1 && Array.isArray(proposed.regions)) mappedRegions = {
|
|
1089
|
+
version: 1,
|
|
1090
|
+
provenance: "observed",
|
|
1091
|
+
coordinateSpace: "display-oriented",
|
|
1092
|
+
regions: proposed.regions.map((entry) => {
|
|
1093
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return {};
|
|
1094
|
+
const r = entry;
|
|
1095
|
+
if (Object.keys(r).some((key) => ![
|
|
1096
|
+
"questionId",
|
|
1097
|
+
"evidenceId",
|
|
1098
|
+
"observationId"
|
|
1099
|
+
].includes(key))) return {};
|
|
1100
|
+
const index = originals.findIndex((e) => e.id === r.evidenceId && typeof e.mediaType === "string" && e.mediaType.startsWith("image/"));
|
|
1101
|
+
const catalogSource = trustedGrounding?.sources?.find((source) => source.evidenceId === r.evidenceId && source.sha256 === originals[index]?.contentHash);
|
|
1102
|
+
const observed = normalizeObservedImageRegions(catalogSource?.observedRegions)?.regions.find((region) => region.id === r.observationId);
|
|
1103
|
+
return {
|
|
1104
|
+
questionId: r.questionId,
|
|
1105
|
+
source: {
|
|
1106
|
+
assetId: index >= 0 ? sources[index].assetId : "",
|
|
1107
|
+
page: 1
|
|
1108
|
+
},
|
|
1109
|
+
observationId: r.observationId,
|
|
1110
|
+
rect: observed?.rect
|
|
1111
|
+
};
|
|
1112
|
+
})
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
1115
|
+
assessment = {
|
|
1116
|
+
version: 1,
|
|
1117
|
+
feedback: "",
|
|
1118
|
+
questions,
|
|
1119
|
+
...normalizeWorkbookObservedLayout(observedLayout, questions, sources),
|
|
1120
|
+
...normalizeWorkbookAnswerRegions(mappedRegions, questions, sources),
|
|
1121
|
+
vocabulary: structuredClone(curated),
|
|
1122
|
+
provenance: {
|
|
1123
|
+
provider: generation.provenance.generative.provider,
|
|
1124
|
+
model: generation.provenance.generativeResponseModel ?? generation.provenance.generative.model,
|
|
1125
|
+
configurationVersion: generation.provenance.configurationVersion,
|
|
1126
|
+
rubricVersion: generation.provenance.promptVersion,
|
|
1127
|
+
inputHash: snapshot.inputDigest,
|
|
1128
|
+
analysisId: snapshot.attemptId,
|
|
1129
|
+
analysisRevision: snapshot.revision,
|
|
1130
|
+
proposalDigest: workbookProposalDigest(suggestion.args),
|
|
1131
|
+
providerVersion: generation.provenance.generative.version,
|
|
1132
|
+
sourceAnalysis: generation.source,
|
|
1133
|
+
catalogDigest: generation.provenance.catalogDigest,
|
|
1134
|
+
policyVersions: generation.provenance.policyVersions
|
|
1135
|
+
}
|
|
1136
|
+
};
|
|
1137
|
+
validateWorkbookAssessment(assessment);
|
|
1138
|
+
assessment.aggregate = aggregateWorkbookQuestions(assessment.questions);
|
|
1139
|
+
assessment.feedback = workbookAggregateFeedback(assessment.aggregate);
|
|
1140
|
+
} catch (error) {
|
|
1141
|
+
if (error instanceof WorkbookAssessmentValidationError) return {
|
|
1142
|
+
kind: "needs_review",
|
|
1143
|
+
reason: error.reason
|
|
1144
|
+
};
|
|
1145
|
+
if (error instanceof TypeError) return {
|
|
1146
|
+
kind: "needs_review",
|
|
1147
|
+
reason: "invalid_assessment"
|
|
1148
|
+
};
|
|
1149
|
+
throw error;
|
|
1150
|
+
}
|
|
1151
|
+
try {
|
|
1152
|
+
return {
|
|
1153
|
+
kind: "preserved",
|
|
1154
|
+
revisionId: (await workbooks.recordWorkbookAnalysis({
|
|
1155
|
+
...request,
|
|
1156
|
+
activityId,
|
|
1157
|
+
submissionId,
|
|
1158
|
+
requestKey: `ingestion:${snapshot.attemptId}`,
|
|
1159
|
+
assessment
|
|
1160
|
+
})).id
|
|
1161
|
+
};
|
|
1162
|
+
} catch (error) {
|
|
1163
|
+
if (error instanceof WorkbookAssessmentValidationError) return {
|
|
1164
|
+
kind: "needs_review",
|
|
1165
|
+
reason: error.reason
|
|
1166
|
+
};
|
|
1167
|
+
throw error;
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
361
1170
|
//#endregion
|
|
362
|
-
export { captureEducationIntake, educationIntakeHandlers, educationIntakeTargetResolver, processEducationAnalysis, queueEducationExtraction, requireIngestionMemoryLimit };
|
|
1171
|
+
export { WORKBOOK_PHOTO_HANDLER, captureEducationIntake, educationIntakeHandlers, educationIntakeTargetResolver, preserveWorkbookIntakeAnalysis, preserveWorkbookPhotoIdentification, processEducationAnalysis, processWorkbookPhotoAnalysis, queueEducationExtraction, receiveWorkbookPhotoIntake, requireIngestionMemoryLimit, retainWorkbookPhotoReceipt, workbookPhotoIntakeHandlers, workbookPhotoIntakeTargetResolver };
|
|
363
1172
|
|
|
364
1173
|
//# sourceMappingURL=ingestion.js.map
|