@learnpack/learnpack 5.0.363 → 5.0.364

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.
@@ -49,6 +49,12 @@ import {
49
49
  insertStepInCorrectPosition,
50
50
  } from "../utils/creatorUtilities"
51
51
  import { findFinishedLessons } from "../utils/lessonRecovery"
52
+ import {
53
+ MAX_AUTO_RETRIES,
54
+ isStaleWebhook,
55
+ planGenerationRetry,
56
+ RetryPlan,
57
+ } from "../utils/generationRetry"
52
58
  // import { handleAssetCreation } from "./publish"
53
59
  import axios from "axios"
54
60
  import * as FormData from "form-data"
@@ -129,6 +135,18 @@ import type { RedisClientType } from "redis"
129
135
  import { HistoryManager } from "../managers/historyManager"
130
136
  import { ReadmeHistoryService } from "../managers/readmeHistoryService"
131
137
  import { sanitizeReadmeNewlines } from "../utils/readmeSanitizer"
138
+ import {
139
+ buildInteractivityGuide,
140
+ ComponentsGuideUnavailableError,
141
+ componentsGuideLoader,
142
+ decidedByFromGuide,
143
+ } from "../utils/interactivity/componentsGuide"
144
+ import {
145
+ describeInteractivityFailure,
146
+ isInteractivityV2Enabled,
147
+ planInteractivityRequest,
148
+ } from "../utils/interactivity/interactivityPlan"
149
+ import { processInteractivityResult } from "../utils/interactivity/structuredComponents"
132
150
 
133
151
  // eslint-disable-next-line
134
152
  const frontMatter = require("front-matter");
@@ -159,7 +177,11 @@ function opId(): string {
159
177
  return crypto.randomBytes(4).toString("hex")
160
178
  }
161
179
 
162
- // Serialization per course to prevent syllabus race conditions
180
+ // Serialization per course to prevent syllabus race conditions. The queue lives
181
+ // in memory, so it only orders requests within one process: serve runs on a
182
+ // single Heroku dyno. With more than one, it has to move to Redis.
183
+ // A function running in the queue must not call serializeByCourse for the same
184
+ // course, or it waits for itself.
163
185
  const courseQueues = new Map<string, Promise<unknown>>()
164
186
 
