@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,173 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.stripMarkdownImages = stripMarkdownImages;
4
+ exports.normalizeReadme = normalizeReadme;
5
+ exports.sha256 = sha256;
6
+ exports.simhash64 = simhash64;
7
+ exports.hammingHex = hammingHex;
8
+ exports.fingerprintReadme = fingerprintReadme;
9
+ exports.getSimhashThreshold = getSimhashThreshold;
10
+ exports.isDescriptionStale = isDescriptionStale;
11
+ const crypto = require("crypto");
12
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
13
+ const frontMatter = require("front-matter");
14
+ /**
15
+ * Utilities to detect whether a step description is stale relative to its
16
+ * README content.
17
+ *
18
+ * Two signals are stored per (step, lang) at generation time:
19
+ * - sha256(normalizeReadme(content)) -> exact fast-path + content-addressing
20
+ * - simhash64(normalizeReadme(content)) -> near-duplicate significance filter
21
+ *
22
+ * The sweep re-hashes the current README and compares against the stored
23
+ * values: exact SHA-256 match => no-op; otherwise if the SimHash Hamming
24
+ * distance to the *generation baseline* is <= k the change is cosmetic and is
25
+ * ignored, else the description is regenerated. The H1 title is part of the
26
+ * normalized content (a deliberate product decision).
27
+ */
28
+ /**
29
+ * Remove every image from markdown: inline `![alt](url)`, reference-style
30
+ * `![alt][ref]` and raw `<img>` tags. Regular links `[text](url)` are kept.
31
+ *
32
+ * Images are dropped from BOTH sides of the description pipeline — here for
33
+ * fingerprinting, and again before sending the README to the LLM. Adding,
34
+ * replacing or removing an illustration does not change what the lesson
35
+ * teaches, so it must not trigger a regeneration; and the alt text of generated
36
+ * illustrations is a full image-generation prompt (hundreds of tokens) that
37
+ * only invites the model to describe the picture instead of the lesson.
38
+ *
39
+ * Note the alt text may legitimately contain parentheses, so the alt is matched
40
+ * as "anything up to the closing bracket" rather than as balanced text.
41
+ */
42
+ function stripMarkdownImages(content) {
43
+ return (content
44
+ // ![alt](url) — alt may span lines and contain parentheses
45
+ .replace(/!\[[^\]]*]\([^)]*\)/g, "")
46
+ // ![alt][ref] — i.e. `!` followed by two bracketed groups
47
+ .replace(/!(?:\[[^\]]*]){2}/g, "")
48
+ // <img ...> / <img ... />
49
+ .replace(/<img\b[^>]*>/gi, "")
50
+ // [](url) left behind by linked images such as badges
51
+ .replace(/\[\s*]\([^)]*\)/g, ""));
52
+ }
53
+ /**
54
+ * Canonicalize a README for hashing. Strips the frontmatter (metadata such as
55
+ * tutorial/intro video URLs, which do not describe the lesson), removes images
56
+ * (see `stripMarkdownImages`) and collapses all whitespace runs so that
57
+ * reformatting, blank lines and trailing spaces do not register as content
58
+ * changes. The H1 and the prose are preserved.
59
+ */
60
+ function normalizeReadme(content) {
61
+ let body = content;
62
+ try {
63
+ const parsed = frontMatter(content);
64
+ body = typeof parsed.body === "string" ? parsed.body : content;
65
+ }
66
+ catch (_a) {
67
+ body = content;
68
+ }
69
+ return stripMarkdownImages(body)
70
+ .replace(/\r/g, "")
71
+ .replace(/\s+/g, " ")
72
+ .trim();
73
+ }
74
+ function sha256(input) {
75
+ return crypto.createHash("sha256").update(input).digest("hex");
76
+ }
77
+ // The build targets < ES2020, so BigInt literals are unavailable. The 64-bit
78
+ // SimHash is therefore represented as two unsigned 32-bit halves { hi, lo } and
79
+ // serialized as a 16-char hex string (hi then lo).
80
+ function toHex32(x) {
81
+ return (x >>> 0).toString(16).padStart(8, "0");
82
+ }
83
+ function popcount32(input) {
84
+ let x = input >>> 0;
85
+ let count = 0;
86
+ while (x !== 0) {
87
+ count += x & 1;
88
+ x >>>= 1;
89
+ }
90
+ return count;
91
+ }
92
+ function hashToken64(token) {
93
+ // First 8 bytes (16 hex chars) of an md5 digest -> two 32-bit halves.
94
+ const hex = crypto.createHash("md5").update(token).digest("hex").slice(0, 16);
95
+ return {
96
+ hi: Number.parseInt(hex.slice(0, 8), 16) >>> 0,
97
+ lo: Number.parseInt(hex.slice(8, 16), 16) >>> 0,
98
+ };
99
+ }
100
+ /**
101
+ * 64-bit SimHash (Charikar) over whitespace-delimited tokens, returned as a
102
+ * zero-padded 16-char hex string.
103
+ */
104
+ function simhash64(input) {
105
+ const tokens = input.split(/\s+/).filter(Boolean);
106
+ if (tokens.length === 0) {
107
+ return "0".repeat(16);
108
+ }
109
+ const weights = Array.from({ length: 64 }, () => 0);
110
+ for (const token of tokens) {
111
+ const { hi, lo } = hashToken64(token);
112
+ for (let i = 0; i < 32; i++) {
113
+ weights[i] += ((lo >>> i) & 1) === 1 ? 1 : -1;
114
+ weights[32 + i] += ((hi >>> i) & 1) === 1 ? 1 : -1;
115
+ }
116
+ }
117
+ let lo = 0;
118
+ let hi = 0;
119
+ for (let i = 0; i < 32; i++) {
120
+ if (weights[i] > 0) {
121
+ lo |= 1 << i;
122
+ }
123
+ if (weights[32 + i] > 0) {
124
+ hi |= 1 << i;
125
+ }
126
+ }
127
+ return toHex32(hi) + toHex32(lo);
128
+ }
129
+ /** Hamming distance between two 64-bit hex fingerprints. */
130
+ function hammingHex(a, b) {
131
+ const hiA = Number.parseInt(a.slice(0, 8), 16) >>> 0;
132
+ const loA = Number.parseInt(a.slice(8, 16), 16) >>> 0;
133
+ const hiB = Number.parseInt(b.slice(0, 8), 16) >>> 0;
134
+ const loB = Number.parseInt(b.slice(8, 16), 16) >>> 0;
135
+ return popcount32((hiA ^ hiB) >>> 0) + popcount32((loA ^ loB) >>> 0);
136
+ }
137
+ /** Convenience: normalize + both fingerprints in one call. */
138
+ function fingerprintReadme(content) {
139
+ const normalized = normalizeReadme(content);
140
+ return {
141
+ sha256: sha256(normalized),
142
+ simhash: simhash64(normalized),
143
+ };
144
+ }
145
+ /**
146
+ * Default significance threshold (Hamming distance over the 64-bit SimHash).
147
+ * k=3 is the classic near-duplicate threshold for 64-bit SimHash
148
+ * (Charikar 2002; Manku, Jain & Das Sarma, WWW 2007). Overridable via env.
149
+ */
150
+ function getSimhashThreshold() {
151
+ const raw = process.env.DESCRIPTION_SIMHASH_THRESHOLD;
152
+ const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN;
153
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 3;
154
+ }
155
+ /**
156
+ * Decide whether a description generated from `previous` is stale given the
157
+ * `current` README content. Exact match => not stale; else stale only if the
158
+ * SimHash Hamming distance exceeds the threshold (cosmetic edits are ignored).
159
+ */
160
+ function isDescriptionStale(previous, current, threshold = getSimhashThreshold()) {
161
+ if (!previous || !previous.sha256) {
162
+ // Never generated -> treat as needing generation (i.e. "stale").
163
+ return true;
164
+ }
165
+ if (previous.sha256 === current.sha256) {
166
+ return false;
167
+ }
168
+ if (!previous.simhash) {
169
+ // No baseline fingerprint to compare against -> be conservative.
170
+ return true;
171
+ }
172
+ return hammingHex(previous.simhash, current.simhash) > threshold;
173
+ }
@@ -0,0 +1,60 @@
1
+ import { fetchPackageInfo, sendPublishEvent } from "../publishEvents";
2
+ import { CourseDescriptionsStorage, GenerateCourseDescriptionsResult } from "./generateCourseDescriptions";
3
+ /**
4
+ * `package_manifest_updated` for courses reconciled by the backfill.
5
+ *
6
+ * The backfill regenerates descriptions of packages that are already published,
7
+ * so breathecode needs the same notification a publication sends — but there is
8
+ * no publication here: no `package_published` precedes this event, and no
9
+ * journal tracks it (the sweep resumes publications, and a backfill is simply
10
+ * re-run instead).
11
+ *
12
+ * That is what makes `publish_id` awkward. The field exists to correlate the two
13
+ * events of one publication, a pair this event has no half of. Since it is
14
+ * required, the value has to be something that cannot be mistaken for a real
15
+ * publication: a bare uuid — the obvious choice — would be exactly that mistake,
16
+ * and if breathecode ever starts pairing events it would search forever for a
17
+ * `package_published` that was never sent.
18
+ *
19
+ * Hence `backfill-{runId}-{slug}`:
20
+ *
21
+ * - the `backfill-` prefix makes the origin readable in the webhook log and
22
+ * discriminable in code (`isBackfillPublishId`); real ids are bare uuids from
23
+ * `createJournal`, so the two spaces cannot collide;
24
+ * - `{slug}` keeps it unique per event, which a per-run id alone would not be;
25
+ * - a `{runId}` shared by every course of one invocation makes "every event from
26
+ * the run I fired at 15:40" a single substring query, which is the question
27
+ * you actually ask when the catalogue is backfilled in small batches.
28
+ *
29
+ * Encoding this in `publish_id`, rather than adding an `origin` field, keeps the
30
+ * event contract untouched: breathecode ignores `publish_id` today, so this
31
+ * costs them nothing and needs no coordination. If they ever start consuming the
32
+ * provenance, it can be promoted to a field of its own.
33
+ */
34
+ export declare const BACKFILL_PUBLISH_ID_PREFIX = "backfill-";
35
+ /** One per script invocation, shared by every course it touches. */
36
+ export declare function newBackfillRunId(): string;
37
+ export declare function backfillPublishId(runId: string, courseSlug: string): string;
38
+ export declare function isBackfillPublishId(publishId: string): boolean;
39
+ /**
40
+ * `skipped` means the event was never attempted, which is not a failure: a
41
+ * course the backfill did not change has nothing to announce.
42
+ */
43
+ export type BackfillEventOutcome = "skipped" | "delivered" | "failed";
44
+ export type EmitBackfillManifestEventParams = {
45
+ courseSlug: string;
46
+ runId: string;
47
+ result: GenerateCourseDescriptionsResult;
48
+ storage: CourseDescriptionsStorage;
49
+ rigobotToken: string;
50
+ breathecodeToken: string;
51
+ /** Injection points for tests. */
52
+ emit?: typeof sendPublishEvent;
53
+ readPackageInfo?: typeof fetchPackageInfo;
54
+ };
55
+ /**
56
+ * Announce one reconciled course. Never throws: the descriptions and the
57
+ * manifest are already written by the time this runs, and losing the
58
+ * notification must not turn a successful course into a failed one.
59
+ */
60
+ export declare function emitBackfillManifestEvent(params: EmitBackfillManifestEventParams): Promise<BackfillEventOutcome>;
@@ -0,0 +1,107 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BACKFILL_PUBLISH_ID_PREFIX = void 0;
4
+ exports.newBackfillRunId = newBackfillRunId;
5
+ exports.backfillPublishId = backfillPublishId;
6
+ exports.isBackfillPublishId = isBackfillPublishId;
7
+ exports.emitBackfillManifestEvent = emitBackfillManifestEvent;
8
+ const uuid_1 = require("uuid");
9
+ const publishEvents_1 = require("../publishEvents");
10
+ /**
11
+ * `package_manifest_updated` for courses reconciled by the backfill.
12
+ *
13
+ * The backfill regenerates descriptions of packages that are already published,
14
+ * so breathecode needs the same notification a publication sends — but there is
15
+ * no publication here: no `package_published` precedes this event, and no
16
+ * journal tracks it (the sweep resumes publications, and a backfill is simply
17
+ * re-run instead).
18
+ *
19
+ * That is what makes `publish_id` awkward. The field exists to correlate the two
20
+ * events of one publication, a pair this event has no half of. Since it is
21
+ * required, the value has to be something that cannot be mistaken for a real
22
+ * publication: a bare uuid — the obvious choice — would be exactly that mistake,
23
+ * and if breathecode ever starts pairing events it would search forever for a
24
+ * `package_published` that was never sent.
25
+ *
26
+ * Hence `backfill-{runId}-{slug}`:
27
+ *
28
+ * - the `backfill-` prefix makes the origin readable in the webhook log and
29
+ * discriminable in code (`isBackfillPublishId`); real ids are bare uuids from
30
+ * `createJournal`, so the two spaces cannot collide;
31
+ * - `{slug}` keeps it unique per event, which a per-run id alone would not be;
32
+ * - a `{runId}` shared by every course of one invocation makes "every event from
33
+ * the run I fired at 15:40" a single substring query, which is the question
34
+ * you actually ask when the catalogue is backfilled in small batches.
35
+ *
36
+ * Encoding this in `publish_id`, rather than adding an `origin` field, keeps the
37
+ * event contract untouched: breathecode ignores `publish_id` today, so this
38
+ * costs them nothing and needs no coordination. If they ever start consuming the
39
+ * provenance, it can be promoted to a field of its own.
40
+ */
41
+ exports.BACKFILL_PUBLISH_ID_PREFIX = "backfill-";
42
+ /** One per script invocation, shared by every course it touches. */
43
+ function newBackfillRunId() {
44
+ return (0, uuid_1.v4)();
45
+ }
46
+ function backfillPublishId(runId, courseSlug) {
47
+ return `${exports.BACKFILL_PUBLISH_ID_PREFIX}${runId}-${courseSlug}`;
48
+ }
49
+ function isBackfillPublishId(publishId) {
50
+ return publishId.startsWith(exports.BACKFILL_PUBLISH_ID_PREFIX);
51
+ }
52
+ /**
53
+ * Announce one reconciled course. Never throws: the descriptions and the
54
+ * manifest are already written by the time this runs, and losing the
55
+ * notification must not turn a successful course into a failed one.
56
+ */
57
+ async function emitBackfillManifestEvent(params) {
58
+ var _a, _b;
59
+ const { courseSlug, result } = params;
60
+ if (result.status === "skipped") {
61
+ return "skipped";
62
+ }
63
+ const emit = (_a = params.emit) !== null && _a !== void 0 ? _a : publishEvents_1.sendPublishEvent;
64
+ const readPackageInfo = (_b = params.readPackageInfo) !== null && _b !== void 0 ? _b : publishEvents_1.fetchPackageInfo;
65
+ // Enriching the payload must never prevent the delivery: both the manifest and
66
+ // the package info are optional in the contract, and an event with less
67
+ // context beats an event that never arrives.
68
+ let manifest = null;
69
+ try {
70
+ if (params.storage.readManifest) {
71
+ manifest = await params.storage.readManifest(courseSlug);
72
+ }
73
+ }
74
+ catch (error) {
75
+ console.error(`[backfill-events] Could not read the manifest of "${courseSlug}", reporting without it:`, error.message);
76
+ }
77
+ let packageInfo = null;
78
+ try {
79
+ packageInfo = await readPackageInfo(courseSlug, params.rigobotToken);
80
+ }
81
+ catch (error) {
82
+ console.error(`[backfill-events] Could not read the package "${courseSlug}", reporting without it:`, error.message);
83
+ }
84
+ try {
85
+ const delivered = await emit((0, publishEvents_1.buildPackageManifestUpdatedEvent)({
86
+ publishId: backfillPublishId(params.runId, courseSlug),
87
+ courseSlug,
88
+ packageInfo,
89
+ manifest,
90
+ },
91
+ // A failed projection does not fail the run — the syllabus is saved
92
+ // either way — but this event reports the manifest, so it cannot claim
93
+ // success over one that was never rewritten.
94
+ result.status === "failed" || result.manifestProjected === false ?
95
+ "failed" :
96
+ "success", {
97
+ generated: result.generated,
98
+ failed: result.failed,
99
+ missing: result.missing,
100
+ }), params.breathecodeToken);
101
+ return delivered ? "delivered" : "failed";
102
+ }
103
+ catch (error) {
104
+ console.error(`[backfill-events] Could not deliver the manifest event for "${courseSlug}":`, error.message);
105
+ return "failed";
106
+ }
107
+ }
@@ -0,0 +1,16 @@
1
+ import { Bucket } from "@google-cloud/storage";
2
+ import { CourseDescriptionsStorage } from "./generateCourseDescriptions";
3
+ /**
4
+ * Draft-bucket (GCS) adapter.
5
+ *
6
+ * Descriptions are never generated against this bucket — it keeps moving while
7
+ * the teacher edits, so it is not a stable snapshot. It is only written by the
8
+ * mirror-back, which copies descriptions generated from the published package
9
+ * whenever the draft content still matches.
10
+ *
11
+ * Note there is no `reprojectManifest`: the manifest is only rebuilt when
12
+ * publishing.
13
+ */
14
+ export declare const gcsSyllabusPath: (courseSlug: string) => string;
15
+ export declare const gcsReadmePath: (courseSlug: string, exerciseSlug: string, fileName: string) => string;
16
+ export declare function createGcsDescriptionsStorage(bucket: Bucket): CourseDescriptionsStorage;
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.gcsReadmePath = exports.gcsSyllabusPath = void 0;
4
+ exports.createGcsDescriptionsStorage = createGcsDescriptionsStorage;
5
+ const configBuilder_1 = require("../configBuilder");
6
+ const syllabusSync_1 = require("../syllabusSync");
7
+ /**
8
+ * Draft-bucket (GCS) adapter.
9
+ *
10
+ * Descriptions are never generated against this bucket — it keeps moving while
11
+ * the teacher edits, so it is not a stable snapshot. It is only written by the
12
+ * mirror-back, which copies descriptions generated from the published package
13
+ * whenever the draft content still matches.
14
+ *
15
+ * Note there is no `reprojectManifest`: the manifest is only rebuilt when
16
+ * publishing.
17
+ */
18
+ const gcsSyllabusPath = (courseSlug) => `courses/${courseSlug}/.learn/initialSyllabus.json`;
19
+ exports.gcsSyllabusPath = gcsSyllabusPath;
20
+ const gcsReadmePath = (courseSlug, exerciseSlug, fileName) => `courses/${courseSlug}/exercises/${exerciseSlug}/${fileName}`;
21
+ exports.gcsReadmePath = gcsReadmePath;
22
+ function createGcsDescriptionsStorage(bucket) {
23
+ return {
24
+ async readSyllabus(courseSlug) {
25
+ try {
26
+ const [buf] = await bucket.file((0, exports.gcsSyllabusPath)(courseSlug)).download();
27
+ return JSON.parse(buf.toString());
28
+ }
29
+ catch (_a) {
30
+ return null;
31
+ }
32
+ },
33
+ async writeSyllabus(courseSlug, syllabus) {
34
+ await bucket
35
+ .file((0, exports.gcsSyllabusPath)(courseSlug))
36
+ .save(Buffer.from(JSON.stringify(syllabus), "utf8"), {
37
+ contentType: "application/json",
38
+ });
39
+ },
40
+ async listExercises(courseSlug) {
41
+ const { exercises } = await (0, configBuilder_1.buildConfig)(bucket, courseSlug);
42
+ return exercises.map(exercise => ({
43
+ slug: exercise.slug,
44
+ translations: exercise.translations || {},
45
+ }));
46
+ },
47
+ async readReadme(courseSlug, exerciseSlug, fileName) {
48
+ try {
49
+ const [buf] = await bucket
50
+ .file((0, exports.gcsReadmePath)(courseSlug, exerciseSlug, fileName))
51
+ .download();
52
+ return buf.toString();
53
+ }
54
+ catch (_a) {
55
+ return null;
56
+ }
57
+ },
58
+ syllabusSyncStorage: (0, syllabusSync_1.createGcsSyllabusSyncStorage)(bucket),
59
+ };
60
+ }
@@ -0,0 +1,66 @@
1
+ import { Syllabus } from "../../models/creator";
2
+ import { PackageManifest } from "../packageManifest";
3
+ import { TGenerateStepDescriptionsParams, TGenerateStepDescriptionsResult } from "../rigoActions";
4
+ import { SyllabusSyncStorage } from "../syllabusSync";
5
+ import { CourseExercise, CourseReadmes } from "./workList";
6
+ /**
7
+ * Generate and persist the step descriptions of one published course.
8
+ *
9
+ * This is the single implementation behind every flow that produces
10
+ * descriptions: the background stage that runs after publishing, the sweep that
11
+ * recovers interrupted publications, and the one-off backfill over the
12
+ * historical catalogue. They differ only in what triggers them.
13
+ *
14
+ * Storage is a port so the service can be driven against the published bucket
15
+ * (the real case) or an in-memory double (tests), and so the manifest
16
+ * re-projection stays where the storage-specific knowledge lives.
17
+ */
18
+ export declare const DEFAULT_DESCRIPTIONS_CONCURRENCY = 5;
19
+ export interface CourseDescriptionsStorage {
20
+ readSyllabus(courseSlug: string): Promise<Syllabus | null>;
21
+ writeSyllabus(courseSlug: string, syllabus: Syllabus): Promise<void>;
22
+ listExercises(courseSlug: string): Promise<CourseExercise[]>;
23
+ readReadme(courseSlug: string, exerciseSlug: string, fileName: string): Promise<string | null>;
24
+ /** Re-generate `package-manifest.json` from the syllabus and invalidate the CDN. */
25
+ reprojectManifest?(courseSlug: string): Promise<void>;
26
+ /** Read the stored manifest, to report the updated one in the event. */
27
+ readManifest?(courseSlug: string): Promise<PackageManifest | null>;
28
+ /** Enables the provisional additive syllabus reconciliation. */
29
+ syllabusSyncStorage?: SyllabusSyncStorage;
30
+ }
31
+ export type GenerateCourseDescriptionsOptions = {
32
+ token: string;
33
+ dryRun?: boolean;
34
+ force?: boolean;
35
+ concurrency?: number;
36
+ targetWordCount?: number;
37
+ promptVersion?: number;
38
+ /** Additive syllabus reconciliation before computing the work list. */
39
+ reconcile?: boolean;
40
+ /** Injection point for tests. */
41
+ generate?: (token: string, params: TGenerateStepDescriptionsParams) => Promise<TGenerateStepDescriptionsResult | null>;
42
+ };
43
+ export type GenerateCourseDescriptionsResult = {
44
+ /** `skipped` means there was nothing to do, not that something went wrong. */
45
+ status: "skipped" | "processed" | "failed";
46
+ steps: number;
47
+ /** (step, language) pairs written. */
48
+ generated: number;
49
+ /** Steps whose completion failed outright. */
50
+ failed: number;
51
+ /** (step, language) pairs the model never answered; retried later. */
52
+ missing: number;
53
+ errors: string[];
54
+ /** Completion durations, to watch how close we run to the request limits. */
55
+ durationsSeconds: number[];
56
+ /**
57
+ * Whether `package-manifest.json` was re-projected; null when the storage does
58
+ * not project at all. A failed projection is not a failed run — the syllabus,
59
+ * which is the source of truth, is already saved — but callers that announce
60
+ * the manifest downstream must not report success on it.
61
+ */
62
+ manifestProjected: boolean | null;
63
+ };
64
+ /** Read every README of the course, keyed by exercise slug and language. */
65
+ export declare function readCourseReadmes(storage: CourseDescriptionsStorage, courseSlug: string, exercises: CourseExercise[], concurrency: number): Promise<CourseReadmes>;
66
+ export declare function generateCourseDescriptions(storage: CourseDescriptionsStorage, courseSlug: string, options: GenerateCourseDescriptionsOptions): Promise<GenerateCourseDescriptionsResult>;
@@ -0,0 +1,176 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_DESCRIPTIONS_CONCURRENCY = void 0;
4
+ exports.readCourseReadmes = readCourseReadmes;
5
+ exports.generateCourseDescriptions = generateCourseDescriptions;
6
+ const rigoActions_1 = require("../rigoActions");
7
+ const packageManifestBackfill_1 = require("../s3/packageManifestBackfill");
8
+ const syllabusSync_1 = require("../syllabusSync");
9
+ const workList_1 = require("./workList");
10
+ /**
11
+ * Generate and persist the step descriptions of one published course.
12
+ *
13
+ * This is the single implementation behind every flow that produces
14
+ * descriptions: the background stage that runs after publishing, the sweep that
15
+ * recovers interrupted publications, and the one-off backfill over the
16
+ * historical catalogue. They differ only in what triggers them.
17
+ *
18
+ * Storage is a port so the service can be driven against the published bucket
19
+ * (the real case) or an in-memory double (tests), and so the manifest
20
+ * re-projection stays where the storage-specific knowledge lives.
21
+ */
22
+ exports.DEFAULT_DESCRIPTIONS_CONCURRENCY = 5;
23
+ function emptyResult(status) {
24
+ return {
25
+ status,
26
+ steps: 0,
27
+ generated: 0,
28
+ failed: 0,
29
+ missing: 0,
30
+ errors: [],
31
+ durationsSeconds: [],
32
+ manifestProjected: null,
33
+ };
34
+ }
35
+ /** Read every README of the course, keyed by exercise slug and language. */
36
+ async function readCourseReadmes(storage, courseSlug, exercises, concurrency) {
37
+ const readmes = {};
38
+ const targets = [];
39
+ for (const exercise of exercises) {
40
+ for (const [lang, file] of Object.entries(exercise.translations || {})) {
41
+ targets.push({ exerciseSlug: exercise.slug, lang, file });
42
+ }
43
+ }
44
+ const contents = await (0, packageManifestBackfill_1.mapWithConcurrency)(targets, concurrency, target => storage.readReadme(courseSlug, target.exerciseSlug, target.file));
45
+ for (const [index, target] of targets.entries()) {
46
+ const content = contents[index];
47
+ if (typeof content !== "string") {
48
+ continue;
49
+ }
50
+ if (!readmes[target.exerciseSlug]) {
51
+ readmes[target.exerciseSlug] = {};
52
+ }
53
+ readmes[target.exerciseSlug][target.lang] = content;
54
+ }
55
+ return readmes;
56
+ }
57
+ /** Ensure the translation slot exists before writing a description into it. */
58
+ function ensureSlot(lesson, lang) {
59
+ if (!lesson.translations) {
60
+ lesson.translations = {};
61
+ }
62
+ if (!lesson.translations[lang]) {
63
+ lesson.translations[lang] = { completionId: 0, startedAt: Date.now() };
64
+ }
65
+ return lesson.translations[lang];
66
+ }
67
+ function applyStepResult(step, result) {
68
+ let generated = 0;
69
+ for (const language of step.languages) {
70
+ if (!(language.lang in result.descriptionsByLanguage)) {
71
+ // Never answered: leave the slot untouched so a later run retries it.
72
+ // Writing a null plus a fingerprint here would look settled forever.
73
+ continue;
74
+ }
75
+ const slot = ensureSlot(step.lesson, language.lang);
76
+ if (slot.descriptionSource === "human") {
77
+ continue;
78
+ }
79
+ const description = result.descriptionsByLanguage[language.lang];
80
+ slot.description = description;
81
+ slot.descriptionStatus = description ? "generated" : "none";
82
+ slot.descriptionSource = "auto";
83
+ slot.descriptionPromptVersion = result.promptVersion;
84
+ slot.sourceContentHash = language.fingerprint.sha256;
85
+ slot.sourceSimHash = language.fingerprint.simhash;
86
+ generated += 1;
87
+ }
88
+ return { generated, missing: result.missingLanguages.length };
89
+ }
90
+ async function generateCourseDescriptions(storage, courseSlug, options) {
91
+ var _a, _b;
92
+ const concurrency = (_a = options.concurrency) !== null && _a !== void 0 ? _a : exports.DEFAULT_DESCRIPTIONS_CONCURRENCY;
93
+ const generate = (_b = options.generate) !== null && _b !== void 0 ? _b : rigoActions_1.generateStepDescriptions;
94
+ const syllabus = await storage.readSyllabus(courseSlug);
95
+ if (!syllabus) {
96
+ return emptyResult("skipped");
97
+ }
98
+ const exercises = await storage.listExercises(courseSlug);
99
+ if (exercises.length === 0) {
100
+ return emptyResult("skipped");
101
+ }
102
+ if (options.reconcile && storage.syllabusSyncStorage) {
103
+ try {
104
+ await (0, syllabusSync_1.synchronizeSyllabusWithBucket)(storage.syllabusSyncStorage, courseSlug, syllabus, { prune: false });
105
+ }
106
+ catch (error) {
107
+ // Provisional patch: never let reconciliation block generation.
108
+ console.error(`[descriptions] Reconciliation failed for "${courseSlug}":`, error.message);
109
+ }
110
+ }
111
+ const readmes = await readCourseReadmes(storage, courseSlug, exercises, concurrency);
112
+ const work = (0, workList_1.buildDescriptionWorkList)({
113
+ syllabus,
114
+ exercises,
115
+ readmes,
116
+ promptVersion: options.promptVersion,
117
+ force: options.force,
118
+ });
119
+ if (work.length === 0) {
120
+ return emptyResult("skipped");
121
+ }
122
+ const result = emptyResult("processed");
123
+ result.steps = work.length;
124
+ if (options.dryRun) {
125
+ result.generated = (0, workList_1.countWorkItems)(work);
126
+ return result;
127
+ }
128
+ // One completion per step, several in flight: a slow step never stalls the
129
+ // course and Rigobot is never hit with an unbounded burst.
130
+ const outcomes = await (0, packageManifestBackfill_1.mapWithConcurrency)(work, concurrency, async (step) => {
131
+ var _a;
132
+ return ({
133
+ step,
134
+ generated: await generate(options.token, {
135
+ readmeContent: step.baseContent,
136
+ sourceLanguage: step.baseLanguage,
137
+ outputLanguages: step.languages.map(language => language.lang),
138
+ lessonTitle: step.lesson.title,
139
+ courseTitle: (_a = syllabus.courseInfo) === null || _a === void 0 ? void 0 : _a.title,
140
+ targetWordCount: options.targetWordCount,
141
+ promptVersion: options.promptVersion,
142
+ }),
143
+ });
144
+ });
145
+ for (const outcome of outcomes) {
146
+ if (!outcome.generated) {
147
+ result.failed += 1;
148
+ result.errors.push(`generation failed for "${outcome.step.exerciseSlug}"`);
149
+ continue;
150
+ }
151
+ if (outcome.generated.durationSeconds !== null) {
152
+ result.durationsSeconds.push(outcome.generated.durationSeconds);
153
+ }
154
+ const applied = applyStepResult(outcome.step, outcome.generated);
155
+ result.generated += applied.generated;
156
+ result.missing += applied.missing;
157
+ }
158
+ if (result.generated === 0 && result.failed > 0) {
159
+ result.status = "failed";
160
+ return result;
161
+ }
162
+ await storage.writeSyllabus(courseSlug, syllabus);
163
+ if (storage.reprojectManifest) {
164
+ try {
165
+ await storage.reprojectManifest(courseSlug);
166
+ result.manifestProjected = true;
167
+ }
168
+ catch (error) {
169
+ // The syllabus (the source of truth) is already saved; a failed
170
+ // projection is recoverable and must not lose the generated text.
171
+ result.manifestProjected = false;
172
+ result.errors.push(`manifest projection failed: ${error.message}`);
173
+ }
174
+ }
175
+ return result;
176
+ }