@learnpack/learnpack 5.0.352 → 5.0.354

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.
Files changed (85) hide show
  1. package/lib/commands/publish.js +6 -0
  2. package/lib/commands/serve.js +85 -259
  3. package/lib/models/creator.d.ts +6 -0
  4. package/lib/scripts/descriptionsGcsBackfill.d.ts +1 -0
  5. package/lib/scripts/descriptionsGcsBackfill.js +141 -0
  6. package/lib/scripts/descriptionsS3Backfill.d.ts +1 -0
  7. package/lib/scripts/descriptionsS3Backfill.js +277 -0
  8. package/lib/scripts/descriptionsSweep.d.ts +1 -0
  9. package/lib/scripts/descriptionsSweep.js +142 -0
  10. package/lib/utils/api.d.ts +7 -0
  11. package/lib/utils/api.js +8 -1
  12. package/lib/utils/awsCredentials.d.ts +20 -0
  13. package/lib/utils/awsCredentials.js +43 -0
  14. package/lib/utils/creatorUtilities.js +2 -1
  15. package/lib/utils/descriptionHash.d.ts +66 -0
  16. package/lib/utils/descriptionHash.js +173 -0
  17. package/lib/utils/descriptions/backfillEvents.d.ts +60 -0
  18. package/lib/utils/descriptions/backfillEvents.js +107 -0
  19. package/lib/utils/descriptions/gcsStorage.d.ts +16 -0
  20. package/lib/utils/descriptions/gcsStorage.js +60 -0
  21. package/lib/utils/descriptions/generateCourseDescriptions.d.ts +66 -0
  22. package/lib/utils/descriptions/generateCourseDescriptions.js +176 -0
  23. package/lib/utils/descriptions/mirrorDescriptions.d.ts +49 -0
  24. package/lib/utils/descriptions/mirrorDescriptions.js +109 -0
  25. package/lib/utils/descriptions/publishStage.d.ts +69 -0
  26. package/lib/utils/descriptions/publishStage.js +245 -0
  27. package/lib/utils/descriptions/resumePublication.d.ts +36 -0
  28. package/lib/utils/descriptions/resumePublication.js +128 -0
  29. package/lib/utils/descriptions/s3Storage.d.ts +30 -0
  30. package/lib/utils/descriptions/s3Storage.js +141 -0
  31. package/lib/utils/descriptions/workList.d.ts +75 -0
  32. package/lib/utils/descriptions/workList.js +177 -0
  33. package/lib/utils/gcsBucketName.d.ts +10 -0
  34. package/lib/utils/gcsBucketName.js +19 -0
  35. package/lib/utils/packageManifest.d.ts +22 -0
  36. package/lib/utils/packageManifest.js +57 -5
  37. package/lib/utils/publishEvents.d.ts +66 -0
  38. package/lib/utils/publishEvents.js +111 -0
  39. package/lib/utils/publishJournal.d.ts +119 -0
  40. package/lib/utils/publishJournal.js +275 -0
  41. package/lib/utils/repair/legacyPackageRepair.d.ts +131 -0
  42. package/lib/utils/repair/legacyPackageRepair.js +492 -0
  43. package/lib/utils/repair/repairStorage.d.ts +68 -0
  44. package/lib/utils/repair/repairStorage.js +89 -0
  45. package/lib/utils/rigoActions.d.ts +44 -0
  46. package/lib/utils/rigoActions.js +75 -1
  47. package/lib/utils/s3/packageManifestBackfill.d.ts +2 -0
  48. package/lib/utils/s3/packageManifestBackfill.js +5 -8
  49. package/lib/utils/s3/packageSourcesAudit.d.ts +75 -0
  50. package/lib/utils/s3/packageSourcesAudit.js +184 -0
  51. package/lib/utils/syllabusSync.d.ts +71 -0
  52. package/lib/utils/syllabusSync.js +273 -0
  53. package/package.json +3 -1
  54. package/src/commands/publish.ts +7 -0
  55. package/src/commands/serve.ts +144 -335
  56. package/src/models/creator.ts +9 -0
  57. package/src/scripts/README.md +244 -0
  58. package/src/scripts/descriptionsGcsBackfill.ts +193 -0
  59. package/src/scripts/descriptionsS3Backfill.ts +376 -0
  60. package/src/scripts/descriptionsSweep.ts +185 -0
  61. package/src/ui/_app/app.css +1 -1
  62. package/src/ui/_app/app.js +142 -140
  63. package/src/ui/app.tar.gz +0 -0
  64. package/src/utils/api.ts +9 -0
  65. package/src/utils/awsCredentials.ts +57 -0
  66. package/src/utils/creatorUtilities.ts +2 -1
  67. package/src/utils/descriptionHash.ts +196 -0
  68. package/src/utils/descriptions/backfillEvents.ts +152 -0
  69. package/src/utils/descriptions/gcsStorage.ts +67 -0
  70. package/src/utils/descriptions/generateCourseDescriptions.ts +311 -0
  71. package/src/utils/descriptions/mirrorDescriptions.ts +191 -0
  72. package/src/utils/descriptions/publishStage.ts +394 -0
  73. package/src/utils/descriptions/resumePublication.ts +217 -0
  74. package/src/utils/descriptions/s3Storage.ts +214 -0
  75. package/src/utils/descriptions/workList.ts +283 -0
  76. package/src/utils/gcsBucketName.ts +19 -0
  77. package/src/utils/packageManifest.ts +62 -5
  78. package/src/utils/publishEvents.ts +181 -0
  79. package/src/utils/publishJournal.ts +383 -0
  80. package/src/utils/repair/legacyPackageRepair.ts +731 -0
  81. package/src/utils/repair/repairStorage.ts +168 -0
  82. package/src/utils/rigoActions.ts +130 -0
  83. package/src/utils/s3/packageManifestBackfill.ts +771 -776
  84. package/src/utils/s3/packageSourcesAudit.ts +311 -0
  85. package/src/utils/syllabusSync.ts +390 -0
