@learnpack/learnpack 5.0.352 → 5.0.353

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 (69) 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 +157 -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/creatorUtilities.js +2 -1
  13. package/lib/utils/descriptionHash.d.ts +66 -0
  14. package/lib/utils/descriptionHash.js +173 -0
  15. package/lib/utils/descriptions/gcsStorage.d.ts +16 -0
  16. package/lib/utils/descriptions/gcsStorage.js +60 -0
  17. package/lib/utils/descriptions/generateCourseDescriptions.d.ts +59 -0
  18. package/lib/utils/descriptions/generateCourseDescriptions.js +173 -0
  19. package/lib/utils/descriptions/mirrorDescriptions.d.ts +49 -0
  20. package/lib/utils/descriptions/mirrorDescriptions.js +109 -0
  21. package/lib/utils/descriptions/publishStage.d.ts +69 -0
  22. package/lib/utils/descriptions/publishStage.js +234 -0
  23. package/lib/utils/descriptions/resumePublication.d.ts +36 -0
  24. package/lib/utils/descriptions/resumePublication.js +113 -0
  25. package/lib/utils/descriptions/s3Storage.d.ts +30 -0
  26. package/lib/utils/descriptions/s3Storage.js +134 -0
  27. package/lib/utils/descriptions/workList.d.ts +75 -0
  28. package/lib/utils/descriptions/workList.js +177 -0
  29. package/lib/utils/gcsBucketName.d.ts +10 -0
  30. package/lib/utils/gcsBucketName.js +19 -0
  31. package/lib/utils/packageManifest.d.ts +14 -0
  32. package/lib/utils/packageManifest.js +49 -5
  33. package/lib/utils/publishEvents.d.ts +66 -0
  34. package/lib/utils/publishEvents.js +111 -0
  35. package/lib/utils/publishJournal.d.ts +119 -0
  36. package/lib/utils/publishJournal.js +275 -0
  37. package/lib/utils/rigoActions.d.ts +44 -0
  38. package/lib/utils/rigoActions.js +75 -1
  39. package/lib/utils/s3/packageManifestBackfill.d.ts +2 -0
  40. package/lib/utils/s3/packageManifestBackfill.js +2 -0
  41. package/lib/utils/syllabusSync.d.ts +71 -0
  42. package/lib/utils/syllabusSync.js +273 -0
  43. package/package.json +1 -1
  44. package/src/commands/publish.ts +7 -0
  45. package/src/commands/serve.ts +144 -335
  46. package/src/models/creator.ts +9 -0
  47. package/src/scripts/descriptionsGcsBackfill.ts +193 -0
  48. package/src/scripts/descriptionsS3Backfill.ts +208 -0
  49. package/src/scripts/descriptionsSweep.ts +185 -0
  50. package/src/ui/_app/app.css +1 -1
  51. package/src/ui/_app/app.js +142 -140
  52. package/src/ui/app.tar.gz +0 -0
  53. package/src/utils/api.ts +9 -0
  54. package/src/utils/creatorUtilities.ts +2 -1
  55. package/src/utils/descriptionHash.ts +196 -0
  56. package/src/utils/descriptions/gcsStorage.ts +67 -0
  57. package/src/utils/descriptions/generateCourseDescriptions.ts +301 -0
  58. package/src/utils/descriptions/mirrorDescriptions.ts +191 -0
  59. package/src/utils/descriptions/publishStage.ts +382 -0
  60. package/src/utils/descriptions/resumePublication.ts +200 -0
  61. package/src/utils/descriptions/s3Storage.ts +206 -0
  62. package/src/utils/descriptions/workList.ts +283 -0
  63. package/src/utils/gcsBucketName.ts +19 -0
  64. package/src/utils/packageManifest.ts +54 -4
  65. package/src/utils/publishEvents.ts +181 -0
  66. package/src/utils/publishJournal.ts +383 -0
  67. package/src/utils/rigoActions.ts +130 -0
  68. package/src/utils/s3/packageManifestBackfill.ts +2 -2
  69. package/src/utils/syllabusSync.ts +390 -0
@@ -5,6 +5,11 @@ import { PackageInfo } from "./creatorUtilities"
5
5
  import * as fs from "fs"
6
6
  import * as path from "path"
7
7
  import { RIGOBOT_HOST } from "./api"
