@edpire/sdk 0.7.0 → 0.8.1

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/dist/core.d.mts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { RuntimeAnswer } from '@youssefalmia/edpire-runtime';
2
+ import { RichContent } from '@youssefalmia/edpire-schema/authoring';
2
3
 
3
4
  /** Flat per-question answer collected during a custom player session. */
4
5
  interface StoredAnswer {
@@ -44,6 +45,195 @@ declare function buildSubmitPayload(stored: StoredAnswer[]): {
44
45
  }>;
45
46
  };
46
47
 
48
+ /**
49
+ * Public types for the polished Assessment Shell.
50
+ *
51
+ * The shell renders the same UI used at edpire.com's hosted player.
52
+ * Consumers using the EdpireAssessment.mount() API get these types
53
+ * indirectly via MountOptions; consumers rendering <AssessmentShell>
54
+ * directly import them from "@edpire/sdk/react".
55
+ */
56
+
57
+ /** Branding payload accepted by AssessmentShell. Mirrors what edpire.com sends. */
58
+ interface TakeBranding {
59
+ /** Org display name — shown as the alt text on the logo. */
60
+ name: string;
61
+ /** Logo URL (any https URL). When absent, the Edpire logo is shown. */
62
+ logoUrl: string | null;
63
+ branding: {
64
+ /** CSS color for the primary action buttons (Submit, View Report, etc.). */
65
+ primaryColor?: string;
66
+ /** CSS color used as the background of the top bar and per-exercise headers. */
67
+ accentColor?: string;
68
+ /** font-family applied to the shell. Pair with `fontUrl` for non-system fonts. */
69
+ fontFamily?: string;
70
+ /** A Google Fonts stylesheet URL; the shell injects a <link> tag at mount. */
71
+ fontUrl?: string;
72
+ /** When false, the "Powered by Edpire" watermark is hidden. Default true. */
73
+ showPoweredBy?: boolean;
74
+ } | null;
75
+ }
76
+
77
+ /**
78
+ * Public types for the Edpire Embeddable JS SDK.
79
+ */
80
+
81
+ type NodeFeedbackStatus = "correct" | "partial" | "incorrect" | "awaiting_review";
82
+ interface EmbedResult {
83
+ submission_id: string;
84
+ score: number;
85
+ max_score: number;
86
+ percentage: number;
87
+ passed: boolean;
88
+ passing_score_percent: number;
89
+ attempt_number: number;
90
+ /** True when the submission contains open-response nodes still pending teacher review. */
91
+ awaiting_manual_grading?: boolean;
92
+ /** True when an AI-marked answer has no mark yet: it is being marked, not scored 0. */
93
+ ai_pending?: boolean;
94
+ exercise_results: Array<{
95
+ exercise_id: string;
96
+ total_score: number;
97
+ max_score: number;
98
+ question_results: Array<{
99
+ question_id: string;
100
+ score: number;
101
+ correct: boolean;
102
+ points: number;
103
+ }>;
104
+ }>;
105
+ exercise_feedback: Array<{
106
+ exercise_id: string;
107
+ questions: Array<{
108
+ question_id: string;
109
+ node_results: Array<{
110
+ node_id: string;
111
+ status: NodeFeedbackStatus;
112
+ score: number;
113
+ max_score: number;
114
+ feedback?: string;
115
+ display_answer?: string;
116
+ detail?: {
117
+ type: "choiceSet";
118
+ correctChoiceIds: string[];
119
+ } | {
120
+ type: "matchingSet";
121
+ pairFeedback: Array<{
122
+ leftId: string;
123
+ rightId: string;
124
+ correct: boolean;
125
+ }>;
126
+ correctPairings: Array<{
127
+ leftId: string;
128
+ rightId: string;
129
+ }>;
130
+ };
131
+ /**
132
+ * The teacher's written feedback on an open response. Only ever present
133
+ * in a review (`GET /submissions/{id}/review`), since at submit time no
134
+ * teacher has graded anything yet.
135
+ */
136
+ teacher_feedback?: RichContent[];
137
+ }>;
138
+ }>;
139
+ }>;
140
+ }
141
+ /**
142
+ * Response of `GET /api/v1/submissions/{id}/review`: one finished attempt, with
143
+ * everything needed to draw it as the corrected paper. Fetch it on your server
144
+ * (it needs an API key with `read:results`), send it to the browser, and pass it
145
+ * to `EdpireAssessment.review()` unchanged.
146
+ */
147
+ interface SubmissionReview {
148
+ submission: {
149
+ id: string;
150
+ assessment_id: string;
151
+ learner_ref: string | null;
152
+ status: string;
153
+ attempt_number: number;
154
+ /** How many attempts this learner has made at this assessment, this one included. */
155
+ total_attempts: number;
156
+ score: number;
157
+ max_score: number;
158
+ percentage: number;
159
+ passed: boolean;
160
+ passing_score_percent: number;
161
+ is_fully_graded: boolean;
162
+ /**
163
+ * True while a teacher still has open responses to grade. `score`,
164
+ * `percentage` and `passed` are provisional until it turns false, and the
165
+ * player hides them.
166
+ */
167
+ awaiting_manual_grading: boolean;
168
+ /** True while an AI-marked answer has no mark yet. Optional: older servers did not send it. */
169
+ ai_pending?: boolean;
170
+ submitted_at: string | null;
171
+ };
172
+ assessment: AssessmentLoaderResponse["assessment"];
173
+ meta: Omit<AssessmentLoaderResponse["meta"], "type">;
174
+ branding?: TakeBranding | null;
175
+ /** The learner's answers, in the shape the player collected them. */
176
+ answers: {
177
+ exerciseAnswers: Array<{
178
+ exerciseId: string;
179
+ questionAnswers: Array<{
180
+ questionId: string;
181
+ answers: RuntimeAnswer[];
182
+ }>;
183
+ }>;
184
+ };
185
+ exercise_feedback: EmbedResult["exercise_feedback"];
186
+ }
187
+ interface AssessmentLoaderResponse {
188
+ assessment: {
189
+ /**
190
+ * Assessment-level annex — reading texts, figures, formula sheets. The
191
+ * player shows it as a side panel on wide screens and a drawer on narrow
192
+ * ones. Null when the assessment has none.
193
+ */
194
+ annex?: unknown | null;
195
+ exercises: Array<{
196
+ id: string;
197
+ sharedContext: unknown | null;
198
+ questions: Array<{
199
+ id: string;
200
+ version: "1.0";
201
+ content: unknown;
202
+ /** Sent by the content loader, not by a review. Nothing in the player reads either. */
203
+ points?: number;
204
+ sequenceNumber?: number;
205
+ answerKeys: never[];
206
+ }>;
207
+ }>;
208
+ };
209
+ meta: {
210
+ id: string;
211
+ title: string;
212
+ type: string;
213
+ category?: string | null;
214
+ /**
215
+ * The assessment's learner-facing language, which also decides text
216
+ * direction. The player uses this unless the host passes `locale`
217
+ * explicitly, so a French assessment reads left-to-right even when embedded
218
+ * in a right-to-left product.
219
+ */
220
+ language?: "en" | "fr" | "ar";
221
+ settings: {
222
+ time_limit_minutes: number | null;
223
+ passing_score_percent: number;
224
+ allowed_attempts: number;
225
+ show_solutions: string;
226
+ };
227
+ };
228
+ attempt_info: {
229
+ attempt_number: number;
230
+ can_retry: boolean;
231
+ attempts_remaining: number | null;
232
+ };
233
+ /** Org branding loaded server-side. May be overridden by MountOptions.branding. */
234
+ branding?: TakeBranding | null;
235
+ }
236
+
47
237
  /**
48
238
  * @edpire/sdk/client — Server-side API client for Edpire.
49
239
  *
@@ -146,6 +336,71 @@ interface FlatStep {
146
336
  */
