@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
@@ -0,0 +1,275 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.STAGE_NAMES = exports.JOURNAL_PREFIX = void 0;
4
+ exports.journalKey = journalKey;
5
+ exports.coursePrefix = coursePrefix;
6
+ exports.parseJournalKey = parseJournalKey;
7
+ exports.createJournal = createJournal;
8
+ exports.markStage = markStage;
9
+ exports.readJournal = readJournal;
10
+ exports.readJournalByKey = readJournalByKey;
11
+ exports.listJournalRefs = listJournalRefs;
12
+ exports.listJournalRefsForCourse = listJournalRefsForCourse;
13
+ exports.isStageTerminal = isStageTerminal;
14
+ exports.hasFailedStage = hasFailedStage;
15
+ exports.isJournalSettled = isJournalSettled;
16
+ exports.pendingStages = pendingStages;
17
+ exports.isJournalStale = isJournalStale;
18
+ exports.incrementAttempts = incrementAttempts;
19
+ exports.abandonJournal = abandonJournal;
20
+ exports.finalizeJournal = finalizeJournal;
21
+ exports.createGcsJournalStorage = createGcsJournalStorage;
22
+ const uuid_1 = require("uuid");
23
+ /**
24
+ * Publish journal — per-stage state of a publication.
25
+ *
26
+ * Publishing is a distributed transaction across three systems (S3 via Rigobot,
27
+ * breathecode assets, and our own buckets) plus an asynchronous description
28
+ * stage. Some of its outcomes are NOT derivable from content afterwards: in
29
+ * particular "was the manifest-updated event already emitted?" leaves no trace
30
+ * in S3 or GCS. The journal records what happened per stage so the sweep can
31
+ * resume an interrupted publication and honour the event contract (a publish
32
+ * that announced `descriptions: "queued"` must always be followed by exactly
33
+ * one `package_manifest_updated`).
34
+ *
35
+ * Design notes:
36
+ *
37
+ * - **Best effort, never a lock.** Every write swallows its errors: if the
38
+ * journal cannot be written the publication still proceeds. Losing the
39
+ * journal degrades us to the previous behaviour (no recovery, no diagnosis),
40
+ * which is acceptable; blocking a publication because a bookkeeping write
41
+ * failed is not.
42
+ * - **Flat key space.** Journals live in `publish-journal/{slug}__{id}.json`,
43
+ * a sibling prefix of `courses/`, so the sweep can list every unfinished
44
+ * publication with one prefix query and the concurrency guard can list a
45
+ * single course with another, without maintaining an index.
46
+ * - **Explicit cleanup.** A journal is deleted once every stage succeeded, and
47
+ * kept when any stage failed (visibility + retry). GCS lifecycle rules would
48
+ * be the natural alternative, but the service account only holds
49
+ * object-level permissions (`storage.buckets.*` is denied), so the cleanup
50
+ * has to happen in code.
51
+ */
52
+ exports.JOURNAL_PREFIX = "publish-journal/";
53
+ /**
54
+ * Stages of a publication, in execution order.
55
+ *
56
+ * Only stages the orchestrator can observe on its own: writing the asset id
57
+ * back into Rigobot happens inside the asset sync call, so it is covered by
58
+ * `assetsSync` rather than being a stage that could never be marked.
59
+ */
60
+ exports.STAGE_NAMES = [
61
+ "deploy",
62
+ "assetsSync",
63
+ "publishedEvent",
64
+ "descriptions",
65
+ "gcsMirror",
66
+ "manifestEvent",
67
+ ];
68
+ function journalKey(courseSlug, publishId) {
69
+ return `${exports.JOURNAL_PREFIX}${courseSlug}__${publishId}.json`;
70
+ }
71
+ function coursePrefix(courseSlug) {
72
+ return `${exports.JOURNAL_PREFIX}${courseSlug}__`;
73
+ }
74
+ /** Inverse of `journalKey`; returns null for keys that don't match the shape. */
75
+ function parseJournalKey(key) {
76
+ if (!key.startsWith(exports.JOURNAL_PREFIX) || !key.endsWith(".json")) {
77
+ return null;
78
+ }
79
+ const name = key.slice(exports.JOURNAL_PREFIX.length, -".json".length);
80
+ const separator = name.indexOf("__");
81
+ if (separator <= 0) {
82
+ return null;
83
+ }
84
+ const courseSlug = name.slice(0, separator);
85
+ const publishId = name.slice(separator + 2);
86
+ if (!courseSlug || !publishId) {
87
+ return null;
88
+ }
89
+ return { courseSlug, publishId, key };
90
+ }
91
+ function emptyStages() {
92
+ const stages = {};
93
+ for (const name of exports.STAGE_NAMES) {
94
+ stages[name] = { status: "pending" };
95
+ }
96
+ return stages;
97
+ }
98
+ /** Persist the journal. Never throws: bookkeeping must not break a publish. */
99
+ async function persist(storage, journal) {
100
+ try {
101
+ await storage.save(journalKey(journal.courseSlug, journal.publishId), JSON.stringify(journal, null, 2));
102
+ }
103
+ catch (error) {
104
+ console.warn(`[publish-journal] Could not persist journal for "${journal.courseSlug}":`, error.message);
105
+ }
106
+ }
107
+ /**
108
+ * Start a journal for a new publication. The generated `publishId` correlates
109
+ * the two publish events and is returned even if persisting failed.
110
+ */
111
+ async function createJournal(storage, courseSlug, publishId = (0, uuid_1.v4)()) {
112
+ const now = new Date().toISOString();
113
+ const journal = {
114
+ publishId,
115
+ courseSlug,
116
+ startedAt: now,
117
+ updatedAt: now,
118
+ attempts: 0,
119
+ stages: emptyStages(),
120
+ };
121
+ await persist(storage, journal);
122
+ return journal;
123
+ }
124
+ /** Update one stage and persist. Mutates and returns the same journal object. */
125
+ async function markStage(storage, journal, stage, status, extra = {}) {
126
+ const next = { status, at: new Date().toISOString() };
127
+ if (extra.error !== undefined) {
128
+ next.error = extra.error;
129
+ }
130
+ if (extra.meta !== undefined) {
131
+ next.meta = extra.meta;
132
+ }
133
+ journal.stages[stage] = next;
134
+ journal.updatedAt = next.at;
135
+ await persist(storage, journal);
136
+ return journal;
137
+ }
138
+ /** Read a journal. Returns null when missing or unreadable. */
139
+ async function readJournal(storage, courseSlug, publishId) {
140
+ return readJournalByKey(storage, journalKey(courseSlug, publishId));
141
+ }
142
+ async function readJournalByKey(storage, key) {
143
+ try {
144
+ const raw = await storage.read(key);
145
+ if (!raw) {
146
+ return null;
147
+ }
148
+ const parsed = JSON.parse(raw);
149
+ if (!parsed || !parsed.publishId || !parsed.stages) {
150
+ return null;
151
+ }
152
+ // Tolerate journals written before a stage was added to STAGE_NAMES.
153
+ for (const name of exports.STAGE_NAMES) {
154
+ if (!parsed.stages[name]) {
155
+ parsed.stages[name] = { status: "pending" };
156
+ }
157
+ }
158
+ return parsed;
159
+ }
160
+ catch (error) {
161
+ console.warn(`[publish-journal] Could not read journal "${key}":`, error.message);
162
+ return null;
163
+ }
164
+ }
165
+ /**
166
+ * Every journal still stored, i.e. every publication that did not finish
167
+ * cleanly. Returns an empty list on error (the sweep falls back to its deep
168
+ * scan rather than crashing).
169
+ */
170
+ async function listJournalRefs(storage) {
171
+ return listRefsByPrefix(storage, exports.JOURNAL_PREFIX);
172
+ }
173
+ /** Journals of a single course; backs the concurrent-publish guard. */
174
+ async function listJournalRefsForCourse(storage, courseSlug) {
175
+ return listRefsByPrefix(storage, coursePrefix(courseSlug));
176
+ }
177
+ async function listRefsByPrefix(storage, prefix) {
178
+ try {
179
+ const keys = await storage.list(prefix);
180
+ const refs = [];
181
+ for (const key of keys) {
182
+ const ref = parseJournalKey(key);
183
+ if (ref) {
184
+ refs.push(ref);
185
+ }
186
+ }
187
+ return refs;
188
+ }
189
+ catch (error) {
190
+ console.warn(`[publish-journal] Could not list journals "${prefix}":`, error.message);
191
+ return [];
192
+ }
193
+ }
194
+ function isStageTerminal(stage) {
195
+ return stage.status === "done" || stage.status === "failed";
196
+ }
197
+ function hasFailedStage(journal) {
198
+ return exports.STAGE_NAMES.some(name => journal.stages[name].status === "failed");
199
+ }
200
+ /** True when no stage is left to run (all done, or done/failed mix). */
201
+ function isJournalSettled(journal) {
202
+ return exports.STAGE_NAMES.every(name => isStageTerminal(journal.stages[name]));
203
+ }
204
+ /** Stages the sweep still has work to do on. */
205
+ function pendingStages(journal) {
206
+ return exports.STAGE_NAMES.filter(name => !isStageTerminal(journal.stages[name]));
207
+ }
208
+ /** A journal untouched for longer than `maxAgeMs` is presumed interrupted. */
209
+ function isJournalStale(journal, maxAgeMs, now = Date.now()) {
210
+ const updatedAt = Date.parse(journal.updatedAt);
211
+ if (Number.isNaN(updatedAt)) {
212
+ return true;
213
+ }
214
+ return now - updatedAt > maxAgeMs;
215
+ }
216
+ async function incrementAttempts(storage, journal) {
217
+ journal.attempts += 1;
218
+ journal.updatedAt = new Date().toISOString();
219
+ await persist(storage, journal);
220
+ return journal;
221
+ }
222
+ /**
223
+ * Give up on a journal after too many attempts. It is kept, not deleted: a
224
+ * publication that never completed is exactly what someone needs to see.
225
+ */
226
+ async function abandonJournal(storage, journal) {
227
+ journal.abandoned = true;
228
+ journal.updatedAt = new Date().toISOString();
229
+ await persist(storage, journal);
230
+ return journal;
231
+ }
232
+ /**
233
+ * Close a journal: delete it when every stage succeeded, keep it otherwise so
234
+ * the sweep can retry and a human can inspect it. Never throws.
235
+ */
236
+ async function finalizeJournal(storage, journal) {
237
+ if (!isJournalSettled(journal) || hasFailedStage(journal)) {
238
+ await persist(storage, journal);
239
+ return "kept";
240
+ }
241
+ try {
242
+ await storage.remove(journalKey(journal.courseSlug, journal.publishId));
243
+ return "deleted";
244
+ }
245
+ catch (error) {
246
+ console.warn(`[publish-journal] Could not delete journal for "${journal.courseSlug}":`, error.message);
247
+ return "kept";
248
+ }
249
+ }
250
+ /** GCS-backed adapter. Object-level operations only, by design. */
251
+ function createGcsJournalStorage(bucket) {
252
+ return {
253
+ async save(key, contents) {
254
+ await bucket.file(key).save(Buffer.from(contents, "utf8"), {
255
+ contentType: "application/json",
256
+ });
257
+ },
258
+ async read(key) {
259
+ try {
260
+ const [buf] = await bucket.file(key).download();
261
+ return buf.toString();
262
+ }
263
+ catch (_a) {
264
+ return null;
265
+ }
266
+ },
267
+ async remove(key) {
268
+ await bucket.file(key).delete();
269
+ },
270
+ async list(prefix) {
271
+ const [files] = await bucket.getFiles({ prefix });
272
+ return files.map(file => file.name);
273
+ },
274
+ };
275
+ }
@@ -99,6 +99,50 @@ type TInitialContentGeneratorInputs = {
99
99
  topic_description?: string;
100
100
  };
