@learnpack/learnpack 5.0.351 → 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 (70) 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 +18 -0
  32. package/lib/utils/packageManifest.js +81 -7
  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 +84 -0
  40. package/lib/utils/s3/packageManifestBackfill.js +487 -0
  41. package/lib/utils/syllabusSync.d.ts +71 -0
  42. package/lib/utils/syllabusSync.js +273 -0
  43. package/package.json +4 -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 +143 -141
  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/export/README.md +0 -2
  64. package/src/utils/gcsBucketName.ts +19 -0
  65. package/src/utils/packageManifest.ts +101 -10
  66. package/src/utils/publishEvents.ts +181 -0
  67. package/src/utils/publishJournal.ts +383 -0
  68. package/src/utils/rigoActions.ts +130 -0
  69. package/src/utils/s3/packageManifestBackfill.ts +776 -0
  70. package/src/utils/syllabusSync.ts +390 -0
@@ -0,0 +1,157 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ /* eslint-disable no-await-in-loop */
4
+ /**
5
+ * Fase 0a — S3 descriptions backfill (step 2 of the S3 pipeline).
6
+ *
7
+ * One-off fill of the published catalogue (~400+ courses). The per-course work
8
+ * is the shared description service (`generateCourseDescriptions`), the same one
9
+ * the post-publish stage and the sweep use; this script only decides WHICH
10
+ * courses to run it on and in what batches.
11
+ *
12
+ * - Processes at most --limit courses per run (default 50), ordered by the
13
+ * initialSyllabus.json modification time (most recently edited first),
14
+ * skipping courses already settled at the current prompt version. Re-run to
15
+ * advance through the catalogue.
16
+ * - Or target a single course with --slug (remediation / on-demand).
17
+ *
18
+ * Writes descriptions into the published initialSyllabus.json. Manifest
19
+ * projection is a separate pass here: run the package-manifest backfill
20
+ * (runBatch) afterwards, which is cheaper than re-projecting course by course.
21
+ *
22
+ * Secrets/infra via env: RIGOBOT_SYSTEM_TOKEN (required), plus S3_PACKAGES_BUCKET
23
+ * and AWS_REGION as fallbacks for --s3-bucket / --region.
24
+ *
25
+ * Per-run flags:
26
+ * --s3-bucket <name> S3 bucket (or env S3_PACKAGES_BUCKET, default learnpack-paquetes)
27
+ * --region <region> AWS region (default us-east-1, or env AWS_REGION)
28
+ * --slug <slug> process a single course (remediation / on-demand)
29
+ * --limit <n> courses per run (default 50)
30
+ * --target-words <n> words per description (default 25)
31
+ * --concurrency <n> completions in flight per course (default 5)
32
+ * --dry-run preview without writing/generating
33
+ * --no-reconcile skip the additive syllabus reconciliation
34
+ * --force regenerate even courses already settled at this prompt version
35
+ *
36
+ * Examples:
37
+ * node lib/scripts/descriptionsS3Backfill.js --limit 50
38
+ * node lib/scripts/descriptionsS3Backfill.js --slug my-course
39
+ * node lib/scripts/descriptionsS3Backfill.js --dry-run --limit 10
40
+ */
41
+ const node_util_1 = require("node:util");
42
+ const client_s3_1 = require("@aws-sdk/client-s3");
43
+ const packageManifest_1 = require("../utils/packageManifest");
44
+ const generateCourseDescriptions_1 = require("../utils/descriptions/generateCourseDescriptions");
45
+ const s3Storage_1 = require("../utils/descriptions/s3Storage");
46
+ const workList_1 = require("../utils/descriptions/workList");
47
+ const SYLLABUS_KEY_PATTERN = /^([^/]+)\/\.learn\/initialSyllabus\.json$/;
48
+ /** Courses (by slug) ordered by initialSyllabus.json LastModified, newest first. */
49
+ async function listCoursesByRecency(s3, bucket) {
50
+ var _a, _b;
51
+ const mtimes = new Map();
52
+ let continuationToken;
53
+ do {
54
+ const response = (await s3.send(new client_s3_1.ListObjectsV2Command({
55
+ Bucket: bucket,
56
+ ContinuationToken: continuationToken,
57
+ })));
58
+ for (const object of response.Contents || []) {
59
+ const match = (_a = object.Key) === null || _a === void 0 ? void 0 : _a.match(SYLLABUS_KEY_PATTERN);
60
+ if (match) {
61
+ mtimes.set(match[1], ((_b = object.LastModified) === null || _b === void 0 ? void 0 : _b.getTime()) || 0);
62
+ }
63
+ }
64
+ continuationToken = response.NextContinuationToken;
65
+ } while (continuationToken);
66
+ return [...mtimes.entries()]
67
+ .sort((a, b) => b[1] - a[1])
68
+ .map(([slug]) => slug);
69
+ }
70
+ async function main() {
71
+ var _a, _b, _c;
72
+ const { values } = (0, node_util_1.parseArgs)({
73
+ options: {
74
+ "s3-bucket": { type: "string" },
75
+ region: { type: "string" },
76
+ slug: { type: "string" },
77
+ limit: { type: "string" },
78
+ "target-words": { type: "string" },
79
+ concurrency: { type: "string" },
80
+ "dry-run": { type: "boolean", default: false },
81
+ "no-reconcile": { type: "boolean", default: false },
82
+ force: { type: "boolean", default: false },
83
+ },
84
+ });
85
+ const token = process.env.RIGOBOT_SYSTEM_TOKEN;
86
+ if (!token) {
87
+ console.error("[s3-backfill] RIGOBOT_SYSTEM_TOKEN (env) is required");
88
+ process.exit(1);
89
+ }
90
+ const bucket = values["s3-bucket"] ||
91
+ process.env.S3_PACKAGES_BUCKET ||
92
+ "learnpack-paquetes";
93
+ const region = values.region || process.env.AWS_REGION || "us-east-1";
94
+ const limit = Number.parseInt(values.limit || "50", 10) || 50;
95
+ const dryRun = (_a = values["dry-run"]) !== null && _a !== void 0 ? _a : false;
96
+ const force = (_b = values.force) !== null && _b !== void 0 ? _b : false;
97
+ const s3 = new client_s3_1.S3Client({ region });
98
+ // Manifests are projected in a separate pass after this backfill.
99
+ const storage = (0, s3Storage_1.createS3DescriptionsStorage)(s3, bucket, {
100
+ reprojectManifest: false,
101
+ });
102
+ const singleSlug = values.slug;
103
+ const slugs = singleSlug ?
104
+ [singleSlug] :
105
+ await listCoursesByRecency(s3, bucket);
106
+ console.log(`[s3-backfill] Starting${singleSlug ? ` (slug=${singleSlug})` : ` (limit=${limit})`}${dryRun ? " (dry-run)" : ""} over ${slugs.length} candidate course(s)`);
107
+ let processed = 0;
108
+ let generated = 0;
109
+ let failed = 0;
110
+ for (const slug of slugs) {
111
+ if (!singleSlug && processed >= limit) {
112
+ break;
113
+ }
114
+ try {
115
+ // Cheap pre-filter: skip finished courses without reading their READMEs.
116
+ if (!force) {
117
+ const syllabus = await storage.readSyllabus(slug);
118
+ if (!syllabus) {
119
+ continue;
120
+ }
121
+ const exercises = await (0, s3Storage_1.fetchExercises)(s3, bucket, slug);
122
+ if (exercises.length === 0 || (0, workList_1.isCourseSettled)(syllabus, exercises)) {
123
+ continue;
124
+ }
125
+ }
126
+ const result = await (0, generateCourseDescriptions_1.generateCourseDescriptions)(storage, slug, {
127
+ token,
128
+ dryRun,
129
+ force,
130
+ reconcile: !((_c = values["no-reconcile"]) !== null && _c !== void 0 ? _c : false),
131
+ concurrency: Number.parseInt(values.concurrency || "", 10) ||
132
+ generateCourseDescriptions_1.DEFAULT_DESCRIPTIONS_CONCURRENCY,
133
+ targetWordCount: Number.parseInt(values["target-words"] || "", 10) ||
134
+ packageManifest_1.DESCRIPTION_TARGET_WORD_COUNT,
135
+ });
136
+ if (result.status === "skipped") {
137
+ continue;
138
+ }
139
+ processed += 1;
140
+ generated += result.generated;
141
+ failed += result.failed;
142
+ console.log(`[s3-backfill] "${slug}": ${result.steps} step(s), ${result.generated} description(s)${dryRun ? " (dry-run)" : ""}${result.failed > 0 ? `, ${result.failed} failed` : ""}${result.missing > 0 ? `, ${result.missing} unanswered` : ""}`);
143
+ }
144
+ catch (error) {
145
+ processed += 1;
146
+ failed += 1;
147
+ console.error(`[s3-backfill] Failed processing "${slug}":`, error.message);
148
+ }
149
+ }
150
+ console.log(`[s3-backfill] Done. ${processed} course(s), ${generated} description(s) written, ${failed} failure(s).`);
151
+ }
152
+ main()
153
+ .then(() => process.exit(0))
154
+ .catch(error => {
155
+ console.error("[s3-backfill] Fatal error:", error);
156
+ process.exit(1);
157
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,142 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ /* eslint-disable no-await-in-loop */
4
+ /**
5
+ * Descriptions sweep — safety net for the post-publish stage.
6
+ *
7
+ * The stage that generates descriptions runs inside the web process, so a dyno
8
+ * restart mid-flight leaves a package published but not described and, worse,
9
+ * breathecode waiting for a manifest event that was promised and never arrived.
10
+ * This job finishes those publications.
11
+ *
12
+ * Runs as a one-off dyno via Heroku Scheduler (hourly is plenty):
13
+ * node lib/scripts/descriptionsSweep.js
14
+ *
15
+ * Two sources of work:
16
+ * 1. Publish journals (`publish-journal/` in GCS) — the primary one. Each
17
+ * journal that stalled is resumed: missing descriptions are generated, the
18
+ * draft is mirrored, and the owed event is emitted. Journals are dropped
19
+ * once complete and abandoned after a few attempts.
20
+ * 2. A deep scan of published courses (DESCRIPTIONS_SWEEP_FULL=1) — catches
21
+ * packages that never got a journal at all, at the cost of reading every
22
+ * course. Off by default.
23
+ *
24
+ * Required env: GCP_CREDENTIALS_JSON, RIGOBOT_SYSTEM_TOKEN.
25
+ * Optional env: GCP_BUCKET_NAME, S3_PACKAGES_BUCKET, AWS_REGION,
26
+ * CLOUDFRONT_DISTRIBUTION_ID, BREATHECODE_SYSTEM_TOKEN (needed to emit the
27
+ * pending events), DESCRIPTIONS_SWEEP_FULL, DESCRIPTIONS_SWEEP_LIMIT,
28
+ * DESCRIPTION_STALE_AFTER_MS.
29
+ */
30
+ const storage_1 = require("@google-cloud/storage");
31
+ const client_s3_1 = require("@aws-sdk/client-s3");
32
+ const publishJournal_1 = require("../utils/publishJournal");
33
+ const packageManifestBackfill_1 = require("../utils/s3/packageManifestBackfill");
34
+ const gcsBucketName_1 = require("../utils/gcsBucketName");
35
+ const gcsStorage_1 = require("../utils/descriptions/gcsStorage");
36
+ const s3Storage_1 = require("../utils/descriptions/s3Storage");
37
+ const generateCourseDescriptions_1 = require("../utils/descriptions/generateCourseDescriptions");
38
+ const resumePublication_1 = require("../utils/descriptions/resumePublication");
39
+ async function main() {
40
+ const credentialsEnv = process.env.GCP_CREDENTIALS_JSON;
41
+ if (!credentialsEnv) {
42
+ console.error("[sweep] GCP_CREDENTIALS_JSON (env) is required");
43
+ process.exit(1);
44
+ }
45
+ const rigobotToken = process.env.RIGOBOT_SYSTEM_TOKEN;
46
+ if (!rigobotToken) {
47
+ console.error("[sweep] RIGOBOT_SYSTEM_TOKEN (env) is required");
48
+ process.exit(1);
49
+ }
50
+ const breathecodeToken = process.env.BREATHECODE_SYSTEM_TOKEN;
51
+ if (!breathecodeToken) {
52
+ console.warn("[sweep] BREATHECODE_SYSTEM_TOKEN is not set: pending events cannot be emitted");
53
+ }
54
+ const bucket = new storage_1.Storage({
55
+ credentials: JSON.parse(credentialsEnv),
56
+ }).bucket((0, gcsBucketName_1.requireGcsBucketName)());
57
+ const journalStorage = (0, publishJournal_1.createGcsJournalStorage)(bucket);
58
+ const publishedStorage = (0, s3Storage_1.createS3DescriptionsStorageFromEnv)();
59
+ const draftStorage = (0, gcsStorage_1.createGcsDescriptionsStorage)(bucket);
60
+ const staleAfterMs = Number.parseInt(process.env.DESCRIPTION_STALE_AFTER_MS || "", 10) ||
61
+ resumePublication_1.DEFAULT_STALE_AFTER_MS;
62
+ const refs = await (0, publishJournal_1.listJournalRefs)(journalStorage);
63
+ console.log(`[sweep] ${refs.length} unfinished publication(s)`);
64
+ const tally = {
65
+ "skipped-recent": 0,
66
+ "skipped-abandoned": 0,
67
+ abandoned: 0,
68
+ resumed: 0,
69
+ "nothing-to-do": 0,
70
+ };
71
+ for (const ref of refs) {
72
+ try {
73
+ const journal = await (0, publishJournal_1.readJournalByKey)(journalStorage, ref.key);
74
+ if (!journal) {
75
+ continue;
76
+ }
77
+ const outcome = await (0, resumePublication_1.resumePublication)(journal, {
78
+ journalStorage,
79
+ publishedStorage,
80
+ draftStorage,
81
+ rigobotToken,
82
+ breathecodeToken,
83
+ staleAfterMs,
84
+ });
85
+ tally[outcome] += 1;
86
+ if (outcome === "resumed") {
87
+ console.log(`[sweep] Resumed "${ref.courseSlug}" (${ref.publishId})`);
88
+ }
89
+ }
90
+ catch (error) {
91
+ console.error(`[sweep] Failed resuming "${ref.courseSlug}":`, error.message);
92
+ }
93
+ }
94
+ console.log(`[sweep] Journals: ${tally.resumed} resumed, ${tally["skipped-recent"]} still in flight, ` +
95
+ `${tally["nothing-to-do"]} nothing to do, ${tally.abandoned} abandoned, ` +
96
+ `${tally["skipped-abandoned"]} already abandoned`);
97
+ if (process.env.DESCRIPTIONS_SWEEP_FULL === "1") {
98
+ await deepScan(rigobotToken, publishedStorage);
99
+ }
100
+ }
101
+ /**
102
+ * Fallback for packages with no journal at all (published before this existed,
103
+ * or whose journal write failed). Expensive: it reads every published course,
104
+ * so it is opt-in rather than the default path.
105
+ */
106
+ async function deepScan(rigobotToken, publishedStorage) {
107
+ const s3 = new client_s3_1.S3Client({
108
+ region: process.env.AWS_REGION || "us-east-1",
109
+ });
110
+ const bucket = process.env.S3_PACKAGES_BUCKET || "learnpack-paquetes";
111
+ const limit = Number.parseInt(process.env.DESCRIPTIONS_SWEEP_LIMIT || "", 10) || 25;
112
+ const slugs = await (0, packageManifestBackfill_1.listPublishedSlugs)(s3, bucket);
113
+ console.log(`[sweep] Deep scan over ${slugs.length} published course(s), limit ${limit}`);
114
+ let processed = 0;
115
+ for (const slug of slugs) {
116
+ if (processed >= limit) {
117
+ break;
118
+ }
119
+ try {
120
+ const result = await (0, generateCourseDescriptions_1.generateCourseDescriptions)(publishedStorage, slug, {
121
+ token: rigobotToken,
122
+ reconcile: true,
123
+ });
124
+ if (result.status === "skipped") {
125
+ continue;
126
+ }
127
+ processed += 1;
128
+ console.log(`[sweep] "${slug}": ${result.generated} description(s), ${result.failed} failed`);
129
+ }
130
+ catch (error) {
131
+ processed += 1;
132
+ console.error(`[sweep] Failed on "${slug}":`, error.message);
133
+ }
134
+ }
135
+ console.log(`[sweep] Deep scan done. ${processed} course(s) touched.`);
136
+ }
137
+ main()
138
+ .then(() => process.exit(0))
139
+ .catch(error => {
140
+ console.error("[sweep] Fatal error:", error);
141
+ process.exit(1);
142
+ });
@@ -1,3 +1,10 @@
1
+ /**
2
+ * Student telemetry, and publication events. Both share the endpoint: a body
3
+ * carrying a top-level `event` key is dispatched as an event, anything else
4
+ * keeps being processed as telemetry.
5
+ */
6
+ export declare const BREATHECODE_TELEMETRY_URL = "https://breathecode.herokuapp.com/v1/assignment/me/telemetry";
7
+ export declare const BREATHECODE_EVENTS_URL = "https://breathecode.herokuapp.com/v1/assignment/me/telemetry";
1
8
  export declare const RIGOBOT_HOST: string;
2
9
  export declare const RIGOBOT_REALTIME_HOST = "https://ai.4geeks.com";
3
10
  type TConsumableSlug = "ai-conversation-message" | "ai-compilation" | "ai-tutorial-generation" | "ai-generation" | "learnpack-publish";
package/lib/utils/api.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.getCurrentTechnologies = exports.fetchTechnologies = exports.doesAssetExists = exports.createAsset = exports.validateToken = exports.listUserAcademies = exports.getConsumable = exports.countConsumables = exports.RIGOBOT_REALTIME_HOST = exports.RIGOBOT_HOST = void 0;
3
+ exports.getCurrentTechnologies = exports.fetchTechnologies = exports.doesAssetExists = exports.createAsset = exports.validateToken = exports.listUserAcademies = exports.getConsumable = exports.countConsumables = exports.RIGOBOT_REALTIME_HOST = exports.RIGOBOT_HOST = exports.BREATHECODE_EVENTS_URL = exports.BREATHECODE_TELEMETRY_URL = void 0;
4
4
  exports.resolveLearnpackPackageId = resolveLearnpackPackageId;
5
5
  const console_1 = require("../utils/console");
6
6
  const storage = require("node-persist");
@@ -9,6 +9,13 @@ const axios_1 = require("axios");
9
9
  const dotenv = require("dotenv");
10
10
  dotenv.config();
11
11
  const HOST = "https://breathecode.herokuapp.com";
12
+ /**
13
+ * Student telemetry, and publication events. Both share the endpoint: a body
14
+ * carrying a top-level `event` key is dispatched as an event, anything else
15
+ * keeps being processed as telemetry.
16
+ */
17
+ exports.BREATHECODE_TELEMETRY_URL = `${HOST}/v1/assignment/me/telemetry`;
18
+ exports.BREATHECODE_EVENTS_URL = exports.BREATHECODE_TELEMETRY_URL;
12
19
  exports.RIGOBOT_HOST = process.env.RIGOBOT_HOST || "https://rigobot.herokuapp.com";
13
20
  exports.RIGOBOT_REALTIME_HOST = "https://ai.4geeks.com";
14
21
  // export const RIGOBOT_REALTIME_HOST = "http://127.0.0.1:8003"
@@ -17,6 +17,7 @@ exports.countSentences = countSentences;
17
17
  exports.howManyDifficultParagraphs = howManyDifficultParagraphs;
18
18
  exports.insertStepInCorrectPosition = insertStepInCorrectPosition;
19
19
  const console_1 = require("./console");
20
+ const api_1 = require("./api");
20
21
  const frontMatter = require("front-matter");
21
22
  const MarkdownIt = require("markdown-it");
22
23
  const syllable_1 = require("syllable");
@@ -146,7 +147,7 @@ const makePackageInfo = (choices) => {
146
147
  .replace(/ /g, "-")
147
148
  .replace(/[^\w-]+/g, ""),
148
149
  telemetry: {
149
- batch: "https://breathecode.herokuapp.com/v1/assignment/me/telemetry",
150
+ batch: api_1.BREATHECODE_TELEMETRY_URL,
150
151
  },
151
152
  };
152
153
  return packageInfo;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Utilities to detect whether a step description is stale relative to its
3
+ * README content.
4
+ *
5
+ * Two signals are stored per (step, lang) at generation time:
6
+ * - sha256(normalizeReadme(content)) -> exact fast-path + content-addressing
7
+ * - simhash64(normalizeReadme(content)) -> near-duplicate significance filter
8
+ *
9
+ * The sweep re-hashes the current README and compares against the stored
10
+ * values: exact SHA-256 match => no-op; otherwise if the SimHash Hamming
11
+ * distance to the *generation baseline* is <= k the change is cosmetic and is
12
+ * ignored, else the description is regenerated. The H1 title is part of the
13
+ * normalized content (a deliberate product decision).
14
+ */
15
+ /**
16
+ * Remove every image from markdown: inline `![alt](url)`, reference-style
17
+ * `![alt][ref]` and raw `<img>` tags. Regular links `[text](url)` are kept.
18
+ *
19
+ * Images are dropped from BOTH sides of the description pipeline — here for
20
+ * fingerprinting, and again before sending the README to the LLM. Adding,
21
+ * replacing or removing an illustration does not change what the lesson
22
+ * teaches, so it must not trigger a regeneration; and the alt text of generated
23
+ * illustrations is a full image-generation prompt (hundreds of tokens) that
24
+ * only invites the model to describe the picture instead of the lesson.
25
+ *
26
+ * Note the alt text may legitimately contain parentheses, so the alt is matched
27
+ * as "anything up to the closing bracket" rather than as balanced text.
28
+ */
29
+ export declare function stripMarkdownImages(content: string): string;
30
+ /**
31
+ * Canonicalize a README for hashing. Strips the frontmatter (metadata such as
32
+ * tutorial/intro video URLs, which do not describe the lesson), removes images
33
+ * (see `stripMarkdownImages`) and collapses all whitespace runs so that
34
+ * reformatting, blank lines and trailing spaces do not register as content
35
+ * changes. The H1 and the prose are preserved.
36
+ */
37
+ export declare function normalizeReadme(content: string): string;
38
+ export declare function sha256(input: string): string;
39
+ /**
40
+ * 64-bit SimHash (Charikar) over whitespace-delimited tokens, returned as a
41
+ * zero-padded 16-char hex string.
42
+ */
43
+ export declare function simhash64(input: string): string;
44
+ /** Hamming distance between two 64-bit hex fingerprints. */
45
+ export declare function hammingHex(a: string, b: string): number;
46
+ export type ContentFingerprint = {
47
+ sha256: string;
48
+ simhash: string;
49
+ };
50
+ /** Convenience: normalize + both fingerprints in one call. */
51
+ export declare function fingerprintReadme(content: string): ContentFingerprint;
52
+ /**
53
+ * Default significance threshold (Hamming distance over the 64-bit SimHash).
54
+ * k=3 is the classic near-duplicate threshold for 64-bit SimHash
55
+ * (Charikar 2002; Manku, Jain & Das Sarma, WWW 2007). Overridable via env.
56
+ */
57
+ export declare function getSimhashThreshold(): number;
58
+ /**
59
+ * Decide whether a description generated from `previous` is stale given the
60
+ * `current` README content. Exact match => not stale; else stale only if the
61
+ * SimHash Hamming distance exceeds the threshold (cosmetic edits are ignored).
62
+ */
63
+ export declare function isDescriptionStale(previous: {
64
+ sha256?: string;
65
+ simhash?: string;
66
+ } | undefined, current: ContentFingerprint, threshold?: number): boolean;
@@ -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,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
+ }