147
337
  declare function flattenAssessment(assessment: Assessment): FlatStep[];
148
338
 
339
+ /**
340
+ * flattenReview: a finished attempt as a linear sequence of steps, one per
341
+ * question, each ready to hand to `renderQuestion()` read-only.
342
+ *
343
+ * The review counterpart of `flattenAssessment()`. Where the full paper
344
+ * (`EdpireAssessment.review()`) draws the whole attempt at once, this is for
345
+ * flows that show one question per screen, Arcade-style, and want the learner
346
+ * to page back through a past attempt the same way they took it.
347
+ *
348
+ * @example
349
+ * const review = await fetch(`/api/attempts/${id}/review`).then((r) => r.json())
350
+ * const steps = flattenReview(review)
351
+ *
352
+ * const q = renderQuestion({
353
+ * container: "#question",
354
+ * content: steps[i].content,
355
+ * initialAnswers: steps[i].answers,
356
+ * feedback: steps[i].feedback,
357
+ * readOnly: true,
358
+ * })
359
+ * // steps[i].status, steps[i].score / steps[i].maxScore for your own chrome
360
+ *
361
+ * Pure: no React, no DOM. Also exported from `@edpire/sdk/core`.
362
+ */
363
+
364
+ interface ReviewStep {
365
+ exerciseId: string;
366
+ questionId: string;
367
+ /** ContentAST. Pass to renderQuestion's `content`. */
368
+ content: unknown;
369
+ /**
370
+ * The exercise's shared context (reading passage, figure, worked example),
371
+ * or null. Unlike `flattenAssessment()`, carried on every step: a
372
+ * comprehension question reviewed without its passage makes no sense.
373
+ * Render it with renderQuestion too; it is plain content.
374
+ */
375
+ sharedContext: unknown | null;
376
+ /** What the learner answered. Pass to renderQuestion's `initialAnswers`. */
377
+ answers: RuntimeAnswer[];
378
+ /**
379
+ * The marking, in the same shape POST /check returns, so it goes straight
380
+ * into renderQuestion's `feedback`. Includes the expected answer on anything
381
+ * missed and the teacher's comment on an open response.
382
+ */
383
+ feedback: Record<string, unknown>;
384
+ score: number;
385
+ maxScore: number;
386
+ /**
387
+ * The question as a whole. `awaiting_review` while a teacher has yet to mark
388
+ * any part of it; its score is then provisional. `unscored` when it is worth
389
+ * nothing (no answerable input, or 0 points): there was nothing to get
390
+ * wrong, so do not show it as wrong.
391
+ */
392
+ status: "correct" | "partial" | "incorrect" | "awaiting_review" | "unscored";
393
+ /** 0-based position, in the order the assessment presents questions. */
394
+ index: number;
395
+ }
396
+ /**
397
+ * The order is the assessment's (exercises, then questions within each), not
398
+ * the order a shuffled Arcade attempt happened to show. Edpire does not record
399
+ * the order an attempt was presented in; store it yourself if you need to
400
+ * replay it exactly.
401
+ */
402
+ declare function flattenReview(review: SubmissionReview): ReviewStep[];
403
+
149
404
  /**
150
405
  * "Did the learner actually answer this?"
151
406
  *
@@ -180,4 +435,4 @@ declare function isAnswered(answer: RuntimeAnswer): boolean;
180
435
  */