8
+ import { stripMarkdownImages } from "./descriptionHash"
9
+ import {
10
+ DESCRIPTION_PROMPT_VERSION,
11
+ DESCRIPTION_TARGET_WORD_COUNT,
12
+ } from "./packageManifest"
8
13
 
9
14
  type TCreateReadmeInputs = {
10
15
  title: string;
@@ -542,6 +547,131 @@ export const initialContentGenerator = async (
542
547
  }
543
548
  }
544
549
 
550
+ // Step description generation. Populates initialSyllabus, which is then
551
+ // projected into the package-manifest. Contract (as built) in
552
+ // docs/rigobot-descriptions-contrato.md.
553
+
554
+ export type TGenerateStepDescriptionsParams = {
555
+ /** README of the step, in `sourceLanguage`. Images are stripped before sending. */
556
+ readmeContent: string;
557
+ sourceLanguage: string;
558
+ /** Every language to produce; may or may not include `sourceLanguage`. */
559
+ outputLanguages: string[];
560
+ lessonTitle: string;
561
+ courseTitle?: string;
562
+ targetWordCount?: number;
563
+ promptVersion?: number;
564
+ };
565
+
566
+ export type TGenerateStepDescriptionsResult = {
567
+ /**
568
+ * Descriptions keyed by the `language_code` Rigobot answered with — building
569
+ * this record IS the reassociation, the response order is not contractual.
570
+ * A `null` value is the deliberate fallback for content too thin to describe.
571
+ */
572
+ descriptionsByLanguage: Record<string, string | null>;
573
+ /** Requested languages absent from the response (a protocol problem, not a
574
+ * "no description" answer: callers should retry these rather than storing a
575
+ * null that would look settled). */
576
+ missingLanguages: string[];
577
+ promptVersion: number;
578
+ /** Seconds the completion took; worth logging to watch the 30s Heroku edge. */
579
+ durationSeconds: number | null;
580
+ completionId: number | null;
581
+ };
582
+
583
+ /**
584
+ * Generate one short description per language for a single step.
585
+ *
586
+ * One request per step: the eye-catching saving is on the *languages* axis (one
587
+ * README in, N descriptions out, ~67% fewer input tokens for a typical course),
588
+ * while batching several steps together would save nothing and would cost cache
589
+ * granularity and partial results.
590
+ *
591
+ * Synchronous by design (`execute_async: false`): the caller owns the write, so
592
+ * every flow — the post-publish stage, the sweep and the backfill — persists
593
+ * its own results instead of relying on a callback. Callers throttle with a
594
+ * concurrency pool.
595
+ *
596
+ * All inputs travel as strings: Rigobot fills the template with plain string
597
+ * replacement and a non-string raises before reaching the LLM.
598
+ */
599
+ export const generateStepDescriptions = async (
600
+ token: string,
601
+ params: TGenerateStepDescriptionsParams,
602
+ endpointSlug = "generate-step-descriptions"
603
+ ): Promise<TGenerateStepDescriptionsResult | null> => {
604
+ const promptVersion = params.promptVersion ?? DESCRIPTION_PROMPT_VERSION
605
+ const targetWordCount =
606
+ params.targetWordCount ?? DESCRIPTION_TARGET_WORD_COUNT
607
+
608
+ const inputs = {
609
+ // Images never help a description and their alt text may be a whole
610
+ // image-generation prompt; markdown structure is kept, it helps the model.
611
+ readme_content: stripMarkdownImages(params.readmeContent),
612
+ source_language: params.sourceLanguage,
613
+ output_languages: params.outputLanguages.join(","),
614
+ lesson_title: params.lessonTitle,
615
+ course_title: params.courseTitle ?? "",
616
+ target_word_count: String(targetWordCount),
617
+ prompt_version: String(promptVersion),
618
+ }
619
+
620
+ try {
621
+ const response = await axios.post(
622
+ `${RIGOBOT_HOST}/v1/prompting/completion/${endpointSlug}/`,
623
+ { inputs, execute_async: false },
624
+ {
625
+ headers: {
626
+ "Content-Type": "application/json",
627
+ Authorization: "Token " + token.trim(),
628
+ },
629
+ }
630
+ )
631
+
632
+ const data = response.data
633
+ if (!data || data.status === "ERROR") {
634
+ console.error(
635
+ "Error in generateStepDescriptions:",
636
+ data?.status_text || "completion returned ERROR"
637
+ )
638
+ return null
639
+ }
640
+
641
+ const entries = Array.isArray(data.parsed?.descriptions) ?
642
+ data.parsed.descriptions :
643
+ []
644
+
645
+ const descriptionsByLanguage: Record<string, string | null> = {}
646
+ for (const entry of entries) {
647
+ const code = entry?.language_code
648
+ if (typeof code !== "string" || !code) {
649
+ continue
650
+ }
651
+
652
+ descriptionsByLanguage[code] =
653
+ typeof entry.description === "string" ? entry.description : null
654
+ }
655
+
656
+ const echoedVersion = Number.parseInt(data.inputs?.prompt_version, 10)
657
+
658
+ return {
659
+ descriptionsByLanguage,
660
+ missingLanguages: params.outputLanguages.filter(
661
+ lang => !(lang in descriptionsByLanguage)
662
+ ),
663
+ promptVersion: Number.isFinite(echoedVersion) ?
664
+ echoedVersion :
665
+ promptVersion,
666
+ durationSeconds: typeof data.duration === "number" ? data.duration : null,
667
+ completionId: typeof data.id === "number" ? data.id : null,
668
+ }
669
+ } catch (error) {
670
+ console.error("Error in generateStepDescriptions:", error)
671
+ return null
672
+ }
673
+ }
674
+
545
675
  type TAddInteractivityInputs = {
546
676
  components: string;
547
677
  prev_lesson: string;
@@ -259,7 +259,7 @@ export async function fetchJsonObject<T>(
259
259
  }
260
260
  }
