@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,214 @@
1
+ import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"
2
+ import { CloudFrontClient } from "@aws-sdk/client-cloudfront"
3
+ import { Syllabus } from "../../models/creator"
4
+ import { PACKAGE_MANIFEST_REL_PATH, PackageManifest } from "../packageManifest"
5
+ import {
6
+ AwsClient,
7
+ fetchJsonObject,
8
+ fetchTextObject,
9
+ invalidatePackageManifestPaths,
10
+ listObjectKeys,
11
+ processPackage,
12
+ withRetry,
13
+ } from "../s3/packageManifestBackfill"
14
+ import { SyllabusSyncStorage } from "../syllabusSync"
15
+ import {
16
+ awsRegion,
17
+ requireAwsCredentials,
18
+ requireS3PackagesBucket,
19
+ } from "../awsCredentials"
20
+ import { CourseDescriptionsStorage } from "./generateCourseDescriptions"
21
+ import { CourseExercise } from "./workList"
22
+
23
+ /**
24
+ * Published-bucket (S3) adapter for the description flows.
25
+ *
26
+ * The published package is the snapshot descriptions are generated from: it is
27
+ * immutable between publications, unlike the draft in GCS, which keeps moving
28
+ * while the teacher edits.
29
+ */
30
+
31
+ export const syllabusKey = (slug: string) =>
32
+ `${slug}/.learn/initialSyllabus.json`
33
+
34
+ export const manifestKey = (slug: string) =>
35
+ `${slug}/${PACKAGE_MANIFEST_REL_PATH}`
36
+
37
+ const configKeys = (slug: string) => [
38
+ `${slug}/.learn/config.json`,
39
+ `${slug}/config.json`,
40
+ ]
41
+
42
+ type ConfigShape = {
43
+ exercises?: CourseExercise[];
44
+ };
45
+
46
+ export async function fetchExercises(
47
+ s3: AwsClient,
48
+ bucket: string,
49
+ slug: string
50
+ ): Promise<CourseExercise[]> {
51
+ for (const key of configKeys(slug)) {
52
+ // eslint-disable-next-line no-await-in-loop -- the second key is a fallback
53
+ const config = await fetchJsonObject<ConfigShape>(s3, bucket, key)
54
+ if (config?.exercises && Array.isArray(config.exercises)) {
55
+ return config.exercises
56
+ }
57
+ }
58
+
59
+ return []
60
+ }
61
+
62
+ export function createS3SyllabusSyncStorage(
63
+ s3: AwsClient,
64
+ bucket: string
65
+ ): SyllabusSyncStorage {
66
+ return {
67
+ async listExerciseFolderFileCounts(courseSlug) {
68
+ const prefix = `${courseSlug}/exercises/`
69
+ const keys = await listObjectKeys(s3, bucket, prefix)
70
+ const counts = new Map<string, number>()
71
+ for (const key of keys) {
72
+ const segment = key.slice(prefix.length).split("/")[0]
73
+ if (segment) {
74
+ counts.set(segment, (counts.get(segment) || 0) + 1)
75
+ }
76
+ }
77
+
78
+ return counts
79
+ },
80
+ async translationLangsBySlug(courseSlug) {
81
+ const exercises = await fetchExercises(s3, bucket, courseSlug)
82
+ const map = new Map<string, string[]>()
83
+ for (const exercise of exercises) {
84
+ const langs = Object.keys(exercise.translations || {})
85
+ .map(lang => lang.toLowerCase())
86
+ .filter(Boolean)
87
+ if (langs.length > 0) {
88
+ map.set(exercise.slug, [...new Set(langs)])
89
+ }
90
+ }
91
+
92
+ return map
93
+ },
94
+ }
95
+ }
96
+
97
+ export type S3DescriptionsStorageOptions = {
98
+ /** CloudFront distribution to invalidate after re-projecting the manifest. */
99
+ cloudFront?: { client: AwsClient; distributionId: string };
100
+ /** Set false in the backfill, where manifests are projected in a later pass. */
101
+ reprojectManifest?: boolean;
102
+ };
103
+
104
+ /**
105
+ * Storage for the published bucket, wired from the environment. Used by the
106
+ * post-publish stage and the sweep, which have no CLI flags to read.
107
+ */
108
+ export function createS3DescriptionsStorageFromEnv(): CourseDescriptionsStorage {
109
+ // Validated here so a missing variable fails with its own name, instead of
110
+ // surfacing minutes later as "Could not load credentials from any providers"
111
+ // from inside a background job.
112
+ const credentials = requireAwsCredentials()
113
+ const region = awsRegion()
114
+
115
+ const s3 = new S3Client({ region, credentials }) as unknown as AwsClient
116
+
117
+ const distributionId = (process.env.CLOUDFRONT_DISTRIBUTION_ID || "").trim()
118
+ const cloudFront = distributionId ?
119
+ {
120
+ client: new CloudFrontClient({
121
+ region,
122
+ credentials,
123
+ }) as unknown as AwsClient,
124
+ distributionId,
125
+ } :
126
+ undefined
127
+
128
+ if (!cloudFront) {
129
+ console.warn(
130
+ "[descriptions] CLOUDFRONT_DISTRIBUTION_ID is not set: the manifest will be re-projected but not invalidated"
131
+ )
132
+ }
133
+
134
+ return createS3DescriptionsStorage(s3, requireS3PackagesBucket(), {
135
+ cloudFront,
136
+ })
137
+ }
138
+
139
+ export function createS3DescriptionsStorage(
140
+ s3: AwsClient,
141
+ bucket: string,
142
+ options: S3DescriptionsStorageOptions = {}
143
+ ): CourseDescriptionsStorage {
144
+ const storage: CourseDescriptionsStorage = {
145
+ async readSyllabus(courseSlug) {
146
+ return fetchJsonObject<Syllabus>(s3, bucket, syllabusKey(courseSlug))
147
+ },
148
+ async writeSyllabus(courseSlug, syllabus) {
149
+ await withRetry(() =>
150
+ s3.send(
151
+ new PutObjectCommand({
152
+ Bucket: bucket,
153
+ Key: syllabusKey(courseSlug),
154
+ Body: JSON.stringify(syllabus, null, 2),
155
+ ContentType: "application/json",
156
+ })
157
+ )
158
+ )
159
+ },
160
+ async listExercises(courseSlug) {
161
+ return fetchExercises(s3, bucket, courseSlug)
162
+ },
163
+ async readReadme(courseSlug, exerciseSlug, fileName) {
164
+ return fetchTextObject(
165
+ s3,
166
+ bucket,
167
+ `${courseSlug}/exercises/${exerciseSlug}/${fileName}`
168
+ )
169
+ },
170
+ async readManifest(courseSlug) {
171
+ return fetchJsonObject<PackageManifest>(
172
+ s3,
173
+ bucket,
174
+ manifestKey(courseSlug)
175
+ )
176
+ },
177
+ syllabusSyncStorage: createS3SyllabusSyncStorage(s3, bucket),
178
+ }
179
+
180
+ if (options.reprojectManifest !== false) {
181
+ storage.reprojectManifest = async courseSlug => {
182
+ // Preserve the publication timestamp: this is a re-projection of an
183
+ // already published package, not a new publication.
184
+ const existing = await fetchJsonObject<PackageManifest>(
185
+ s3,
186
+ bucket,
187
+ manifestKey(courseSlug)
188
+ )
189
+
190
+ const result = await processPackage(s3, bucket, courseSlug, {
191
+ dryRun: false,
192
+ force: false,
193
+ skipExisting: false,
194
+ publishedAt: existing?.publishedAt ?? null,
195
+ })
196
+
197
+ if (result.status === "failed") {
198
+ throw new Error(result.error || "manifest projection failed")
199
+ }
200
+
201
+ if (result.status === "skipped" || !options.cloudFront) {
202
+ return
203
+ }
204
+
205
+ await invalidatePackageManifestPaths(
206
+ options.cloudFront.client,
207
+ options.cloudFront.distributionId,
208
+ [`/${manifestKey(courseSlug)}`]
209
+ )
210
+ }
211
+ }
212
+
213
+ return storage
214
+ }
@@ -0,0 +1,283 @@
1
+ import { Lesson, Syllabus } from "../../models/creator"
2
+ import { slugify } from "../creatorUtilities"
3
+ import {
4
+ ContentFingerprint,
5
+ fingerprintReadme,
6
+ getSimhashThreshold,
7
+ isDescriptionStale,
8
+ } from "../descriptionHash"
9
+ import { DESCRIPTION_PROMPT_VERSION } from "../packageManifest"
10
+
11
+ /**
12
+ * Deciding WHAT needs a description, separated from generating it.
13
+ *
14
+ * The publish route needs the answer before doing any work (to tell breathecode
15
+ * whether a second event is coming), and the generation service needs the very
16
+ * same list right after. Keeping the decision here, pure and content-only,
17
+ * means both answer identically — the alternative, two implementations of
18
+ * "is this stale?", is exactly how the backfill and the sweep drifted apart.
19
+ */
20
+
21
+ export type CourseExercise = {
22
+ slug: string;
23
+ /** language code -> README file name */
24
+ translations: Record<string, string>;
25
+ };
26
+
27
+ /** README contents by exercise slug and language. */
28
+ export type CourseReadmes = Record<string, Record<string, string>>;
29
+
30
+ export type LanguageWork = {
31
+ lang: string;
32
+ fingerprint: ContentFingerprint;
33
+ };
34
+
35
+ export type StepWork = {
36
+ exerciseSlug: string;
37
+ /** Reference into the syllabus; the generation service writes through it. */
38
+ lesson: Lesson;
39
+ /** Language the description is written from and translated out of. */
40
+ baseLanguage: string;
41
+ baseContent: string;
42
+ languages: LanguageWork[];
43
+ };
44
+
45
+ export type BuildWorkListInput = {
46
+ syllabus: Syllabus;
47
+ exercises: CourseExercise[];
48
+ readmes: CourseReadmes;
49
+ promptVersion?: number;
50
+ simhashThreshold?: number;
51
+ /** Regenerate everything except human-edited descriptions. */
52
+ force?: boolean;
53
+ };
54
+
55
+ export function findSyllabusLesson(
56
+ syllabus: Syllabus,
57
+ exerciseSlug: string
58
+ ): Lesson | undefined {
59
+ return syllabus.lessons.find(
60
+ lesson =>
61
+ lesson.uid === exerciseSlug ||
62
+ slugify(lesson.id + "-" + lesson.title) === exerciseSlug
63
+ )
64
+ }
65
+
66
+ /**
67
+ * Language a course is actually written in.
68
+ *
69
+ * `courseInfo.language` is trusted only when some exercise really has a README
70
+ * in it; otherwise the language is inferred from the content itself (the most
71
+ * frequent translation across exercises). Defaulting to "en" when the field is
72
+ * missing would label Spanish courses as English and generate their
73
+ * descriptions from a translation instead of the original.
74
+ */
75
+ export function inferCourseBaseLanguage(
76
+ syllabus: Syllabus,
77
+ exercises: CourseExercise[]
78
+ ): string | null {
79
+ const counts = new Map<string, number>()
80
+ for (const exercise of exercises) {
81
+ for (const lang of Object.keys(exercise.translations || {})) {
82
+ counts.set(lang, (counts.get(lang) || 0) + 1)
83
+ }
84
+ }
85
+
86
+ if (counts.size === 0) {
87
+ return null
88
+ }
89
+
90
+ const declared = syllabus.courseInfo?.language?.toLowerCase()
91
+ if (declared) {
92
+ for (const lang of counts.keys()) {
93
+ if (lang.toLowerCase() === declared) {
94
+ return lang
95
+ }
96
+ }
97
+ }
98
+
99
+ let best: string | null = null
100
+ let bestCount = 0
101
+ for (const [lang, count] of counts) {
102
+ if (count > bestCount) {
103
+ best = lang
104
+ bestCount = count
105
+ }
106
+ }
107
+
108
+ return best
109
+ }
110
+
111
+ /** Language to generate this step from: the course's, when it has it. */
112
+ function resolveStepBaseLanguage(
113
+ exercise: CourseExercise,
114
+ courseBaseLanguage: string | null
115
+ ): string | null {
116
+ const langs = Object.keys(exercise.translations || {})
117
+ if (langs.length === 0) {
118
+ return null
119
+ }
120
+
121
+ if (courseBaseLanguage && langs.includes(courseBaseLanguage)) {
122
+ return courseBaseLanguage
123
+ }
124
+
125
+ return langs[0]
126
+ }
127
+
128
+ /**
129
+ * Whether a (step, language) needs generating. Human-edited descriptions are
130
+ * never touched; an explicit "not enough content" answer (a stored null with a
131
+ * fingerprint) is respected until the content itself changes.
132
+ */
133
+ export function needsGeneration(
134
+ slot: NonNullable<Lesson["translations"]>[string] | undefined,
135
+ fingerprint: ContentFingerprint,
136
+ options: {
137
+ promptVersion: number;
138
+ simhashThreshold: number;
139
+ force: boolean;
140
+ }
141
+ ): boolean {
142
+ if (slot?.descriptionSource === "human") {
143
+ return false
144
+ }
145
+
146
+ if (!slot) {
147
+ return true
148
+ }
149
+
150
+ if (options.force) {
151
+ return true
152
+ }
153
+
154
+ if (slot.descriptionStatus === "error") {
155
+ return true
156
+ }
157
+
158
+ if ((slot.descriptionPromptVersion ?? 0) < options.promptVersion) {
159
+ return true
160
+ }
161
+
162
+ return isDescriptionStale(
163
+ { sha256: slot.sourceContentHash, simhash: slot.sourceSimHash },
164
+ fingerprint,
165
+ options.simhashThreshold
166
+ )
167
+ }
168
+
169
+ /** The (step, language) pairs whose description is missing or out of date. */
170
+ export function buildDescriptionWorkList(
171
+ input: BuildWorkListInput
172
+ ): StepWork[] {
173
+ const promptVersion = input.promptVersion ?? DESCRIPTION_PROMPT_VERSION
174
+ const simhashThreshold = input.simhashThreshold ?? getSimhashThreshold()
175
+ const force = input.force ?? false
176
+ const courseBaseLanguage = inferCourseBaseLanguage(
177
+ input.syllabus,
178
+ input.exercises
179
+ )
180
+
181
+ const work: StepWork[] = []
182
+
183
+ for (const exercise of input.exercises) {
184
+ const lesson = findSyllabusLesson(input.syllabus, exercise.slug)
185
+ if (!lesson) {
186
+ continue
187
+ }
188
+
189
+ const baseLanguage = resolveStepBaseLanguage(exercise, courseBaseLanguage)
190
+ if (!baseLanguage) {
191
+ continue
192
+ }
193
+
194
+ const exerciseReadmes = input.readmes[exercise.slug] || {}
195
+ const baseContent = exerciseReadmes[baseLanguage]
196
+ if (typeof baseContent !== "string") {
197
+ continue
198
+ }
199
+
200
+ const languages: LanguageWork[] = []
201
+ for (const lang of Object.keys(exercise.translations || {})) {
202
+ const content = exerciseReadmes[lang]
203
+ if (typeof content !== "string") {
204
+ continue
205
+ }
206
+
207
+ // Each language's staleness is anchored to its OWN content, even though
208
+ // the description is generated from the base language: that is what makes
209
+ // "the Spanish README changed" regenerate only Spanish.
210
+ const fingerprint = fingerprintReadme(content)
211
+ if (
212
+ !needsGeneration(lesson.translations?.[lang], fingerprint, {
213
+ promptVersion,
214
+ simhashThreshold,
215
+ force,
216
+ })
217
+ ) {
218
+ continue
219
+ }
220
+
221
+ languages.push({ lang, fingerprint })
222
+ }
223
+
224
+ if (languages.length === 0) {
225
+ continue
226
+ }
227
+
228
+ work.push({
229
+ exerciseSlug: exercise.slug,
230
+ lesson,
231
+ baseLanguage,
232
+ baseContent,
233
+ languages,
234
+ })
235
+ }
236
+
237
+ return work
238
+ }
239
+
240
+ /** Total (step, language) pairs in a work list. */
241
+ export function countWorkItems(work: StepWork[]): number {
242
+ return work.reduce((total, step) => total + step.languages.length, 0)
243
+ }
244
+
245
+ /**
246
+ * Cheap "has this course ever been fully processed at this prompt version?"
247
+ * check, from the syllabus alone.
248
+ *
249
+ * Deliberately weaker than the work list: it cannot see content changes because
250
+ * it never reads a README. That is the point — it lets a batched run skip
251
+ * finished courses without paying thousands of object reads. Use the work list
252
+ * whenever correctness matters; use this only as a pre-filter.
253
+ */
254
+ export function isCourseSettled(
255
+ syllabus: Syllabus,
256
+ exercises: CourseExercise[],
257
+ promptVersion: number = DESCRIPTION_PROMPT_VERSION
258
+ ): boolean {
259
+ for (const exercise of exercises) {
260
+ const lesson = findSyllabusLesson(syllabus, exercise.slug)
261
+ if (!lesson) {
262
+ return false
263
+ }
264
+
265
+ for (const lang of Object.keys(exercise.translations || {})) {
266
+ const slot = lesson.translations?.[lang]
267
+ if (slot?.descriptionSource === "human") {
268
+ continue
269
+ }
270
+
271
+ if (
272
+ !slot ||
273
+ slot.descriptionStatus === "error" ||
274
+ !slot.sourceContentHash ||
275
+ (slot.descriptionPromptVersion ?? 0) < promptVersion
276
+ ) {
277
+ return false
278
+ }
279
+ }
280
+ }
281
+
282
+ return true
283
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The draft bucket name, required rather than defaulted.
3
+ *
4
+ * The code used to fall back to "learnpack-packages", a name the service
5
+ * account cannot reach: with the variable missing, the process would start
6
+ * cleanly and then fail with a 403 on every single object operation. Failing at
7
+ * boot with the reason turns a puzzling runtime outage into an obvious
8
+ * misconfiguration.
9
+ */
10
+ export function requireGcsBucketName(): string {
11
+ const name = (process.env.GCP_BUCKET_NAME || "").trim()
12
+ if (!name) {
13
+ throw new Error(
14
+ "GCP_BUCKET_NAME (env) is required: it names the bucket holding course drafts"
15
+ )
16
+ }
17
+
18
+ return name
19
+ }
@@ -13,6 +13,14 @@ const frontMatter = require("front-matter")
13
13
  export const PACKAGE_MANIFEST_FILENAME = "package-manifest.json"
14
14
  export const PACKAGE_MANIFEST_REL_PATH = ".learn/package-manifest.json"
15
15
  export const SCHEMA_VERSION = 1
16
+ // Version of the Rigobot prompt/model used to generate step descriptions. It
17
+ // travels inside the completion inputs, so bumping it both busts Rigobot's
18
+ // cache and marks every description stored at a lower version for regeneration.
19
+ // Keep in sync with the prompt body provisioned in Rigobot.
20
+ export const DESCRIPTION_PROMPT_VERSION = 1
21
+
22
+ // Target length, in words, of a generated step description.
23
+ export const DESCRIPTION_TARGET_WORD_COUNT = 25
16
24
 
17
25
  export type LessonType = "READ" | "CODE" | "QUIZ";
18
26
 
@@ -86,7 +94,14 @@ function findSyllabusLesson(
86
94
  })
