@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
package/src/ui/app.tar.gz
CHANGED
|
Binary file
|
package/src/utils/api.ts
CHANGED
|
@@ -7,6 +7,15 @@ import * as dotenv from "dotenv"
|
|
|
7
7
|
dotenv.config()
|
|
8
8
|
|
|
9
9
|
const HOST = "https://breathecode.herokuapp.com"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Student telemetry, and publication events. Both share the endpoint: a body
|
|
13
|
+
* carrying a top-level `event` key is dispatched as an event, anything else
|
|
14
|
+
* keeps being processed as telemetry.
|
|
15
|
+
*/
|
|
16
|
+
export const BREATHECODE_TELEMETRY_URL = `${HOST}/v1/assignment/me/telemetry`
|
|
17
|
+
export const BREATHECODE_EVENTS_URL = BREATHECODE_TELEMETRY_URL
|
|
18
|
+
|
|
10
19
|
export const RIGOBOT_HOST =
|
|
11
20
|
process.env.RIGOBOT_HOST || "https://rigobot.herokuapp.com"
|
|
12
21
|
export const RIGOBOT_REALTIME_HOST = "https://ai.4geeks.com"
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import Console from "./console"
|
|
2
|
+
import { BREATHECODE_TELEMETRY_URL } from "./api"
|
|
2
3
|
|
|
3
4
|
const frontMatter = require("front-matter")
|
|
4
5
|
import * as MarkdownIt from "markdown-it"
|
|
@@ -189,7 +190,7 @@ export const makePackageInfo = (choices: any) => {
|
|
|
189
190
|
.replace(/ /g, "-")
|
|
190
191
|
.replace(/[^\w-]+/g, ""),
|
|
191
192
|
telemetry: {
|
|
192
|
-
batch:
|
|
193
|
+
batch: BREATHECODE_TELEMETRY_URL,
|
|
193
194
|
},
|
|
194
195
|
}
|
|
195
196
|
return packageInfo
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import * as crypto from "crypto"
|
|
2
|
+
|
|
3
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
4
|
+
const frontMatter = require("front-matter")
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Utilities to detect whether a step description is stale relative to its
|
|
8
|
+
* README content.
|
|
9
|
+
*
|
|
10
|
+
* Two signals are stored per (step, lang) at generation time:
|
|
11
|
+
* - sha256(normalizeReadme(content)) -> exact fast-path + content-addressing
|
|
12
|
+
* - simhash64(normalizeReadme(content)) -> near-duplicate significance filter
|
|
13
|
+
*
|
|
14
|
+
* The sweep re-hashes the current README and compares against the stored
|
|
15
|
+
* values: exact SHA-256 match => no-op; otherwise if the SimHash Hamming
|
|
16
|
+
* distance to the *generation baseline* is <= k the change is cosmetic and is
|
|
17
|
+
* ignored, else the description is regenerated. The H1 title is part of the
|
|
18
|
+
* normalized content (a deliberate product decision).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Remove every image from markdown: inline ``, reference-style
|
|
23
|
+
* `![alt][ref]` and raw `<img>` tags. Regular links `[text](url)` are kept.
|
|
24
|
+
*
|
|
25
|
+
* Images are dropped from BOTH sides of the description pipeline — here for
|
|
26
|
+
* fingerprinting, and again before sending the README to the LLM. Adding,
|
|
27
|
+
* replacing or removing an illustration does not change what the lesson
|
|
28
|
+
* teaches, so it must not trigger a regeneration; and the alt text of generated
|
|
29
|
+
* illustrations is a full image-generation prompt (hundreds of tokens) that
|
|
30
|
+
* only invites the model to describe the picture instead of the lesson.
|
|
31
|
+
*
|
|
32
|
+
* Note the alt text may legitimately contain parentheses, so the alt is matched
|
|
33
|
+
* as "anything up to the closing bracket" rather than as balanced text.
|
|
34
|
+
*/
|
|
35
|
+
export function stripMarkdownImages(content: string): string {
|
|
36
|
+
return (
|
|
37
|
+
content
|
|
38
|
+
//  — alt may span lines and contain parentheses
|
|
39
|
+
.replace(/!\[[^\]]*]\([^)]*\)/g, "")
|
|
40
|
+
// ![alt][ref] — i.e. `!` followed by two bracketed groups
|
|
41
|
+
.replace(/!(?:\[[^\]]*]){2}/g, "")
|
|
42
|
+
// <img ...> / <img ... />
|
|
43
|
+
.replace(/<img\b[^>]*>/gi, "")
|
|
44
|
+
// [](url) left behind by linked images such as badges
|
|
45
|
+
.replace(/\[\s*]\([^)]*\)/g, "")
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Canonicalize a README for hashing. Strips the frontmatter (metadata such as
|
|
51
|
+
* tutorial/intro video URLs, which do not describe the lesson), removes images
|
|
52
|
+
* (see `stripMarkdownImages`) and collapses all whitespace runs so that
|
|
53
|
+
* reformatting, blank lines and trailing spaces do not register as content
|
|
54
|
+
* changes. The H1 and the prose are preserved.
|
|
55
|
+
*/
|
|
56
|
+
export function normalizeReadme(content: string): string {
|
|
57
|
+
let body = content
|
|
58
|
+
try {
|
|
59
|
+
const parsed = frontMatter(content)
|
|
60
|
+
body = typeof parsed.body === "string" ? parsed.body : content
|
|
61
|
+
} catch {
|
|
62
|
+
body = content
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return stripMarkdownImages(body)
|
|
66
|
+
.replace(/\r/g, "")
|
|
67
|
+
.replace(/\s+/g, " ")
|
|
68
|
+
.trim()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function sha256(input: string): string {
|
|
72
|
+
return crypto.createHash("sha256").update(input).digest("hex")
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The build targets < ES2020, so BigInt literals are unavailable. The 64-bit
|
|
76
|
+
// SimHash is therefore represented as two unsigned 32-bit halves { hi, lo } and
|
|
77
|
+
// serialized as a 16-char hex string (hi then lo).
|
|
78
|
+
|
|
79
|
+
function toHex32(x: number): string {
|
|
80
|
+
return (x >>> 0).toString(16).padStart(8, "0")
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function popcount32(input: number): number {
|
|
84
|
+
let x = input >>> 0
|
|
85
|
+
let count = 0
|
|
86
|
+
while (x !== 0) {
|
|
87
|
+
count += x & 1
|
|
88
|
+
x >>>= 1
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return count
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function hashToken64(token: string): { hi: number; lo: number } {
|
|
95
|
+
// First 8 bytes (16 hex chars) of an md5 digest -> two 32-bit halves.
|
|
96
|
+
const hex = crypto.createHash("md5").update(token).digest("hex").slice(0, 16)
|
|
97
|
+
return {
|
|
98
|
+
hi: Number.parseInt(hex.slice(0, 8), 16) >>> 0,
|
|
99
|
+
lo: Number.parseInt(hex.slice(8, 16), 16) >>> 0,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 64-bit SimHash (Charikar) over whitespace-delimited tokens, returned as a
|
|
105
|
+
* zero-padded 16-char hex string.
|
|
106
|
+
*/
|
|
107
|
+
export function simhash64(input: string): string {
|
|
108
|
+
const tokens = input.split(/\s+/).filter(Boolean)
|
|
109
|
+
if (tokens.length === 0) {
|
|
110
|
+
return "0".repeat(16)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const weights = Array.from({ length: 64 }, () => 0)
|
|
114
|
+
for (const token of tokens) {
|
|
115
|
+
const { hi, lo } = hashToken64(token)
|
|
116
|
+
for (let i = 0; i < 32; i++) {
|
|
117
|
+
weights[i] += ((lo >>> i) & 1) === 1 ? 1 : -1
|
|
118
|
+
weights[32 + i] += ((hi >>> i) & 1) === 1 ? 1 : -1
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let lo = 0
|
|
123
|
+
let hi = 0
|
|
124
|
+
for (let i = 0; i < 32; i++) {
|
|
125
|
+
if (weights[i] > 0) {
|
|
126
|
+
lo |= 1 << i
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (weights[32 + i] > 0) {
|
|
130
|
+
hi |= 1 << i
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return toHex32(hi) + toHex32(lo)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Hamming distance between two 64-bit hex fingerprints. */
|
|
138
|
+
export function hammingHex(a: string, b: string): number {
|
|
139
|
+
const hiA = Number.parseInt(a.slice(0, 8), 16) >>> 0
|
|
140
|
+
const loA = Number.parseInt(a.slice(8, 16), 16) >>> 0
|
|
141
|
+
const hiB = Number.parseInt(b.slice(0, 8), 16) >>> 0
|
|
142
|
+
const loB = Number.parseInt(b.slice(8, 16), 16) >>> 0
|
|
143
|
+
return popcount32((hiA ^ hiB) >>> 0) + popcount32((loA ^ loB) >>> 0)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export type ContentFingerprint = {
|
|
147
|
+
sha256: string;
|
|
148
|
+
simhash: string;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/** Convenience: normalize + both fingerprints in one call. */
|
|
152
|
+
export function fingerprintReadme(content: string): ContentFingerprint {
|
|
153
|
+
const normalized = normalizeReadme(content)
|
|
154
|
+
return {
|
|
155
|
+
sha256: sha256(normalized),
|
|
156
|
+
simhash: simhash64(normalized),
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Default significance threshold (Hamming distance over the 64-bit SimHash).
|
|
162
|
+
* k=3 is the classic near-duplicate threshold for 64-bit SimHash
|
|
163
|
+
* (Charikar 2002; Manku, Jain & Das Sarma, WWW 2007). Overridable via env.
|
|
164
|
+
*/
|
|
165
|
+
export function getSimhashThreshold(): number {
|
|
166
|
+
const raw = process.env.DESCRIPTION_SIMHASH_THRESHOLD
|
|
167
|
+
const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN
|
|
168
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 3
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Decide whether a description generated from `previous` is stale given the
|
|
173
|
+
* `current` README content. Exact match => not stale; else stale only if the
|
|
174
|
+
* SimHash Hamming distance exceeds the threshold (cosmetic edits are ignored).
|
|
175
|
+
*/
|
|
176
|
+
export function isDescriptionStale(
|
|
177
|
+
previous: { sha256?: string; simhash?: string } | undefined,
|
|
178
|
+
current: ContentFingerprint,
|
|
179
|
+
threshold: number = getSimhashThreshold()
|
|
180
|
+
): boolean {
|
|
181
|
+
if (!previous || !previous.sha256) {
|
|
182
|
+
// Never generated -> treat as needing generation (i.e. "stale").
|
|
183
|
+
return true
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (previous.sha256 === current.sha256) {
|
|
187
|
+
return false
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (!previous.simhash) {
|
|
191
|
+
// No baseline fingerprint to compare against -> be conservative.
|
|
192
|
+
return true
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return hammingHex(previous.simhash, current.simhash) > threshold
|
|
196
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Bucket } from "@google-cloud/storage"
|
|
2
|
+
import { Syllabus } from "../../models/creator"
|
|
3
|
+
import { buildConfig } from "../configBuilder"
|
|
4
|
+
import { createGcsSyllabusSyncStorage } from "../syllabusSync"
|
|
5
|
+
import { CourseDescriptionsStorage } from "./generateCourseDescriptions"
|
|
6
|
+
import { CourseExercise } from "./workList"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Draft-bucket (GCS) adapter.
|
|
10
|
+
*
|
|
11
|
+
* Descriptions are never generated against this bucket — it keeps moving while
|
|
12
|
+
* the teacher edits, so it is not a stable snapshot. It is only written by the
|
|
13
|
+
* mirror-back, which copies descriptions generated from the published package
|
|
14
|
+
* whenever the draft content still matches.
|
|
15
|
+
*
|
|
16
|
+
* Note there is no `reprojectManifest`: the manifest is only rebuilt when
|
|
17
|
+
* publishing.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const gcsSyllabusPath = (courseSlug: string) =>
|
|
21
|
+
`courses/${courseSlug}/.learn/initialSyllabus.json`
|
|
22
|
+
|
|
23
|
+
export const gcsReadmePath = (
|
|
24
|
+
courseSlug: string,
|
|
25
|
+
exerciseSlug: string,
|
|
26
|
+
fileName: string
|
|
27
|
+
) => `courses/${courseSlug}/exercises/${exerciseSlug}/${fileName}`
|
|
28
|
+
|
|
29
|
+
export function createGcsDescriptionsStorage(
|
|
30
|
+
bucket: Bucket
|
|
31
|
+
): CourseDescriptionsStorage {
|
|
32
|
+
return {
|
|
33
|
+
async readSyllabus(courseSlug) {
|
|
34
|
+
try {
|
|
35
|
+
const [buf] = await bucket.file(gcsSyllabusPath(courseSlug)).download()
|
|
36
|
+
return JSON.parse(buf.toString()) as Syllabus
|
|
37
|
+
} catch {
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
async writeSyllabus(courseSlug, syllabus) {
|
|
42
|
+
await bucket
|
|
43
|
+
.file(gcsSyllabusPath(courseSlug))
|
|
44
|
+
.save(Buffer.from(JSON.stringify(syllabus), "utf8"), {
|
|
45
|
+
contentType: "application/json",
|
|
46
|
+
})
|
|
47
|
+
},
|
|
48
|
+
async listExercises(courseSlug): Promise<CourseExercise[]> {
|
|
49
|
+
const { exercises } = await buildConfig(bucket, courseSlug)
|
|
50
|
+
return exercises.map(exercise => ({
|
|
51
|
+
slug: exercise.slug,
|
|
52
|
+
translations: exercise.translations || {},
|
|
53
|
+
}))
|
|
54
|
+
},
|
|
55
|
+
async readReadme(courseSlug, exerciseSlug, fileName) {
|
|
56
|
+
try {
|
|
57
|
+
const [buf] = await bucket
|
|
58
|
+
.file(gcsReadmePath(courseSlug, exerciseSlug, fileName))
|
|
59
|
+
.download()
|
|
60
|
+
return buf.toString()
|
|
61
|
+
} catch {
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
syllabusSyncStorage: createGcsSyllabusSyncStorage(bucket),
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { Lesson, Syllabus } from "../../models/creator"
|
|
2
|
+
import { PackageManifest } from "../packageManifest"
|
|
3
|
+
import {
|
|
4
|
+
generateStepDescriptions,
|
|
5
|
+
TGenerateStepDescriptionsParams,
|
|
6
|
+
TGenerateStepDescriptionsResult,
|
|
7
|
+
} from "../rigoActions"
|
|
8
|
+
import { mapWithConcurrency } from "../s3/packageManifestBackfill"
|
|
9
|
+
import {
|
|
10
|
+
SyllabusSyncStorage,
|
|
11
|
+
synchronizeSyllabusWithBucket,
|
|
12
|
+
} from "../syllabusSync"
|
|
13
|
+
import {
|
|
14
|
+
buildDescriptionWorkList,
|
|
15
|
+
countWorkItems,
|
|
16
|
+
CourseExercise,
|
|
17
|
+
CourseReadmes,
|
|
18
|
+
StepWork,
|
|
19
|
+
} from "./workList"
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Generate and persist the step descriptions of one published course.
|
|
23
|
+
*
|
|
24
|
+
* This is the single implementation behind every flow that produces
|
|
25
|
+
* descriptions: the background stage that runs after publishing, the sweep that
|
|
26
|
+
* recovers interrupted publications, and the one-off backfill over the
|
|
27
|
+
* historical catalogue. They differ only in what triggers them.
|
|
28
|
+
*
|
|
29
|
+
* Storage is a port so the service can be driven against the published bucket
|
|
30
|
+
* (the real case) or an in-memory double (tests), and so the manifest
|
|
31
|
+
* re-projection stays where the storage-specific knowledge lives.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
export const DEFAULT_DESCRIPTIONS_CONCURRENCY = 5
|
|
35
|
+
|
|
36
|
+
export interface CourseDescriptionsStorage {
|
|
37
|
+
readSyllabus(courseSlug: string): Promise<Syllabus | null>;
|
|
38
|
+
writeSyllabus(courseSlug: string, syllabus: Syllabus): Promise<void>;
|
|
39
|
+
listExercises(courseSlug: string): Promise<CourseExercise[]>;
|
|
40
|
+
readReadme(
|
|
41
|
+
courseSlug: string,
|
|
42
|
+
exerciseSlug: string,
|
|
43
|
+
fileName: string
|
|
44
|
+
): Promise<string | null>;
|
|
45
|
+
/** Re-generate `package-manifest.json` from the syllabus and invalidate the CDN. */
|
|
46
|
+
reprojectManifest?(courseSlug: string): Promise<void>;
|
|
47
|
+
/** Read the stored manifest, to report the updated one in the event. */
|
|
48
|
+
readManifest?(courseSlug: string): Promise<PackageManifest | null>;
|
|
49
|
+
/** Enables the provisional additive syllabus reconciliation. */
|
|
50
|
+
syllabusSyncStorage?: SyllabusSyncStorage;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type GenerateCourseDescriptionsOptions = {
|
|
54
|
+
token: string;
|
|
55
|
+
dryRun?: boolean;
|
|
56
|
+
force?: boolean;
|
|
57
|
+
concurrency?: number;
|
|
58
|
+
targetWordCount?: number;
|
|
59
|
+
promptVersion?: number;
|
|
60
|
+
/** Additive syllabus reconciliation before computing the work list. */
|
|
61
|
+
reconcile?: boolean;
|
|
62
|
+
/** Injection point for tests. */
|
|
63
|
+
generate?: (
|
|
64
|
+
token: string,
|
|
65
|
+
params: TGenerateStepDescriptionsParams
|
|
66
|
+
) => Promise<TGenerateStepDescriptionsResult | null>;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export type GenerateCourseDescriptionsResult = {
|
|
70
|
+
/** `skipped` means there was nothing to do, not that something went wrong. */
|
|
71
|
+
status: "skipped" | "processed" | "failed";
|
|
72
|
+
steps: number;
|
|
73
|
+
/** (step, language) pairs written. */
|
|
74
|
+
generated: number;
|
|
75
|
+
/** Steps whose completion failed outright. */
|
|
76
|
+
failed: number;
|
|
77
|
+
/** (step, language) pairs the model never answered; retried later. */
|
|
78
|
+
missing: number;
|
|
79
|
+
errors: string[];
|
|
80
|
+
/** Completion durations, to watch how close we run to the request limits. */
|
|
81
|
+
durationsSeconds: number[];
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
function emptyResult(
|
|
85
|
+
status: GenerateCourseDescriptionsResult["status"]
|
|
86
|
+
): GenerateCourseDescriptionsResult {
|
|
87
|
+
return {
|
|
88
|
+
status,
|
|
89
|
+
steps: 0,
|
|
90
|
+
generated: 0,
|
|
91
|
+
failed: 0,
|
|
92
|
+
missing: 0,
|
|
93
|
+
errors: [],
|
|
94
|
+
durationsSeconds: [],
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Read every README of the course, keyed by exercise slug and language. */
|
|
99
|
+
export async function readCourseReadmes(
|
|
100
|
+
storage: CourseDescriptionsStorage,
|
|
101
|
+
courseSlug: string,
|
|
102
|
+
exercises: CourseExercise[],
|
|
103
|
+
concurrency: number
|
|
104
|
+
): Promise<CourseReadmes> {
|
|
105
|
+
const readmes: CourseReadmes = {}
|
|
106
|
+
|
|
107
|
+
const targets: Array<{ exerciseSlug: string; lang: string; file: string }> =
|
|
108
|
+
[]
|
|
109
|
+
for (const exercise of exercises) {
|
|
110
|
+
for (const [lang, file] of Object.entries(exercise.translations || {})) {
|
|
111
|
+
targets.push({ exerciseSlug: exercise.slug, lang, file })
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const contents = await mapWithConcurrency(targets, concurrency, target =>
|
|
116
|
+
storage.readReadme(courseSlug, target.exerciseSlug, target.file)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
for (const [index, target] of targets.entries()) {
|
|
120
|
+
const content = contents[index]
|
|
121
|
+
if (typeof content !== "string") {
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (!readmes[target.exerciseSlug]) {
|
|
126
|
+
readmes[target.exerciseSlug] = {}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
readmes[target.exerciseSlug][target.lang] = content
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return readmes
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Ensure the translation slot exists before writing a description into it. */
|
|
136
|
+
function ensureSlot(
|
|
137
|
+
lesson: Lesson,
|
|
138
|
+
lang: string
|
|
139
|
+
): NonNullable<Lesson["translations"]>[string] {
|
|
140
|
+
if (!lesson.translations) {
|
|
141
|
+
lesson.translations = {}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!lesson.translations[lang]) {
|
|
145
|
+
lesson.translations[lang] = { completionId: 0, startedAt: Date.now() }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return lesson.translations[lang]
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function applyStepResult(
|
|
152
|
+
step: StepWork,
|
|
153
|
+
result: TGenerateStepDescriptionsResult
|
|
154
|
+
): { generated: number; missing: number } {
|
|
155
|
+
let generated = 0
|
|
156
|
+
|
|
157
|
+
for (const language of step.languages) {
|
|
158
|
+
if (!(language.lang in result.descriptionsByLanguage)) {
|
|
159
|
+
// Never answered: leave the slot untouched so a later run retries it.
|
|
160
|
+
// Writing a null plus a fingerprint here would look settled forever.
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const slot = ensureSlot(step.lesson, language.lang)
|
|
165
|
+
if (slot.descriptionSource === "human") {
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const description = result.descriptionsByLanguage[language.lang]
|
|
170
|
+
slot.description = description
|
|
171
|
+
slot.descriptionStatus = description ? "generated" : "none"
|
|
172
|
+
slot.descriptionSource = "auto"
|
|
173
|
+
slot.descriptionPromptVersion = result.promptVersion
|
|
174
|
+
slot.sourceContentHash = language.fingerprint.sha256
|
|
175
|
+
slot.sourceSimHash = language.fingerprint.simhash
|
|
176
|
+
generated += 1
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return { generated, missing: result.missingLanguages.length }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function generateCourseDescriptions(
|
|
183
|
+
storage: CourseDescriptionsStorage,
|
|
184
|
+
courseSlug: string,
|
|
185
|
+
options: GenerateCourseDescriptionsOptions
|
|
186
|
+
): Promise<GenerateCourseDescriptionsResult> {
|
|
187
|
+
const concurrency = options.concurrency ?? DEFAULT_DESCRIPTIONS_CONCURRENCY
|
|
188
|
+
const generate = options.generate ?? generateStepDescriptions
|
|
189
|
+
|
|
190
|
+
const syllabus = await storage.readSyllabus(courseSlug)
|
|
191
|
+
if (!syllabus) {
|
|
192
|
+
return emptyResult("skipped")
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const exercises = await storage.listExercises(courseSlug)
|
|
196
|
+
if (exercises.length === 0) {
|
|
197
|
+
return emptyResult("skipped")
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (options.reconcile && storage.syllabusSyncStorage) {
|
|
201
|
+
try {
|
|
202
|
+
await synchronizeSyllabusWithBucket(
|
|
203
|
+
storage.syllabusSyncStorage,
|
|
204
|
+
courseSlug,
|
|
205
|
+
syllabus,
|
|
206
|
+
{ prune: false }
|
|
207
|
+
)
|
|
208
|
+
} catch (error) {
|
|
209
|
+
// Provisional patch: never let reconciliation block generation.
|
|
210
|
+
console.error(
|
|
211
|
+
`[descriptions] Reconciliation failed for "${courseSlug}":`,
|
|
212
|
+
(error as Error).message
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const readmes = await readCourseReadmes(
|
|
218
|
+
storage,
|
|
219
|
+
courseSlug,
|
|
220
|
+
exercises,
|
|
221
|
+
concurrency
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
const work = buildDescriptionWorkList({
|
|
225
|
+
syllabus,
|
|
226
|
+
exercises,
|
|
227
|
+
readmes,
|
|
228
|
+
promptVersion: options.promptVersion,
|
|
229
|
+
force: options.force,
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
if (work.length === 0) {
|
|
233
|
+
return emptyResult("skipped")
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const result = emptyResult("processed")
|
|
237
|
+
result.steps = work.length
|
|
238
|
+
|
|
239
|
+
if (options.dryRun) {
|
|
240
|
+
result.generated = countWorkItems(work)
|
|
241
|
+
return result
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// One completion per step, several in flight: a slow step never stalls the
|
|
245
|
+
// course and Rigobot is never hit with an unbounded burst.
|
|
246
|
+
const outcomes = await mapWithConcurrency(
|
|
247
|
+
work,
|
|
248
|
+
concurrency,
|
|
249
|
+
async step => ({
|
|
250
|
+
step,
|
|
251
|
+
generated: await generate(options.token, {
|
|
252
|
+
readmeContent: step.baseContent,
|
|
253
|
+
sourceLanguage: step.baseLanguage,
|
|
254
|
+
outputLanguages: step.languages.map(language => language.lang),
|
|
255
|
+
lessonTitle: step.lesson.title,
|
|
256
|
+
courseTitle: syllabus.courseInfo?.title,
|
|
257
|
+
targetWordCount: options.targetWordCount,
|
|
258
|
+
promptVersion: options.promptVersion,
|
|
259
|
+
}),
|
|
260
|
+
})
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
for (const outcome of outcomes) {
|
|
264
|
+
if (!outcome.generated) {
|
|
265
|
+
result.failed += 1
|
|
266
|
+
result.errors.push(
|
|
267
|
+
`generation failed for "${outcome.step.exerciseSlug}"`
|
|
268
|
+
)
|
|
269
|
+
continue
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (outcome.generated.durationSeconds !== null) {
|
|
273
|
+
result.durationsSeconds.push(outcome.generated.durationSeconds)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const applied = applyStepResult(outcome.step, outcome.generated)
|
|
277
|
+
result.generated += applied.generated
|
|
278
|
+
result.missing += applied.missing
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (result.generated === 0 && result.failed > 0) {
|
|
282
|
+
result.status = "failed"
|
|
283
|
+
return result
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
await storage.writeSyllabus(courseSlug, syllabus)
|
|
287
|
+
|
|
288
|
+
if (storage.reprojectManifest) {
|
|
289
|
+
try {
|
|
290
|
+
await storage.reprojectManifest(courseSlug)
|
|
291
|
+
} catch (error) {
|
|
292
|
+
// The syllabus (the source of truth) is already saved; a failed
|
|
293
|
+
// projection is recoverable and must not lose the generated text.
|
|
294
|
+
result.errors.push(
|
|
295
|
+
`manifest projection failed: ${(error as Error).message}`
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return result
|
|
301
|
+
}
|