@happyvertical/smrt-education 0.57.32 → 0.57.34

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.
Files changed (55) hide show
  1. package/AGENTS.md +8 -1
  2. package/README.md +117 -13
  3. package/agents/catalog-sync.md +46 -0
  4. package/agents/photo-intake.md +249 -0
  5. package/agents/workbook-service.md +355 -0
  6. package/agents/workbook-test-matrix.md +74 -0
  7. package/dist/catalog-acquisition-service.d.ts.map +1 -1
  8. package/dist/catalog-capability.d.ts +21 -0
  9. package/dist/catalog-capability.d.ts.map +1 -0
  10. package/dist/catalog-contracts.d.ts +40 -0
  11. package/dist/catalog-contracts.d.ts.map +1 -1
  12. package/dist/catalog-database-sync-store.d.ts.map +1 -1
  13. package/dist/catalog-models.d.ts +5 -1
  14. package/dist/catalog-models.d.ts.map +1 -1
  15. package/dist/catalog-read-service.d.ts +2 -1
  16. package/dist/catalog-read-service.d.ts.map +1 -1
  17. package/dist/catalog-service.d.ts +5 -0
  18. package/dist/catalog-service.d.ts.map +1 -1
  19. package/dist/chunks/workbook-photo-service-1wcnf-Xf.js +27359 -0
  20. package/dist/chunks/workbook-photo-service-1wcnf-Xf.js.map +1 -0
  21. package/dist/index.d.ts +11 -2
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +339 -24864
  24. package/dist/index.js.map +1 -1
  25. package/dist/ingestion.d.ts +37 -0
  26. package/dist/ingestion.d.ts.map +1 -1
  27. package/dist/ingestion.js +829 -20
  28. package/dist/ingestion.js.map +1 -1
  29. package/dist/learner-service.d.ts +15 -0
  30. package/dist/learner-service.d.ts.map +1 -1
  31. package/dist/manifest.json +4735 -6
  32. package/dist/smrt-knowledge.json +1760 -26
  33. package/dist/sources/public-illustrative-mathematics-adapter.d.ts.map +1 -1
  34. package/dist/sources/public-illustrative-mathematics-parser.d.ts.map +1 -1
  35. package/dist/submission-service.d.ts +38 -11
  36. package/dist/submission-service.d.ts.map +1 -1
  37. package/dist/workbook-answer-regions.d.ts +11 -0
  38. package/dist/workbook-answer-regions.d.ts.map +1 -0
  39. package/dist/workbook-contracts.d.ts +224 -0
  40. package/dist/workbook-contracts.d.ts.map +1 -0
  41. package/dist/workbook-layout.d.ts +13 -0
  42. package/dist/workbook-layout.d.ts.map +1 -0
  43. package/dist/workbook-models.d.ts +129 -0
  44. package/dist/workbook-models.d.ts.map +1 -0
  45. package/dist/workbook-photo-contracts.d.ts +56 -0
  46. package/dist/workbook-photo-contracts.d.ts.map +1 -0
  47. package/dist/workbook-photo-ingestion.d.ts +70 -0
  48. package/dist/workbook-photo-ingestion.d.ts.map +1 -0
  49. package/dist/workbook-photo-models.d.ts +63 -0
  50. package/dist/workbook-photo-models.d.ts.map +1 -0
  51. package/dist/workbook-photo-service.d.ts +72 -0
  52. package/dist/workbook-photo-service.d.ts.map +1 -0
  53. package/dist/workbook-service.d.ts +158 -0
  54. package/dist/workbook-service.d.ts.map +1 -0
  55. 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, generation, `education:interpret:${result.attemptId}`)).revision
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
- score: {
256
- type: ["number", "null"],
257
- minimum: 0,
258
- maximum: 1
259
- },
260
- feedback: {
261
- type: "string",
262
- maxLength: 1e4
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
- proposedScore: args.score,
307
- proposedFeedback: args.feedback,
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: `attempt:${submission.id}:${submission.status}`
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