87
95
  }
88
96
 
89
- function deriveLessonId(exerciseSlug: string): string {
97
+ /**
98
+ * Lesson id when no syllabus entry matches: the numeric prefix of the folder.
99
+ *
100
+ * Exported because `legacyPackageRepair` has to synthesize syllabus ids with
101
+ * exactly this rule — a repaired package whose ids differed from the ones its
102
+ * manifest already carries would look like a content change to every consumer.
103
+ */
104
+ export function deriveLessonId(exerciseSlug: string): string {
90
105
  const match = exerciseSlug.match(/^(\d+(?:\.\d+)?)/)
91
106
  return match ? match[1] : exerciseSlug
92
107
  }
@@ -167,13 +182,18 @@ function resolveLessonTitle(
167
182
  }
168
183
 
169
184
  function resolveLessonDescriptions(
170
- exercise: Exercise
185
+ exercise: Exercise,
186
+ syllabusLesson: Lesson | undefined
171
187
  ): Record<string, string | null> {
172
188
  const description: Record<string, string | null> = {}
173
189
 
190
+ // Descriptions are a projection: the source of truth lives in the syllabus
191
+ // lesson (translations[lang].description), populated by the sweep/backfill.
192
+ // When absent we emit null (the frontend hides the area) rather than a
193
+ // low-quality placeholder.
174
194
  for (const lang of Object.keys(exercise.translations)) {
175
- // TODO: add description content
176
- description[lang] = null
195
+ description[lang] =
196
+ syllabusLesson?.translations?.[lang]?.description ?? null
177
197
  }
178
198
 
179
199
  return description
@@ -240,7 +260,7 @@ export function buildPackageManifestFromSources(
240
260
  courseLang,
241
261
  readmeFrontmatters[exercise.slug] ?? {}
242
262
  ),
243
- description: resolveLessonDescriptions(exercise),
263
+ description: resolveLessonDescriptions(exercise, syllabusLesson),
244
264
  video: resolveLessonVideos(exercise, readmeFrontmatters),
245
265
  }
246
266
  })