@@ -0,0 +1,311 @@
1
+ import { Lesson, Syllabus } from "../../models/creator"
2
+ import { PackageManifest } from "../packageManifest"
3
+ import {
4
+ generateStepDescriptions,
5
+ TGenerateStepDescriptionsParams,
6
+ TGenerateStepDescriptionsResult,
7
+ } from "../rigoActions"
8
+ import { mapWithConcurrency } from "../s3/packageManifestBackfill"
9
+ import {
10
+ SyllabusSyncStorage,
11
+ synchronizeSyllabusWithBucket,
12
+ } from "../syllabusSync"
13
+ import {
14
+ buildDescriptionWorkList,
15
+ countWorkItems,
16
+ CourseExercise,
17
+ CourseReadmes,
18
+ StepWork,
19
+ } from "./workList"
20
+
21
+ /**
22
+ * Generate and persist the step descriptions of one published course.
23
+ *
24
+ * This is the single implementation behind every flow that produces
25
+ * descriptions: the background stage that runs after publishing, the sweep that
26
+ * recovers interrupted publications, and the one-off backfill over the
27
+ * historical catalogue. They differ only in what triggers them.
28
+ *
29
+ * Storage is a port so the service can be driven against the published bucket
30
+ * (the real case) or an in-memory double (tests), and so the manifest
31
+ * re-projection stays where the storage-specific knowledge lives.
32
+ */
33
+
34
+ export const DEFAULT_DESCRIPTIONS_CONCURRENCY = 5
35
+
36
+ export interface CourseDescriptionsStorage {
37
+ readSyllabus(courseSlug: string): Promise<Syllabus | null>;
38
+ writeSyllabus(courseSlug: string, syllabus: Syllabus): Promise<void>;
39
+ listExercises(courseSlug: string): Promise<CourseExercise[]>;
40
+ readReadme(
41
+ courseSlug: string,
42
+ exerciseSlug: string,
43
+ fileName: string
44
+ ): Promise<string | null>;
45
+ /** Re-generate `package-manifest.json` from the syllabus and invalidate the CDN. */
46
+ reprojectManifest?(courseSlug: string): Promise<void>;
47
+ /** Read the stored manifest, to report the updated one in the event. */
48
+ readManifest?(courseSlug: string): Promise<PackageManifest | null>;
49
+ /** Enables the provisional additive syllabus reconciliation. */
50
+ syllabusSyncStorage?: SyllabusSyncStorage;
51
+ }
52
+
53
+ export type GenerateCourseDescriptionsOptions = {
54
+ token: string;
55
+ dryRun?: boolean;
56
+ force?: boolean;
57
+ concurrency?: number;
58
+ targetWordCount?: number;
59
+ promptVersion?: number;
60
+ /** Additive syllabus reconciliation before computing the work list. */
61
+ reconcile?: boolean;
62
+ /** Injection point for tests. */
63
+ generate?: (
64
+ token: string,
65
+ params: TGenerateStepDescriptionsParams
66
+ ) => Promise<TGenerateStepDescriptionsResult | null>;
67
+ };
68
+
69
+ export type GenerateCourseDescriptionsResult = {
70
+ /** `skipped` means there was nothing to do, not that something went wrong. */
71
+ status: "skipped" | "processed" | "failed";
72
+ steps: number;
73
+ /** (step, language) pairs written. */
74
+ generated: number;
75
+ /** Steps whose completion failed outright. */
76
+ failed: number;
77
+ /** (step, language) pairs the model never answered; retried later. */
78
+ missing: number;
79
+ errors: string[];
80
+ /** Completion durations, to watch how close we run to the request limits. */
81
+ durationsSeconds: number[];
82
+ /**
83
+ * Whether `package-manifest.json` was re-projected; null when the storage does
84
+ * not project at all. A failed projection is not a failed run — the syllabus,
85
+ * which is the source of truth, is already saved — but callers that announce
86
+ * the manifest downstream must not report success on it.
87
+ */
88
+ manifestProjected: boolean | null;
89
+ };
90
+
91
+ function emptyResult(
92
+ status: GenerateCourseDescriptionsResult["status"]
93
+ ): GenerateCourseDescriptionsResult {
94
+ return {
95
+ status,
96
+ steps: 0,
97
+ generated: 0,
98
+ failed: 0,
99
+ missing: 0,
100
+ errors: [],
101
+ durationsSeconds: [],
102
+ manifestProjected: null,
103
+ }
104
+ }
105
+
106
+ /** Read every README of the course, keyed by exercise slug and language. */
107
+ export async function readCourseReadmes(
108
+ storage: CourseDescriptionsStorage,
109
+ courseSlug: string,
110
+ exercises: CourseExercise[],
111
+ concurrency: number
112
+ ): Promise<CourseReadmes> {
113
+ const readmes: CourseReadmes = {}
114
+
115
+ const targets: Array<{ exerciseSlug: string; lang: string; file: string }> =
116
+ []
117
+ for (const exercise of exercises) {
118
+ for (const [lang, file] of Object.entries(exercise.translations || {})) {
119
+ targets.push({ exerciseSlug: exercise.slug, lang, file })
120
+ }
121
+ }
122
+
123
+ const contents = await mapWithConcurrency(targets, concurrency, target =>
124
+ storage.readReadme(courseSlug, target.exerciseSlug, target.file)
125
+ )
126
+
127
+ for (const [index, target] of targets.entries()) {
128
+ const content = contents[index]
129
+ if (typeof content !== "string") {
130
+ continue
131
+ }
132
+
133
+ if (!readmes[target.exerciseSlug]) {
134
+ readmes[target.exerciseSlug] = {}
135
+ }
136
+
137
+ readmes[target.exerciseSlug][target.lang] = content
138
+ }
139
+
140
+ return readmes
141
+ }
142
+
143
+ /** Ensure the translation slot exists before writing a description into it. */
144
+ function ensureSlot(
145
+ lesson: Lesson,
146
+ lang: string
147
+ ): NonNullable<Lesson["translations"]>[string] {
148
+ if (!lesson.translations) {
149
+ lesson.translations = {}
150
+ }
151
+
152
+ if (!lesson.translations[lang]) {
153
+ lesson.translations[lang] = { completionId: 0, startedAt: Date.now() }
154
+ }
155
+
156
+ return lesson.translations[lang]
157
+ }
158
+
159
+ function applyStepResult(
160
+ step: StepWork,
161
+ result: TGenerateStepDescriptionsResult
162
+ ): { generated: number; missing: number } {
163
+ let generated = 0
164
+
165
+ for (const language of step.languages) {
166
+ if (!(language.lang in result.descriptionsByLanguage)) {
167
+ // Never answered: leave the slot untouched so a later run retries it.
168
+ // Writing a null plus a fingerprint here would look settled forever.
169
+ continue
170
+ }
171
+
172
+ const slot = ensureSlot(step.lesson, language.lang)
173
+ if (slot.descriptionSource === "human") {
174
+ continue
175
+ }
176
+
177
+ const description = result.descriptionsByLanguage[language.lang]
178
+ slot.description = description
179
+ slot.descriptionStatus = description ? "generated" : "none"
180
+ slot.descriptionSource = "auto"
181
+ slot.descriptionPromptVersion = result.promptVersion
182
+ slot.sourceContentHash = language.fingerprint.sha256
183
+ slot.sourceSimHash = language.fingerprint.simhash
184
+ generated += 1
185
+ }
186
+
187
+ return { generated, missing: result.missingLanguages.length }
188
+ }
189
+
190
+ export async function generateCourseDescriptions(
191
+ storage: CourseDescriptionsStorage,
192
+ courseSlug: string,
193
+ options: GenerateCourseDescriptionsOptions
194
+ ): Promise<GenerateCourseDescriptionsResult> {
195
+ const concurrency = options.concurrency ?? DEFAULT_DESCRIPTIONS_CONCURRENCY
196
+ const generate = options.generate ?? generateStepDescriptions
197
+
198
+ const syllabus = await storage.readSyllabus(courseSlug)
199
+ if (!syllabus) {
200
+ return emptyResult("skipped")
201
+ }
202
+
203
+ const exercises = await storage.listExercises(courseSlug)
204
+ if (exercises.length === 0) {
205
+ return emptyResult("skipped")
206
+ }
207
+
208
+ if (options.reconcile && storage.syllabusSyncStorage) {
209
+ try {
210
+ await synchronizeSyllabusWithBucket(
211
+ storage.syllabusSyncStorage,
212
+ courseSlug,
213
+ syllabus,
214
+ { prune: false }
215
+ )
216
+ } catch (error) {
217
+ // Provisional patch: never let reconciliation block generation.
218
+ console.error(
219
+ `[descriptions] Reconciliation failed for "${courseSlug}":`,
220
+ (error as Error).message
221
+ )
222
+ }
223
+ }
224
+
225
+ const readmes = await readCourseReadmes(
226
+ storage,
227
+ courseSlug,
228
+ exercises,
229
+ concurrency
230
+ )
231
+
232
+ const work = buildDescriptionWorkList({
233
+ syllabus,
234
+ exercises,
235
+ readmes,
236
+ promptVersion: options.promptVersion,
237
+ force: options.force,
238
+ })
239
+
240
+ if (work.length === 0) {
241
+ return emptyResult("skipped")
242
+ }
243
+
244
+ const result = emptyResult("processed")
245
+ result.steps = work.length
246
+
247
+ if (options.dryRun) {
248
+ result.generated = countWorkItems(work)
249
+ return result
250
+ }
251
+
252
+ // One completion per step, several in flight: a slow step never stalls the
253
+ // course and Rigobot is never hit with an unbounded burst.
254
+ const outcomes = await mapWithConcurrency(
255
+ work,
256
+ concurrency,
257
+ async step => ({
258
+ step,
259
+ generated: await generate(options.token, {
260
+ readmeContent: step.baseContent,
261
+ sourceLanguage: step.baseLanguage,
262
+ outputLanguages: step.languages.map(language => language.lang),
263
+ lessonTitle: step.lesson.title,
264
+ courseTitle: syllabus.courseInfo?.title,
265
+ targetWordCount: options.targetWordCount,
266
+ promptVersion: options.promptVersion,
267
+ }),
268
+ })
269
+ )
270
+
271
+ for (const outcome of outcomes) {
272
+ if (!outcome.generated) {
273
+ result.failed += 1
274
+ result.errors.push(
275
+ `generation failed for "${outcome.step.exerciseSlug}"`
276
+ )
277
+ continue
278
+ }
279
+
280
+ if (outcome.generated.durationSeconds !== null) {
281
+ result.durationsSeconds.push(outcome.generated.durationSeconds)
282
+ }
283
+
284
+ const applied = applyStepResult(outcome.step, outcome.generated)
285
+ result.generated += applied.generated
286
+ result.missing += applied.missing
287
+ }
288
+
289
+ if (result.generated === 0 && result.failed > 0) {
290
+ result.status = "failed"
291
+ return result
292
+ }
293
+
294
+ await storage.writeSyllabus(courseSlug, syllabus)
295
+
296
+ if (storage.reprojectManifest) {
297
+ try {
298
+ await storage.reprojectManifest(courseSlug)
299
+ result.manifestProjected = true
300
+ } catch (error) {
301
+ // The syllabus (the source of truth) is already saved; a failed
302
+ // projection is recoverable and must not lose the generated text.
303
+ result.manifestProjected = false
304
+ result.errors.push(
305
+ `manifest projection failed: ${(error as Error).message}`
306
+ )
307
+ }
308
+ }
309
+
310
+ return result
311
+ }
@@ -0,0 +1,191 @@
1
+ import { Syllabus } from "../../models/creator"
2
+ import { fingerprintReadme } from "../descriptionHash"
3
+ import { DESCRIPTION_PROMPT_VERSION } from "../packageManifest"
4
+ import {
5
+ CourseDescriptionsStorage,
6
+ readCourseReadmes,
7
+ DEFAULT_DESCRIPTIONS_CONCURRENCY,
8
+ } from "./generateCourseDescriptions"
9
+ import { CourseExercise, CourseReadmes, findSyllabusLesson } from "./workList"
10
+
11
+ /**
12
+ * Copy descriptions from the published syllabus back into the draft one.
13
+ *
14
+ * Descriptions are generated against the published package because it is an
15
+ * immutable snapshot. The draft in GCS, meanwhile, keeps moving: between the
16
+ * publication and the moment the descriptions land, steps may have been added,
17
+ * removed, renamed, reordered or edited.
18
+ *
19
+ * The guard against that drift is the content hash, not a timestamp or a
20
+ * position: a description is copied only when the draft README hashes to the
21
+ * very content the description was generated from. Anything that moved simply
22
+ * misses and waits for the next publication — a stale description is never
23
+ * written onto content it does not describe.
24
+ *
25
+ * Without this mirror the draft would never hold a description, and every
26
+ * publication would regenerate the whole course from scratch: the freshness
27
+ * check reads the fingerprints stored in the syllabus that travels inside the
28
+ * zip.
29
+ */
30
+
31
+ export type MirrorResult = {
32
+ /** (step, language) pairs copied into the draft. */
33
+ copied: number;
34
+ /** Pairs with no reusable description: content moved on, or none published. */
35
+ missed: number;
36
+ /** Pairs the draft already had up to date. */
37
+ fresh: number;
38
+ };
39
+
40
+ export type MirrorOptions = {
41
+ promptVersion?: number;
42
+ dryRun?: boolean;
43
+ concurrency?: number;
44
+ };
45
+
46
+ function emptyMirrorResult(): MirrorResult {
47
+ return { copied: 0, missed: 0, fresh: 0 }
48
+ }
49
+
50
+ /**
51
+ * Pure core: writes into `targetSyllabus` in place. Callers own the I/O, which
52
+ * keeps the drift rules testable without a bucket.
53
+ */
54
+ export function mirrorDescriptionsIntoSyllabus(input: {
55
+ sourceSyllabus: Syllabus | null;
56
+ targetSyllabus: Syllabus;
57
+ exercises: CourseExercise[];
58
+ /** READMEs of the TARGET (draft), which is what must match the hash. */
59
+ readmes: CourseReadmes;
60
+ promptVersion?: number;
61
+ }): MirrorResult {
62
+ const result = emptyMirrorResult()
63
+ if (!input.sourceSyllabus) {
64
+ return result
65
+ }
66
+
67
+ const promptVersion = input.promptVersion ?? DESCRIPTION_PROMPT_VERSION
68
+
69
+ for (const exercise of input.exercises) {
70
+ const targetLesson = findSyllabusLesson(
71
+ input.targetSyllabus,
72
+ exercise.slug
73
+ )
74
+ if (!targetLesson) {
75
+ // The step is not in the draft (removed, or renamed after publishing).
76
+ continue
77
+ }
78
+
79
+ const sourceLesson = findSyllabusLesson(
80
+ input.sourceSyllabus,
81
+ exercise.slug
82
+ )
83
+ const exerciseReadmes = input.readmes[exercise.slug] || {}
84
+
85
+ for (const lang of Object.keys(exercise.translations || {})) {
86
+ const content = exerciseReadmes[lang]
87
+ if (typeof content !== "string") {
88
+ continue
89
+ }
90
+
91
+ const fingerprint = fingerprintReadme(content)
92
+ const targetSlot = targetLesson.translations?.[lang]
93
+
94
+ if (targetSlot?.descriptionSource === "human") {
95
+ result.fresh += 1
96
+ continue
97
+ }
98
+
99
+ if (
100
+ targetSlot?.sourceContentHash === fingerprint.sha256 &&
101
+ (targetSlot?.descriptionPromptVersion ?? 0) >= promptVersion
102
+ ) {
103
+ result.fresh += 1
104
+ continue
105
+ }
106
+
107
+ const sourceSlot = sourceLesson?.translations?.[lang]
108
+ // The hash guard: only reuse a description generated from exactly this
109
+ // content. Note a published `description: null` is reusable too — it is a
110
+ // real answer ("not enough content to describe"), not a missing one.
111
+ const reusable =
112
+ sourceSlot !== undefined &&
113
+ sourceSlot.sourceContentHash === fingerprint.sha256 &&
114
+ (sourceSlot.descriptionPromptVersion ?? 0) >= promptVersion &&
115
+ sourceSlot.descriptionStatus !== "error"
116
+
117
+ if (!reusable) {
118
+ result.missed += 1
119
+ continue
120
+ }
121
+
122
+ if (!targetLesson.translations) {
123
+ targetLesson.translations = {}
124
+ }
125
+
126
+ if (!targetLesson.translations[lang]) {
127
+ targetLesson.translations[lang] = {
128
+ completionId: 0,
129
+ startedAt: Date.now(),
130
+ }
131
+ }
132
+
133
+ const destination = targetLesson.translations[lang]
134
+ destination.description = sourceSlot.description ?? null
135
+ destination.descriptionStatus = sourceSlot.description ?
136
+ "generated" :
137
+ "none"
138
+ destination.descriptionSource = "auto"
139
+ destination.descriptionPromptVersion =
140
+ sourceSlot.descriptionPromptVersion ?? promptVersion
141
+ destination.sourceContentHash = fingerprint.sha256
142
+ destination.sourceSimHash = fingerprint.simhash
143
+ result.copied += 1
144
+ }
145
+ }
146
+
147
+ return result
148
+ }
149
+
150
+ /** Read the draft, mirror into it, and save it when something changed. */
151
+ export async function mirrorDescriptionsToDraft(
152
+ storage: CourseDescriptionsStorage,
153
+ courseSlug: string,
154
+ sourceSyllabus: Syllabus | null,
155
+ options: MirrorOptions = {}
156
+ ): Promise<MirrorResult> {
157
+ if (!sourceSyllabus) {
158
+ return emptyMirrorResult()
159
+ }
160
+
161
+ const targetSyllabus = await storage.readSyllabus(courseSlug)
162
+ if (!targetSyllabus) {
163
+ return emptyMirrorResult()
164
+ }
165
+
166
+ const exercises = await storage.listExercises(courseSlug)
167
+ if (exercises.length === 0) {
168
+ return emptyMirrorResult()
169
+ }
170
+
171
+ const readmes = await readCourseReadmes(
172
+ storage,
173
+ courseSlug,
174
+ exercises,
175
+ options.concurrency ?? DEFAULT_DESCRIPTIONS_CONCURRENCY
176
+ )
177
+
178
+ const result = mirrorDescriptionsIntoSyllabus({
179
+ sourceSyllabus,
180
+ targetSyllabus,
181
+ exercises,
182
+ readmes,
183
+ promptVersion: options.promptVersion,
184
+ })
185
+
186
+ if (result.copied > 0 && !options.dryRun) {
187
+ await storage.writeSyllabus(courseSlug, targetSyllabus)
188
+ }
189
+
190
+ return result
191
+ }