181
436
  declare function countAnswerableNodes(content: unknown): number;
182
437
 
183
- export { type FlatStep, type StoredAnswer, buildSubmitPayload, countAnswerableNodes, flattenAssessment, isAnswered };
438
+ export { type FlatStep, type ReviewStep, type StoredAnswer, type SubmissionReview, buildSubmitPayload, countAnswerableNodes, flattenAssessment, flattenReview, isAnswered };
package/dist/core.mjs CHANGED
@@ -1 +1 @@
1
- function c(e){let n=[],t=0;for(let s of e.exercises)for(let r of s.questions)n.push({exerciseId:s.id,questionId:r.id,content:r.content_ast,points:r.points,sequenceNumber:r.sequence_number,index:t++});return n}function u(e){let n=new Map;for(let t of e)n.has(t.exerciseId)||n.set(t.exerciseId,[]),n.get(t.exerciseId).push({questionId:t.questionId,answers:t.answers});return{exerciseAnswers:Array.from(n.entries()).map(([t,s])=>({exerciseId:t,questionAnswers:s}))}}var a=new Set(["choiceSet","inlineChoiceSet","blank","aiAnswerInput","matchingSet","openResponseNode","mathResponse","codingResponse"]),f=new Set(["blankPool","inlinePool"]);function d(e){switch(e.type){case"choiceSet":case"inlineChoiceSet":return e.answer.selectedIds.length>0;case"blank":return e.answer.mode==="choice"?e.answer.choiceId.length>0:e.answer.text.trim().length>0;case"matchingSet":return e.answer.connections.length>0;case"aiAnswer":return e.answer.text.trim().length>0;case"openResponse":{let n=e.answer;return n.recording||n.files&&n.files.length>0?!0:(n.text?.wordCount??0)>0}case"mathResponse":return e.answer.latex.trim().length>0;case"codingResponse":return e.answer.source.trim().length>0;default:{let n=e;return!0}}}function m(e){let n=0,t=s=>{if(Array.isArray(s)){for(let i of s)t(i);return}if(!s||typeof s!="object")return;let{type:r,content:o}=s;if(typeof r=="string"){if(a.has(r)){n+=1;return}if(f.has(r))return}o!==void 0&&t(o)};return t(e),n}export{u as buildSubmitPayload,m as countAnswerableNodes,c as flattenAssessment,d as isAnswered};
1
+ function x(t){let n=[],s=0;for(let e of t.exercises)for(let i of e.questions)n.push({exerciseId:e.id,questionId:i.id,content:i.content_ast,points:i.points,sequenceNumber:i.sequence_number,index:s++});return n}function l(t){let n=new Map;for(let s of t){let e=new Map;for(let i of s.questions){let o=new Map;for(let r of i.nodeResults){let a={state:r.status,showFeedback:!0,score:r.score,...r.feedback!==void 0?{message:r.feedback}:{},...r.displayAnswer!==void 0?{displayAnswer:r.displayAnswer}:{},...r.teacherFeedback!==void 0?{teacherFeedback:r.teacherFeedback}:{}};r.detail?.type==="choiceSet"?a.correctChoiceIds=r.detail.correctChoiceIds:r.detail?.type==="matchingSet"&&(a.pairFeedback=r.detail.pairFeedback,a.correctPairings=r.detail.correctPairings),o.set(r.nodeId,a)}e.set(i.questionId,o)}n.set(s.exerciseId,e)}return n}function m(t){return(t??[]).map(n=>({exerciseId:n.exercise_id,questions:n.questions.map(s=>({questionId:s.question_id,nodeResults:s.node_results.map(e=>({nodeId:e.node_id,status:e.status,score:e.score,maxScore:e.max_score,...e.feedback!==void 0?{feedback:e.feedback}:{},...e.display_answer!==void 0?{displayAnswer:e.display_answer}:{},...e.detail!==void 0?{detail:e.detail}:{},...e.teacher_feedback!==void 0?{teacherFeedback:e.teacher_feedback}:{}}))}))}))}function b(t){let n=l(m(t.exercise_feedback)),s=new Map;for(let o of t.answers.exerciseAnswers)for(let r of o.questionAnswers)s.set(r.questionId,r.answers);let e=[],i=0;for(let o of t.assessment.exercises){let r=n.get(o.id);for(let a of o.questions){let p=r?.get(a.id),w=t.exercise_feedback.find(c=>c.exercise_id===o.id)?.questions.find(c=>c.question_id===a.id)?.node_results??[],d=0,u=0,f=!1;for(let c of w)d+=c.score,u+=c.max_score,c.status==="awaiting_review"&&(f=!0);e.push({exerciseId:o.id,questionId:a.id,content:a.content,sharedContext:o.sharedContext??null,answers:s.get(a.id)??[],feedback:p?Object.fromEntries(p):{},score:d,maxScore:u,status:f?"awaiting_review":u===0?"unscored":d>=u?"correct":d>0?"partial":"incorrect",index:i++})}}return e}function g(t){let n=new Map;for(let s of t)n.has(s.exerciseId)||n.set(s.exerciseId,[]),n.get(s.exerciseId).push({questionId:s.questionId,answers:s.answers});return{exerciseAnswers:Array.from(n.entries()).map(([s,e])=>({exerciseId:s,questionAnswers:e}))}}var h=new Set(["choiceSet","inlineChoiceSet","blank","aiAnswerInput","matchingSet","openResponseNode","mathResponse","codingResponse"]),k=new Set(["blankPool","inlinePool"]);function y(t){switch(t.type){case"choiceSet":case"inlineChoiceSet":return t.answer.selectedIds.length>0;case"blank":return t.answer.mode==="choice"?t.answer.choiceId.length>0:t.answer.text.trim().length>0;case"matchingSet":return t.answer.connections.length>0;case"aiAnswer":return t.answer.text.trim().length>0;case"openResponse":{let n=t.answer;return n.recording||n.files&&n.files.length>0?!0:(n.text?.wordCount??0)>0}case"mathResponse":return t.answer.latex.trim().length>0;case"codingResponse":return t.answer.source.trim().length>0;default:{let n=t;return!0}}}function A(t){let n=0,s=e=>{if(Array.isArray(e)){for(let r of e)s(r);return}if(!e||typeof e!="object")return;let{type:i,content:o}=e;if(typeof i=="string"){if(h.has(i)){n+=1;return}if(k.has(i))return}o!==void 0&&s(o)};return s(t),n}export{g as buildSubmitPayload,A as countAnswerableNodes,x as flattenAssessment,b as flattenReview,y as isAnswered};
package/dist/index.d.mts CHANGED
@@ -96,6 +96,12 @@ interface SubmitResult {
96
96
  exerciseFeedback: ExerciseFeedbackData[];
97
97
  /** When true, score banners hide the score (still being reviewed). */
98
98
  awaitingManualGrading?: boolean;
99
+ /**
100
+ * When true, an AI-marked answer has no mark yet (it did not come back in
101
+ * time). The banner then says the answers are still being marked, rather
102
+ * than that a teacher is reviewing open responses.
103
+ */
104
+ aiPending?: boolean;
99
105
  }