@@ -312,6 +332,43 @@ export function serializePackageManifest(manifest: PackageManifest): string {
312
332
  return JSON.stringify(manifest, null, 2)
313
333
  }
314
334
 
335
+ /** The manifest as it travels inside a publication event. */
336
+ export type PackageManifestEventView = Omit<PackageManifest, "generatedAt">;
337
+
338
+ /**
339
+ * Project a manifest into the shape sent to breathecode.
340
+ *
341
+ * Today this is nearly the identity — only `generatedAt`, an internal detail of
342
+ * the projection, is dropped. It exists as an explicit pick so that fields
343
+ * added to the manifest later (hashes, generation flags) do not silently become
344
+ * part of an external contract: joining the event payload has to be a decision
345
+ * visible in a diff.
346
+ */
347
+ export function serializePackageManifestForEvent(
348
+ manifest: PackageManifest
349
+ ): PackageManifestEventView {
350
+ return {
351
+ schemaVersion: manifest.schemaVersion,
352
+ publishedAt: manifest.publishedAt,
353
+ slug: manifest.slug,
354
+ title: manifest.title,
355
+ description: manifest.description,
356
+ preview: manifest.preview,
357
+ technologies: manifest.technologies,
358
+ difficulty: manifest.difficulty,
359
+ duration: manifest.duration,
360
+ lessons: manifest.lessons.map(lesson => ({
361
+ id: lesson.id,
362
+ slug: lesson.slug,
363
+ position: lesson.position,
364
+ type: lesson.type,
365
+ title: lesson.title,
366
+ description: lesson.description,
367
+ video: lesson.video,
368
+ })),
369
+ }
370
+ }
371
+
315
372
  async function readReadmeFrontmatter(
316
373
  bucket: Bucket,
317
374
  exerciseSlug: string,