@edpire/sdk 0.6.12 → 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/dist/core.mjs CHANGED
@@ -1 +1 @@
1
- function i(r){let s=[],e=0;for(let t of r.exercises)for(let n of t.questions)s.push({exerciseId:t.id,questionId:n.id,content:n.content_ast,points:n.points,sequenceNumber:n.sequence_number,index:e++});return s}function o(r){let s=new Map;for(let e of r)s.has(e.exerciseId)||s.set(e.exerciseId,[]),s.get(e.exerciseId).push({questionId:e.questionId,answers:e.answers});return{exerciseAnswers:Array.from(s.entries()).map(([e,t])=>({exerciseId:e,questionAnswers:t}))}}export{o as buildSubmitPayload,i as flattenAssessment};
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
@@ -30,6 +30,28 @@ interface TakeBranding {
30
30
  showPoweredBy?: boolean;
31
31
  } | null;
32
32
  }
33
+ /**
34
+ * Geometry of the question column. Branding covers colour and type; this covers
35
+ * the one thing branding could not reach.
36
+ */
37
+ interface ShellLayout {
38
+ /**
39
+ * Max width of the question column. Any CSS length, or `"none"` to fill the
40
+ * container. Default `"48rem"` (768px).
41
+ *
42
+ * The default is a reading measure, not an arbitrary cap: at the runtime's
43
+ * base size it yields roughly 70–90 characters per line, already the top of
44
+ * the comfortable range. Past about `"60rem"` the eye starts losing its place
45
+ * on the return sweep, which costs a learner more on a long comprehension
46
+ * passage than the empty gutters ever did.
47
+ *
48
+ * Keep the default when the player owns the whole page (`h-dvh`, no host
49
+ * chrome) — that is what it was measured for. Set `"none"` when you mount the
50
+ * player inside your own layout and your container already supplies the
51
+ * measure, otherwise learners see a column inside a column.
52
+ */
53
+ maxWidth?: string;
54
+ }
33
55
  type NodeDetail = {
34
56
  type: "choiceSet";
35
57
  correctChoiceIds: string[];
@@ -154,6 +176,12 @@ interface EmbedResult {
154
176
  rightId: string;
155
177
  }>;
156
178
  };
179
+ /**
180
+ * The teacher's written feedback on an open response. Only ever present
181
+ * in a review (`GET /submissions/{id}/review`), since at submit time no
182
+ * teacher has graded anything yet.
183
+ */
184
+ teacher_feedback?: RichContent[];
157
185
  }>;
158
186
  }>;
159
187
  }>;
@@ -189,6 +217,15 @@ interface MountOptions {
189
217
  * — useful for white-labelling.
190
218
  */
191
219
  branding?: TakeBranding;
220
+ /**
221
+ * Geometry of the question column, for the cases branding cannot reach.
222
+ *
223
+ * The player fills the container you give it, but inside that the questions
224
+ * sit in a centred 768px reading column. That is right when the player owns
225
+ * the page and wrong when you mount it inside your own layout, where it reads
226
+ * as a column inside a column. Pass `{ maxWidth: "none" }` for the latter.
227
+ */
228
+ layout?: ShellLayout;
192
229
  /**
193
230
  * When set, the post-submit "View Full Report" button redirects the browser
194
231
  * to this URL with `?submission_id=...&score=...&max_score=...` appended.
@@ -221,6 +258,66 @@ interface EmbedInstance {
221
258
  /** Unmounts the assessment player and cleans up event listeners. */
222
259
  unmount: () => void;
223
260
  }
