@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.
- package/lib/commands/publish.js +6 -0
- package/lib/commands/serve.js +85 -259
- package/lib/models/creator.d.ts +6 -0
- package/lib/scripts/descriptionsGcsBackfill.d.ts +1 -0
- package/lib/scripts/descriptionsGcsBackfill.js +141 -0
- package/lib/scripts/descriptionsS3Backfill.d.ts +1 -0
- package/lib/scripts/descriptionsS3Backfill.js +157 -0
- package/lib/scripts/descriptionsSweep.d.ts +1 -0
- package/lib/scripts/descriptionsSweep.js +142 -0
- package/lib/utils/api.d.ts +7 -0
- package/lib/utils/api.js +8 -1
- package/lib/utils/creatorUtilities.js +2 -1
- package/lib/utils/descriptionHash.d.ts +66 -0
- package/lib/utils/descriptionHash.js +173 -0
- package/lib/utils/descriptions/gcsStorage.d.ts +16 -0
- package/lib/utils/descriptions/gcsStorage.js +60 -0
- package/lib/utils/descriptions/generateCourseDescriptions.d.ts +59 -0
- package/lib/utils/descriptions/generateCourseDescriptions.js +173 -0
- package/lib/utils/descriptions/mirrorDescriptions.d.ts +49 -0
- package/lib/utils/descriptions/mirrorDescriptions.js +109 -0
- package/lib/utils/descriptions/publishStage.d.ts +69 -0
- package/lib/utils/descriptions/publishStage.js +234 -0
- package/lib/utils/descriptions/resumePublication.d.ts +36 -0
- package/lib/utils/descriptions/resumePublication.js +113 -0
- package/lib/utils/descriptions/s3Storage.d.ts +30 -0
- package/lib/utils/descriptions/s3Storage.js +134 -0
- package/lib/utils/descriptions/workList.d.ts +75 -0
- package/lib/utils/descriptions/workList.js +177 -0
- package/lib/utils/gcsBucketName.d.ts +10 -0
- package/lib/utils/gcsBucketName.js +19 -0
- package/lib/utils/packageManifest.d.ts +18 -0
- package/lib/utils/packageManifest.js +81 -7
- package/lib/utils/publishEvents.d.ts +66 -0
- package/lib/utils/publishEvents.js +111 -0
- package/lib/utils/publishJournal.d.ts +119 -0
- package/lib/utils/publishJournal.js +275 -0
- package/lib/utils/rigoActions.d.ts +44 -0
- package/lib/utils/rigoActions.js +75 -1
- package/lib/utils/s3/packageManifestBackfill.d.ts +84 -0
- package/lib/utils/s3/packageManifestBackfill.js +487 -0
- package/lib/utils/syllabusSync.d.ts +71 -0
- package/lib/utils/syllabusSync.js +273 -0
- package/package.json +4 -1
- package/src/commands/publish.ts +7 -0
- package/src/commands/serve.ts +144 -335
- package/src/models/creator.ts +9 -0
- package/src/scripts/descriptionsGcsBackfill.ts +193 -0
- package/src/scripts/descriptionsS3Backfill.ts +208 -0
- package/src/scripts/descriptionsSweep.ts +185 -0
- package/src/ui/_app/app.css +1 -1
- package/src/ui/_app/app.js +143 -141
- package/src/ui/app.tar.gz +0 -0
- package/src/utils/api.ts +9 -0
- package/src/utils/creatorUtilities.ts +2 -1
- package/src/utils/descriptionHash.ts +196 -0
- package/src/utils/descriptions/gcsStorage.ts +67 -0
- package/src/utils/descriptions/generateCourseDescriptions.ts +301 -0
- package/src/utils/descriptions/mirrorDescriptions.ts +191 -0
- package/src/utils/descriptions/publishStage.ts +382 -0
- package/src/utils/descriptions/resumePublication.ts +200 -0
- package/src/utils/descriptions/s3Storage.ts +206 -0
- package/src/utils/descriptions/workList.ts +283 -0
- package/src/utils/export/README.md +0 -2
- package/src/utils/gcsBucketName.ts +19 -0
- package/src/utils/packageManifest.ts +101 -10
- package/src/utils/publishEvents.ts +181 -0
- package/src/utils/publishJournal.ts +383 -0
- package/src/utils/rigoActions.ts +130 -0
- package/src/utils/s3/packageManifestBackfill.ts +776 -0
- 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
|
+
});
|
package/lib/utils/api.d.ts
CHANGED
|
@@ -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:
|
|
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 ``, 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 ``, 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 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
|
+
}
|