101
101
  export declare const initialContentGenerator: (token: string, inputs: TInitialContentGeneratorInputs, webhookUrl?: string, endpointSlug?: string) => Promise<any>;
102
+ export type TGenerateStepDescriptionsParams = {
103
+ /** README of the step, in `sourceLanguage`. Images are stripped before sending. */
104
+ readmeContent: string;
105
+ sourceLanguage: string;
106
+ /** Every language to produce; may or may not include `sourceLanguage`. */
107
+ outputLanguages: string[];
108
+ lessonTitle: string;
109
+ courseTitle?: string;
110
+ targetWordCount?: number;
111
+ promptVersion?: number;
112
+ };
113
+ export type TGenerateStepDescriptionsResult = {
114
+ /**
115
+ * Descriptions keyed by the `language_code` Rigobot answered with — building
116
+ * this record IS the reassociation, the response order is not contractual.
117
+ * A `null` value is the deliberate fallback for content too thin to describe.
118
+ */
119
+ descriptionsByLanguage: Record<string, string | null>;
120
+ /** Requested languages absent from the response (a protocol problem, not a
121
+ * "no description" answer: callers should retry these rather than storing a
122
+ * null that would look settled). */
123
+ missingLanguages: string[];
124
+ promptVersion: number;
125
+ /** Seconds the completion took; worth logging to watch the 30s Heroku edge. */
126
+ durationSeconds: number | null;
127
+ completionId: number | null;
128
+ };
129
+ /**
130
+ * Generate one short description per language for a single step.
131
+ *
132
+ * One request per step: the eye-catching saving is on the *languages* axis (one
133
+ * README in, N descriptions out, ~67% fewer input tokens for a typical course),
134
+ * while batching several steps together would save nothing and would cost cache
135
+ * granularity and partial results.
136
+ *
137
+ * Synchronous by design (`execute_async: false`): the caller owns the write, so
138
+ * every flow — the post-publish stage, the sweep and the backfill — persists
139
+ * its own results instead of relying on a callback. Callers throttle with a
140
+ * concurrency pool.
141
+ *
142
+ * All inputs travel as strings: Rigobot fills the template with plain string
143
+ * replacement and a non-string raises before reaching the LLM.
144
+ */
145
+ export declare const generateStepDescriptions: (token: string, params: TGenerateStepDescriptionsParams, endpointSlug?: string) => Promise<TGenerateStepDescriptionsResult | null>;
102
146
  type TAddInteractivityInputs = {
103
147
  components: string;
104
148
  prev_lesson: string;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.generateStepSlug = exports.generateCodeChallenge = exports.addInteractivity = exports.initialContentGenerator = exports.getLanguageCodes = exports.isPackageAuthor = exports.fillSidebarJSON = exports.generateCourseShortName = exports.isValidRigoToken = exports.translateCourseMetadata = exports.createStructuredPreviewReadme = exports.readmeCreator = exports.createCodingReadme = exports.createCodeFile = exports.interactiveCreation = exports.generateCourseIntroduction = exports.translateExercise = exports.generateImage = exports.hasCreatorPermission = exports.createReadme = void 0;
3
+ exports.generateStepSlug = exports.generateCodeChallenge = exports.addInteractivity = exports.generateStepDescriptions = exports.initialContentGenerator = exports.getLanguageCodes = exports.isPackageAuthor = exports.fillSidebarJSON = exports.generateCourseShortName = exports.isValidRigoToken = exports.translateCourseMetadata = exports.createStructuredPreviewReadme = exports.readmeCreator = exports.createCodingReadme = exports.createCodeFile = exports.interactiveCreation = exports.generateCourseIntroduction = exports.translateExercise = exports.generateImage = exports.hasCreatorPermission = exports.createReadme = void 0;
4
4
  exports.downloadImage = downloadImage;
5
5
  exports.createPreviewReadme = createPreviewReadme;
6
6
  exports.makeReadmeReadable = makeReadmeReadable;
@@ -10,6 +10,8 @@ const console_1 = require("../utils/console");
10
10
  const fs = require("fs");
11
11
  const path = require("path");
12
12
  const api_1 = require("./api");
13
+ const descriptionHash_1 = require("./descriptionHash");
14
+ const packageManifest_1 = require("./packageManifest");
13
15
  const createReadme = async (token, inputs, purpose, webhookUrl) => {
14
16
  try {
15
17
  const response = await axios_1.default.post(`${api_1.RIGOBOT_HOST}/v1/prompting/completion/create-sequencial-readme/`, {
@@ -304,6 +306,78 @@ const initialContentGenerator = async (token, inputs, webhookUrl, endpointSlug =
304
306
  }
305
307
  };
306
308
  exports.initialContentGenerator = initialContentGenerator;
309
+ /**
310
+ * Generate one short description per language for a single step.
311
+ *
312
+ * One request per step: the eye-catching saving is on the *languages* axis (one
313
+ * README in, N descriptions out, ~67% fewer input tokens for a typical course),
314
+ * while batching several steps together would save nothing and would cost cache
315
+ * granularity and partial results.
316
+ *
317
+ * Synchronous by design (`execute_async: false`): the caller owns the write, so
318
+ * every flow — the post-publish stage, the sweep and the backfill — persists
319
+ * its own results instead of relying on a callback. Callers throttle with a
320
+ * concurrency pool.
321
+ *
322
+ * All inputs travel as strings: Rigobot fills the template with plain string
323
+ * replacement and a non-string raises before reaching the LLM.
324
+ */
325
+ const generateStepDescriptions = async (token, params, endpointSlug = "generate-step-descriptions") => {
326
+ var _a, _b, _c, _d, _e;
327
+ const promptVersion = (_a = params.promptVersion) !== null && _a !== void 0 ? _a : packageManifest_1.DESCRIPTION_PROMPT_VERSION;
328
+ const targetWordCount = (_b = params.targetWordCount) !== null && _b !== void 0 ? _b : packageManifest_1.DESCRIPTION_TARGET_WORD_COUNT;
329
+ const inputs = {
330
+ // Images never help a description and their alt text may be a whole
331
+ // image-generation prompt; markdown structure is kept, it helps the model.
332
+ readme_content: (0, descriptionHash_1.stripMarkdownImages)(params.readmeContent),
333
+ source_language: params.sourceLanguage,
334
+ output_languages: params.outputLanguages.join(","),
335
+ lesson_title: params.lessonTitle,
336
+ course_title: (_c = params.courseTitle) !== null && _c !== void 0 ? _c : "",
337
+ target_word_count: String(targetWordCount),
338
+ prompt_version: String(promptVersion),
339
+ };
340
+ try {
341
+ const response = await axios_1.default.post(`${api_1.RIGOBOT_HOST}/v1/prompting/completion/${endpointSlug}/`, { inputs, execute_async: false }, {
342
+ headers: {
343
+ "Content-Type": "application/json",
344
+ Authorization: "Token " + token.trim(),
345
+ },
346
+ });
347
+ const data = response.data;
348
+ if (!data || data.status === "ERROR") {
349
+ console.error("Error in generateStepDescriptions:", (data === null || data === void 0 ? void 0 : data.status_text) || "completion returned ERROR");
350
+ return null;
351
+ }
352
+ const entries = Array.isArray((_d = data.parsed) === null || _d === void 0 ? void 0 : _d.descriptions) ?
353
+ data.parsed.descriptions :
354
+ [];
355
+ const descriptionsByLanguage = {};
356
+ for (const entry of entries) {
357
+ const code = entry === null || entry === void 0 ? void 0 : entry.language_code;
358
+ if (typeof code !== "string" || !code) {
359
+ continue;
360
+ }
361
+ descriptionsByLanguage[code] =
362
+ typeof entry.description === "string" ? entry.description : null;
363
+ }
364
+ const echoedVersion = Number.parseInt((_e = data.inputs) === null || _e === void 0 ? void 0 : _e.prompt_version, 10);
365
+ return {
366
+ descriptionsByLanguage,
367
+ missingLanguages: params.outputLanguages.filter(lang => !(lang in descriptionsByLanguage)),
368
+ promptVersion: Number.isFinite(echoedVersion) ?
369
+ echoedVersion :
370
+ promptVersion,
371
+ durationSeconds: typeof data.duration === "number" ? data.duration : null,
372
+ completionId: typeof data.id === "number" ? data.id : null,
373
+ };
374
+ }
375
+ catch (error) {
376
+ console.error("Error in generateStepDescriptions:", error);
377
+ return null;
378
+ }
379
+ };
380
+ exports.generateStepDescriptions = generateStepDescriptions;
307
381
  const addInteractivity = async (token, inputs, webhookUrl) => {
308
382
  try {
309
383
  const response = await axios_1.default.post(`${api_1.RIGOBOT_HOST}/v1/prompting/completion/add-lesson-interactivy/`, {
@@ -64,6 +64,8 @@ export declare function withRetry<T>(fn: () => Promise<T>, attempts?: number, at
64
64
  export declare function mapWithConcurrency<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]>;
65
65
  export declare function listPublishedSlugs(s3: AwsClient, bucket: string): Promise<string[]>;
66
66
  export declare function fetchJsonObject<T>(s3: AwsClient, bucket: string, key: string): Promise<T | null>;
67
+ export declare function fetchTextObject(s3: AwsClient, bucket: string, key: string): Promise<string | null>;
68
+ export declare function listObjectKeys(s3: AwsClient, bucket: string, prefix: string): Promise<string[]>;
67
69
  export declare function fetchPackageSources(s3: AwsClient, bucket: string, slug: string): Promise<PackageSources>;
68
70
  export declare function buildManifestFromS3Sources(slug: string, sources: PackageSources, options: {
69
71
  publishedAt: string | null;
@@ -5,6 +5,8 @@ exports.withRetry = withRetry;
5
5
  exports.mapWithConcurrency = mapWithConcurrency;
6
6
  exports.listPublishedSlugs = listPublishedSlugs;
7
7
  exports.fetchJsonObject = fetchJsonObject;
8
+ exports.fetchTextObject = fetchTextObject;
9
+ exports.listObjectKeys = listObjectKeys;
8
10
  exports.fetchPackageSources = fetchPackageSources;
9
11
  exports.buildManifestFromS3Sources = buildManifestFromS3Sources;
10
12
  exports.processPackage = processPackage;
@@ -0,0 +1,71 @@
1
+ import { Bucket } from "@google-cloud/storage";
2
+ import { Syllabus } from "../models/creator";
3
+ /**
4
+ * Provisional syllabus ↔ bucket reconciliation.
5
+ *
6
+ * The `initialSyllabus.json` occasionally drifts from what actually lives in the
7
+ * bucket (lessons/translations present as files but missing from the syllabus,
8
+ * or vice versa). Because step descriptions are stored per (step, lang) in the
9
+ * syllabus, that drift leaves missing slots and causes skipped/spurious
10
+ * generations. This reconciles the two.
11
+ *
12
+ * Extracted from the inline `/actions/synchronize-syllabus` handler so it can be
13
+ * reused by the sweep/backfill, and generalized over a minimal storage adapter
14
+ * so it works for both GCS and S3.
15
+ *
16
+ * NOTE: this is a temporary patch until the root cause of the drift is fixed.
17
+ * Track its removal via the `DESCRIPTIONS_RECONCILE_SYLLABUS` flag on callers.
18
+ */
19
+ export interface SyllabusSyncStorage {
20
+ /**
21
+ * Map of `folderSlug -> fileCount` for every folder directly under
22
+ * `exercises/`. A single authoritative listing: if it throws, callers must
23
+ * abort any destructive action (never delete on an errored/unknown listing).
24
+ */
25
+ listExerciseFolderFileCounts(courseSlug: string): Promise<Map<string, number>>;
26
+ /** README language codes (lowercased) present per exercise folder slug. */
27
+ translationLangsBySlug(courseSlug: string): Promise<Map<string, string[]>>;
28
+ }
29
+ export type SyllabusSyncOptions = {
30
+ /**
31
+ * When false (default, "additive" mode) only non-destructive passes run:
32
+ * add-missing, repair-translations, unstick-status. Removal/dedupe are
33
+ * skipped. When true (manual dev button / root-cause runs) removals apply.
34
+ */
35
+ prune?: boolean;
36
+ };
37
+ export type SyllabusSyncCounters = {
38
+ totalLessons: number;
39
+ keptLessons: number;
40
+ removedLessons: number;
41
+ duplicatesResolved: number;
42
+ addedLessons: number;
43
+ fixedLessons: number;
44
+ repairedTranslationsInLessons: number;
45
+ repairedTranslationEntries: number;
46
+ };
47
+ type LessonRef = {
48
+ id: string;
49
+ title: string;
50
+ slug: string;
51
+ };
52
+ type LessonRefWithCount = LessonRef & {
53
+ fileCount: number;
54
+ };
55
+ export type SyllabusSyncResult = {
56
+ syllabus: Syllabus;
57
+ changed: boolean;
58
+ counters: SyllabusSyncCounters;
59
+ details: {
60
+ removed: LessonRef[];
61
+ duplicates: LessonRefWithCount[];
62
+ kept: Array<LessonRef & {
63
+ fileCount?: number;
64
+ }>;
65
+ added: LessonRef[];
66
+ };
67
+ };
68
+ export declare function synchronizeSyllabusWithBucket(storage: SyllabusSyncStorage, courseSlug: string, syllabus: Syllabus, options?: SyllabusSyncOptions): Promise<SyllabusSyncResult>;
69
+ /** GCS-backed storage adapter (uses the bucket listing + buildConfig). */
70
+ export declare function createGcsSyllabusSyncStorage(bucket: Bucket): SyllabusSyncStorage;
71
+ export {};