@learnpack/learnpack 5.0.363 → 5.0.365
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 +482 -286
- package/lib/models/creator.d.ts +1 -0
- package/lib/utils/continueGeneration.d.ts +65 -0
- package/lib/utils/continueGeneration.js +82 -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 +5 -2
- package/lib/utils/rigoActions.js +5 -3
- package/package.json +1 -1
- package/src/commands/serve.ts +720 -408
- package/src/models/creator.ts +87 -84
- package/src/ui/_app/app.js +130 -72
- package/src/ui/app.tar.gz +0 -0
- package/src/utils/continueGeneration.ts +108 -0
- 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 +8 -3
package/src/commands/serve.ts
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
addInteractivity,
|
|
40
40
|
generateCodeChallenge,
|
|
41
41
|
generateStepSlug,
|
|
42
|
+
getCompletionJob,
|
|
42
43
|
} from "../utils/rigoActions"
|
|
43
44
|
import * as dotenv from "dotenv"
|
|
44
45
|
|
|
@@ -49,6 +50,17 @@ import {
|
|
|
49
50
|
insertStepInCorrectPosition,
|
|
50
51
|
} from "../utils/creatorUtilities"
|
|
51
52
|
import { findFinishedLessons } from "../utils/lessonRecovery"
|
|
53
|
+
import {
|
|
54
|
+
MAX_AUTO_RETRIES,
|
|
55
|
+
isStaleWebhook,
|
|
56
|
+
planGenerationRetry,
|
|
57
|
+
RetryPlan,
|
|
58
|
+
} from "../utils/generationRetry"
|
|
59
|
+
import {
|
|
60
|
+
currentJobId,
|
|
61
|
+
isGenerationRunning,
|
|
62
|
+
resolveGenerationMode,
|
|
63
|
+
} from "../utils/continueGeneration"
|
|
52
64
|
// import { handleAssetCreation } from "./publish"
|
|
53
65
|
import axios from "axios"
|
|
54
66
|
import * as FormData from "form-data"
|
|
@@ -129,6 +141,18 @@ import type { RedisClientType } from "redis"
|
|
|
129
141
|
import { HistoryManager } from "../managers/historyManager"
|
|
130
142
|
import { ReadmeHistoryService } from "../managers/readmeHistoryService"
|
|
131
143
|
import { sanitizeReadmeNewlines } from "../utils/readmeSanitizer"
|
|
144
|
+
import {
|
|
145
|
+
buildInteractivityGuide,
|
|
146
|
+
ComponentsGuideUnavailableError,
|
|
147
|
+
componentsGuideLoader,
|
|
148
|
+
decidedByFromGuide,
|
|
149
|
+
} from "../utils/interactivity/componentsGuide"
|
|
150
|
+
import {
|
|
151
|
+
describeInteractivityFailure,
|
|
152
|
+
isInteractivityV2Enabled,
|
|
153
|
+
planInteractivityRequest,
|
|
154
|
+
} from "../utils/interactivity/interactivityPlan"
|
|
155
|
+
import { processInteractivityResult } from "../utils/interactivity/structuredComponents"
|
|
132
156
|
|
|
133
157
|
// eslint-disable-next-line
|
|
134
158
|
const frontMatter = require("front-matter");
|
|
@@ -159,7 +183,11 @@ function opId(): string {
|
|
|
159
183
|
return crypto.randomBytes(4).toString("hex")
|
|
160
184
|
}
|
|
161
185
|
|
|
162
|
-
// Serialization per course to prevent syllabus race conditions
|
|
186
|
+
// Serialization per course to prevent syllabus race conditions. The queue lives
|
|
187
|
+
// in memory, so it only orders requests within one process: serve runs on a
|
|
188
|
+
// single Heroku dyno. With more than one, it has to move to Redis.
|
|
189
|
+
// A function running in the queue must not call serializeByCourse for the same
|
|
190
|
+
// course, or it waits for itself.
|
|
163
191
|
const courseQueues = new Map<string, Promise<unknown>>()
|
|
164
192
|
|
|
165
193
|
async function serializeByCourse<T>(
|
|
@@ -167,11 +195,11 @@ async function serializeByCourse<T>(
|
|
|
167
195
|
fn: () => Promise<T>
|
|
168
196
|
): Promise<T> {
|
|
169
197
|
const prev = courseQueues.get(courseSlug) ?? Promise.resolve()
|
|
198
|
+
// A failure belongs to its own caller. Without the catch, every function
|
|
199
|
+
// queued after it would be skipped and fail with the same error.
|
|
170
200
|
const next = prev
|
|
201
|
+
.catch(() => null)
|
|
171
202
|
.then(() => fn())
|
|
172
|
-
.catch(error => {
|
|
173
|
-
throw error
|
|
174
|
-
})
|
|
175
203
|
.finally(() => {
|
|
176
204
|
if (courseQueues.get(courseSlug) === next) {
|
|
177
205
|
courseQueues.delete(courseSlug)
|
|
@@ -696,12 +724,45 @@ async function startInteractivityGeneration(
|
|
|
696
724
|
courseSlug: string,
|
|
697
725
|
purposeSlug: string,
|
|
698
726
|
bucket: Bucket,
|
|
699
|
-
lastLesson = ""
|
|
727
|
+
lastLesson = "",
|
|
728
|
+
options: { forceV1?: boolean } = {}
|
|
700
729
|
): Promise<number> {
|
|
701
730
|
const exSlug = slugify(exercise.id + "-" + exercise.title)
|
|
702
731
|
console.log("Starting interactivity generation for", exSlug)
|
|
703
732
|
|
|
704
|
-
const
|
|
733
|
+
const request = planInteractivityRequest({
|
|
734
|
+
v2Enabled: isInteractivityV2Enabled(),
|
|
735
|
+
forceV1: options.forceV1,
|
|
736
|
+
})
|
|
737
|
+
|
|
738
|
+
// The structured template (v2) needs the components guide: without it the
|
|
739
|
+
// model does not know what each component field means. The guide is loaded
|
|
740
|
+
// before anything else so that rigobot is never called without it.
|
|
741
|
+
let componentsYml: string
|
|
742
|
+
if (request.tpl === "v2") {
|
|
743
|
+
try {
|
|
744
|
+
componentsYml = buildInteractivityGuide(
|
|
745
|
+
await componentsGuideLoader.load()
|
|
746
|
+
)
|
|
747
|
+
} catch (error) {
|
|
748
|
+
if (error instanceof ComponentsGuideUnavailableError) {
|
|
749
|
+
console.error("Components guide unavailable:", error.message)
|
|
750
|
+
emitToCourse(courseSlug, "course-creation", {
|
|
751
|
+
lesson: exSlug,
|
|
752
|
+
status: "error",
|
|
753
|
+
log: `❌ Could not load the components guide for lesson ${exercise.title}; retry the generation`,
|
|
754
|
+
})
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
throw error
|
|
758
|
+
}
|
|
759
|
+
} else {
|
|
760
|
+
componentsYml = await fetchComponentsYml()
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// The template and the attempt travel in the webhook URL: rigobot posts back
|
|
764
|
+
// to this exact URL, and its error payload says neither.
|
|
765
|
+
const webhookUrl = `${process.env.HOST}/webhooks/${courseSlug}/interactivity-processor/${exercise.uid}/${rigoToken}${request.webhookQuery}`
|
|
705
766
|
|
|
706
767
|
// Emit notification that interactivity generation is starting
|
|
707
768
|
emitToCourse(courseSlug, "course-creation", {
|
|
@@ -710,8 +771,6 @@ async function startInteractivityGeneration(
|
|
|
710
771
|
log: `🔄 Starting interactivity generation for lesson: ${exercise.title}`,
|
|
711
772
|
})
|
|
712
773
|
|
|
713
|
-
const componentsYml = await fetchComponentsYml()
|
|
714
|
-
|
|
715
774
|
const currentSyllabus = await getSyllabus(courseSlug, bucket)
|
|
716
775
|
|
|
717
776
|
const fullSyllabus = {
|
|
@@ -734,9 +793,16 @@ async function startInteractivityGeneration(
|
|
|
734
793
|
current_syllabus: JSON.stringify(fullSyllabus),
|
|
735
794
|
lesson_info: JSON.stringify(lessonCleaner(exercise)),
|
|
736
795
|
},
|
|
737
|
-
webhookUrl
|
|
796
|
+
webhookUrl,
|
|
797
|
+
request.slug
|
|
738
798
|
)
|
|
739
799
|
|
|
800
|
+
if (!res) {
|
|
801
|
+
throw new Error(
|
|
802
|
+
`Could not start interactivity generation for lesson ${exSlug} (${request.slug})`
|
|
803
|
+
)
|
|
804
|
+
}
|
|
805
|
+
|
|
740
806
|
return res.id
|
|
741
807
|
}
|
|
742
808
|
|
|
@@ -923,6 +989,63 @@ async function updateLessonWithInitialContent(
|
|
|
923
989
|
return lesson
|
|
924
990
|
}
|
|
925
991
|
|
|
992
|
+
/**
|
|
993
|
+
* Changes one lesson on a fresh read of the syllabus. Saving a copy read
|
|
994
|
+
* earlier would undo whatever was saved since, like the lesson's
|
|
995
|
+
* `initialContent`.
|
|
996
|
+
* @param courseSlug - The course.
|
|
997
|
+
* @param lessonUID - The lesson to change.
|
|
998
|
+
* @param bucket - The bucket that holds the syllabus.
|
|
999
|
+
* @param patch - Changes the lesson in place. Gets the course language.
|
|
1000
|
+
* @returns Once the syllabus is saved, or right away if the lesson is gone.
|
|
1001
|
+
*/
|
|
1002
|
+
async function patchLesson(
|
|
1003
|
+
courseSlug: string,
|
|
1004
|
+
lessonUID: string,
|
|
1005
|
+
bucket: Bucket,
|
|
1006
|
+
patch: (lesson: Lesson, language: string) => void
|
|
1007
|
+
): Promise<void> {
|
|
1008
|
+
const syllabus = await getSyllabus(courseSlug, bucket)
|
|
1009
|
+
const lesson = syllabus.lessons.find(lesson => lesson.uid === lessonUID)
|
|
1010
|
+
|
|
1011
|
+
if (!lesson) {
|
|
1012
|
+
console.error(`Lesson ${lessonUID} not found in syllabus`)
|
|
1013
|
+
return
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
patch(lesson, syllabus.courseInfo.language || "en")
|
|
1017
|
+
await saveSyllabus(courseSlug, syllabus, bucket)
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/**
|
|
1021
|
+
* The translation entry of a lesson that waits for a new job.
|
|
1022
|
+
* @param completionId - The Rigobot job.
|
|
1023
|
+
* @param autoRetries - The automatic retries of the current phase so far.
|
|
1024
|
+
* @returns The entry to save under the course language.
|
|
1025
|
+
*/
|
|
1026
|
+
const startedJob = (completionId: number, autoRetries = 0) => ({
|
|
1027
|
+
completionId,
|
|
1028
|
+
startedAt: Date.now(),
|
|
1029
|
+
completedAt: 0,
|
|
1030
|
+
autoRetries,
|
|
1031
|
+
})
|
|
1032
|
+
|
|
1033
|
+
/**
|
|
1034
|
+
* The creator log for a failed job that is not retried.
|
|
1035
|
+
* @param reason - Why there is no retry.
|
|
1036
|
+
* @param phase - What the job generated.
|
|
1037
|
+
* @param exSlug - The lesson slug.
|
|
1038
|
+
* @returns The log line.
|
|
1039
|
+
*/
|
|
1040
|
+
const noRetryLog = (
|
|
1041
|
+
reason: Extract<RetryPlan, { retry: false }>["reason"],
|
|
1042
|
+
phase: string,
|
|
1043
|
+
exSlug: string
|
|
1044
|
+
): string =>
|
|
1045
|
+
reason === "permanent" ?
|
|
1046
|
+
`❌ Error generating ${phase} for lesson ${exSlug}. Retrying cannot fix this error, so it was not retried` :
|
|
1047
|
+
`❌ Error generating ${phase} for lesson ${exSlug} after ${MAX_AUTO_RETRIES} retries`
|
|
1048
|
+
|
|
926
1049
|
async function updateLessonStatusToError(
|
|
927
1050
|
courseSlug: string,
|
|
928
1051
|
lessonUID: string,
|
|
@@ -2147,88 +2270,122 @@ class ServeCommand extends SessionCommand {
|
|
|
2147
2270
|
)
|
|
2148
2271
|
}
|
|
2149
2272
|
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
)
|
|
2155
|
-
const exerciseIndex = syllabus.lessons.findIndex(
|
|
2156
|
-
lesson => lesson.uid === lessonUid
|
|
2157
|
-
)
|
|
2273
|
+
// In the course queue, so the webhook of this job cannot be handled
|
|
2274
|
+
// before the lesson is saved as generating.
|
|
2275
|
+
await serializeByCourse(courseSlug, async () => {
|
|
2276
|
+
const syllabus = await getSyllabus(courseSlug, bucket)
|
|
2158
2277
|
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
throw new NotFoundError(
|
|
2162
|
-
`Lesson with UID "${lessonUid}" not found in course "${courseSlug}"`
|
|
2278
|
+
const exercise = syllabus.lessons.find(
|
|
2279
|
+
lesson => lesson.uid === lessonUid
|
|
2163
2280
|
)
|
|
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}"`
|
|
2281
|
+
const exerciseIndex = syllabus.lessons.findIndex(
|
|
2282
|
+
lesson => lesson.uid === lessonUid
|
|
2170
2283
|
)
|
|
2171
|
-
}
|
|
2172
2284
|
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2285
|
+
// Validate that exercise exists
|
|
2286
|
+
if (!exercise) {
|
|
2287
|
+
throw new NotFoundError(
|
|
2288
|
+
`Lesson with UID "${lessonUid}" not found in course "${courseSlug}"`
|
|
2289
|
+
)
|
|
2290
|
+
}
|
|
2179
2291
|
|
|
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()
|
|
2292
|
+
// Validate that exercise index is valid (defensive check)
|
|
2293
|
+
if (exerciseIndex === -1) {
|
|
2294
|
+
throw new NotFoundError(
|
|
2295
|
+
`Lesson with UID "${lessonUid}" not found in course "${courseSlug}"`
|
|
2296
|
+
)
|
|
2198
2297
|
}
|
|
2199
|
-
}
|
|
2200
2298
|
|
|
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
|
-
)
|
|
2299
|
+
// Validate that exercise has required properties
|
|
2300
|
+
if (!exercise.id || !exercise.title) {
|
|
2301
|
+
throw new ValidationError(
|
|
2302
|
+
`Lesson "${lessonUid}" is missing required properties (id or title)`
|
|
2303
|
+
)
|
|
2304
|
+
}
|
|
2213
2305
|
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2306
|
+
const language = syllabus.courseInfo.language || "en"
|
|
2307
|
+
const previousStatus = exercise.status
|
|
2308
|
+
|
|
2309
|
+
// A second click, or a retry while the job still runs, would start
|
|
2310
|
+
// another paid job for the same lesson.
|
|
2311
|
+
if (previousStatus === "GENERATING") {
|
|
2312
|
+
const jobId = currentJobId(exercise, language)
|
|
2313
|
+
let job = null
|
|
2314
|
+
if (jobId) {
|
|
2315
|
+
try {
|
|
2316
|
+
job = await getCompletionJob(rigoToken, jobId, {
|
|
2317
|
+
timeoutMs: 5000,
|
|
2318
|
+
})
|
|
2319
|
+
} catch (error) {
|
|
2320
|
+
console.error(`Could not check job ${jobId}:`, error)
|
|
2321
|
+
}
|
|
2322
|
+
}
|
|
2228
2323
|
|
|
2229
|
-
|
|
2324
|
+
if (isGenerationRunning(exercise, language, job, Date.now())) {
|
|
2325
|
+
console.log(
|
|
2326
|
+
`Lesson ${lessonUid} is already being generated (job ${jobId}), not starting another one`
|
|
2327
|
+
)
|
|
2328
|
+
throw new ConflictError(
|
|
2329
|
+
`Lesson "${exercise.title}" is already being generated`,
|
|
2330
|
+
{ code: "LESSON_ALREADY_GENERATING", completionId: jobId }
|
|
2331
|
+
)
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
// previous exercise
|
|
2336
|
+
let previousReadme = "---"
|
|
2337
|
+
const previousExercise = syllabus.lessons[exerciseIndex - 1]
|
|
2338
|
+
if (previousExercise) {
|
|
2339
|
+
// Get the readme of the previous exercise
|
|
2340
|
+
const exSlug = slugify(
|
|
2341
|
+
previousExercise.id + "-" + previousExercise.title
|
|
2342
|
+
)
|
|
2343
|
+
// llist the files un
|
|
2344
|
+
const [files] = await bucket.getFiles({
|
|
2345
|
+
prefix: `courses/${courseSlug}/exercises/${exSlug}/README`,
|
|
2346
|
+
})
|
|
2347
|
+
// select any README
|
|
2348
|
+
const readmeFiles = files.map(f => f.name)
|
|
2349
|
+
const readmeFile = readmeFiles.find(f => f.includes("README"))
|
|
2350
|
+
if (readmeFile) {
|
|
2351
|
+
const [content] = await bucket.file(readmeFile).download()
|
|
2352
|
+
previousReadme = content.toString()
|
|
2353
|
+
}
|
|
2354
|
+
}
|
|
2230
2355
|
|
|
2231
|
-
|
|
2356
|
+
// Use new two-phase generation workflow
|
|
2357
|
+
const completionId = await startInitialContentGeneration(
|
|
2358
|
+
rigoToken,
|
|
2359
|
+
syllabus.lessons,
|
|
2360
|
+
syllabus.courseInfo,
|
|
2361
|
+
exercise,
|
|
2362
|
+
courseSlug,
|
|
2363
|
+
syllabus.courseInfo.purpose,
|
|
2364
|
+
previousReadme +
|
|
2365
|
+
"\n\nThe user provided the following feedback related to the content of the course so far: \n\n" +
|
|
2366
|
+
feedback
|
|
2367
|
+
)
|
|
2368
|
+
|
|
2369
|
+
// A new job by the user, so the automatic retries start over.
|
|
2370
|
+
syllabus.lessons[exerciseIndex].status = "GENERATING"
|
|
2371
|
+
syllabus.lessons[exerciseIndex].translations = {
|
|
2372
|
+
[language]: startedJob(completionId),
|
|
2373
|
+
}
|
|
2374
|
+
console.log("Lesson", syllabus.lessons[exerciseIndex])
|
|
2375
|
+
if (syllabus.feedback && typeof syllabus.feedback === "string") {
|
|
2376
|
+
syllabus.feedback += "\n\n" + feedback
|
|
2377
|
+
} else {
|
|
2378
|
+
syllabus.feedback = feedback
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
syllabus.generationMode = resolveGenerationMode(
|
|
2382
|
+
previousStatus,
|
|
2383
|
+
syllabus.generationMode,
|
|
2384
|
+
mode
|
|
2385
|
+
)
|
|
2386
|
+
|
|
2387
|
+
await saveSyllabus(courseSlug, syllabus, bucket)
|
|
2388
|
+
})
|
|
2232
2389
|
|
|
2233
2390
|
res.json({ status: "SUCCESS" })
|
|
2234
2391
|
})
|
|
@@ -2546,223 +2703,152 @@ class ServeCommand extends SessionCommand {
|
|
|
2546
2703
|
|
|
2547
2704
|
console.log("RECEIVING INITIAL CONTENT WEBHOOK", response)
|
|
2548
2705
|
|
|
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
|
-
})
|
|
2706
|
+
try {
|
|
2707
|
+
// In the course queue, nothing else saves the syllabus between
|
|
2708
|
+
// reading the lesson and saving it.
|
|
2709
|
+
await serializeByCourse(courseSlug, async () => {
|
|
2710
|
+
const syllabus = await getSyllabus(courseSlug, bucket)
|
|
2711
|
+
const language = syllabus.courseInfo.language || "en"
|
|
2578
2712
|
|
|
2579
|
-
const
|
|
2580
|
-
|
|
2581
|
-
syllabus.lessons,
|
|
2582
|
-
syllabus.courseInfo,
|
|
2583
|
-
exercise,
|
|
2584
|
-
courseSlug,
|
|
2585
|
-
syllabus.courseInfo.purpose,
|
|
2586
|
-
""
|
|
2713
|
+
const exerciseIndex = syllabus.lessons.findIndex(
|
|
2714
|
+
lesson => lesson.uid === lessonUID
|
|
2587
2715
|
)
|
|
2588
2716
|
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
[syllabus.courseInfo.language || "en"]: {
|
|
2593
|
-
completionId: retryCompletionId,
|
|
2594
|
-
startedAt: Date.now(),
|
|
2595
|
-
completedAt: 0,
|
|
2596
|
-
},
|
|
2717
|
+
if (exerciseIndex === -1) {
|
|
2718
|
+
console.error("Exercise not found receiving webhook:", lessonUID)
|
|
2719
|
+
return res.json({ status: "ERROR", error: "Exercise not found" })
|
|
2597
2720
|
}
|
|
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
|
-
|
|
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
|
-
})
|
|
2620
|
-
|
|
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
2721
|
|
|
2634
|
-
|
|
2722
|
+
let exercise: Lesson | null = syllabus.lessons[exerciseIndex]
|
|
2723
|
+
const exSlug = slugify(exercise.id + "-" + exercise.title)
|
|
2635
2724
|
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
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
|
-
)}`
|
|
2725
|
+
if (isStaleWebhook(exercise, language, response.id)) {
|
|
2726
|
+
console.log(
|
|
2727
|
+
`Ignoring initial content webhook of job ${response.id} for lesson ${exSlug}: the lesson no longer waits for it`
|
|
2648
2728
|
)
|
|
2649
|
-
|
|
2650
|
-
lastLesson = content.toString()
|
|
2651
|
-
} catch (error) {
|
|
2652
|
-
console.error("Error searching previous lesson content:", error)
|
|
2729
|
+
return res.json({ status: "IGNORED" })
|
|
2653
2730
|
}
|
|
2654
|
-
}
|
|
2655
|
-
|
|
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
2731
|
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
completedAt: 0,
|
|
2674
|
-
},
|
|
2675
|
-
}
|
|
2676
|
-
await saveSyllabus(courseSlug, syllabus, bucket)
|
|
2732
|
+
// Handle errors
|
|
2733
|
+
if (response.status === "ERROR") {
|
|
2734
|
+
console.error(
|
|
2735
|
+
`Initial content job ${response.id} failed for lesson ${exSlug}:`,
|
|
2736
|
+
response.status_text
|
|
2737
|
+
)
|
|
2677
2738
|
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2739
|
+
const retryPlan = planGenerationRetry(
|
|
2740
|
+
exercise,
|
|
2741
|
+
language,
|
|
2742
|
+
response.status_text
|
|
2743
|
+
)
|
|
2683
2744
|
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2745
|
+
if (!retryPlan.retry) {
|
|
2746
|
+
await updateLessonStatusToError(courseSlug, lessonUID, bucket)
|
|
2747
|
+
emitToCourse(courseSlug, "course-creation", {
|
|
2748
|
+
lesson: exSlug,
|
|
2749
|
+
status: "error",
|
|
2750
|
+
log: noRetryLog(retryPlan.reason, "initial content", exSlug),
|
|
2751
|
+
})
|
|
2752
|
+
return res.json({ status: "ERROR" })
|
|
2753
|
+
}
|
|
2689
2754
|
|
|
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
|
-
)
|
|
2755
|
+
const attempt = retryPlan.attempt
|
|
2705
2756
|
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2757
|
+
try {
|
|
2758
|
+
emitToCourse(courseSlug, "course-creation", {
|
|
2759
|
+
lesson: exSlug,
|
|
2760
|
+
status: "generating",
|
|
2761
|
+
log: `🔄 Retrying initial content generation for lesson ${exSlug} (${attempt} of ${MAX_AUTO_RETRIES})`,
|
|
2762
|
+
})
|
|
2712
2763
|
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2764
|
+
const retryCompletionId = await startInitialContentGeneration(
|
|
2765
|
+
rigoToken,
|
|
2766
|
+
syllabus.lessons,
|
|
2767
|
+
syllabus.courseInfo,
|
|
2768
|
+
exercise,
|
|
2769
|
+
courseSlug,
|
|
2770
|
+
syllabus.courseInfo.purpose,
|
|
2771
|
+
""
|
|
2772
|
+
)
|
|
2716
2773
|
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2774
|
+
await patchLesson(
|
|
2775
|
+
courseSlug,
|
|
2776
|
+
lessonUID,
|
|
2777
|
+
bucket,
|
|
2778
|
+
(lesson, lang) => {
|
|
2779
|
+
lesson.status = "GENERATING"
|
|
2780
|
+
lesson.translations = {
|
|
2781
|
+
[lang]: startedJob(retryCompletionId, attempt),
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
)
|
|
2785
|
+
} catch (retryError) {
|
|
2786
|
+
console.error(
|
|
2787
|
+
"Error retrying initial content generation:",
|
|
2788
|
+
retryError
|
|
2789
|
+
)
|
|
2790
|
+
await updateLessonStatusToError(courseSlug, lessonUID, bucket)
|
|
2791
|
+
emitToCourse(courseSlug, "course-creation", {
|
|
2792
|
+
lesson: exSlug,
|
|
2793
|
+
status: "error",
|
|
2794
|
+
log: `❌ Failed to retry initial content generation for lesson ${exSlug}`,
|
|
2795
|
+
})
|
|
2796
|
+
}
|
|
2725
2797
|
|
|
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]
|
|
2798
|
+
return res.json({ status: "ERROR" })
|
|
2799
|
+
}
|
|
2733
2800
|
|
|
2734
|
-
|
|
2801
|
+
try {
|
|
2735
2802
|
emitToCourse(courseSlug, "course-creation", {
|
|
2736
|
-
lesson:
|
|
2803
|
+
lesson: exSlug,
|
|
2737
2804
|
status: "generating",
|
|
2738
|
-
log:
|
|
2805
|
+
log: `✅ Initial content generated for lesson ${exSlug}`,
|
|
2739
2806
|
})
|
|
2740
2807
|
|
|
2741
|
-
//
|
|
2808
|
+
// Update lesson with initial content
|
|
2809
|
+
exercise = await updateLessonWithInitialContent(
|
|
2810
|
+
courseSlug,
|
|
2811
|
+
lessonUID,
|
|
2812
|
+
response.parsed,
|
|
2813
|
+
bucket
|
|
2814
|
+
)
|
|
2815
|
+
|
|
2816
|
+
if (!exercise) {
|
|
2817
|
+
console.error(
|
|
2818
|
+
"Exercise not found after updating initial content"
|
|
2819
|
+
)
|
|
2820
|
+
return res.json({
|
|
2821
|
+
status: "ERROR",
|
|
2822
|
+
error: "Exercise not found",
|
|
2823
|
+
})
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2742
2826
|
let lastLesson = ""
|
|
2743
|
-
|
|
2827
|
+
|
|
2828
|
+
const prevLessonIndex = exerciseIndex - 1
|
|
2744
2829
|
if (prevLessonIndex >= 0) {
|
|
2745
2830
|
try {
|
|
2746
2831
|
const prevLesson = syllabus.lessons[prevLessonIndex]
|
|
2832
|
+
// search the lesson content in the bucket
|
|
2747
2833
|
const prevLessonSlug = slugify(
|
|
2748
2834
|
prevLesson.id + "-" + prevLesson.title
|
|
2749
2835
|
)
|
|
2750
2836
|
const file = bucket.file(
|
|
2751
2837
|
`courses/${courseSlug}/exercises/${prevLessonSlug}/README${getReadmeExtension(
|
|
2752
|
-
|
|
2838
|
+
language
|
|
2753
2839
|
)}`
|
|
2754
2840
|
)
|
|
2755
2841
|
const [content] = await file.download()
|
|
2756
2842
|
lastLesson = content.toString()
|
|
2757
2843
|
} catch (error) {
|
|
2758
2844
|
console.error(
|
|
2759
|
-
"Error
|
|
2845
|
+
"Error searching previous lesson content:",
|
|
2760
2846
|
error
|
|
2761
2847
|
)
|
|
2762
2848
|
}
|
|
2763
2849
|
}
|
|
2764
2850
|
|
|
2765
|
-
const
|
|
2851
|
+
const completionId = await startInteractivityGeneration(
|
|
2766
2852
|
rigoToken,
|
|
2767
2853
|
syllabus.lessons,
|
|
2768
2854
|
syllabus.courseInfo,
|
|
@@ -2773,120 +2859,337 @@ class ServeCommand extends SessionCommand {
|
|
|
2773
2859
|
lastLesson
|
|
2774
2860
|
)
|
|
2775
2861
|
|
|
2776
|
-
//
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2862
|
+
// Saved on a fresh read: `syllabus` was read before the initial
|
|
2863
|
+
// content was saved, and saving it would erase that content.
|
|
2864
|
+
await patchLesson(
|
|
2865
|
+
courseSlug,
|
|
2866
|
+
lessonUID,
|
|
2867
|
+
bucket,
|
|
2868
|
+
(lesson, lang) => {
|
|
2869
|
+
lesson.status = "GENERATING"
|
|
2870
|
+
lesson.translations = { [lang]: startedJob(completionId) }
|
|
2871
|
+
}
|
|
2872
|
+
)
|
|
2873
|
+
|
|
2874
|
+
emitToCourse(courseSlug, "course-creation", {
|
|
2875
|
+
lesson: exSlug,
|
|
2876
|
+
status: "generating",
|
|
2877
|
+
log: `🔄 Starting interactivity phase for lesson ${exercise.title}`,
|
|
2878
|
+
})
|
|
2879
|
+
|
|
2880
|
+
emitToCourse(courseSlug, "course-creation", {
|
|
2881
|
+
lesson: exSlug,
|
|
2882
|
+
status: "initial-content-complete",
|
|
2883
|
+
log: `✅ Initial content generated for lesson ${exSlug}, starting interactivity phase`,
|
|
2884
|
+
})
|
|
2885
|
+
|
|
2886
|
+
res.json({ status: "SUCCESS" })
|
|
2887
|
+
} catch (error) {
|
|
2888
|
+
console.error("Error processing initial content webhook:", error)
|
|
2889
|
+
await updateLessonStatusToError(courseSlug, lessonUID, bucket)
|
|
2890
|
+
emitToCourse(courseSlug, "course-creation", {
|
|
2891
|
+
lesson: exSlug,
|
|
2892
|
+
status: "error",
|
|
2893
|
+
log: `❌ Error processing initial content for lesson ${exSlug}`,
|
|
2894
|
+
})
|
|
2895
|
+
res
|
|
2896
|
+
.status(500)
|
|
2897
|
+
.json({ status: "ERROR", error: (error as Error).message })
|
|
2786
2898
|
}
|
|
2787
|
-
}
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
status: "error",
|
|
2795
|
-
log: `❌ Failed to retry interactivity generation for lesson ${lessonUID}`,
|
|
2796
|
-
})
|
|
2899
|
+
})
|
|
2900
|
+
} catch (error) {
|
|
2901
|
+
console.error("Error handling initial content webhook:", error)
|
|
2902
|
+
if (!res.headersSent) {
|
|
2903
|
+
res
|
|
2904
|
+
.status(500)
|
|
2905
|
+
.json({ status: "ERROR", error: (error as Error).message })
|
|
2797
2906
|
}
|
|
2798
|
-
|
|
2799
|
-
return res.json({ status: "ERROR" })
|
|
2800
2907
|
}
|
|
2908
|
+
}
|
|
2909
|
+
)
|
|
2910
|
+
|
|
2911
|
+
// Phase 2: Interactivity generation webhook (replaces exercise-processor logic)
|
|
2912
|
+
app.post(
|
|
2913
|
+
"/webhooks/:courseSlug/interactivity-processor/:lessonUID/:rigoToken",
|
|
2914
|
+
async (req, res) => {
|
|
2915
|
+
const { courseSlug, lessonUID, rigoToken } = req.params
|
|
2916
|
+
const response = req.body
|
|
2917
|
+
|
|
2918
|
+
console.log("RECEIVING INTERACTIVITY WEBHOOK")
|
|
2919
|
+
// console.log("LESSON UID", lessonUID)
|
|
2920
|
+
// console.log("RESPONSE", response)
|
|
2801
2921
|
|
|
2802
2922
|
try {
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2923
|
+
// In the course queue, nothing else saves the syllabus between
|
|
2924
|
+
// reading the lesson and saving it.
|
|
2925
|
+
await serializeByCourse(courseSlug, async () => {
|
|
2926
|
+
const syllabus = await getSyllabus(courseSlug, bucket)
|
|
2927
|
+
const language = syllabus.courseInfo.language || "en"
|
|
2807
2928
|
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
}
|
|
2929
|
+
const exerciseIndex = syllabus.lessons.findIndex(
|
|
2930
|
+
lesson => lesson.uid === lessonUID
|
|
2931
|
+
)
|
|
2812
2932
|
|
|
2813
|
-
|
|
2814
|
-
|
|
2933
|
+
if (exerciseIndex === -1) {
|
|
2934
|
+
console.error("Exercise not found receiving webhook:", lessonUID)
|
|
2935
|
+
return res.json({ status: "ERROR", error: "Exercise not found" })
|
|
2936
|
+
}
|
|
2815
2937
|
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
response.parsed.final_content,
|
|
2819
|
-
PARAMS.max_words,
|
|
2820
|
-
3
|
|
2821
|
-
)
|
|
2938
|
+
const exercise = syllabus.lessons[exerciseIndex]
|
|
2939
|
+
const exSlug = slugify(exercise.id + "-" + exercise.title)
|
|
2822
2940
|
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2941
|
+
if (isStaleWebhook(exercise, language, response.id)) {
|
|
2942
|
+
console.log(
|
|
2943
|
+
`Ignoring interactivity webhook of job ${response.id} for lesson ${exSlug}: the lesson no longer waits for it`
|
|
2944
|
+
)
|
|
2945
|
+
return res.json({ status: "IGNORED" })
|
|
2946
|
+
}
|
|
2828
2947
|
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
"en"
|
|
2836
|
-
)}`
|
|
2948
|
+
// Handle errors
|
|
2949
|
+
if (response.status === "ERROR") {
|
|
2950
|
+
console.error(
|
|
2951
|
+
`Interactivity job ${response.id} failed for lesson ${exSlug}:`,
|
|
2952
|
+
response.status_text
|
|
2953
|
+
)
|
|
2837
2954
|
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2955
|
+
// Every retry uses v1. A failed v2 job gets its v1 retry even
|
|
2956
|
+
// when its error would repeat, because the error belongs to the
|
|
2957
|
+
// v2 template.
|
|
2958
|
+
const { fromV2, retryStatusText } = describeInteractivityFailure(
|
|
2959
|
+
req.query,
|
|
2960
|
+
response.status_text
|
|
2961
|
+
)
|
|
2843
2962
|
|
|
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
|
-
}
|
|
2963
|
+
const retryPlan = planGenerationRetry(
|
|
2964
|
+
exercise,
|
|
2965
|
+
language,
|
|
2966
|
+
retryStatusText
|
|
2967
|
+
)
|
|
2862
2968
|
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2969
|
+
// The retry builds on the initial content, so without it there
|
|
2970
|
+
// is nothing to retry.
|
|
2971
|
+
if (!retryPlan.retry || !exercise.initialContent) {
|
|
2972
|
+
await updateLessonStatusToError(courseSlug, lessonUID, bucket)
|
|
2973
|
+
emitToCourse(courseSlug, "course-creation", {
|
|
2974
|
+
lesson: exSlug,
|
|
2975
|
+
status: "error",
|
|
2976
|
+
log: retryPlan.retry ?
|
|
2977
|
+
`❌ Error adding interactivity to lesson ${exSlug}. Its initial content is missing, so it was not retried` :
|
|
2978
|
+
noRetryLog(retryPlan.reason, "interactivity", exSlug),
|
|
2979
|
+
})
|
|
2980
|
+
return res.json({ status: "ERROR" })
|
|
2981
|
+
}
|
|
2871
2982
|
|
|
2872
|
-
|
|
2873
|
-
lesson: exSlug,
|
|
2874
|
-
status: "done",
|
|
2875
|
-
log: `✅ The lesson ${exercise.id} - ${exercise.title} has been generated successfully with interactivity!`,
|
|
2876
|
-
})
|
|
2983
|
+
const attempt = retryPlan.attempt
|
|
2877
2984
|
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2985
|
+
try {
|
|
2986
|
+
emitToCourse(courseSlug, "course-creation", {
|
|
2987
|
+
lesson: exSlug,
|
|
2988
|
+
status: "generating",
|
|
2989
|
+
log: fromV2 ?
|
|
2990
|
+
`⚠️ Structured interactivity failed for lesson ${exSlug}, retrying with the previous method (${attempt} of ${MAX_AUTO_RETRIES})` :
|
|
2991
|
+
`🔄 Retrying interactivity generation for lesson ${exSlug} (${attempt} of ${MAX_AUTO_RETRIES})`,
|
|
2992
|
+
})
|
|
2993
|
+
|
|
2994
|
+
// Get previous lesson content for context
|
|
2995
|
+
let lastLesson = ""
|
|
2996
|
+
const prevLessonIndex = exerciseIndex - 1
|
|
2997
|
+
if (prevLessonIndex >= 0) {
|
|
2998
|
+
try {
|
|
2999
|
+
const prevLesson = syllabus.lessons[prevLessonIndex]
|
|
3000
|
+
const prevLessonSlug = slugify(
|
|
3001
|
+
prevLesson.id + "-" + prevLesson.title
|
|
3002
|
+
)
|
|
3003
|
+
const file = bucket.file(
|
|
3004
|
+
`courses/${courseSlug}/exercises/${prevLessonSlug}/README${getReadmeExtension(
|
|
3005
|
+
language
|
|
3006
|
+
)}`
|
|
3007
|
+
)
|
|
3008
|
+
const [content] = await file.download()
|
|
3009
|
+
lastLesson = content.toString()
|
|
3010
|
+
} catch (error) {
|
|
3011
|
+
console.error(
|
|
3012
|
+
"Error getting previous lesson content for retry:",
|
|
3013
|
+
error
|
|
3014
|
+
)
|
|
3015
|
+
}
|
|
3016
|
+
}
|
|
3017
|
+
|
|
3018
|
+
const retryCompletionId = await startInteractivityGeneration(
|
|
3019
|
+
rigoToken,
|
|
3020
|
+
syllabus.lessons,
|
|
3021
|
+
syllabus.courseInfo,
|
|
3022
|
+
exercise,
|
|
3023
|
+
courseSlug,
|
|
3024
|
+
syllabus.courseInfo.purpose,
|
|
3025
|
+
bucket,
|
|
3026
|
+
lastLesson,
|
|
3027
|
+
{ forceV1: true }
|
|
3028
|
+
)
|
|
3029
|
+
|
|
3030
|
+
await patchLesson(
|
|
3031
|
+
courseSlug,
|
|
3032
|
+
lessonUID,
|
|
3033
|
+
bucket,
|
|
3034
|
+
(lesson, lang) => {
|
|
3035
|
+
lesson.status = "GENERATING"
|
|
3036
|
+
lesson.translations = {
|
|
3037
|
+
[lang]: startedJob(retryCompletionId, attempt),
|
|
3038
|
+
}
|
|
3039
|
+
}
|
|
3040
|
+
)
|
|
3041
|
+
} catch (retryError) {
|
|
3042
|
+
console.error(
|
|
3043
|
+
"Error retrying interactivity generation:",
|
|
3044
|
+
retryError
|
|
3045
|
+
)
|
|
3046
|
+
await updateLessonStatusToError(courseSlug, lessonUID, bucket)
|
|
3047
|
+
emitToCourse(courseSlug, "course-creation", {
|
|
3048
|
+
lesson: exSlug,
|
|
3049
|
+
status: "error",
|
|
3050
|
+
log: `❌ Failed to retry interactivity generation for lesson ${exSlug}`,
|
|
3051
|
+
})
|
|
3052
|
+
}
|
|
3053
|
+
|
|
3054
|
+
return res.json({ status: "ERROR" })
|
|
3055
|
+
}
|
|
3056
|
+
|
|
3057
|
+
try {
|
|
3058
|
+
let finalContent: string = response.parsed?.final_content
|
|
3059
|
+
let usedComponents: unknown = response.parsed?.used_components
|
|
3060
|
+
|
|
3061
|
+
// The structured template (v2) returns component data and
|
|
3062
|
+
// placeholders; the CLI writes the component markdown.
|
|
3063
|
+
if (req.query.tpl === "v2") {
|
|
3064
|
+
if (!Array.isArray(response.parsed?.multiple_choice_question)) {
|
|
3065
|
+
console.warn(
|
|
3066
|
+
`Structured interactivity response for ${exSlug} has no component lists`
|
|
3067
|
+
)
|
|
3068
|
+
}
|
|
3069
|
+
|
|
3070
|
+
const guide = componentsGuideLoader.peek()
|
|
3071
|
+
const result = processInteractivityResult(response.parsed, {
|
|
3072
|
+
stepType: exercise.type,
|
|
3073
|
+
isFirstOrLast:
|
|
3074
|
+
exerciseIndex === 0 ||
|
|
3075
|
+
exerciseIndex === syllabus.lessons.length - 1,
|
|
3076
|
+
lessonId: exercise.id,
|
|
3077
|
+
decidedBy: guide ? decidedByFromGuide(guide) : undefined,
|
|
3078
|
+
})
|
|
3079
|
+
finalContent = result.markdown
|
|
3080
|
+
usedComponents = result.usedComponents
|
|
3081
|
+
|
|
3082
|
+
if (result.warnings.length > 0) {
|
|
3083
|
+
console.warn(
|
|
3084
|
+
`Component warnings for ${exSlug}:\n- ${result.warnings
|
|
3085
|
+
.map(warning => warning.message)
|
|
3086
|
+
.join("\n- ")}`
|
|
3087
|
+
)
|
|
3088
|
+
}
|
|
3089
|
+
|
|
3090
|
+
if (result.dropped.length > 0) {
|
|
3091
|
+
console.warn(
|
|
3092
|
+
`Components removed from ${exSlug}:\n- ${result.dropped
|
|
3093
|
+
.map(issue => issue.message)
|
|
3094
|
+
.join("\n- ")}`
|
|
3095
|
+
)
|
|
3096
|
+
emitToCourse(courseSlug, "course-creation", {
|
|
3097
|
+
lesson: exSlug,
|
|
3098
|
+
status: "generating",
|
|
3099
|
+
log: `⚠️ ${
|
|
3100
|
+
result.dropped.length
|
|
3101
|
+
} component(s) removed from lesson ${
|
|
3102
|
+
exercise.title
|
|
3103
|
+
} because they could not be rendered: ${result.dropped
|
|
3104
|
+
.map(issue => issue.message)
|
|
3105
|
+
.join("; ")}`,
|
|
3106
|
+
})
|
|
3107
|
+
}
|
|
3108
|
+
|
|
3109
|
+
if (exercise.type === "QUIZ" && result.assessmentCount === 0) {
|
|
3110
|
+
emitToCourse(courseSlug, "course-creation", {
|
|
3111
|
+
lesson: exSlug,
|
|
3112
|
+
status: "generating",
|
|
3113
|
+
log: `⚠️ The quiz ${exercise.title} has no questions; review it or regenerate it`,
|
|
3114
|
+
})
|
|
3115
|
+
}
|
|
3116
|
+
}
|
|
3117
|
+
|
|
3118
|
+
// Process readability of final content
|
|
3119
|
+
const readability = checkReadability(
|
|
3120
|
+
finalContent,
|
|
3121
|
+
PARAMS.max_words,
|
|
3122
|
+
3
|
|
3123
|
+
)
|
|
3124
|
+
|
|
3125
|
+
emitToCourse(courseSlug, "course-creation", {
|
|
3126
|
+
lesson: exSlug,
|
|
3127
|
+
status: "generating",
|
|
3128
|
+
log: `🔄 The lesson ${exercise.title} has a readability score of ${readability.fkglResult.fkgl}`,
|
|
3129
|
+
})
|
|
3130
|
+
|
|
3131
|
+
// Upload README with final content
|
|
3132
|
+
const exercisesDir = `courses/${courseSlug}/exercises`
|
|
3133
|
+
const targetDir = `${exercisesDir}/${exSlug}`
|
|
3134
|
+
const readmeFilename = `README${getReadmeExtension(
|
|
3135
|
+
response.parsed.output_language || language
|
|
3136
|
+
)}`
|
|
3137
|
+
|
|
3138
|
+
await uploadFileToBucket(
|
|
3139
|
+
bucket,
|
|
3140
|
+
readability.newMarkdown,
|
|
3141
|
+
`${targetDir}/${readmeFilename}`
|
|
3142
|
+
)
|
|
3143
|
+
|
|
3144
|
+
// Update used components (v1: listed by the AI; v2: the
|
|
3145
|
+
// components that were actually written)
|
|
3146
|
+
if (Array.isArray(usedComponents)) {
|
|
3147
|
+
await updateUsedComponents(courseSlug, usedComponents, bucket)
|
|
3148
|
+
emitToCourse(courseSlug, "course-creation", {
|
|
3149
|
+
lesson: exSlug,
|
|
3150
|
+
status: "generating",
|
|
3151
|
+
log: `📊 Updated component usage tracking: ${usedComponents.join(
|
|
3152
|
+
", "
|
|
3153
|
+
)}`,
|
|
3154
|
+
})
|
|
3155
|
+
}
|
|
3156
|
+
|
|
3157
|
+
// Continue with next lesson
|
|
3158
|
+
await continueWithNextLesson(
|
|
3159
|
+
courseSlug,
|
|
3160
|
+
exerciseIndex,
|
|
3161
|
+
rigoToken,
|
|
3162
|
+
finalContent,
|
|
3163
|
+
bucket
|
|
3164
|
+
)
|
|
3165
|
+
|
|
3166
|
+
emitToCourse(courseSlug, "course-creation", {
|
|
3167
|
+
lesson: exSlug,
|
|
3168
|
+
status: "done",
|
|
3169
|
+
log: `✅ The lesson ${exercise.id} - ${exercise.title} has been generated successfully with interactivity!`,
|
|
3170
|
+
})
|
|
3171
|
+
|
|
3172
|
+
res.json({ status: "SUCCESS" })
|
|
3173
|
+
} catch (error) {
|
|
3174
|
+
console.error("Error processing interactivity webhook:", error)
|
|
3175
|
+
await updateLessonStatusToError(courseSlug, lessonUID, bucket)
|
|
3176
|
+
emitToCourse(courseSlug, "course-creation", {
|
|
3177
|
+
lesson: exSlug,
|
|
3178
|
+
status: "error",
|
|
3179
|
+
log: `❌ Error processing interactivity for lesson ${exSlug}`,
|
|
3180
|
+
})
|
|
3181
|
+
res
|
|
3182
|
+
.status(500)
|
|
3183
|
+
.json({ status: "ERROR", error: (error as Error).message })
|
|
3184
|
+
}
|
|
2886
3185
|
})
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
3186
|
+
} catch (error) {
|
|
3187
|
+
console.error("Error handling interactivity webhook:", error)
|
|
3188
|
+
if (!res.headersSent) {
|
|
3189
|
+
res
|
|
3190
|
+
.status(500)
|
|
3191
|
+
.json({ status: "ERROR", error: (error as Error).message })
|
|
3192
|
+
}
|
|
2890
3193
|
}
|
|
2891
3194
|
}
|
|
2892
3195
|
)
|
|
@@ -3855,42 +4158,43 @@ class ServeCommand extends SessionCommand {
|
|
|
3855
4158
|
uid: stepSlug,
|
|
3856
4159
|
}
|
|
3857
4160
|
|
|
3858
|
-
|
|
3859
|
-
|
|
3860
|
-
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
|
|
3864
|
-
|
|
3865
|
-
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
4161
|
+
// In the course queue, so the webhook of this job finds the new lesson
|
|
4162
|
+
// saved, and the syllabus saved here is not older than the last save.
|
|
4163
|
+
await serializeByCourse(courseSlug, async () => {
|
|
4164
|
+
const syllabus = await getSyllabus(courseSlug, bucket)
|
|
4165
|
+
const newLessons = insertStepInCorrectPosition(
|
|
4166
|
+
syllabus.lessons,
|
|
4167
|
+
newLesson
|
|
4168
|
+
)
|
|
4169
|
+
// Use new two-phase generation workflow
|
|
4170
|
+
const completionId = await startInitialContentGeneration(
|
|
4171
|
+
rigoToken,
|
|
4172
|
+
newLessons,
|
|
4173
|
+
syllabus.courseInfo,
|
|
4174
|
+
newLesson,
|
|
4175
|
+
courseSlug,
|
|
4176
|
+
syllabus.courseInfo.purpose,
|
|
4177
|
+
"lastResult"
|
|
4178
|
+
)
|
|
4179
|
+
newLesson.translations = {
|
|
4180
|
+
[syllabus.courseInfo.language || "en"]: startedJob(completionId),
|
|
4181
|
+
}
|
|
3879
4182
|
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
|
|
4183
|
+
await uploadFileToBucket(
|
|
4184
|
+
bucket,
|
|
4185
|
+
JSON.stringify({ ...syllabus, lessons: newLessons }),
|
|
4186
|
+
`courses/${courseSlug}/.learn/initialSyllabus.json`
|
|
4187
|
+
)
|
|
3885
4188
|
|
|
3886
|
-
|
|
4189
|
+
const targetDir = `courses/${courseSlug}/exercises/${stepSlug}`
|
|
3887
4190
|
|
|
3888
|
-
|
|
3889
|
-
|
|
3890
|
-
|
|
3891
|
-
|
|
3892
|
-
|
|
3893
|
-
|
|
4191
|
+
await uploadInitialReadme(
|
|
4192
|
+
bucket,
|
|
4193
|
+
stepSlug,
|
|
4194
|
+
targetDir,
|
|
4195
|
+
syllabus.courseInfo
|
|
4196
|
+
)
|
|
4197
|
+
})
|
|
3894
4198
|
|
|
3895
4199
|
res.json({ status: "SUCCESS", message: "Exercise generati on started!" })
|
|
3896
4200
|
})
|
|
@@ -5214,11 +5518,19 @@ class ServeCommand extends SessionCommand {
|
|
|
5214
5518
|
syllabus.courseInfo.language
|
|
5215
5519
|
)
|
|
5216
5520
|
|
|
5521
|
+
const language = syllabus.courseInfo.language || "en"
|
|
5217
5522
|
const initialSyllabus = {
|
|
5218
5523
|
...syllabus,
|
|
5219
5524
|
lessons: syllabus.lessons.map((lesson, index) => {
|
|
5220
5525
|
if (index < 1) {
|
|
5221
|
-
|
|
5526
|
+
// Its start time, so a retry right away is refused even before
|
|
5527
|
+
// the job id is saved below.
|
|
5528
|
+
return {
|
|
5529
|
+
...lesson,
|
|
5530
|
+
generated: false,
|
|
5531
|
+
status: "GENERATING",
|
|
5532
|
+
translations: { [language]: startedJob(0) },
|
|
5533
|
+
}
|
|
5222
5534
|
}
|
|
5223
5535
|
|
|
5224
5536
|
return {
|
|
@@ -5243,25 +5555,25 @@ class ServeCommand extends SessionCommand {
|
|
|
5243
5555
|
|
|
5244
5556
|
const lastResult = "---"
|
|
5245
5557
|
|
|
5246
|
-
//
|
|
5247
|
-
|
|
5248
|
-
|
|
5249
|
-
|
|
5250
|
-
|
|
5251
|
-
|
|
5252
|
-
|
|
5253
|
-
|
|
5254
|
-
|
|
5255
|
-
|
|
5256
|
-
|
|
5257
|
-
|
|
5258
|
-
|
|
5259
|
-
|
|
5260
|
-
|
|
5261
|
-
|
|
5262
|
-
}
|
|
5263
|
-
}
|
|
5264
|
-
}
|
|
5558
|
+
// In the course queue, so the webhook of this job waits until its id
|
|
5559
|
+
// is saved. The id was never saved before, so the first lesson had no
|
|
5560
|
+
// protection against a second job.
|
|
5561
|
+
await serializeByCourse(courseSlug, async () => {
|
|
5562
|
+
// Use new two-phase generation workflow
|
|
5563
|
+
const completionId = await startInitialContentGeneration(
|
|
5564
|
+
rigoToken,
|
|
5565
|
+
syllabus.lessons,
|
|
5566
|
+
syllabus.courseInfo,
|
|
5567
|
+
firstLesson,
|
|
5568
|
+
courseSlug,
|
|
5569
|
+
syllabus.courseInfo.purpose,
|
|
5570
|
+
lastResult
|
|
5571
|
+
)
|
|
5572
|
+
|
|
5573
|
+
await patchLesson(courseSlug, firstLesson.uid, bucket, lesson => {
|
|
5574
|
+
lesson.translations = { [language]: startedJob(completionId) }
|
|
5575
|
+
})
|
|
5576
|
+
})
|
|
5265
5577
|
|
|
5266
5578
|
await createInitialReadme(
|
|
5267
5579
|
JSON.stringify(syllabus.courseInfo),
|