165
187
  async function serializeByCourse<T>(
@@ -167,11 +189,11 @@ async function serializeByCourse<T>(
167
189
  fn: () => Promise<T>
168
190
  ): Promise<T> {
169
191
  const prev = courseQueues.get(courseSlug) ?? Promise.resolve()
192
+ // A failure belongs to its own caller. Without the catch, every function
193
+ // queued after it would be skipped and fail with the same error.
170
194
  const next = prev
195
+ .catch(() => null)
171
196
  .then(() => fn())
172
- .catch(error => {
173
- throw error
174
- })
175
197
  .finally(() => {
176
198
  if (courseQueues.get(courseSlug) === next) {
177
199
  courseQueues.delete(courseSlug)
@@ -696,12 +718,45 @@ async function startInteractivityGeneration(
696
718
  courseSlug: string,
697
719
  purposeSlug: string,
698
720
  bucket: Bucket,
699
- lastLesson = ""
721
+ lastLesson = "",
722
+ options: { forceV1?: boolean } = {}
700
723
  ): Promise<number> {
701
724
  const exSlug = slugify(exercise.id + "-" + exercise.title)
702
725
  console.log("Starting interactivity generation for", exSlug)
703
726
 
704
- const webhookUrl = `${process.env.HOST}/webhooks/${courseSlug}/interactivity-processor/${exercise.uid}/${rigoToken}`
727
+ const request = planInteractivityRequest({
728
+ v2Enabled: isInteractivityV2Enabled(),
729
+ forceV1: options.forceV1,
730
+ })
731
+
732
+ // The structured template (v2) needs the components guide: without it the
733
+ // model does not know what each component field means. The guide is loaded
734
+ // before anything else so that rigobot is never called without it.
735
+ let componentsYml: string
736
+ if (request.tpl === "v2") {
737
+ try {
738
+ componentsYml = buildInteractivityGuide(
739
+ await componentsGuideLoader.load()
740
+ )
741
+ } catch (error) {
742
+ if (error instanceof ComponentsGuideUnavailableError) {
743
+ console.error("Components guide unavailable:", error.message)
744
+ emitToCourse(courseSlug, "course-creation", {
745
+ lesson: exSlug,
746
+ status: "error",
747
+ log: `❌ Could not load the components guide for lesson ${exercise.title}; retry the generation`,
748
+ })
749
+ }
750
+
751
+ throw error
752
+ }
753
+ } else {
754
+ componentsYml = await fetchComponentsYml()
755
+ }
756
+
757
+ // The template and the attempt travel in the webhook URL: rigobot posts back
758
+ // to this exact URL, and its error payload says neither.
759
+ const webhookUrl = `${process.env.HOST}/webhooks/${courseSlug}/interactivity-processor/${exercise.uid}/${rigoToken}${request.webhookQuery}`
705
760
 
706
761
  // Emit notification that interactivity generation is starting
707
762
  emitToCourse(courseSlug, "course-creation", {
@@ -710,8 +765,6 @@ async function startInteractivityGeneration(
710
765
  log: `🔄 Starting interactivity generation for lesson: ${exercise.title}`,
711
766
  })
712
767
 
713
- const componentsYml = await fetchComponentsYml()
714
-
715
768
  const currentSyllabus = await getSyllabus(courseSlug, bucket)
716
769
 
717
770
  const fullSyllabus = {
@@ -734,9 +787,16 @@ async function startInteractivityGeneration(
734
787
  current_syllabus: JSON.stringify(fullSyllabus),
735
788
  lesson_info: JSON.stringify(lessonCleaner(exercise)),
736
789
  },
737
- webhookUrl
790
+ webhookUrl,
791
+ request.slug
738
792
  )
739
793
 
794
+ if (!res) {
795
+ throw new Error(
796
+ `Could not start interactivity generation for lesson ${exSlug} (${request.slug})`
797
+ )
798
+ }
799
+
740
800
  return res.id
741
801
  }
742
802
 
@@ -923,6 +983,63 @@ async function updateLessonWithInitialContent(
923
983
  return lesson
924
984
  }
925
985
 
986
+ /**
987
+ * Changes one lesson on a fresh read of the syllabus. Saving a copy read
988
+ * earlier would undo whatever was saved since, like the lesson's
989
+ * `initialContent`.
990
+ * @param courseSlug - The course.
991
+ * @param lessonUID - The lesson to change.
992
+ * @param bucket - The bucket that holds the syllabus.
993
+ * @param patch - Changes the lesson in place. Gets the course language.
994
+ * @returns Once the syllabus is saved, or right away if the lesson is gone.
995
+ */
996
+ async function patchLesson(
997
+ courseSlug: string,
998
+ lessonUID: string,
999
+ bucket: Bucket,
1000
+ patch: (lesson: Lesson, language: string) => void
1001
+ ): Promise<void> {
1002
+ const syllabus = await getSyllabus(courseSlug, bucket)
1003
+ const lesson = syllabus.lessons.find(lesson => lesson.uid === lessonUID)
1004
+
1005
+ if (!lesson) {
1006
+ console.error(`Lesson ${lessonUID} not found in syllabus`)
1007
+ return
1008
+ }
1009
+
1010
+ patch(lesson, syllabus.courseInfo.language || "en")
1011
+ await saveSyllabus(courseSlug, syllabus, bucket)
1012
+ }
1013
+
1014
+ /**
1015
+ * The translation entry of a lesson that waits for a new job.
1016
+ * @param completionId - The Rigobot job.
1017
+ * @param autoRetries - The automatic retries of the current phase so far.
1018
+ * @returns The entry to save under the course language.
1019
+ */
1020
+ const startedJob = (completionId: number, autoRetries = 0) => ({
1021
+ completionId,
1022
+ startedAt: Date.now(),
1023
+ completedAt: 0,
1024
+ autoRetries,
1025
+ })
1026
+
1027
+ /**
1028
+ * The creator log for a failed job that is not retried.
1029
+ * @param reason - Why there is no retry.
1030
+ * @param phase - What the job generated.
1031
+ * @param exSlug - The lesson slug.
1032
+ * @returns The log line.
1033
+ */
1034
+ const noRetryLog = (
1035
+ reason: Extract<RetryPlan, { retry: false }>["reason"],
1036
+ phase: string,
1037
+ exSlug: string
1038
+ ): string =>
1039
+ reason === "permanent" ?
1040
+ `❌ Error generating ${phase} for lesson ${exSlug}. Retrying cannot fix this error, so it was not retried` :
1041
+ `❌ Error generating ${phase} for lesson ${exSlug} after ${MAX_AUTO_RETRIES} retries`
1042
+
926
1043
  async function updateLessonStatusToError(
927
1044
  courseSlug: string,
928
1045
  lessonUID: string,
@@ -2147,88 +2264,89 @@ class ServeCommand extends SessionCommand {
2147
2264
  )
2148
2265
  }
2149
2266
 
2150
- const syllabus = await getSyllabus(courseSlug, bucket)
2151
-
2152
- const exercise = syllabus.lessons.find(
2153
- lesson => lesson.uid === lessonUid
2154
- )
2155
- const exerciseIndex = syllabus.lessons.findIndex(
2156
- lesson => lesson.uid === lessonUid
2157
- )
2267
+ // In the course queue, so the webhook of this job cannot be handled
2268
+ // before the lesson is saved as generating.
2269
+ await serializeByCourse(courseSlug, async () => {
2270
+ const syllabus = await getSyllabus(courseSlug, bucket)
2158
2271
 
2159
- // Validate that exercise exists
2160
- if (!exercise) {
2161
- throw new NotFoundError(
2162
- `Lesson with UID "${lessonUid}" not found in course "${courseSlug}"`
2272
+ const exercise = syllabus.lessons.find(
2273
+ lesson => lesson.uid === lessonUid
2163
2274
  )
2164
- }
2165
-
2166
- // Validate that exercise index is valid (defensive check)
2167
- if (exerciseIndex === -1) {
2168
- throw new NotFoundError(
2169
- `Lesson with UID "${lessonUid}" not found in course "${courseSlug}"`
2275
+ const exerciseIndex = syllabus.lessons.findIndex(
2276
+ lesson => lesson.uid === lessonUid
2170
2277
  )
2171
- }
2172
2278
 
2173
- // Validate that exercise has required properties
2174
- if (!exercise.id || !exercise.title) {
2175
- throw new ValidationError(
2176
- `Lesson "${lessonUid}" is missing required properties (id or title)`
2177
- )
2178
- }
2279
+ // Validate that exercise exists
2280
+ if (!exercise) {
2281
+ throw new NotFoundError(
2282
+ `Lesson with UID "${lessonUid}" not found in course "${courseSlug}"`
2283
+ )
2284
+ }
2179
2285
 
2180
- // previous exercise
2181
- let previousReadme = "---"
2182
- const previousExercise = syllabus.lessons[exerciseIndex - 1]
2183
- if (previousExercise) {
2184
- // Get the readme of the previous exercise
2185
- const exSlug = slugify(
2186
- previousExercise.id + "-" + previousExercise.title
2187
- )
2188
- // llist the files un
2189
- const [files] = await bucket.getFiles({
2190
- prefix: `courses/${courseSlug}/exercises/${exSlug}/README`,
2191
- })
2192
- // select any README
2193
- const readmeFiles = files.map(f => f.name)
2194
- const readmeFile = readmeFiles.find(f => f.includes("README"))
2195
- if (readmeFile) {
2196
- const [content] = await bucket.file(readmeFile).download()
2197
- previousReadme = content.toString()
2286
+ // Validate that exercise index is valid (defensive check)
2287
+ if (exerciseIndex === -1) {
2288
+ throw new NotFoundError(
2289
+ `Lesson with UID "${lessonUid}" not found in course "${courseSlug}"`
2290
+ )
2198
2291
  }
2199
- }
2200
2292
 
2201
- // Use new two-phase generation workflow
2202
- const completionId = await startInitialContentGeneration(
2203
- rigoToken,
2204
- syllabus.lessons,
2205
- syllabus.courseInfo,
2206
- exercise,
2207
- courseSlug,
2208
- syllabus.courseInfo.purpose,
2209
- previousReadme +
2210
- "\n\nThe user provided the following feedback related to the content of the course so far: \n\n" +
2211
- feedback
2212
- )
2293
+ // Validate that exercise has required properties
2294
+ if (!exercise.id || !exercise.title) {
2295
+ throw new ValidationError(
2296
+ `Lesson "${lessonUid}" is missing required properties (id or title)`
2297
+ )
2298
+ }
2213
2299
 
2214
- syllabus.lessons[exerciseIndex].status = "GENERATING"
2215
- syllabus.lessons[exerciseIndex].translations = {
2216
- [syllabus.courseInfo.language || "en"]: {
2217
- completionId,
2218
- startedAt: Date.now(),
2219
- completedAt: 0,
2220
- },
2221
- }
2222
- console.log("Lesson", syllabus.lessons[exerciseIndex])
2223
- if (syllabus.feedback && typeof syllabus.feedback === "string") {
2224
- syllabus.feedback += "\n\n" + feedback
2225
- } else {
2226
- syllabus.feedback = feedback
2227
- }
2300
+ // previous exercise
2301
+ let previousReadme = "---"
2302
+ const previousExercise = syllabus.lessons[exerciseIndex - 1]
2303
+ if (previousExercise) {
2304
+ // Get the readme of the previous exercise
2305
+ const exSlug = slugify(
2306
+ previousExercise.id + "-" + previousExercise.title
2307
+ )
2308
+ // llist the files un
2309
+ const [files] = await bucket.getFiles({
2310
+ prefix: `courses/${courseSlug}/exercises/${exSlug}/README`,
2311
+ })
2312
+ // select any README
2313
+ const readmeFiles = files.map(f => f.name)
2314
+ const readmeFile = readmeFiles.find(f => f.includes("README"))
2315
+ if (readmeFile) {
2316
+ const [content] = await bucket.file(readmeFile).download()
2317
+ previousReadme = content.toString()
2318
+ }
2319
+ }
2320
+
2321
+ // Use new two-phase generation workflow
2322
+ const completionId = await startInitialContentGeneration(
2323
+ rigoToken,
2324
+ syllabus.lessons,
2325
+ syllabus.courseInfo,
2326
+ exercise,
2327
+ courseSlug,
2328
+ syllabus.courseInfo.purpose,
2329
+ previousReadme +
2330
+ "\n\nThe user provided the following feedback related to the content of the course so far: \n\n" +
2331
+ feedback
2332
+ )
2333
+
2334
+ // A new job by the user, so the automatic retries start over.
2335
+ syllabus.lessons[exerciseIndex].status = "GENERATING"
2336
+ syllabus.lessons[exerciseIndex].translations = {
2337
+ [syllabus.courseInfo.language || "en"]: startedJob(completionId),
2338
+ }
2339
+ console.log("Lesson", syllabus.lessons[exerciseIndex])
2340
+ if (syllabus.feedback && typeof syllabus.feedback === "string") {
2341
+ syllabus.feedback += "\n\n" + feedback
2342
+ } else {
2343
+ syllabus.feedback = feedback
2344
+ }
2228
2345
 
2229
- syllabus.generationMode = mode
2346
+ syllabus.generationMode = mode
2230
2347
 
2231
- await saveSyllabus(courseSlug, syllabus, bucket)
2348
+ await saveSyllabus(courseSlug, syllabus, bucket)
2349
+ })
2232
2350
 
2233
2351
  res.json({ status: "SUCCESS" })
2234
2352
  })
@@ -2546,223 +2664,152 @@ class ServeCommand extends SessionCommand {
2546
2664
 
2547
2665
  console.log("RECEIVING INITIAL CONTENT WEBHOOK", response)
2548
2666
 
2549
- const syllabus = await getSyllabus(courseSlug, bucket)
2550
-
2551
- const exerciseIndex = syllabus.lessons.findIndex(
2552
- lesson => lesson.uid === lessonUID
2553
- )
2554
-
2555
- if (exerciseIndex === -1) {
2556
- console.error("Exercise not found receiving webhook:", lessonUID)
2557
- return res.json({ status: "ERROR", error: "Exercise not found" })
2558
- }
2559
-
2560
- let exercise: Lesson | null = syllabus.lessons[exerciseIndex]
2561
- const exSlug = slugify(exercise.id + "-" + exercise.title)
2562
- // Handle errors
2563
- if (response.status === "ERROR") {
2564
- await updateLessonStatusToError(courseSlug, lessonUID, bucket)
2565
- emitToCourse(courseSlug, "course-creation", {
2566
- lesson: exSlug,
2567
- status: "error",
2568
- log: `❌ Error generating initial content for lesson ${exSlug}`,
2569
- })
2570
-
2571
- // Retry initial content generation
2572
- try {
2573
- emitToCourse(courseSlug, "course-creation", {
2574
- lesson: exSlug,
2575
- status: "generating",
2576
- log: `🔄 Retrying initial content generation for lesson ${exSlug}`,
2577
- })
2667
+ try {
2668
+ // In the course queue, nothing else saves the syllabus between
2669
+ // reading the lesson and saving it.
2670
+ await serializeByCourse(courseSlug, async () => {
2671
+ const syllabus = await getSyllabus(courseSlug, bucket)
2672
+ const language = syllabus.courseInfo.language || "en"
2578
2673
 
2579
- const retryCompletionId = await startInitialContentGeneration(
2580
- rigoToken,
2581
- syllabus.lessons,
2582
- syllabus.courseInfo,
2583
- exercise,
2584
- courseSlug,
2585
- syllabus.courseInfo.purpose,
2586
- ""
2674
+ const exerciseIndex = syllabus.lessons.findIndex(
2675
+ lesson => lesson.uid === lessonUID
2587
2676
  )
2588
2677
 
2589
- // Update lesson status to show it's retrying
2590
- exercise.status = "GENERATING"
2591
- exercise.translations = {
2592
- [syllabus.courseInfo.language || "en"]: {
2593
- completionId: retryCompletionId,
2594
- startedAt: Date.now(),
2595
- completedAt: 0,
2596
- },
2678
+ if (exerciseIndex === -1) {
2679
+ console.error("Exercise not found receiving webhook:", lessonUID)
2680
+ return res.json({ status: "ERROR", error: "Exercise not found" })
2597
2681
  }
2598
- await saveSyllabus(courseSlug, syllabus, bucket)
2599
- } catch (retryError) {
2600
- console.error(
2601
- "Error retrying initial content generation:",
2602
- retryError
2603
- )
2604
- emitToCourse(courseSlug, "course-creation", {
2605
- lesson: lessonUID,
2606
- status: "error",
2607
- log: `❌ Failed to retry initial content generation for lesson ${lessonUID}`,
2608
- })
2609
- }
2610
2682
 
2611
- return res.json({ status: "ERROR" })
2612
- }
2613
-
2614
- try {
2615
- emitToCourse(courseSlug, "course-creation", {
2616
- lesson: exSlug,
2617
- status: "generating",
2618
- log: `✅ Initial content generated for lesson ${exSlug}`,
2619
- })
2683
+ let exercise: Lesson | null = syllabus.lessons[exerciseIndex]
2684
+ const exSlug = slugify(exercise.id + "-" + exercise.title)
2620
2685
 
2621
- // Update lesson with initial content
2622
- exercise = await updateLessonWithInitialContent(
2623
- courseSlug,
2624
- lessonUID,
2625
- response.parsed,
2626
- bucket
2627
- )
2628
-
2629
- if (!exercise) {
2630
- console.error("Exercise not found after updating initial content")
2631
- return res.json({ status: "ERROR", error: "Exercise not found" })
2632
- }
2633
-
2634
- let lastLesson = ""
2635
-
2636
- const prevLessonIndex = exerciseIndex - 1
2637
- if (prevLessonIndex >= 0) {
2638
- try {
2639
- const prevLesson = syllabus.lessons[prevLessonIndex]
2640
- // search the lesson content in the bucket
2641
- const prevLessonSlug = slugify(
2642
- prevLesson.id + "-" + prevLesson.title
2643
- )
2644
- const file = bucket.file(
2645
- `courses/${courseSlug}/exercises/${prevLessonSlug}/README${getReadmeExtension(
2646
- syllabus.courseInfo.language || "en"
2647
- )}`
2686
+ if (isStaleWebhook(exercise, language, response.id)) {
2687
+ console.log(
2688
+ `Ignoring initial content webhook of job ${response.id} for lesson ${exSlug}: the lesson no longer waits for it`
2648
2689
  )
2649
- const [content] = await file.download()
2650
- lastLesson = content.toString()
2651
- } catch (error) {
2652
- console.error("Error searching previous lesson content:", error)
2690
+ return res.json({ status: "IGNORED" })
2653
2691
  }
2654
- }
2655
2692
 
2656
- const completionId = await startInteractivityGeneration(
2657
- rigoToken,
2658
- syllabus.lessons,
2659
- syllabus.courseInfo,
2660
- exercise,
2661
- courseSlug,
2662
- syllabus.courseInfo.purpose,
2663
- bucket,
2664
- lastLesson
2665
- )
2666
-
2667
- // Update lesson status to show it's in Phase 2
2668
- exercise.status = "GENERATING"
2669
- exercise.translations = {
2670
- [syllabus.courseInfo.language || "en"]: {
2671
- completionId,
2672
- startedAt: Date.now(),
2673
- completedAt: 0,
2674
- },
2675
- }
2676
- await saveSyllabus(courseSlug, syllabus, bucket)
2693
+ // Handle errors
2694
+ if (response.status === "ERROR") {
2695
+ console.error(
2696
+ `Initial content job ${response.id} failed for lesson ${exSlug}:`,
2697
+ response.status_text
2698
+ )
2677
2699
 
2678
- emitToCourse(courseSlug, "course-creation", {
2679
- lesson: exSlug,
2680
- status: "generating",
2681
- log: `🔄 Starting interactivity phase for lesson ${exercise.title}`,
2682
- })
2700
+ const retryPlan = planGenerationRetry(
2701
+ exercise,
2702
+ language,
2703
+ response.status_text
2704
+ )
2683
2705
 
2684
- emitToCourse(courseSlug, "course-creation", {
2685
- lesson: exSlug,
2686
- status: "initial-content-complete",
2687
- log: `✅ Initial content generated for lesson ${exSlug}, starting interactivity phase`,
2688
- })
2706
+ if (!retryPlan.retry) {
2707
+ await updateLessonStatusToError(courseSlug, lessonUID, bucket)
2708
+ emitToCourse(courseSlug, "course-creation", {
2709
+ lesson: exSlug,
2710
+ status: "error",
2711
+ log: noRetryLog(retryPlan.reason, "initial content", exSlug),
2712
+ })
2713
+ return res.json({ status: "ERROR" })
2714
+ }
2689
2715
 
2690
- res.json({ status: "SUCCESS" })
2691
- } catch (error) {
2692
- console.error("Error processing initial content webhook:", error)
2693
- await updateLessonStatusToError(courseSlug, lessonUID, bucket)
2694
- emitToCourse(courseSlug, "course-creation", {
2695
- lesson: exSlug,
2696
- status: "error",
2697
- log: `❌ Error processing initial content for lesson ${exSlug}`,
2698
- })
2699
- res
2700
- .status(500)
2701
- .json({ status: "ERROR", error: (error as Error).message })
2702
- }
2703
- }
2704
- )
2716
+ const attempt = retryPlan.attempt
2705
2717
 
2706
- // Phase 2: Interactivity generation webhook (replaces exercise-processor logic)
2707
- app.post(
2708
- "/webhooks/:courseSlug/interactivity-processor/:lessonUID/:rigoToken",
2709
- async (req, res) => {
2710
- const { courseSlug, lessonUID, rigoToken } = req.params
2711
- const response = req.body
2718
+ try {
2719
+ emitToCourse(courseSlug, "course-creation", {
2720
+ lesson: exSlug,
2721
+ status: "generating",
2722
+ log: `🔄 Retrying initial content generation for lesson ${exSlug} (${attempt} of ${MAX_AUTO_RETRIES})`,
2723
+ })
2712
2724
 
2713
- console.log("RECEIVING INTERACTIVITY WEBHOOK")
2714
- // console.log("LESSON UID", lessonUID)
2715
- // console.log("RESPONSE", response)
2725
+ const retryCompletionId = await startInitialContentGeneration(
2726
+ rigoToken,
2727
+ syllabus.lessons,
2728
+ syllabus.courseInfo,
2729
+ exercise,
2730
+ courseSlug,
2731
+ syllabus.courseInfo.purpose,
2732
+ ""
2733
+ )
2716
2734
 
2717
- // Handle errors
2718
- if (response.status === "ERROR") {
2719
- await updateLessonStatusToError(courseSlug, lessonUID, bucket)
2720
- emitToCourse(courseSlug, "course-creation", {
2721
- lesson: lessonUID,
2722
- status: "error",
2723
- log: `❌ Error adding interactivity to lesson ${lessonUID}`,
2724
- })
2735
+ await patchLesson(
2736
+ courseSlug,
2737
+ lessonUID,
2738
+ bucket,
2739
+ (lesson, lang) => {
2740
+ lesson.status = "GENERATING"
2741
+ lesson.translations = {
2742
+ [lang]: startedJob(retryCompletionId, attempt),
2743
+ }
2744
+ }
2745
+ )
2746
+ } catch (retryError) {
2747
+ console.error(
2748
+ "Error retrying initial content generation:",
2749
+ retryError
2750
+ )
2751
+ await updateLessonStatusToError(courseSlug, lessonUID, bucket)
2752
+ emitToCourse(courseSlug, "course-creation", {
2753
+ lesson: exSlug,
2754
+ status: "error",
2755
+ log: `❌ Failed to retry initial content generation for lesson ${exSlug}`,
2756
+ })
2757
+ }
2725
2758
 
2726
- // Retry interactivity generation
2727
- try {
2728
- const syllabus = await getSyllabus(courseSlug, bucket)
2729
- const lessonIndex = syllabus.lessons.findIndex(
2730
- lesson => lesson.uid === lessonUID
2731
- )
2732
- const exercise = syllabus.lessons[lessonIndex]
2759
+ return res.json({ status: "ERROR" })
2760
+ }
2733
2761
 
2734
- if (exercise && exercise.initialContent) {
2762
+ try {
2735
2763
  emitToCourse(courseSlug, "course-creation", {
2736
- lesson: lessonUID,
2764
+ lesson: exSlug,
2737
2765
  status: "generating",
2738
- log: `🔄 Retrying interactivity generation for lesson ${lessonUID}`,
2766
+ log: `✅ Initial content generated for lesson ${exSlug}`,
2739
2767
  })
2740
2768
 
2741
- // Get previous lesson content for context
2769
+ // Update lesson with initial content
2770
+ exercise = await updateLessonWithInitialContent(
2771
+ courseSlug,
2772
+ lessonUID,
2773
+ response.parsed,
2774
+ bucket
2775
+ )
2776
+
2777
+ if (!exercise) {
2778
+ console.error(
2779
+ "Exercise not found after updating initial content"
2780
+ )
2781
+ return res.json({
2782
+ status: "ERROR",
2783
+ error: "Exercise not found",
2784
+ })
2785
+ }
2786
+
2742
2787
  let lastLesson = ""
2743
- const prevLessonIndex = lessonIndex - 1
2788
+
2789
+ const prevLessonIndex = exerciseIndex - 1
2744
2790
  if (prevLessonIndex >= 0) {
2745
2791
  try {
2746
2792
  const prevLesson = syllabus.lessons[prevLessonIndex]
2793
+ // search the lesson content in the bucket
2747
2794
  const prevLessonSlug = slugify(
2748
2795
  prevLesson.id + "-" + prevLesson.title
2749
2796
  )
2750
2797
  const file = bucket.file(
2751
2798
  `courses/${courseSlug}/exercises/${prevLessonSlug}/README${getReadmeExtension(
2752
- syllabus.courseInfo.language || "en"
2799
+ language
2753
2800
  )}`
2754
2801
  )
2755
2802
  const [content] = await file.download()
2756
2803
  lastLesson = content.toString()
2757
2804
  } catch (error) {
2758
2805
  console.error(
2759
- "Error getting previous lesson content for retry:",
2806
+ "Error searching previous lesson content:",
2760
2807
  error
2761
2808
  )
2762
2809
  }
2763
2810
  }
2764
2811
 
2765
- const retryCompletionId = await startInteractivityGeneration(
2812
+ const completionId = await startInteractivityGeneration(
2766
2813
  rigoToken,
2767
2814
  syllabus.lessons,
2768
2815
  syllabus.courseInfo,
@@ -2773,120 +2820,337 @@ class ServeCommand extends SessionCommand {
2773
2820
  lastLesson
2774
2821
  )
2775
2822
 
2776
- // Update lesson status to show it's retrying
2777
- exercise.status = "GENERATING"
2778
- exercise.translations = {
2779
- [syllabus.courseInfo.language || "en"]: {
2780
- completionId: retryCompletionId,
2781
- startedAt: Date.now(),
2782
- completedAt: 0,
2783
- },
2784
- }
2785
- await saveSyllabus(courseSlug, syllabus, bucket)
2823
+ // Saved on a fresh read: `syllabus` was read before the initial
2824
+ // content was saved, and saving it would erase that content.
2825
+ await patchLesson(
2826
+ courseSlug,
2827
+ lessonUID,
2828
+ bucket,
2829
+ (lesson, lang) => {
2830
+ lesson.status = "GENERATING"
2831
+ lesson.translations = { [lang]: startedJob(completionId) }
2832
+ }
2833
+ )
2834
+
2835
+ emitToCourse(courseSlug, "course-creation", {
2836
+ lesson: exSlug,
2837
+ status: "generating",
2838
+ log: `🔄 Starting interactivity phase for lesson ${exercise.title}`,
2839
+ })
2840
+
2841
+ emitToCourse(courseSlug, "course-creation", {
2842
+ lesson: exSlug,
2843
+ status: "initial-content-complete",
2844
+ log: `✅ Initial content generated for lesson ${exSlug}, starting interactivity phase`,
2845
+ })
2846
+
2847
+ res.json({ status: "SUCCESS" })
2848
+ } catch (error) {
2849
+ console.error("Error processing initial content webhook:", error)
2850
+ await updateLessonStatusToError(courseSlug, lessonUID, bucket)
2851
+ emitToCourse(courseSlug, "course-creation", {
2852
+ lesson: exSlug,
2853
+ status: "error",
2854
+ log: `❌ Error processing initial content for lesson ${exSlug}`,
2855
+ })
2856
+ res
2857
+ .status(500)
2858
+ .json({ status: "ERROR", error: (error as Error).message })
2786
2859
  }
2787
- } catch (retryError) {
2788
- console.error(
2789
- "Error retrying interactivity generation:",
2790
- retryError
2791
- )
2792
- emitToCourse(courseSlug, "course-creation", {
2793
- lesson: lessonUID,
2794
- status: "error",
2795
- log: `❌ Failed to retry interactivity generation for lesson ${lessonUID}`,
2796
- })
2860
+ })
2861
+ } catch (error) {
2862
+ console.error("Error handling initial content webhook:", error)
2863
+ if (!res.headersSent) {
2864
+ res
2865
+ .status(500)
2866
+ .json({ status: "ERROR", error: (error as Error).message })
2797
2867
  }
2798
-
2799
- return res.json({ status: "ERROR" })
2800
2868
  }
2869
+ }
2870
+ )
2871
+
2872
+ // Phase 2: Interactivity generation webhook (replaces exercise-processor logic)
2873
+ app.post(
2874
+ "/webhooks/:courseSlug/interactivity-processor/:lessonUID/:rigoToken",
2875
+ async (req, res) => {
2876
+ const { courseSlug, lessonUID, rigoToken } = req.params
2877
+ const response = req.body
2878
+
2879
+ console.log("RECEIVING INTERACTIVITY WEBHOOK")
2880
+ // console.log("LESSON UID", lessonUID)
2881
+ // console.log("RESPONSE", response)
2801
2882
 
2802
2883
  try {
2803
- const syllabus = await getSyllabus(courseSlug, bucket)
2804
- const exerciseIndex = syllabus.lessons.findIndex(
2805
- lesson => lesson.uid === lessonUID
2806
- )
2884
+ // In the course queue, nothing else saves the syllabus between
2885
+ // reading the lesson and saving it.
2886
+ await serializeByCourse(courseSlug, async () => {
2887
+ const syllabus = await getSyllabus(courseSlug, bucket)
2888
+ const language = syllabus.courseInfo.language || "en"
2807
2889
 
2808
- if (exerciseIndex === -1) {
2809
- console.error("Exercise not found receiving webhook:", lessonUID)
2810
- return res.json({ status: "ERROR", error: "Exercise not found" })
2811
- }
2890
+ const exerciseIndex = syllabus.lessons.findIndex(
2891
+ lesson => lesson.uid === lessonUID
2892
+ )
2812
2893
 
2813
- const exercise = syllabus.lessons[exerciseIndex]
2814
- const exSlug = slugify(exercise.id + "-" + exercise.title)
2894
+ if (exerciseIndex === -1) {
2895
+ console.error("Exercise not found receiving webhook:", lessonUID)
2896
+ return res.json({ status: "ERROR", error: "Exercise not found" })
2897
+ }
2815
2898
 
2816
- // Process readability of final content
2817
- const readability = checkReadability(
2818
- response.parsed.final_content,
2819
- PARAMS.max_words,
2820
- 3
2821
- )
2899
+ const exercise = syllabus.lessons[exerciseIndex]
2900
+ const exSlug = slugify(exercise.id + "-" + exercise.title)
2822
2901
 
2823
- emitToCourse(courseSlug, "course-creation", {
2824
- lesson: exSlug,
2825
- status: "generating",
2826
- log: `🔄 The lesson ${exercise.title} has a readability score of ${readability.fkglResult.fkgl}`,
2827
- })
2902
+ if (isStaleWebhook(exercise, language, response.id)) {
2903
+ console.log(
2904
+ `Ignoring interactivity webhook of job ${response.id} for lesson ${exSlug}: the lesson no longer waits for it`
2905
+ )
2906
+ return res.json({ status: "IGNORED" })
2907
+ }
2828
2908
 
2829
- // Upload README with final content
2830
- const exercisesDir = `courses/${courseSlug}/exercises`
2831
- const targetDir = `${exercisesDir}/${exSlug}`
2832
- const readmeFilename = `README${getReadmeExtension(
2833
- response.parsed.output_language ||
2834
- syllabus.courseInfo.language ||
2835
- "en"
2836
- )}`
2909
+ // Handle errors
2910
+ if (response.status === "ERROR") {
2911
+ console.error(
2912
+ `Interactivity job ${response.id} failed for lesson ${exSlug}:`,
2913
+ response.status_text
2914
+ )
2837
2915
 
2838
- await uploadFileToBucket(
2839
- bucket,
2840
- readability.newMarkdown,
2841
- `${targetDir}/${readmeFilename}`
2842
- )
2916
+ // Every retry uses v1. A failed v2 job gets its v1 retry even
2917
+ // when its error would repeat, because the error belongs to the
2918
+ // v2 template.
2919
+ const { fromV2, retryStatusText } = describeInteractivityFailure(
2920
+ req.query,
2921
+ response.status_text
2922
+ )
2843
2923
 
2844
- // Update used components if provided by the AI
2845
- if (
2846
- response.parsed.used_components &&
2847
- Array.isArray(response.parsed.used_components)
2848
- ) {
2849
- await updateUsedComponents(
2850
- courseSlug,
2851
- response.parsed.used_components,
2852
- bucket
2853
- )
2854
- emitToCourse(courseSlug, "course-creation", {
2855
- lesson: exSlug,
2856
- status: "generating",
2857
- log: `📊 Updated component usage tracking: ${response.parsed.used_components.join(
2858
- ", "
2859
- )}`,
2860
- })
2861
- }
2924
+ const retryPlan = planGenerationRetry(
2925
+ exercise,
2926
+ language,
2927
+ retryStatusText
2928
+ )
2862
2929
 
2863
- // Continue with next lesson
2864
- await continueWithNextLesson(
2865
- courseSlug,
2866
- exerciseIndex,
2867
- rigoToken,
2868
- response.parsed.final_content,
2869
- bucket
2870
- )
2930
+ // The retry builds on the initial content, so without it there
2931
+ // is nothing to retry.
2932
+ if (!retryPlan.retry || !exercise.initialContent) {
2933
+ await updateLessonStatusToError(courseSlug, lessonUID, bucket)
2934
+ emitToCourse(courseSlug, "course-creation", {
2935
+ lesson: exSlug,
2936
+ status: "error",
2937
+ log: retryPlan.retry ?
2938
+ `❌ Error adding interactivity to lesson ${exSlug}. Its initial content is missing, so it was not retried` :
2939
+ noRetryLog(retryPlan.reason, "interactivity", exSlug),
2940
+ })
2941
+ return res.json({ status: "ERROR" })
2942
+ }
2871
2943
 
2872
- emitToCourse(courseSlug, "course-creation", {
2873
- lesson: exSlug,
2874
- status: "done",
2875
- log: `✅ The lesson ${exercise.id} - ${exercise.title} has been generated successfully with interactivity!`,
2876
- })
2944
+ const attempt = retryPlan.attempt
2877
2945
 
2878
- res.json({ status: "SUCCESS" })
2879
- } catch (error) {
2880
- console.error("Error processing interactivity webhook:", error)
2881
- await updateLessonStatusToError(courseSlug, lessonUID, bucket)
2882
- emitToCourse(courseSlug, "course-creation", {
2883
- lesson: lessonUID,
2884
- status: "error",
2885
- log: `❌ Error processing interactivity for lesson ${lessonUID}`,
2946
+ try {
2947
+ emitToCourse(courseSlug, "course-creation", {
2948
+ lesson: exSlug,
2949
+ status: "generating",
2950
+ log: fromV2 ?
2951
+ `⚠️ Structured interactivity failed for lesson ${exSlug}, retrying with the previous method (${attempt} of ${MAX_AUTO_RETRIES})` :
2952
+ `🔄 Retrying interactivity generation for lesson ${exSlug} (${attempt} of ${MAX_AUTO_RETRIES})`,
2953
+ })
2954
+
2955
+ // Get previous lesson content for context
2956
+ let lastLesson = ""
2957
+ const prevLessonIndex = exerciseIndex - 1
2958
+ if (prevLessonIndex >= 0) {
2959
+ try {
2960
+ const prevLesson = syllabus.lessons[prevLessonIndex]
2961
+ const prevLessonSlug = slugify(
2962
+ prevLesson.id + "-" + prevLesson.title
2963
+ )
2964
+ const file = bucket.file(
2965
+ `courses/${courseSlug}/exercises/${prevLessonSlug}/README${getReadmeExtension(
2966
+ language
2967
+ )}`
2968
+ )
2969
+ const [content] = await file.download()
2970
+ lastLesson = content.toString()
2971
+ } catch (error) {
2972
+ console.error(
2973
+ "Error getting previous lesson content for retry:",
2974
+ error
2975
+ )
2976
+ }
2977
+ }
2978
+
2979
+ const retryCompletionId = await startInteractivityGeneration(
2980
+ rigoToken,
2981
+ syllabus.lessons,
2982
+ syllabus.courseInfo,
2983
+ exercise,
2984
+ courseSlug,
2985
+ syllabus.courseInfo.purpose,
2986
+ bucket,
2987
+ lastLesson,
2988
+ { forceV1: true }
2989
+ )
2990
+
2991
+ await patchLesson(
2992
+ courseSlug,
2993
+ lessonUID,
2994
+ bucket,
2995
+ (lesson, lang) => {
2996
+ lesson.status = "GENERATING"
2997
+ lesson.translations = {
2998
+ [lang]: startedJob(retryCompletionId, attempt),
2999
+ }
3000
+ }
3001
+ )
3002
+ } catch (retryError) {
3003
+ console.error(
3004
+ "Error retrying interactivity generation:",
3005
+ retryError
3006
+ )
3007
+ await updateLessonStatusToError(courseSlug, lessonUID, bucket)
3008
+ emitToCourse(courseSlug, "course-creation", {
3009
+ lesson: exSlug,
3010
+ status: "error",
3011
+ log: `❌ Failed to retry interactivity generation for lesson ${exSlug}`,
3012
+ })
3013
+ }
3014
+
3015
+ return res.json({ status: "ERROR" })
3016
+ }
3017
+
3018
+ try {
3019
+ let finalContent: string = response.parsed?.final_content
3020
+ let usedComponents: unknown = response.parsed?.used_components
3021
+
3022
+ // The structured template (v2) returns component data and
3023
+ // placeholders; the CLI writes the component markdown.
3024
+ if (req.query.tpl === "v2") {
3025
+ if (!Array.isArray(response.parsed?.multiple_choice_question)) {
3026
+ console.warn(
3027
+ `Structured interactivity response for ${exSlug} has no component lists`
3028
+ )
3029
+ }
3030
+
3031
+ const guide = componentsGuideLoader.peek()
3032
+ const result = processInteractivityResult(response.parsed, {
3033
+ stepType: exercise.type,
3034
+ isFirstOrLast:
3035
+ exerciseIndex === 0 ||
3036
+ exerciseIndex === syllabus.lessons.length - 1,
3037
+ lessonId: exercise.id,
3038
+ decidedBy: guide ? decidedByFromGuide(guide) : undefined,
3039
+ })
3040
+ finalContent = result.markdown
3041
+ usedComponents = result.usedComponents
3042
+
3043
+ if (result.warnings.length > 0) {
3044
+ console.warn(
3045
+ `Component warnings for ${exSlug}:\n- ${result.warnings
3046
+ .map(warning => warning.message)
3047
+ .join("\n- ")}`
3048
+ )
3049
+ }
3050
+
3051
+ if (result.dropped.length > 0) {
3052
+ console.warn(
3053
+ `Components removed from ${exSlug}:\n- ${result.dropped
3054
+ .map(issue => issue.message)
3055
+ .join("\n- ")}`
3056
+ )
3057
+ emitToCourse(courseSlug, "course-creation", {
3058
+ lesson: exSlug,
3059
+ status: "generating",
3060
+ log: `⚠️ ${
3061
+ result.dropped.length
3062
+ } component(s) removed from lesson ${
3063
+ exercise.title
3064
+ } because they could not be rendered: ${result.dropped
3065
+ .map(issue => issue.message)
3066
+ .join("; ")}`,
3067
+ })
3068
+ }
3069
+
3070
+ if (exercise.type === "QUIZ" && result.assessmentCount === 0) {
3071
+ emitToCourse(courseSlug, "course-creation", {
3072
+ lesson: exSlug,
3073
+ status: "generating",
3074
+ log: `⚠️ The quiz ${exercise.title} has no questions; review it or regenerate it`,
3075
+ })
3076
+ }
3077
+ }
3078
+
3079
+ // Process readability of final content
3080
+ const readability = checkReadability(
3081
+ finalContent,
3082
+ PARAMS.max_words,
3083
+ 3
3084
+ )
3085
+
3086
+ emitToCourse(courseSlug, "course-creation", {
3087
+ lesson: exSlug,
3088
+ status: "generating",
3089
+ log: `🔄 The lesson ${exercise.title} has a readability score of ${readability.fkglResult.fkgl}`,
3090
+ })
3091
+
3092
+ // Upload README with final content
3093
+ const exercisesDir = `courses/${courseSlug}/exercises`
3094
+ const targetDir = `${exercisesDir}/${exSlug}`
3095
+ const readmeFilename = `README${getReadmeExtension(
3096
+ response.parsed.output_language || language
3097
+ )}`
3098
+
3099
+ await uploadFileToBucket(
3100
+ bucket,
3101
+ readability.newMarkdown,
3102
+ `${targetDir}/${readmeFilename}`
3103
+ )
3104
+
3105
+ // Update used components (v1: listed by the AI; v2: the
3106
+ // components that were actually written)
3107
+ if (Array.isArray(usedComponents)) {
3108
+ await updateUsedComponents(courseSlug, usedComponents, bucket)
3109
+ emitToCourse(courseSlug, "course-creation", {
3110
+ lesson: exSlug,
3111
+ status: "generating",
3112
+ log: `📊 Updated component usage tracking: ${usedComponents.join(
3113
+ ", "
3114
+ )}`,
3115
+ })
3116
+ }
3117
+
3118
+ // Continue with next lesson
3119
+ await continueWithNextLesson(
3120
+ courseSlug,
3121
+ exerciseIndex,
3122
+ rigoToken,
3123
+ finalContent,
3124
+ bucket
3125
+ )
3126
+
3127
+ emitToCourse(courseSlug, "course-creation", {
3128
+ lesson: exSlug,
3129
+ status: "done",
3130
+ log: `✅ The lesson ${exercise.id} - ${exercise.title} has been generated successfully with interactivity!`,
3131
+ })
3132
+
3133
+ res.json({ status: "SUCCESS" })
3134
+ } catch (error) {
3135
+ console.error("Error processing interactivity webhook:", error)
3136
+ await updateLessonStatusToError(courseSlug, lessonUID, bucket)
3137
+ emitToCourse(courseSlug, "course-creation", {
3138
+ lesson: exSlug,
3139
+ status: "error",
3140
+ log: `❌ Error processing interactivity for lesson ${exSlug}`,
3141
+ })
3142
+ res
3143
+ .status(500)
3144
+ .json({ status: "ERROR", error: (error as Error).message })
3145
+ }
2886
3146
  })
2887
- res
2888
- .status(500)
2889
- .json({ status: "ERROR", error: (error as Error).message })
3147
+ } catch (error) {
3148
+ console.error("Error handling interactivity webhook:", error)
3149
+ if (!res.headersSent) {
3150
+ res
3151
+ .status(500)
3152
+ .json({ status: "ERROR", error: (error as Error).message })
3153
+ }
2890
3154
  }
2891
3155
  }
2892
3156
  )
@@ -3855,42 +4119,43 @@ class ServeCommand extends SessionCommand {
3855
4119
  uid: stepSlug,
3856
4120
  }
3857
4121
 
3858
- const newLessons = insertStepInCorrectPosition(
3859
- initialSyllabus.lessons,
3860
- newLesson
3861
- )
3862
- // Use new two-phase generation workflow
3863
- const completionId = await startInitialContentGeneration(
3864
- rigoToken,
3865
- newLessons,
3866
- initialSyllabus.courseInfo,
3867
- newLesson,
3868
- courseSlug,
3869
- initialSyllabus.courseInfo.purpose,
3870
- "lastResult"
3871
- )
3872
- newLesson.translations = {
3873
- [initialSyllabus.courseInfo.language || "en"]: {
3874
- completionId,
3875
- startedAt: Date.now(),
3876
- completedAt: 0,
3877
- },
3878
- }
4122
+ // In the course queue, so the webhook of this job finds the new lesson
4123
+ // saved, and the syllabus saved here is not older than the last save.
4124
+ await serializeByCourse(courseSlug, async () => {
4125
+ const syllabus = await getSyllabus(courseSlug, bucket)
4126
+ const newLessons = insertStepInCorrectPosition(
4127
+ syllabus.lessons,
4128
+ newLesson
4129
+ )
4130
+ // Use new two-phase generation workflow
4131
+ const completionId = await startInitialContentGeneration(
4132
+ rigoToken,
4133
+ newLessons,
4134
+ syllabus.courseInfo,
4135
+ newLesson,
4136
+ courseSlug,
4137
+ syllabus.courseInfo.purpose,
4138
+ "lastResult"
4139
+ )
4140
+ newLesson.translations = {
4141
+ [syllabus.courseInfo.language || "en"]: startedJob(completionId),
4142
+ }
3879
4143
 
3880
- await uploadFileToBucket(
3881
- bucket,
3882
- JSON.stringify({ ...initialSyllabus, lessons: newLessons }),
3883
- `courses/${courseSlug}/.learn/initialSyllabus.json`
3884
- )
4144
+ await uploadFileToBucket(
4145
+ bucket,
4146
+ JSON.stringify({ ...syllabus, lessons: newLessons }),
4147
+ `courses/${courseSlug}/.learn/initialSyllabus.json`
4148
+ )
3885
4149
 
3886
- const targetDir = `courses/${courseSlug}/exercises/${stepSlug}`
4150
+ const targetDir = `courses/${courseSlug}/exercises/${stepSlug}`
3887
4151
 
3888
- await uploadInitialReadme(
3889
- bucket,
3890
- stepSlug,
3891
- targetDir,
3892
- initialSyllabus.courseInfo
3893
- )
4152
+ await uploadInitialReadme(
4153
+ bucket,
4154
+ stepSlug,
4155
+ targetDir,
4156
+ syllabus.courseInfo
4157
+ )
4158
+ })
3894
4159
 
3895
4160
  res.json({ status: "SUCCESS", message: "Exercise generati on started!" })
3896
4161
  })