@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.
- package/lib/commands/serve.js +444 -273
- package/lib/models/creator.d.ts +1 -0
- package/lib/utils/generationRetry.d.ts +48 -0
- package/lib/utils/generationRetry.js +81 -0
- package/lib/utils/interactivity/componentsGuide.d.ts +50 -0
- package/lib/utils/interactivity/componentsGuide.js +193 -0
- package/lib/utils/interactivity/interactivityPlan.d.ts +20 -0
- package/lib/utils/interactivity/interactivityPlan.js +33 -0
- package/lib/utils/interactivity/structuredComponents.d.ts +34 -0
- package/lib/utils/interactivity/structuredComponents.js +792 -0
- package/lib/utils/rigoActions.d.ts +1 -1
- package/lib/utils/rigoActions.js +2 -2
- package/package.json +1 -1
- package/src/commands/serve.ts +653 -388
- package/src/models/creator.ts +87 -84
- package/src/utils/generationRetry.ts +100 -0
- package/src/utils/interactivity/componentsGuide.ts +294 -0
- package/src/utils/interactivity/interactivityPlan.ts +57 -0
- package/src/utils/interactivity/structuredComponents.ts +1004 -0
- package/src/utils/rigoActions.ts +3 -2
package/src/commands/serve.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
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
|
-
|
|
2160
|
-
|
|
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
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
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
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
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
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
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
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
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
|
-
|
|
2346
|
+
syllabus.generationMode = mode
|
|
2230
2347
|
|
|
2231
|
-
|
|
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
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
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
|
|
2580
|
-
|
|
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
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
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
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2700
|
+
const retryPlan = planGenerationRetry(
|
|
2701
|
+
exercise,
|
|
2702
|
+
language,
|
|
2703
|
+
response.status_text
|
|
2704
|
+
)
|
|
2683
2705
|
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
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
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
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
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
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
|
-
|
|
2727
|
-
|
|
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
|
-
|
|
2762
|
+
try {
|
|
2735
2763
|
emitToCourse(courseSlug, "course-creation", {
|
|
2736
|
-
lesson:
|
|
2764
|
+
lesson: exSlug,
|
|
2737
2765
|
status: "generating",
|
|
2738
|
-
log:
|
|
2766
|
+
log: `✅ Initial content generated for lesson ${exSlug}`,
|
|
2739
2767
|
})
|
|
2740
2768
|
|
|
2741
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2806
|
+
"Error searching previous lesson content:",
|
|
2760
2807
|
error
|
|
2761
2808
|
)
|
|
2762
2809
|
}
|
|
2763
2810
|
}
|
|
2764
2811
|
|
|
2765
|
-
const
|
|
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
|
-
//
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
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
|
-
}
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
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
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
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
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
}
|
|
2890
|
+
const exerciseIndex = syllabus.lessons.findIndex(
|
|
2891
|
+
lesson => lesson.uid === lessonUID
|
|
2892
|
+
)
|
|
2812
2893
|
|
|
2813
|
-
|
|
2814
|
-
|
|
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
|
-
|
|
2817
|
-
|
|
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
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
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
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
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
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
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
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
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
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
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
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
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
|
-
|
|
3859
|
-
|
|
3860
|
-
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
|
|
3864
|
-
|
|
3865
|
-
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
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
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
|
|
4144
|
+
await uploadFileToBucket(
|
|
4145
|
+
bucket,
|
|
4146
|
+
JSON.stringify({ ...syllabus, lessons: newLessons }),
|
|
4147
|
+
`courses/${courseSlug}/.learn/initialSyllabus.json`
|
|
4148
|
+
)
|
|
3885
4149
|
|
|
3886
|
-
|
|
4150
|
+
const targetDir = `courses/${courseSlug}/exercises/${stepSlug}`
|
|
3887
4151
|
|
|
3888
|
-
|
|
3889
|
-
|
|
3890
|
-
|
|
3891
|
-
|
|
3892
|
-
|
|
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
|
})
|