261
261
 
262
- async function fetchTextObject(
262
+ export async function fetchTextObject(
263
263
  s3: AwsClient,
264
264
  bucket: string,
265
265
  key: string
@@ -279,7 +279,7 @@ async function fetchTextObject(
279
279
  }
280
280
  }
281
281
 
282
- async function listObjectKeys(
282
+ export async function listObjectKeys(
283
283
  s3: AwsClient,
284
284
  bucket: string,
285
285
  prefix: string
@@ -0,0 +1,390 @@
1
+ import { Bucket } from "@google-cloud/storage"
2
+ import { Lesson, Syllabus } from "../models/creator"
3
+ import { buildConfig } from "./configBuilder"
4
+ import { slugify } from "./creatorUtilities"
5
+
6
+ /**
7
+ * Provisional syllabus ↔ bucket reconciliation.
8
+ *
9
+ * The `initialSyllabus.json` occasionally drifts from what actually lives in the
10
+ * bucket (lessons/translations present as files but missing from the syllabus,
11
+ * or vice versa). Because step descriptions are stored per (step, lang) in the
12
+ * syllabus, that drift leaves missing slots and causes skipped/spurious
13
+ * generations. This reconciles the two.
14
+ *
15
+ * Extracted from the inline `/actions/synchronize-syllabus` handler so it can be
16
+ * reused by the sweep/backfill, and generalized over a minimal storage adapter
17
+ * so it works for both GCS and S3.
18
+ *
19
+ * NOTE: this is a temporary patch until the root cause of the drift is fixed.
20
+ * Track its removal via the `DESCRIPTIONS_RECONCILE_SYLLABUS` flag on callers.
21
+ */
22
+
23
+ export interface SyllabusSyncStorage {
24
+ /**
25
+ * Map of `folderSlug -> fileCount` for every folder directly under
26
+ * `exercises/`. A single authoritative listing: if it throws, callers must
27
+ * abort any destructive action (never delete on an errored/unknown listing).
28
+ */
29
+ listExerciseFolderFileCounts(
30
+ courseSlug: string
31
+ ): Promise<Map<string, number>>;
32
+ /** README language codes (lowercased) present per exercise folder slug. */
33
+ translationLangsBySlug(courseSlug: string): Promise<Map<string, string[]>>;
34
+ }
35
+
36
+ export type SyllabusSyncOptions = {
37
+ /**
38
+ * When false (default, "additive" mode) only non-destructive passes run:
39
+ * add-missing, repair-translations, unstick-status. Removal/dedupe are
40
+ * skipped. When true (manual dev button / root-cause runs) removals apply.
41
+ */
42
+ prune?: boolean;
43
+ };
44
+
45
+ export type SyllabusSyncCounters = {
46
+ totalLessons: number;
47
+ keptLessons: number;
48
+ removedLessons: number;
49
+ duplicatesResolved: number;
50
+ addedLessons: number;
51
+ fixedLessons: number;
52
+ repairedTranslationsInLessons: number;
53
+ repairedTranslationEntries: number;
54
+ };
55
+
56
+ type LessonRef = { id: string; title: string; slug: string };
57
+ type LessonRefWithCount = LessonRef & { fileCount: number };
58
+
59
+ export type SyllabusSyncResult = {
60
+ syllabus: Syllabus;
61
+ changed: boolean;
62
+ counters: SyllabusSyncCounters;
63
+ details: {
64
+ removed: LessonRef[];
65
+ duplicates: LessonRefWithCount[];
66
+ kept: Array<LessonRef & { fileCount?: number }>;
67
+ added: LessonRef[];
68
+ };
69
+ };
70
+
71
+ function isInFlight(lesson: Lesson): boolean {
72
+ // A lesson still being generated may legitimately have no files yet; never
73
+ // treat it as "missing" (E4 hardening against deleting something real).
74
+ return (
75
+ lesson.generated === false ||
76
+ lesson.status === "GENERATING" ||
77
+ lesson.status === "PENDING"
78
+ )
79
+ }
80
+
81
+ export async function synchronizeSyllabusWithBucket(
82
+ storage: SyllabusSyncStorage,
83
+ courseSlug: string,
84
+ syllabus: Syllabus,
85
+ options: SyllabusSyncOptions = {}
86
+ ): Promise<SyllabusSyncResult> {
87
+ const prune = options.prune ?? false
88
+
89
+ const removed: LessonRef[] = []
90
+ const duplicates: LessonRefWithCount[] = []
91
+ const kept: Array<LessonRef & { fileCount?: number }> = []
92
+ const added: LessonRef[] = []
93
+ let repairedTranslationsInLessons = 0
94
+ let repairedTranslationEntries = 0
95
+ let fixedLessons = 0
96
+ let changed = false
97
+
98
+ // Single authoritative listing. If it fails it throws (callers must not
99
+ // delete on an unknown listing). The adapter is expected to retry internally.
100
+ const folderCounts = await storage.listExerciseFolderFileCounts(courseSlug)
101
+
102
+ // First pass: resolve existence per lesson, confirming absence under both the
103
+ // slugified id-title and the uid folder-name variants.
104
+ const existingLessons: Array<{
105
+ lesson: Lesson;
106
+ slug: string;
107
+ fileCount: number;
108
+ }> = []
109
+
110
+ for (const lesson of syllabus.lessons) {
111
+ const lessonSlug = slugify(lesson.id + "-" + lesson.title)
112
+ let resolvedSlug = lessonSlug
113
+ let fileCount = folderCounts.get(lessonSlug) ?? 0
114
+
115
+ if (
116
+ fileCount === 0 &&
117
+ lesson.uid &&
118
+ (folderCounts.get(lesson.uid) ?? 0) > 0
119
+ ) {
120
+ resolvedSlug = lesson.uid
121
+ fileCount = folderCounts.get(lesson.uid) ?? 0
122
+ }
123
+
124
+ if (fileCount === 0) {
125
+ if (isInFlight(lesson)) {
126
+ // Keep untouched: do not mark for removal nor as existing.
127
+ continue
128
+ }
129
+
130
+ removed.push({ id: lesson.id, title: lesson.title, slug: lessonSlug })
131
+ } else {
132
+ existingLessons.push({ lesson, slug: resolvedSlug, fileCount })
133
+ }
134
+ }
135
+
136
+ // Second pass: detect duplicates (multiple lessons resolving to one folder).
137
+ const slugMap = new Map<
138
+ string,
139
+ Array<{ lesson: Lesson; slug: string; fileCount: number }>
140
+ >()
141
+ for (const item of existingLessons) {
142
+ if (!slugMap.has(item.slug)) {
143
+ slugMap.set(item.slug, [])
144
+ }
145
+
146
+ slugMap.get(item.slug)!.push(item)
147
+ }
148
+
149
+ for (const items of slugMap.values()) {
150
+ if (items.length > 1) {
151
+ items.sort((a, b) => b.fileCount - a.fileCount)
152
+ const winner = items[0]
153
+ kept.push({
154
+ id: winner.lesson.id,
155
+ title: winner.lesson.title,
156
+ slug: winner.slug,
157
+ fileCount: winner.fileCount,
158
+ })
159
+ for (const loser of items.slice(1)) {
160
+ duplicates.push({
161
+ id: loser.lesson.id,
162
+ title: loser.lesson.title,
163
+ slug: loser.slug,
164
+ fileCount: loser.fileCount,
165
+ })
166
+ }
167
+ } else {
168
+ kept.push({
169
+ id: items[0].lesson.id,
170
+ title: items[0].lesson.title,
171
+ slug: items[0].slug,
172
+ fileCount: items[0].fileCount,
173
+ })
174
+ }
175
+ }
176
+
177
+ // Apply removals only in prune mode (additive mode never deletes).
178
+ if (prune && (removed.length > 0 || duplicates.length > 0)) {
179
+ syllabus.lessons = syllabus.lessons.filter(lesson => {
180
+ const lessonSlug = slugify(lesson.id + "-" + lesson.title)
181
+ const isRemoved = removed.some(r => r.slug === lessonSlug)
182
+ const isDuplicate = duplicates.some(
183
+ d => d.id === lesson.id && d.title === lesson.title
184
+ )
185
+ return !isRemoved && !isDuplicate
186
+ })
187
+ changed = true
188
+ }
189
+
190
+ // Third pass: add lessons that exist in the bucket but are missing from the
191
+ // syllabus, then re-sort by numeric id.
192
+ const existingSlugs = new Set(existingLessons.map(e => e.slug))
193
+ for (const [folderSlug, count] of folderCounts) {
194
+ if (count <= 0 || existingSlugs.has(folderSlug)) {
195
+ continue
196
+ }
197
+
198
+ const idMatch = folderSlug.match(/^(\d+(?:\.\d+)?)(?:-(.*))?$/)
199
+ const id = idMatch ? idMatch[1] : folderSlug
200
+ const titlePart = idMatch && idMatch[2] ? idMatch[2] : folderSlug
201
+ const title = titlePart.replace(/-/g, " ").trim() || folderSlug
202
+ const newLesson: Lesson = {
203
+ id,
204
+ uid: folderSlug,
205
+ title,
206
+ type: "READ",
207
+ description: title,
208
+ duration: 2,
209
+ generated: true,
210
+ status: "DONE",
211
+ }
212
+ syllabus.lessons.push(newLesson)
213
+ added.push({ id, title, slug: folderSlug })
214
+ changed = true
215
+ }
216
+
217
+ if (added.length > 0) {
218
+ syllabus.lessons.sort((a, b) => {
219
+ const na = Number.parseFloat(a.id)
220
+ const nb = Number.parseFloat(b.id)
221
+ if (!Number.isNaN(na) && !Number.isNaN(nb)) {
222
+ return na - nb
223
+ }
224
+
225
+ return String(a.id).localeCompare(String(b.id))
226
+ })
227
+ }
228
+
229
+ // Fourth + fifth pass: reconcile translations metadata from the actual README
230
+ // files, and unstick lessons stuck in GENERATING/ERROR whose primary-language
231
+ // README already exists.
232
+ try {
233
+ const translationsBySlug = await storage.translationLangsBySlug(courseSlug)
234
+
235
+ for (const lesson of syllabus.lessons) {
236
+ const candidateSlugs = [
237
+ slugify(lesson.id + "-" + lesson.title),
238
+ lesson.uid,
239
+ ].filter(Boolean) as string[]
240
+ const matchedSlug = candidateSlugs.find(s => translationsBySlug.has(s))
241
+ if (!matchedSlug) {
242
+ continue
243
+ }
244
+
245
+ const languageCodes = translationsBySlug.get(matchedSlug) || []
246
+ if (languageCodes.length === 0) {
247
+ continue
248
+ }
249
+
250
+ const currentTranslations = lesson.translations || {}
251
+ let lessonChanged = false
252
+
253
+ for (const lang of languageCodes) {
254
+ const now = Date.now()
255
+ if (!currentTranslations[lang]) {
256
+ currentTranslations[lang] = {
257
+ completionId: 0,
258
+ startedAt: now,
259
+ completedAt: now,
260
+ }
261
+ repairedTranslationEntries += 1
262
+ lessonChanged = true
263
+ } else {
264
+ if (!currentTranslations[lang].startedAt) {
265
+ currentTranslations[lang].startedAt = now
266
+ lessonChanged = true
267
+ }
268
+
269
+ if (!currentTranslations[lang].completedAt) {
270
+ currentTranslations[lang].completedAt = now
271
+ repairedTranslationEntries += 1
272
+ lessonChanged = true
273
+ }
274
+ }
275
+ }
276
+
277
+ if (lessonChanged) {
278
+ lesson.translations = currentTranslations
279
+ repairedTranslationsInLessons += 1
280
+ changed = true
281
+ }
282
+ }
283
+
284
+ const primaryLanguage = syllabus.courseInfo.language || "en"
285
+ for (const lesson of syllabus.lessons) {
286
+ if (lesson.generated !== false) {
287
+ continue
288
+ }
289
+
290
+ if (lesson.status !== "GENERATING" && lesson.status !== "ERROR") {
291
+ continue
292
+ }
293
+
294
+ const candidateSlugs = [
295
+ slugify(lesson.id + "-" + lesson.title),
296
+ lesson.uid,
297
+ ].filter(Boolean) as string[]
298
+ const matchedSlug = candidateSlugs.find(s => translationsBySlug.has(s))
299
+ if (!matchedSlug) {
300
+ continue
301
+ }
302
+
303
+ const langs = translationsBySlug.get(matchedSlug) || []
304
+ if (!langs.includes(primaryLanguage)) {
305
+ continue
306
+ }
307
+
308
+ lesson.generated = true
309
+ lesson.status = "DONE"
310
+ fixedLessons += 1
311
+ changed = true
312
+ }
313
+ } catch (error) {
314
+ console.error(
315
+ "⚠️ Could not reconcile lesson translations during syllabus sync:",
316
+ error
317
+ )
318
+ }
319
+
320
+ const counters: SyllabusSyncCounters = {
321
+ totalLessons: syllabus.lessons.length,
322
+ keptLessons: kept.length,
323
+ removedLessons: prune ? removed.length : 0,
324
+ duplicatesResolved: prune ? duplicates.length : 0,
325
+ addedLessons: added.length,
326
+ fixedLessons,
327
+ repairedTranslationsInLessons,
328
+ repairedTranslationEntries,
329
+ }
330
+
331
+ return {
332
+ syllabus,
333
+ changed,
334
+ counters,
335
+ details: { removed, duplicates, kept, added },
336
+ }
337
+ }
338
+
339
+ async function withRetry<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
340
+ let lastError: unknown
341
+ for (let attempt = 0; attempt < attempts; attempt++) {
342
+ try {
343
+ // eslint-disable-next-line no-await-in-loop
344
+ return await fn()
345
+ } catch (error) {
346
+ lastError = error
347
+ }
348
+ }
349
+
350
+ throw lastError
351
+ }
352
+
353
+ /** GCS-backed storage adapter (uses the bucket listing + buildConfig). */
354
+ export function createGcsSyllabusSyncStorage(
355
+ bucket: Bucket
356
+ ): SyllabusSyncStorage {
357
+ return {
358
+ async listExerciseFolderFileCounts(courseSlug) {
359
+ const prefix = `courses/${courseSlug}/exercises/`
360
+ const [files] = await withRetry(() => bucket.getFiles({ prefix }))
361
+ const counts = new Map<string, number>()
362
+ for (const file of files) {
363
+ if (!file.name.startsWith(prefix)) {
364
+ continue
365
+ }
366
+
367
+ const segment = file.name.slice(prefix.length).split("/")[0]
368
+ if (segment) {
369
+ counts.set(segment, (counts.get(segment) || 0) + 1)
370
+ }
371
+ }
372
+
373
+ return counts
374
+ },
375
+ async translationLangsBySlug(courseSlug) {
376
+ const { exercises } = await buildConfig(bucket, courseSlug)
377
+ const map = new Map<string, string[]>()
378
+ for (const exercise of exercises) {
379
+ const langs = Object.keys(exercise.translations || {})
380
+ .map(lang => lang.toLowerCase())
381
+ .filter(Boolean)
382
+ if (langs.length > 0) {
383
+ map.set(exercise.slug, [...new Set(langs)])
384
+ }
385
+ }
386
+
387
+ return map
388
+ },
389
+ }
390
+ }