@learnpack/learnpack 5.0.352 → 5.0.354
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +277 -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/awsCredentials.d.ts +20 -0
- package/lib/utils/awsCredentials.js +43 -0
- 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/backfillEvents.d.ts +60 -0
- package/lib/utils/descriptions/backfillEvents.js +107 -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 +66 -0
- package/lib/utils/descriptions/generateCourseDescriptions.js +176 -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 +245 -0
- package/lib/utils/descriptions/resumePublication.d.ts +36 -0
- package/lib/utils/descriptions/resumePublication.js +128 -0
- package/lib/utils/descriptions/s3Storage.d.ts +30 -0
- package/lib/utils/descriptions/s3Storage.js +141 -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 +22 -0
- package/lib/utils/packageManifest.js +57 -5
- 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/repair/legacyPackageRepair.d.ts +131 -0
- package/lib/utils/repair/legacyPackageRepair.js +492 -0
- package/lib/utils/repair/repairStorage.d.ts +68 -0
- package/lib/utils/repair/repairStorage.js +89 -0
- package/lib/utils/rigoActions.d.ts +44 -0
- package/lib/utils/rigoActions.js +75 -1
- package/lib/utils/s3/packageManifestBackfill.d.ts +2 -0
- package/lib/utils/s3/packageManifestBackfill.js +5 -8
- package/lib/utils/s3/packageSourcesAudit.d.ts +75 -0
- package/lib/utils/s3/packageSourcesAudit.js +184 -0
- package/lib/utils/syllabusSync.d.ts +71 -0
- package/lib/utils/syllabusSync.js +273 -0
- package/package.json +3 -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/README.md +244 -0
- package/src/scripts/descriptionsGcsBackfill.ts +193 -0
- package/src/scripts/descriptionsS3Backfill.ts +376 -0
- package/src/scripts/descriptionsSweep.ts +185 -0
- package/src/ui/_app/app.css +1 -1
- package/src/ui/_app/app.js +142 -140
- package/src/ui/app.tar.gz +0 -0
- package/src/utils/api.ts +9 -0
- package/src/utils/awsCredentials.ts +57 -0
- package/src/utils/creatorUtilities.ts +2 -1
- package/src/utils/descriptionHash.ts +196 -0
- package/src/utils/descriptions/backfillEvents.ts +152 -0
- package/src/utils/descriptions/gcsStorage.ts +67 -0
- package/src/utils/descriptions/generateCourseDescriptions.ts +311 -0
- package/src/utils/descriptions/mirrorDescriptions.ts +191 -0
- package/src/utils/descriptions/publishStage.ts +394 -0
- package/src/utils/descriptions/resumePublication.ts +217 -0
- package/src/utils/descriptions/s3Storage.ts +214 -0
- package/src/utils/descriptions/workList.ts +283 -0
- package/src/utils/gcsBucketName.ts +19 -0
- package/src/utils/packageManifest.ts +62 -5
- package/src/utils/publishEvents.ts +181 -0
- package/src/utils/publishJournal.ts +383 -0
- package/src/utils/repair/legacyPackageRepair.ts +731 -0
- package/src/utils/repair/repairStorage.ts +168 -0
- package/src/utils/rigoActions.ts +130 -0
- package/src/utils/s3/packageManifestBackfill.ts +771 -776
- package/src/utils/s3/packageSourcesAudit.ts +311 -0
- package/src/utils/syllabusSync.ts +390 -0
|
@@ -0,0 +1,311 @@
|
|
|
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
|
+
* Whether `package-manifest.json` was re-projected; null when the storage does
|
|
84
|
+
* not project at all. A failed projection is not a failed run — the syllabus,
|
|
85
|
+
* which is the source of truth, is already saved — but callers that announce
|
|
86
|
+
* the manifest downstream must not report success on it.
|
|
87
|
+
*/
|
|
88
|
+
manifestProjected: boolean | null;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
function emptyResult(
|
|
92
|
+
status: GenerateCourseDescriptionsResult["status"]
|
|
93
|
+
): GenerateCourseDescriptionsResult {
|
|
94
|
+
return {
|
|
95
|
+
status,
|
|
96
|
+
steps: 0,
|
|
97
|
+
generated: 0,
|
|
98
|
+
failed: 0,
|
|
99
|
+
missing: 0,
|
|
100
|
+
errors: [],
|
|
101
|
+
durationsSeconds: [],
|
|
102
|
+
manifestProjected: null,
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Read every README of the course, keyed by exercise slug and language. */
|
|
107
|
+
export async function readCourseReadmes(
|
|
108
|
+
storage: CourseDescriptionsStorage,
|
|
109
|
+
courseSlug: string,
|
|
110
|
+
exercises: CourseExercise[],
|
|
111
|
+
concurrency: number
|
|
112
|
+
): Promise<CourseReadmes> {
|
|
113
|
+
const readmes: CourseReadmes = {}
|
|
114
|
+
|
|
115
|
+
const targets: Array<{ exerciseSlug: string; lang: string; file: string }> =
|
|
116
|
+
[]
|
|
117
|
+
for (const exercise of exercises) {
|
|
118
|
+
for (const [lang, file] of Object.entries(exercise.translations || {})) {
|
|
119
|
+
targets.push({ exerciseSlug: exercise.slug, lang, file })
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const contents = await mapWithConcurrency(targets, concurrency, target =>
|
|
124
|
+
storage.readReadme(courseSlug, target.exerciseSlug, target.file)
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
for (const [index, target] of targets.entries()) {
|
|
128
|
+
const content = contents[index]
|
|
129
|
+
if (typeof content !== "string") {
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!readmes[target.exerciseSlug]) {
|
|
134
|
+
readmes[target.exerciseSlug] = {}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
readmes[target.exerciseSlug][target.lang] = content
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return readmes
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Ensure the translation slot exists before writing a description into it. */
|
|
144
|
+
function ensureSlot(
|
|
145
|
+
lesson: Lesson,
|
|
146
|
+
lang: string
|
|
147
|
+
): NonNullable<Lesson["translations"]>[string] {
|
|
148
|
+
if (!lesson.translations) {
|
|
149
|
+
lesson.translations = {}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!lesson.translations[lang]) {
|
|
153
|
+
lesson.translations[lang] = { completionId: 0, startedAt: Date.now() }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return lesson.translations[lang]
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function applyStepResult(
|
|
160
|
+
step: StepWork,
|
|
161
|
+
result: TGenerateStepDescriptionsResult
|
|
162
|
+
): { generated: number; missing: number } {
|
|
163
|
+
let generated = 0
|
|
164
|
+
|
|
165
|
+
for (const language of step.languages) {
|
|
166
|
+
if (!(language.lang in result.descriptionsByLanguage)) {
|
|
167
|
+
// Never answered: leave the slot untouched so a later run retries it.
|
|
168
|
+
// Writing a null plus a fingerprint here would look settled forever.
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const slot = ensureSlot(step.lesson, language.lang)
|
|
173
|
+
if (slot.descriptionSource === "human") {
|
|
174
|
+
continue
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const description = result.descriptionsByLanguage[language.lang]
|
|
178
|
+
slot.description = description
|
|
179
|
+
slot.descriptionStatus = description ? "generated" : "none"
|
|
180
|
+
slot.descriptionSource = "auto"
|
|
181
|
+
slot.descriptionPromptVersion = result.promptVersion
|
|
182
|
+
slot.sourceContentHash = language.fingerprint.sha256
|
|
183
|
+
slot.sourceSimHash = language.fingerprint.simhash
|
|
184
|
+
generated += 1
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return { generated, missing: result.missingLanguages.length }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function generateCourseDescriptions(
|
|
191
|
+
storage: CourseDescriptionsStorage,
|
|
192
|
+
courseSlug: string,
|
|
193
|
+
options: GenerateCourseDescriptionsOptions
|
|
194
|
+
): Promise<GenerateCourseDescriptionsResult> {
|
|
195
|
+
const concurrency = options.concurrency ?? DEFAULT_DESCRIPTIONS_CONCURRENCY
|
|
196
|
+
const generate = options.generate ?? generateStepDescriptions
|
|
197
|
+
|
|
198
|
+
const syllabus = await storage.readSyllabus(courseSlug)
|
|
199
|
+
if (!syllabus) {
|
|
200
|
+
return emptyResult("skipped")
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const exercises = await storage.listExercises(courseSlug)
|
|
204
|
+
if (exercises.length === 0) {
|
|
205
|
+
return emptyResult("skipped")
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (options.reconcile && storage.syllabusSyncStorage) {
|
|
209
|
+
try {
|
|
210
|
+
await synchronizeSyllabusWithBucket(
|
|
211
|
+
storage.syllabusSyncStorage,
|
|
212
|
+
courseSlug,
|
|
213
|
+
syllabus,
|
|
214
|
+
{ prune: false }
|
|
215
|
+
)
|
|
216
|
+
} catch (error) {
|
|
217
|
+
// Provisional patch: never let reconciliation block generation.
|
|
218
|
+
console.error(
|
|
219
|
+
`[descriptions] Reconciliation failed for "${courseSlug}":`,
|
|
220
|
+
(error as Error).message
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const readmes = await readCourseReadmes(
|
|
226
|
+
storage,
|
|
227
|
+
courseSlug,
|
|
228
|
+
exercises,
|
|
229
|
+
concurrency
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
const work = buildDescriptionWorkList({
|
|
233
|
+
syllabus,
|
|
234
|
+
exercises,
|
|
235
|
+
readmes,
|
|
236
|
+
promptVersion: options.promptVersion,
|
|
237
|
+
force: options.force,
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
if (work.length === 0) {
|
|
241
|
+
return emptyResult("skipped")
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const result = emptyResult("processed")
|
|
245
|
+
result.steps = work.length
|
|
246
|
+
|
|
247
|
+
if (options.dryRun) {
|
|
248
|
+
result.generated = countWorkItems(work)
|
|
249
|
+
return result
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// One completion per step, several in flight: a slow step never stalls the
|
|
253
|
+
// course and Rigobot is never hit with an unbounded burst.
|
|
254
|
+
const outcomes = await mapWithConcurrency(
|
|
255
|
+
work,
|
|
256
|
+
concurrency,
|
|
257
|
+
async step => ({
|
|
258
|
+
step,
|
|
259
|
+
generated: await generate(options.token, {
|
|
260
|
+
readmeContent: step.baseContent,
|
|
261
|
+
sourceLanguage: step.baseLanguage,
|
|
262
|
+
outputLanguages: step.languages.map(language => language.lang),
|
|
263
|
+
lessonTitle: step.lesson.title,
|
|
264
|
+
courseTitle: syllabus.courseInfo?.title,
|
|
265
|
+
targetWordCount: options.targetWordCount,
|
|
266
|
+
promptVersion: options.promptVersion,
|
|
267
|
+
}),
|
|
268
|
+
})
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
for (const outcome of outcomes) {
|
|
272
|
+
if (!outcome.generated) {
|
|
273
|
+
result.failed += 1
|
|
274
|
+
result.errors.push(
|
|
275
|
+
`generation failed for "${outcome.step.exerciseSlug}"`
|
|
276
|
+
)
|
|
277
|
+
continue
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (outcome.generated.durationSeconds !== null) {
|
|
281
|
+
result.durationsSeconds.push(outcome.generated.durationSeconds)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const applied = applyStepResult(outcome.step, outcome.generated)
|
|
285
|
+
result.generated += applied.generated
|
|
286
|
+
result.missing += applied.missing
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (result.generated === 0 && result.failed > 0) {
|
|
290
|
+
result.status = "failed"
|
|
291
|
+
return result
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
await storage.writeSyllabus(courseSlug, syllabus)
|
|
295
|
+
|
|
296
|
+
if (storage.reprojectManifest) {
|
|
297
|
+
try {
|
|
298
|
+
await storage.reprojectManifest(courseSlug)
|
|
299
|
+
result.manifestProjected = true
|
|
300
|
+
} catch (error) {
|
|
301
|
+
// The syllabus (the source of truth) is already saved; a failed
|
|
302
|
+
// projection is recoverable and must not lose the generated text.
|
|
303
|
+
result.manifestProjected = false
|
|
304
|
+
result.errors.push(
|
|
305
|
+
`manifest projection failed: ${(error as Error).message}`
|
|
306
|
+
)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return result
|
|
311
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { Syllabus } from "../../models/creator"
|
|
2
|
+
import { fingerprintReadme } from "../descriptionHash"
|
|
3
|
+
import { DESCRIPTION_PROMPT_VERSION } from "../packageManifest"
|
|
4
|
+
import {
|
|
5
|
+
CourseDescriptionsStorage,
|
|
6
|
+
readCourseReadmes,
|
|
7
|
+
DEFAULT_DESCRIPTIONS_CONCURRENCY,
|
|
8
|
+
} from "./generateCourseDescriptions"
|
|
9
|
+
import { CourseExercise, CourseReadmes, findSyllabusLesson } from "./workList"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Copy descriptions from the published syllabus back into the draft one.
|
|
13
|
+
*
|
|
14
|
+
* Descriptions are generated against the published package because it is an
|
|
15
|
+
* immutable snapshot. The draft in GCS, meanwhile, keeps moving: between the
|
|
16
|
+
* publication and the moment the descriptions land, steps may have been added,
|
|
17
|
+
* removed, renamed, reordered or edited.
|
|
18
|
+
*
|
|
19
|
+
* The guard against that drift is the content hash, not a timestamp or a
|
|
20
|
+
* position: a description is copied only when the draft README hashes to the
|
|
21
|
+
* very content the description was generated from. Anything that moved simply
|
|
22
|
+
* misses and waits for the next publication — a stale description is never
|
|
23
|
+
* written onto content it does not describe.
|
|
24
|
+
*
|
|
25
|
+
* Without this mirror the draft would never hold a description, and every
|
|
26
|
+
* publication would regenerate the whole course from scratch: the freshness
|
|
27
|
+
* check reads the fingerprints stored in the syllabus that travels inside the
|
|
28
|
+
* zip.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
export type MirrorResult = {
|
|
32
|
+
/** (step, language) pairs copied into the draft. */
|
|
33
|
+
copied: number;
|
|
34
|
+
/** Pairs with no reusable description: content moved on, or none published. */
|
|
35
|
+
missed: number;
|
|
36
|
+
/** Pairs the draft already had up to date. */
|
|
37
|
+
fresh: number;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type MirrorOptions = {
|
|
41
|
+
promptVersion?: number;
|
|
42
|
+
dryRun?: boolean;
|
|
43
|
+
concurrency?: number;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function emptyMirrorResult(): MirrorResult {
|
|
47
|
+
return { copied: 0, missed: 0, fresh: 0 }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Pure core: writes into `targetSyllabus` in place. Callers own the I/O, which
|
|
52
|
+
* keeps the drift rules testable without a bucket.
|
|
53
|
+
*/
|
|
54
|
+
export function mirrorDescriptionsIntoSyllabus(input: {
|
|
55
|
+
sourceSyllabus: Syllabus | null;
|
|
56
|
+
targetSyllabus: Syllabus;
|
|
57
|
+
exercises: CourseExercise[];
|
|
58
|
+
/** READMEs of the TARGET (draft), which is what must match the hash. */
|
|
59
|
+
readmes: CourseReadmes;
|
|
60
|
+
promptVersion?: number;
|
|
61
|
+
}): MirrorResult {
|
|
62
|
+
const result = emptyMirrorResult()
|
|
63
|
+
if (!input.sourceSyllabus) {
|
|
64
|
+
return result
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const promptVersion = input.promptVersion ?? DESCRIPTION_PROMPT_VERSION
|
|
68
|
+
|
|
69
|
+
for (const exercise of input.exercises) {
|
|
70
|
+
const targetLesson = findSyllabusLesson(
|
|
71
|
+
input.targetSyllabus,
|
|
72
|
+
exercise.slug
|
|
73
|
+
)
|
|
74
|
+
if (!targetLesson) {
|
|
75
|
+
// The step is not in the draft (removed, or renamed after publishing).
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const sourceLesson = findSyllabusLesson(
|
|
80
|
+
input.sourceSyllabus,
|
|
81
|
+
exercise.slug
|
|
82
|
+
)
|
|
83
|
+
const exerciseReadmes = input.readmes[exercise.slug] || {}
|
|
84
|
+
|
|
85
|
+
for (const lang of Object.keys(exercise.translations || {})) {
|
|
86
|
+
const content = exerciseReadmes[lang]
|
|
87
|
+
if (typeof content !== "string") {
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const fingerprint = fingerprintReadme(content)
|
|
92
|
+
const targetSlot = targetLesson.translations?.[lang]
|
|
93
|
+
|
|
94
|
+
if (targetSlot?.descriptionSource === "human") {
|
|
95
|
+
result.fresh += 1
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (
|
|
100
|
+
targetSlot?.sourceContentHash === fingerprint.sha256 &&
|
|
101
|
+
(targetSlot?.descriptionPromptVersion ?? 0) >= promptVersion
|
|
102
|
+
) {
|
|
103
|
+
result.fresh += 1
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const sourceSlot = sourceLesson?.translations?.[lang]
|
|
108
|
+
// The hash guard: only reuse a description generated from exactly this
|
|
109
|
+
// content. Note a published `description: null` is reusable too — it is a
|
|
110
|
+
// real answer ("not enough content to describe"), not a missing one.
|
|
111
|
+
const reusable =
|
|
112
|
+
sourceSlot !== undefined &&
|
|
113
|
+
sourceSlot.sourceContentHash === fingerprint.sha256 &&
|
|
114
|
+
(sourceSlot.descriptionPromptVersion ?? 0) >= promptVersion &&
|
|
115
|
+
sourceSlot.descriptionStatus !== "error"
|
|
116
|
+
|
|
117
|
+
if (!reusable) {
|
|
118
|
+
result.missed += 1
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!targetLesson.translations) {
|
|
123
|
+
targetLesson.translations = {}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!targetLesson.translations[lang]) {
|
|
127
|
+
targetLesson.translations[lang] = {
|
|
128
|
+
completionId: 0,
|
|
129
|
+
startedAt: Date.now(),
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const destination = targetLesson.translations[lang]
|
|
134
|
+
destination.description = sourceSlot.description ?? null
|
|
135
|
+
destination.descriptionStatus = sourceSlot.description ?
|
|
136
|
+
"generated" :
|
|
137
|
+
"none"
|
|
138
|
+
destination.descriptionSource = "auto"
|
|
139
|
+
destination.descriptionPromptVersion =
|
|
140
|
+
sourceSlot.descriptionPromptVersion ?? promptVersion
|
|
141
|
+
destination.sourceContentHash = fingerprint.sha256
|
|
142
|
+
destination.sourceSimHash = fingerprint.simhash
|
|
143
|
+
result.copied += 1
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return result
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Read the draft, mirror into it, and save it when something changed. */
|
|
151
|
+
export async function mirrorDescriptionsToDraft(
|
|
152
|
+
storage: CourseDescriptionsStorage,
|
|
153
|
+
courseSlug: string,
|
|
154
|
+
sourceSyllabus: Syllabus | null,
|
|
155
|
+
options: MirrorOptions = {}
|
|
156
|
+
): Promise<MirrorResult> {
|
|
157
|
+
if (!sourceSyllabus) {
|
|
158
|
+
return emptyMirrorResult()
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const targetSyllabus = await storage.readSyllabus(courseSlug)
|
|
162
|
+
if (!targetSyllabus) {
|
|
163
|
+
return emptyMirrorResult()
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const exercises = await storage.listExercises(courseSlug)
|
|
167
|
+
if (exercises.length === 0) {
|
|
168
|
+
return emptyMirrorResult()
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const readmes = await readCourseReadmes(
|
|
172
|
+
storage,
|
|
173
|
+
courseSlug,
|
|
174
|
+
exercises,
|
|
175
|
+
options.concurrency ?? DEFAULT_DESCRIPTIONS_CONCURRENCY
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
const result = mirrorDescriptionsIntoSyllabus({
|
|
179
|
+
sourceSyllabus,
|
|
180
|
+
targetSyllabus,
|
|
181
|
+
exercises,
|
|
182
|
+
readmes,
|
|
183
|
+
promptVersion: options.promptVersion,
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
if (result.copied > 0 && !options.dryRun) {
|
|
187
|
+
await storage.writeSyllabus(courseSlug, targetSyllabus)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return result
|
|
191
|
+
}
|