100
106
 
101
107
  type SpinnerTheme = "default" | "dots" | "pulse" | "bars";
@@ -139,6 +145,8 @@ interface EmbedResult {
139
145
  attempt_number: number;
140
146
  /** True when the submission contains open-response nodes still pending teacher review. */
141
147
  awaiting_manual_grading?: boolean;
148
+ /** True when an AI-marked answer has no mark yet: it is being marked, not scored 0. */
149
+ ai_pending?: boolean;
142
150
  exercise_results: Array<{
143
151
  exercise_id: string;
144
152
  total_score: number;
@@ -176,6 +184,12 @@ interface EmbedResult {
176
184
  rightId: string;
177
185
  }>;
178
186
  };
187
+ /**
188
+ * The teacher's written feedback on an open response. Only ever present
189
+ * in a review (`GET /submissions/{id}/review`), since at submit time no
190
+ * teacher has graded anything yet.
191
+ */
192
+ teacher_feedback?: RichContent[];
179
193
  }>;
180
194
  }>;
181
195
  }>;
@@ -252,6 +266,68 @@ interface EmbedInstance {
252
266
  /** Unmounts the assessment player and cleans up event listeners. */
253
267
  unmount: () => void;
254
268
  }
269
+ /**
270
+ * Response of `GET /api/v1/submissions/{id}/review`: one finished attempt, with
271
+ * everything needed to draw it as the corrected paper. Fetch it on your server
272
+ * (it needs an API key with `read:results`), send it to the browser, and pass it
273
+ * to `EdpireAssessment.review()` unchanged.
274
+ */
275
+ interface SubmissionReview {
276
+ submission: {
277
+ id: string;
278
+ assessment_id: string;
279
+ learner_ref: string | null;
280
+ status: string;
281
+ attempt_number: number;
282
+ /** How many attempts this learner has made at this assessment, this one included. */
283
+ total_attempts: number;
284
+ score: number;
285
+ max_score: number;
286
+ percentage: number;
287
+ passed: boolean;
288
+ passing_score_percent: number;
289
+ is_fully_graded: boolean;
290
+ /**
291
+ * True while a teacher still has open responses to grade. `score`,
292
+ * `percentage` and `passed` are provisional until it turns false, and the
293
+ * player hides them.
294
+ */
295
+ awaiting_manual_grading: boolean;
296
+ /** True while an AI-marked answer has no mark yet. Optional: older servers did not send it. */
297
+ ai_pending?: boolean;
298
+ submitted_at: string | null;
299
+ };
300
+ assessment: AssessmentLoaderResponse["assessment"];
301
+ meta: Omit<AssessmentLoaderResponse["meta"], "type">;
302
+ branding?: TakeBranding | null;
303
+ /** The learner's answers, in the shape the player collected them. */
304
+ answers: {
305
+ exerciseAnswers: Array<{
306
+ exerciseId: string;
307
+ questionAnswers: Array<{
308
+ questionId: string;
309
+ answers: RuntimeAnswer[];
310
+ }>;
311
+ }>;
312
+ };
313
+ exercise_feedback: EmbedResult["exercise_feedback"];
314
+ }
315
+ interface ReviewOptions {
316
+ /** The response of `GET /api/v1/submissions/{id}/review`, passed through untouched. */
317
+ review: SubmissionReview;
318
+ /** Where to mount the corrected paper. CSS selector string or DOM element. */
319
+ container: string | HTMLElement;
320
+ /** Player UI language. Defaults to the assessment's own language. */
321
+ locale?: ShellLocale;
322
+ /** Branding overrides. Defaults to the org branding carried in the review. */
323
+ branding?: TakeBranding;
324
+ /** Geometry of the question column. Same as `MountOptions.layout`. */
325
+ layout?: ShellLayout;
326
+ /** Shows a back button in the top bar and calls this when it is clicked. */
327
+ onBack?: () => void;
328
+ /** Custom label for the back button. */
329
+ backLabel?: string;
330
+ }
255
331
  interface RenderQuestionOptions {
256
332
  /**
257
333
  * DOM element or CSS selector string to mount into.
@@ -273,6 +349,11 @@ interface RenderQuestionOptions {
273
349
  * Pre-fill answers.
274
350
  */
275
351
  initialAnswers?: RuntimeAnswer[];
352
+ /**
353
+ * Lock the question: answers show but cannot be changed. Since 0.8.0. For
354
+ * reviewing a past attempt one question at a time; see `flattenReview()`.
355
+ */
356
+ readOnly?: boolean;
276
357
  /**
277
358
  * Text direction for the question container.
278
359
  */
@@ -298,6 +379,55 @@ interface QuestionInstance {
298
379
  */
299
380
  unmount(): void;
300
381
  }
382
+ interface AssessmentLoaderResponse {
383
+ assessment: {
384
+ /**
385
+ * Assessment-level annex — reading texts, figures, formula sheets. The
386
+ * player shows it as a side panel on wide screens and a drawer on narrow
387
+ * ones. Null when the assessment has none.
388
+ */
389
+ annex?: unknown | null;
390
+ exercises: Array<{
391
+ id: string;
392
+ sharedContext: unknown | null;
393
+ questions: Array<{
394
+ id: string;
395
+ version: "1.0";
396
+ content: unknown;
397
+ /** Sent by the content loader, not by a review. Nothing in the player reads either. */
398
+ points?: number;
399
+ sequenceNumber?: number;
400
+ answerKeys: never[];
401
+ }>;
402
+ }>;
403
+ };
404
+ meta: {
405
+ id: string;
406
+ title: string;
407
+ type: string;
408
+ category?: string | null;
409
+ /**
410
+ * The assessment's learner-facing language, which also decides text
411
+ * direction. The player uses this unless the host passes `locale`
412
+ * explicitly, so a French assessment reads left-to-right even when embedded
413
+ * in a right-to-left product.
414
+ */
415
+ language?: "en" | "fr" | "ar";
416
+ settings: {
417
+ time_limit_minutes: number | null;
418
+ passing_score_percent: number;
419
+ allowed_attempts: number;
420
+ show_solutions: string;
421
+ };
422
+ };
423
+ attempt_info: {
424
+ attempt_number: number;
425
+ can_retry: boolean;
426
+ attempts_remaining: number | null;
427
+ };
428
+ /** Org branding loaded server-side. May be overridden by MountOptions.branding. */
429
+ branding?: TakeBranding | null;
430
+ }
301
431
 
302
432
  /** Flat per-question answer collected during a custom player session. */
303
433
  interface StoredAnswer {
@@ -447,6 +577,71 @@ declare function flattenAssessment(assessment: Assessment): FlatStep[];
447
577
 
448
578
  declare function renderQuestion(options: RenderQuestionOptions): QuestionInstance;
449
579
 
580
+ /**
581
+ * flattenReview: a finished attempt as a linear sequence of steps, one per
582
+ * question, each ready to hand to `renderQuestion()` read-only.
583
+ *
584
+ * The review counterpart of `flattenAssessment()`. Where the full paper
585
+ * (`EdpireAssessment.review()`) draws the whole attempt at once, this is for
586
+ * flows that show one question per screen, Arcade-style, and want the learner
587
+ * to page back through a past attempt the same way they took it.
588
+ *
589
+ * @example
590
+ * const review = await fetch(`/api/attempts/${id}/review`).then((r) => r.json())
591
+ * const steps = flattenReview(review)
592
+ *
593
+ * const q = renderQuestion({
594
+ * container: "#question",
595
+ * content: steps[i].content,
596
+ * initialAnswers: steps[i].answers,
597
+ * feedback: steps[i].feedback,
598
+ * readOnly: true,
599
+ * })
600
+ * // steps[i].status, steps[i].score / steps[i].maxScore for your own chrome
601
+ *
602
+ * Pure: no React, no DOM. Also exported from `@edpire/sdk/core`.
603
+ */
604
+
605
+ interface ReviewStep {
606
+ exerciseId: string;
607
+ questionId: string;
608
+ /** ContentAST. Pass to renderQuestion's `content`. */
609
+ content: unknown;
610
+ /**
611
+ * The exercise's shared context (reading passage, figure, worked example),
612
+ * or null. Unlike `flattenAssessment()`, carried on every step: a
613
+ * comprehension question reviewed without its passage makes no sense.
614
+ * Render it with renderQuestion too; it is plain content.
615
+ */
616
+ sharedContext: unknown | null;
617
+ /** What the learner answered. Pass to renderQuestion's `initialAnswers`. */
618
+ answers: RuntimeAnswer[];
619
+ /**
620
+ * The marking, in the same shape POST /check returns, so it goes straight
621
+ * into renderQuestion's `feedback`. Includes the expected answer on anything
622
+ * missed and the teacher's comment on an open response.
623
+ */
624
+ feedback: Record<string, unknown>;
625
+ score: number;
626
+ maxScore: number;
627
+ /**
628
+ * The question as a whole. `awaiting_review` while a teacher has yet to mark
629
+ * any part of it; its score is then provisional. `unscored` when it is worth
630
+ * nothing (no answerable input, or 0 points): there was nothing to get
631
+ * wrong, so do not show it as wrong.
632
+ */
633
+ status: "correct" | "partial" | "incorrect" | "awaiting_review" | "unscored";
634
+ /** 0-based position, in the order the assessment presents questions. */
635
+ index: number;
636
+ }
637
+ /**
638
+ * The order is the assessment's (exercises, then questions within each), not
639
+ * the order a shuffled Arcade attempt happened to show. Edpire does not record
640
+ * the order an attempt was presented in; store it yourself if you need to
641
+ * replay it exactly.
642
+ */
643
+ declare function flattenReview(review: SubmissionReview): ReviewStep[];
644
+
450
645
  declare const EdpireAssessment: {
451
646
  /**
452
647
  * Mount an Edpire assessment into the given container.
@@ -463,6 +658,21 @@ declare const EdpireAssessment: {
463
658
  * })
464
659
  */
465
660
  mount(options: MountOptions): EmbedInstance;
661
+ /**
662
+ * Render a finished attempt as the corrected paper: the learner's answers,
663
+ * locked, with every mark, expected answer and teacher comment.
664
+ *
665
+ * Pass the response of `GET /api/v1/submissions/{id}/review` unchanged. Your
666
+ * server fetches it with an API key, after checking the attempt belongs to
667
+ * the signed-in learner. No token, and no request from the browser to Edpire,
668
+ * so the origin allow-list does not apply.
669
+ *
670
+ * @example
671
+ * const review = await fetch(`/api/attempts/${submissionId}/review`).then((r) => r.json())
672
+ * const view = EdpireAssessment.review({ review, container: "#paper" })
673
+ * view.unmount()
674
+ */
675
+ review(options: ReviewOptions): EmbedInstance;
466
676
  };
467
677
 
468
- export { EdpireAssessment, type EmbedError, type EmbedInstance, type EmbedResult, type ExerciseFeedbackData, type FlatStep, type MountOptions, type OverlayConfig, type QuestionInstance, type RenderQuestionOptions, type ShellLayout, type ShellLocale, type SpinnerTheme, type StoredAnswer, type SubmitResult, type TakeBranding, buildSubmitPayload, flattenAssessment, renderQuestion };
678
+ export { EdpireAssessment, type EmbedError, type EmbedInstance, type EmbedResult, type ExerciseFeedbackData, type FlatStep, type MountOptions, type OverlayConfig, type QuestionInstance, type RenderQuestionOptions, type ReviewOptions, type ReviewStep, type ShellLayout, type ShellLocale, type SpinnerTheme, type StoredAnswer, type SubmissionReview, type SubmitResult, type TakeBranding, buildSubmitPayload, flattenAssessment, flattenReview, renderQuestion };