@edpire/sdk 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,65 @@ integrators pin a version and had no way to tell what a bump would change.
9
9
  The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
10
10
  the package follows [semantic versioning](https://semver.org/spec/v2.0.0.html).
11
11
 
12
+ ## [0.8.0] - 2026-09-23
13
+
14
+ ### Added
15
+
16
+ - **`EdpireAssessment.review({ review, container })`**: reopens a finished
17
+ attempt as the corrected paper. The learner's answers are replayed into
18
+ locked questions, with every mark, the expected answer on anything missed,
19
+ and the teacher's comments on open responses. It is the screen the player
20
+ shows on submit, available again afterwards.
21
+
22
+ ```javascript
23
+ const review = await fetch(`/api/attempts/${id}/review`).then((r) => r.json())
24
+ EdpireAssessment.review({ review, container: "#paper" })
25
+ ```
26
+
27
+ `review` is the response of the new `GET /api/v1/submissions/{id}/review`,
28
+ passed through unchanged. Your server fetches it with an API key after
29
+ checking ownership, so there is no token and no browser request to Edpire,
30
+ and the origin allow-list does not apply.
31
+
32
+ - **`flattenReview(review)`**: the same attempt as one step per question, for
33
+ flows that show one question per screen. Each step carries the content, the
34
+ learner's answers, feedback in `/check`'s shape, the score and a status, plus
35
+ the exercise's shared context. Pure, so also in `@edpire/sdk/core` and on the
36
+ CDN global.
37
+
38
+ ```javascript
39
+ const steps = flattenReview(review)
40
+ renderQuestion({ container, content: steps[i].content, initialAnswers: steps[i].answers,
41
+ feedback: steps[i].feedback, readOnly: true })
42
+ ```
43
+
44
+ - **`readOnly`** on `renderQuestion()` and `EdpireQuestion`: answers show but
45
+ cannot change.
46
+
47
+ - **`EdpireClient.getSubmissionReview(id, { learnerRef })`** for that fetch.
48
+ With `learnerRef`, a submission belonging to anyone else is a 404.
49
+
50
+ - **`EdpireClient.getLearnerResults(ref, { assessmentId })`**: one
51
+ assessment's attempt history.
52
+
53
+ - **`AssessmentShellProps.review`** and the **`ShellReview`** type, for React
54
+ consumers rendering the shell directly. **`SubmissionReview`**,
55
+ **`ReviewOptions`** and **`ReviewStep`** types.
56
+
57
+ - **`teacher_feedback`** on `EmbedResult` node results. Only ever present in a
58
+ review: at submit time no teacher has marked anything yet.
59
+
60
+ ### Changed
61
+
62
+ - The back button (`onBack`) now also shows in review mode. Before submission it
63
+ behaves as before.
64
+
65
+ ### Notes for integrators
66
+
67
+ No breaking changes. Review needs the new endpoint, which is live on
68
+ edpire.com from this release. See the attempt history guide:
69
+ https://docs.edpire.com/developer/attempt-history
70
+
12
71
  ## [0.7.0] - 2026-09-22
13
72
 
14
73
  ### Added
package/README.md CHANGED
@@ -90,7 +90,7 @@ embed.unmount()
90
90
  **CDN (no bundler):**
91
91
 
92
92
  ```html
93
- <script src="https://cdn.jsdelivr.net/npm/@edpire/sdk@0.7.0/dist/umd/index.global.js"></script>
93
+ <script src="https://cdn.jsdelivr.net/npm/@edpire/sdk@0.8.0/dist/umd/index.global.js"></script>
94
94
  <script>
95
95
  EdpireSDK.EdpireAssessment.mount({ token, container: "#root", onComplete: handleDone })
96
96
  </script>
@@ -248,6 +248,18 @@ If omitted, rate limiting falls back to `learner_ref`, which accumulates across
248
248
 
249
249
  **Required API key scopes:** `read:assessments` (for fetching and checking) + `write:submissions` (for the final submit).
250
250
 
251
+ **Reviewing a past attempt, one question at a time** (since 0.8.0): fetch it on your server with `client.getSubmissionReview(id, { learnerRef })`, then in the browser:
252
+
253
+ ```typescript
254
+ const steps = flattenReview(review)
255
+ renderQuestion({
256
+ container, content: steps[i].content,
257
+ initialAnswers: steps[i].answers, feedback: steps[i].feedback, readOnly: true,
258
+ })
259
+ ```
260
+
261
+ For the whole attempt at once: `EdpireAssessment.review({ review, container })`. See the [attempt history guide](https://docs.edpire.com/developer/attempt-history).
262
+
251
263
  **When to use:** You want full control over pacing, navigation, animations, or scoring UX. You're building a Duolingo-style flow, a timed drill, a practice mode, or any experience that goes beyond a linear "answer all → submit" form.
252
264
 
253
265
  ---
@@ -316,7 +328,7 @@ The integration uses two parts:
316
328
  </head>
317
329
  <body>
318
330
  <div id="root"></div>
319
- <script src="https://cdn.jsdelivr.net/npm/@edpire/sdk@0.7.0/dist/umd/index.global.js"></script>
331
+ <script src="https://cdn.jsdelivr.net/npm/@edpire/sdk@0.8.0/dist/umd/index.global.js"></script>
320
332
  <script>
321
333
  EdpireSDK.EdpireAssessment.mount({
322
334
  token: "{{EMBED_TOKEN}}", // replace server-side before loading
package/dist/client.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 {
@@ -10,6 +11,191 @@ interface StoredAnswer {
10
11
  answers: RuntimeAnswer[];
11
12
  }
12
13
 
14
+ /**
15
+ * Public types for the polished Assessment Shell.
16
+ *
17
+ * The shell renders the same UI used at edpire.com's hosted player.
18
+ * Consumers using the EdpireAssessment.mount() API get these types
19
+ * indirectly via MountOptions; consumers rendering <AssessmentShell>
20
+ * directly import them from "@edpire/sdk/react".
21
+ */
22
+
23
+ /** Branding payload accepted by AssessmentShell. Mirrors what edpire.com sends. */
24
+ interface TakeBranding {
25
+ /** Org display name — shown as the alt text on the logo. */
26
+ name: string;
27
+ /** Logo URL (any https URL). When absent, the Edpire logo is shown. */
28
+ logoUrl: string | null;
29
+ branding: {
30
+ /** CSS color for the primary action buttons (Submit, View Report, etc.). */
31
+ primaryColor?: string;
32
+ /** CSS color used as the background of the top bar and per-exercise headers. */
33
+ accentColor?: string;
34
+ /** font-family applied to the shell. Pair with `fontUrl` for non-system fonts. */
35
+ fontFamily?: string;
36
+ /** A Google Fonts stylesheet URL; the shell injects a <link> tag at mount. */
37
+ fontUrl?: string;
38
+ /** When false, the "Powered by Edpire" watermark is hidden. Default true. */
39
+ showPoweredBy?: boolean;
40
+ } | null;
41
+ }
42
+
43
+ /**
44
+ * Public types for the Edpire Embeddable JS SDK.
45
+ */
46
+
47
+ type NodeFeedbackStatus = "correct" | "partial" | "incorrect" | "awaiting_review";
48
+ interface EmbedResult {
49
+ submission_id: string;
50
+ score: number;
51
+ max_score: number;
52
+ percentage: number;
53
+ passed: boolean;
54
+ passing_score_percent: number;
55
+ attempt_number: number;
56
+ /** True when the submission contains open-response nodes still pending teacher review. */
57
+ awaiting_manual_grading?: boolean;
58
+ exercise_results: Array<{
59
+ exercise_id: string;
60
+ total_score: number;
61
+ max_score: number;
62
+ question_results: Array<{
63
+ question_id: string;
64
+ score: number;
65
+ correct: boolean;
66
+ points: number;
67
+ }>;
68
+ }>;
69
+ exercise_feedback: Array<{
70
+ exercise_id: string;
71
+ questions: Array<{
72
+ question_id: string;
73
+ node_results: Array<{
74
+ node_id: string;
75
+ status: NodeFeedbackStatus;
76
+ score: number;
77
+ max_score: number;
78
+ feedback?: string;
79
+ display_answer?: string;
80
+ detail?: {
81
+ type: "choiceSet";
82
+ correctChoiceIds: string[];
83
+ } | {
84
+ type: "matchingSet";
85
+ pairFeedback: Array<{
86
+ leftId: string;
87
+ rightId: string;
88
+ correct: boolean;
89
+ }>;
90
+ correctPairings: Array<{
91
+ leftId: string;
92
+ rightId: string;
93
+ }>;
94
+ };
95
+ /**
96
+ * The teacher's written feedback on an open response. Only ever present
97
+ * in a review (`GET /submissions/{id}/review`), since at submit time no
98
+ * teacher has graded anything yet.
99
+ */
100
+ teacher_feedback?: RichContent[];
101
+ }>;
102
+ }>;
103
+ }>;
104
+ }
105
+ /**
106
+ * Response of `GET /api/v1/submissions/{id}/review`: one finished attempt, with
107
+ * everything needed to draw it as the corrected paper. Fetch it on your server
108
+ * (it needs an API key with `read:results`), send it to the browser, and pass it
109
+ * to `EdpireAssessment.review()` unchanged.
110
+ */
111
+ interface SubmissionReview {
112
+ submission: {
113
+ id: string;
114
+ assessment_id: string;
115
+ learner_ref: string | null;
116
+ status: string;
117
+ attempt_number: number;
118
+ /** How many attempts this learner has made at this assessment, this one included. */
119
+ total_attempts: number;
120
+ score: number;
121
+ max_score: number;
122
+ percentage: number;
123
+ passed: boolean;
124
+ passing_score_percent: number;
125
+ is_fully_graded: boolean;
126
+ /**
127
+ * True while a teacher still has open responses to grade. `score`,
128
+ * `percentage` and `passed` are provisional until it turns false, and the
129
+ * player hides them.
130
+ */
131
+ awaiting_manual_grading: boolean;
132
+ submitted_at: string | null;
133
+ };
134
+ assessment: AssessmentLoaderResponse["assessment"];
135
+ meta: Omit<AssessmentLoaderResponse["meta"], "type">;
136
+ branding?: TakeBranding | null;
137
+ /** The learner's answers, in the shape the player collected them. */
138
+ answers: {
139
+ exerciseAnswers: Array<{
140
+ exerciseId: string;
141
+ questionAnswers: Array<{
142
+ questionId: string;
143
+ answers: RuntimeAnswer[];
144
+ }>;
145
+ }>;
146
+ };
147
+ exercise_feedback: EmbedResult["exercise_feedback"];
148
+ }
149
+ interface AssessmentLoaderResponse {
150
+ assessment: {
151
+ /**
152
+ * Assessment-level annex — reading texts, figures, formula sheets. The
153
+ * player shows it as a side panel on wide screens and a drawer on narrow
154
+ * ones. Null when the assessment has none.
155
+ */
156
+ annex?: unknown | null;
157
+ exercises: Array<{
158
+ id: string;
159
+ sharedContext: unknown | null;
160
+ questions: Array<{
161
+ id: string;
162
+ version: "1.0";
163
+ content: unknown;
164
+ /** Sent by the content loader, not by a review. Nothing in the player reads either. */
165
+ points?: number;
166
+ sequenceNumber?: number;
167
+ answerKeys: never[];
168
+ }>;
169
+ }>;
170
+ };
171
+ meta: {
172
+ id: string;
173
+ title: string;
174
+ type: string;
175
+ category?: string | null;
176
+ /**
177
+ * The assessment's learner-facing language, which also decides text
178
+ * direction. The player uses this unless the host passes `locale`
179
+ * explicitly, so a French assessment reads left-to-right even when embedded
180
+ * in a right-to-left product.
181
+ */
182
+ language?: "en" | "fr" | "ar";
183
+ settings: {
184
+ time_limit_minutes: number | null;
185
+ passing_score_percent: number;
186
+ allowed_attempts: number;
187
+ show_solutions: string;
188
+ };
189
+ };
190
+ attempt_info: {
191
+ attempt_number: number;
192
+ can_retry: boolean;
193
+ attempts_remaining: number | null;
194
+ };
195
+ /** Org branding loaded server-side. May be overridden by MountOptions.branding. */
196
+ branding?: TakeBranding | null;
197
+ }
198
+
13
199
  /**
14
200
  * @edpire/sdk/client — Server-side API client for Edpire.
15
201
  *
@@ -345,16 +531,33 @@ declare class EdpireClient {
345
531
  * @throws {EdpireError} 404 if not found, 401/403 for auth issues.
346
532
  */
347
533
  getSubmission(id: string): Promise<Submission>;
534
+ /**
535
+ * Fetch one finished attempt as the corrected paper: content, the learner's
536
+ * answers and the marking. Send the result to the browser and pass it to
537
+ * `EdpireAssessment.review()` unchanged.
538
+ *
539
+ * @param id - Submission UUID.
540
+ * @param params.learnerRef - The learner you believe owns it. When given, a
541
+ * submission belonging to anyone else is a 404, so a bug in your own
542
+ * ownership check cannot show one learner another's paper. Always pass it.
543
+ * @throws {EdpireError} 404 (not found, or not this learner's), 409 (not
544
+ * submitted yet, or the assessment has been unpublished since).
545
+ */
546
+ getSubmissionReview(id: string, params?: {
547
+ learnerRef?: string;
548
+ }): Promise<SubmissionReview>;
348
549
  /**
349
550
  * Fetch all submissions for a learner.
350
551
  *
351
552
  * @param learnerRef - Your stable user ID (the `learner_ref` you passed during submit).
553
+ * @param params.assessmentId - Only this assessment's attempts.
352
554
  * @param params.page - Page number (default 1).
353
555
  * @param params.limit - Items per page (default 50, max 100).
354
556
  * @returns Paginated list of submissions.
355
557
  * @throws {EdpireError} 401/403 for auth issues.
356
558
  */
357
559
  getLearnerResults(learnerRef: string, params?: {
560
+ assessmentId?: string;
358
561
  page?: number;
359
562
  limit?: number;
360
563
  }): Promise<PaginatedResponse<Submission>>;
@@ -528,4 +731,4 @@ declare function createEdpireTokenHandler(opts: EdpireTokenHandlerOptions): (req
528
731
  */
529
732
  declare function toNodeHandler(handler: (req: Request) => Promise<Response>): (req: ConnectRequest, res: ConnectResponse) => void;
530
733
 
531
- export { type Assessment, type AssessmentExercise, type AssessmentQuestion, type AssessmentSummary, type CheckOptions, type CheckResult, EdpireClient, type EdpireClientOptions, EdpireError, type EdpireTokenHandlerOptions, type EmbedToken, type GradeResult, type PaginatedResponse, type StoredAnswer, type Submission, type SubmitOptions, type Webhook, type WebhookWithSecret, createEdpireTokenHandler, toNodeHandler };
734
+ export { type Assessment, type AssessmentExercise, type AssessmentQuestion, type AssessmentSummary, type CheckOptions, type CheckResult, EdpireClient, type EdpireClientOptions, EdpireError, type EdpireTokenHandlerOptions, type EmbedToken, type GradeResult, type PaginatedResponse, type StoredAnswer, type Submission, type SubmissionReview, type SubmitOptions, type Webhook, type WebhookWithSecret, createEdpireTokenHandler, toNodeHandler };
package/dist/client.mjs CHANGED
@@ -182,10 +182,27 @@ var EdpireClient = class {
182
182
  async getSubmission(id) {
183
183
  return this.request("GET", `/submissions/${id}`);
184
184
  }
185
+ /**
186
+ * Fetch one finished attempt as the corrected paper: content, the learner's
187
+ * answers and the marking. Send the result to the browser and pass it to
188
+ * `EdpireAssessment.review()` unchanged.
189
+ *
190
+ * @param id - Submission UUID.
191
+ * @param params.learnerRef - The learner you believe owns it. When given, a
192
+ * submission belonging to anyone else is a 404, so a bug in your own
193
+ * ownership check cannot show one learner another's paper. Always pass it.
194
+ * @throws {EdpireError} 404 (not found, or not this learner's), 409 (not
195
+ * submitted yet, or the assessment has been unpublished since).
196
+ */
197
+ async getSubmissionReview(id, params) {
198
+ const query = params?.learnerRef ? `?learner_ref=${encodeURIComponent(params.learnerRef)}` : "";
199
+ return this.request("GET", `/submissions/${id}/review${query}`);
200
+ }
185
201
  /**
186
202
  * Fetch all submissions for a learner.
187
203
  *
188
204
  * @param learnerRef - Your stable user ID (the `learner_ref` you passed during submit).
205
+ * @param params.assessmentId - Only this assessment's attempts.
189
206
  * @param params.page - Page number (default 1).
190
207
  * @param params.limit - Items per page (default 50, max 100).
191
208
  * @returns Paginated list of submissions.
@@ -194,6 +211,7 @@ var EdpireClient = class {
194
211
  async getLearnerResults(learnerRef, params) {
195
212
  const query = new URLSearchParams();
196
213
  query.set("learner_ref", learnerRef);
214
+ if (params?.assessmentId) query.set("assessment_id", params.assessmentId);
197
215
  if (params?.page) query.set("page", String(params.page));
198
216
  if (params?.limit) query.set("limit", String(params.limit));
199
217
  return this.requestPaginated(
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,191 @@ 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
+ exercise_results: Array<{
93
+ exercise_id: string;
94
+ total_score: number;
95
+ max_score: number;
96
+ question_results: Array<{
97
+ question_id: string;
98
+ score: number;
99
+ correct: boolean;
100
+ points: number;
101
+ }>;
102
+ }>;
103
+ exercise_feedback: Array<{
104
+ exercise_id: string;
105
+ questions: Array<{
106
+ question_id: string;
107
+ node_results: Array<{
108
+ node_id: string;
109
+ status: NodeFeedbackStatus;
110
+ score: number;
111
+ max_score: number;
112
+ feedback?: string;
113
+ display_answer?: string;
114
+ detail?: {
115
+ type: "choiceSet";
116
+ correctChoiceIds: string[];
117
+ } | {
118
+ type: "matchingSet";
119
+ pairFeedback: Array<{
120
+ leftId: string;
121
+ rightId: string;
122
+ correct: boolean;
123
+ }>;
124
+ correctPairings: Array<{
125
+ leftId: string;
126
+ rightId: string;
127
+ }>;
128
+ };
129
+ /**
130
+ * The teacher's written feedback on an open response. Only ever present
131
+ * in a review (`GET /submissions/{id}/review`), since at submit time no
132
+ * teacher has graded anything yet.
133
+ */
134
+ teacher_feedback?: RichContent[];
135
+ }>;
136
+ }>;
137
+ }>;
138
+ }
139
+ /**
140
+ * Response of `GET /api/v1/submissions/{id}/review`: one finished attempt, with
141
+ * everything needed to draw it as the corrected paper. Fetch it on your server
142
+ * (it needs an API key with `read:results`), send it to the browser, and pass it
143
+ * to `EdpireAssessment.review()` unchanged.
144
+ */
145
+ interface SubmissionReview {
146
+ submission: {
147
+ id: string;
148
+ assessment_id: string;
149
+ learner_ref: string | null;
150
+ status: string;
151
+ attempt_number: number;
152
+ /** How many attempts this learner has made at this assessment, this one included. */
153
+ total_attempts: number;
154
+ score: number;
155
+ max_score: number;
156
+ percentage: number;
157
+ passed: boolean;
158
+ passing_score_percent: number;
159
+ is_fully_graded: boolean;
160
+ /**
161
+ * True while a teacher still has open responses to grade. `score`,
162
+ * `percentage` and `passed` are provisional until it turns false, and the
163
+ * player hides them.
164
+ */
165
+ awaiting_manual_grading: boolean;
166
+ submitted_at: string | null;
167
+ };
168
+ assessment: AssessmentLoaderResponse["assessment"];
169
+ meta: Omit<AssessmentLoaderResponse["meta"], "type">;
170
+ branding?: TakeBranding | null;
171
+ /** The learner's answers, in the shape the player collected them. */
172
+ answers: {
173
+ exerciseAnswers: Array<{
174
+ exerciseId: string;
175
+ questionAnswers: Array<{
176
+ questionId: string;
177
+ answers: RuntimeAnswer[];
178
+ }>;
179
+ }>;
180
+ };
181
+ exercise_feedback: EmbedResult["exercise_feedback"];
182
+ }
183
+ interface AssessmentLoaderResponse {
184
+ assessment: {
185
+ /**
186
+ * Assessment-level annex — reading texts, figures, formula sheets. The
187
+ * player shows it as a side panel on wide screens and a drawer on narrow
188
+ * ones. Null when the assessment has none.
189
+ */
190
+ annex?: unknown | null;
191
+ exercises: Array<{
192
+ id: string;
193
+ sharedContext: unknown | null;
194
+ questions: Array<{
195
+ id: string;
196
+ version: "1.0";
197
+ content: unknown;
198
+ /** Sent by the content loader, not by a review. Nothing in the player reads either. */
199
+ points?: number;
200
+ sequenceNumber?: number;
201
+ answerKeys: never[];
202
+ }>;
203
+ }>;
204
+ };
205
+ meta: {
206
+ id: string;
207
+ title: string;
208
+ type: string;
209
+ category?: string | null;
210
+ /**
211
+ * The assessment's learner-facing language, which also decides text
212
+ * direction. The player uses this unless the host passes `locale`
213
+ * explicitly, so a French assessment reads left-to-right even when embedded
214
+ * in a right-to-left product.
215
+ */
216
+ language?: "en" | "fr" | "ar";
217
+ settings: {
218
+ time_limit_minutes: number | null;
219
+ passing_score_percent: number;
220
+ allowed_attempts: number;
221
+ show_solutions: string;
222
+ };
223
+ };
224
+ attempt_info: {
225
+ attempt_number: number;
226
+ can_retry: boolean;
227
+ attempts_remaining: number | null;
228
+ };
229
+ /** Org branding loaded server-side. May be overridden by MountOptions.branding. */
230
+ branding?: TakeBranding | null;
231
+ }
232
+
47
233
  /**
48
234
  * @edpire/sdk/client — Server-side API client for Edpire.
49
235
  *
@@ -146,6 +332,71 @@ interface FlatStep {
146
332
  */
147
333
  declare function flattenAssessment(assessment: Assessment): FlatStep[];
148
334
 
335
+ /**
336
+ * flattenReview: a finished attempt as a linear sequence of steps, one per
337
+ * question, each ready to hand to `renderQuestion()` read-only.
338
+ *
339
+ * The review counterpart of `flattenAssessment()`. Where the full paper
340
+ * (`EdpireAssessment.review()`) draws the whole attempt at once, this is for
341
+ * flows that show one question per screen, Arcade-style, and want the learner
342
+ * to page back through a past attempt the same way they took it.
343
+ *
344
+ * @example
345
+ * const review = await fetch(`/api/attempts/${id}/review`).then((r) => r.json())
346
+ * const steps = flattenReview(review)
347
+ *
348
+ * const q = renderQuestion({
349
+ * container: "#question",
350
+ * content: steps[i].content,
351
+ * initialAnswers: steps[i].answers,
352
+ * feedback: steps[i].feedback,
353
+ * readOnly: true,
354
+ * })
355
+ * // steps[i].status, steps[i].score / steps[i].maxScore for your own chrome
356
+ *
357
+ * Pure: no React, no DOM. Also exported from `@edpire/sdk/core`.
358
+ */
359
+
360
+ interface ReviewStep {
361
+ exerciseId: string;
362
+ questionId: string;
363
+ /** ContentAST. Pass to renderQuestion's `content`. */
364
+ content: unknown;
365
+ /**
366
+ * The exercise's shared context (reading passage, figure, worked example),
367
+ * or null. Unlike `flattenAssessment()`, carried on every step: a
368
+ * comprehension question reviewed without its passage makes no sense.
369
+ * Render it with renderQuestion too; it is plain content.
370
+ */
371
+ sharedContext: unknown | null;
372
+ /** What the learner answered. Pass to renderQuestion's `initialAnswers`. */
373
+ answers: RuntimeAnswer[];
374
+ /**
375
+ * The marking, in the same shape POST /check returns, so it goes straight
376
+ * into renderQuestion's `feedback`. Includes the expected answer on anything
377
+ * missed and the teacher's comment on an open response.
378
+ */
379
+ feedback: Record<string, unknown>;
380
+ score: number;
381
+ maxScore: number;
382
+ /**
383
+ * The question as a whole. `awaiting_review` while a teacher has yet to mark
384
+ * any part of it; its score is then provisional. `unscored` when it is worth
385
+ * nothing (no answerable input, or 0 points): there was nothing to get
386
+ * wrong, so do not show it as wrong.
387
+ */
388
+ status: "correct" | "partial" | "incorrect" | "awaiting_review" | "unscored";
389
+ /** 0-based position, in the order the assessment presents questions. */
390
+ index: number;
391
+ }
392
+ /**
393
+ * The order is the assessment's (exercises, then questions within each), not
394
+ * the order a shuffled Arcade attempt happened to show. Edpire does not record
395
+ * the order an attempt was presented in; store it yourself if you need to
396
+ * replay it exactly.
397
+ */
398
+ declare function flattenReview(review: SubmissionReview): ReviewStep[];
399
+
149
400
  /**
150
401
  * "Did the learner actually answer this?"
151
402
  *
@@ -180,4 +431,4 @@ declare function isAnswered(answer: RuntimeAnswer): boolean;
180
431
  */
181
432
  declare function countAnswerableNodes(content: unknown): number;
182
433
 
183
- export { type FlatStep, type StoredAnswer, buildSubmitPayload, countAnswerableNodes, flattenAssessment, isAnswered };
434
+ 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};