261
+ /**
262
+ * Response of `GET /api/v1/submissions/{id}/review`: one finished attempt, with
263
+ * everything needed to draw it as the corrected paper. Fetch it on your server
264
+ * (it needs an API key with `read:results`), send it to the browser, and pass it
265
+ * to `EdpireAssessment.review()` unchanged.
266
+ */
267
+ interface SubmissionReview {
268
+ submission: {
269
+ id: string;
270
+ assessment_id: string;
271
+ learner_ref: string | null;
272
+ status: string;
273
+ attempt_number: number;
274
+ /** How many attempts this learner has made at this assessment, this one included. */
275
+ total_attempts: number;
276
+ score: number;
277
+ max_score: number;
278
+ percentage: number;
279
+ passed: boolean;
280
+ passing_score_percent: number;
281
+ is_fully_graded: boolean;
282
+ /**
283
+ * True while a teacher still has open responses to grade. `score`,
284
+ * `percentage` and `passed` are provisional until it turns false, and the
285
+ * player hides them.
286
+ */
287
+ awaiting_manual_grading: boolean;
288
+ submitted_at: string | null;
289
+ };
290
+ assessment: AssessmentLoaderResponse["assessment"];
291
+ meta: Omit<AssessmentLoaderResponse["meta"], "type">;
292
+ branding?: TakeBranding | null;
293
+ /** The learner's answers, in the shape the player collected them. */
294
+ answers: {
295
+ exerciseAnswers: Array<{
296
+ exerciseId: string;
297
+ questionAnswers: Array<{
298
+ questionId: string;
299
+ answers: RuntimeAnswer[];
300
+ }>;
301
+ }>;
302
+ };
303
+ exercise_feedback: EmbedResult["exercise_feedback"];
304
+ }
305
+ interface ReviewOptions {
306
+ /** The response of `GET /api/v1/submissions/{id}/review`, passed through untouched. */
307
+ review: SubmissionReview;
308
+ /** Where to mount the corrected paper. CSS selector string or DOM element. */
309
+ container: string | HTMLElement;
310
+ /** Player UI language. Defaults to the assessment's own language. */
311
+ locale?: ShellLocale;
312
+ /** Branding overrides. Defaults to the org branding carried in the review. */
313
+ branding?: TakeBranding;
314
+ /** Geometry of the question column. Same as `MountOptions.layout`. */
315
+ layout?: ShellLayout;
316
+ /** Shows a back button in the top bar and calls this when it is clicked. */
317
+ onBack?: () => void;
318
+ /** Custom label for the back button. */
319
+ backLabel?: string;
320
+ }
224
321
  interface RenderQuestionOptions {
225
322
  /**
226
323
  * DOM element or CSS selector string to mount into.
@@ -242,6 +339,11 @@ interface RenderQuestionOptions {
242
339
  * Pre-fill answers.
243
340
  */
244
341
  initialAnswers?: RuntimeAnswer[];
342
+ /**
343
+ * Lock the question: answers show but cannot be changed. Since 0.8.0. For
344
+ * reviewing a past attempt one question at a time; see `flattenReview()`.
345
+ */
346
+ readOnly?: boolean;
245
347
  /**
246
348
  * Text direction for the question container.
247
349
  */
@@ -267,6 +369,55 @@ interface QuestionInstance {
267
369
  */
268
370
  unmount(): void;
269
371
  }
372
+ interface AssessmentLoaderResponse {
373
+ assessment: {
374
+ /**
375
+ * Assessment-level annex — reading texts, figures, formula sheets. The
376
+ * player shows it as a side panel on wide screens and a drawer on narrow
377
+ * ones. Null when the assessment has none.
378
+ */
379
+ annex?: unknown | null;
380
+ exercises: Array<{
381
+ id: string;
382
+ sharedContext: unknown | null;
383
+ questions: Array<{
384
+ id: string;
385
+ version: "1.0";
386
+ content: unknown;
387
+ /** Sent by the content loader, not by a review. Nothing in the player reads either. */
388
+ points?: number;
389
+ sequenceNumber?: number;
390
+ answerKeys: never[];
391
+ }>;
392
+ }>;
393
+ };
394
+ meta: {
395
+ id: string;
396
+ title: string;
397
+ type: string;
398
+ category?: string | null;
399
+ /**
400
+ * The assessment's learner-facing language, which also decides text
401
+ * direction. The player uses this unless the host passes `locale`
402
+ * explicitly, so a French assessment reads left-to-right even when embedded
403
+ * in a right-to-left product.
404
+ */
405
+ language?: "en" | "fr" | "ar";
406
+ settings: {
407
+ time_limit_minutes: number | null;
408
+ passing_score_percent: number;
409
+ allowed_attempts: number;
410
+ show_solutions: string;
411
+ };
412
+ };
413
+ attempt_info: {
414
+ attempt_number: number;
415
+ can_retry: boolean;
416
+ attempts_remaining: number | null;
417
+ };
418
+ /** Org branding loaded server-side. May be overridden by MountOptions.branding. */
419
+ branding?: TakeBranding | null;
420
+ }
270
421
 
271
422
  /** Flat per-question answer collected during a custom player session. */
272
423
  interface StoredAnswer {
@@ -416,6 +567,71 @@ declare function flattenAssessment(assessment: Assessment): FlatStep[];
416
567
 
417
568
  declare function renderQuestion(options: RenderQuestionOptions): QuestionInstance;
418
569
 
570
+ /**
571
+ * flattenReview: a finished attempt as a linear sequence of steps, one per
572
+ * question, each ready to hand to `renderQuestion()` read-only.
573
+ *
574
+ * The review counterpart of `flattenAssessment()`. Where the full paper
575
+ * (`EdpireAssessment.review()`) draws the whole attempt at once, this is for
576
+ * flows that show one question per screen, Arcade-style, and want the learner
577
+ * to page back through a past attempt the same way they took it.
578
+ *
579
+ * @example
580
+ * const review = await fetch(`/api/attempts/${id}/review`).then((r) => r.json())
581
+ * const steps = flattenReview(review)
582
+ *
583
+ * const q = renderQuestion({
584
+ * container: "#question",
585
+ * content: steps[i].content,
586
+ * initialAnswers: steps[i].answers,
587
+ * feedback: steps[i].feedback,
588
+ * readOnly: true,
589
+ * })
590
+ * // steps[i].status, steps[i].score / steps[i].maxScore for your own chrome
591
+ *
592
+ * Pure: no React, no DOM. Also exported from `@edpire/sdk/core`.
593
+ */
594
+
595
+ interface ReviewStep {
596
+ exerciseId: string;
597
+ questionId: string;
598
+ /** ContentAST. Pass to renderQuestion's `content`. */
599
+ content: unknown;
600
+ /**
601
+ * The exercise's shared context (reading passage, figure, worked example),
602
+ * or null. Unlike `flattenAssessment()`, carried on every step: a
603
+ * comprehension question reviewed without its passage makes no sense.
604
+ * Render it with renderQuestion too; it is plain content.
605
+ */
606
+ sharedContext: unknown | null;
607
+ /** What the learner answered. Pass to renderQuestion's `initialAnswers`. */
608
+ answers: RuntimeAnswer[];
609
+ /**
610
+ * The marking, in the same shape POST /check returns, so it goes straight
611
+ * into renderQuestion's `feedback`. Includes the expected answer on anything
612
+ * missed and the teacher's comment on an open response.
613
+ */
614
+ feedback: Record<string, unknown>;
615
+ score: number;
616
+ maxScore: number;
617
+ /**
618
+ * The question as a whole. `awaiting_review` while a teacher has yet to mark
619
+ * any part of it; its score is then provisional. `unscored` when it is worth
620
+ * nothing (no answerable input, or 0 points): there was nothing to get
621
+ * wrong, so do not show it as wrong.
622
+ */
623
+ status: "correct" | "partial" | "incorrect" | "awaiting_review" | "unscored";
624
+ /** 0-based position, in the order the assessment presents questions. */
625
+ index: number;
626
+ }
627
+ /**
628
+ * The order is the assessment's (exercises, then questions within each), not
629
+ * the order a shuffled Arcade attempt happened to show. Edpire does not record
630
+ * the order an attempt was presented in; store it yourself if you need to
631
+ * replay it exactly.
632
+ */
633
+ declare function flattenReview(review: SubmissionReview): ReviewStep[];
634
+
419
635
  declare const EdpireAssessment: {
420
636
  /**
421
637
  * Mount an Edpire assessment into the given container.
@@ -432,6 +648,21 @@ declare const EdpireAssessment: {
432
648
  * })
433
649
  */
434
650
  mount(options: MountOptions): EmbedInstance;
651
+ /**
652
+ * Render a finished attempt as the corrected paper: the learner's answers,
653
+ * locked, with every mark, expected answer and teacher comment.
654
+ *
655
+ * Pass the response of `GET /api/v1/submissions/{id}/review` unchanged. Your
656
+ * server fetches it with an API key, after checking the attempt belongs to
657
+ * the signed-in learner. No token, and no request from the browser to Edpire,
658
+ * so the origin allow-list does not apply.
659
+ *
660
+ * @example
661
+ * const review = await fetch(`/api/attempts/${submissionId}/review`).then((r) => r.json())
662
+ * const view = EdpireAssessment.review({ review, container: "#paper" })
663
+ * view.unmount()
664
+ */
665
+ review(options: ReviewOptions): EmbedInstance;
435
666
  };
436
667
 
437
- export { EdpireAssessment, type EmbedError, type EmbedInstance, type EmbedResult, type ExerciseFeedbackData, type FlatStep, type MountOptions, type OverlayConfig, type QuestionInstance, type RenderQuestionOptions, type ShellLocale, type SpinnerTheme, type StoredAnswer, type SubmitResult, type TakeBranding, buildSubmitPayload, flattenAssessment, renderQuestion };
668
+ 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 };