@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,383 @@
|
|
|
1
|
+
import { Bucket } from "@google-cloud/storage"
|
|
2
|
+
import { v4 as uuidv4 } from "uuid"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Publish journal — per-stage state of a publication.
|
|
6
|
+
*
|
|
7
|
+
* Publishing is a distributed transaction across three systems (S3 via Rigobot,
|
|
8
|
+
* breathecode assets, and our own buckets) plus an asynchronous description
|
|
9
|
+
* stage. Some of its outcomes are NOT derivable from content afterwards: in
|
|
10
|
+
* particular "was the manifest-updated event already emitted?" leaves no trace
|
|
11
|
+
* in S3 or GCS. The journal records what happened per stage so the sweep can
|
|
12
|
+
* resume an interrupted publication and honour the event contract (a publish
|
|
13
|
+
* that announced `descriptions: "queued"` must always be followed by exactly
|
|
14
|
+
* one `package_manifest_updated`).
|
|
15
|
+
*
|
|
16
|
+
* Design notes:
|
|
17
|
+
*
|
|
18
|
+
* - **Best effort, never a lock.** Every write swallows its errors: if the
|
|
19
|
+
* journal cannot be written the publication still proceeds. Losing the
|
|
20
|
+
* journal degrades us to the previous behaviour (no recovery, no diagnosis),
|
|
21
|
+
* which is acceptable; blocking a publication because a bookkeeping write
|
|
22
|
+
* failed is not.
|
|
23
|
+
* - **Flat key space.** Journals live in `publish-journal/{slug}__{id}.json`,
|
|
24
|
+
* a sibling prefix of `courses/`, so the sweep can list every unfinished
|
|
25
|
+
* publication with one prefix query and the concurrency guard can list a
|
|
26
|
+
* single course with another, without maintaining an index.
|
|
27
|
+
* - **Explicit cleanup.** A journal is deleted once every stage succeeded, and
|
|
28
|
+
* kept when any stage failed (visibility + retry). GCS lifecycle rules would
|
|
29
|
+
* be the natural alternative, but the service account only holds
|
|
30
|
+
* object-level permissions (`storage.buckets.*` is denied), so the cleanup
|
|
31
|
+
* has to happen in code.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
export const JOURNAL_PREFIX = "publish-journal/"
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Stages of a publication, in execution order.
|
|
38
|
+
*
|
|
39
|
+
* Only stages the orchestrator can observe on its own: writing the asset id
|
|
40
|
+
* back into Rigobot happens inside the asset sync call, so it is covered by
|
|
41
|
+
* `assetsSync` rather than being a stage that could never be marked.
|
|
42
|
+
*/
|
|
43
|
+
export const STAGE_NAMES = [
|
|
44
|
+
"deploy",
|
|
45
|
+
"assetsSync",
|
|
46
|
+
"publishedEvent",
|
|
47
|
+
"descriptions",
|
|
48
|
+
"gcsMirror",
|
|
49
|
+
"manifestEvent",
|
|
50
|
+
] as const
|
|
51
|
+
|
|
52
|
+
export type StageName = typeof STAGE_NAMES[number];
|
|
53
|
+
|
|
54
|
+
export type StageStatus = "pending" | "running" | "done" | "failed";
|
|
55
|
+
|
|
56
|
+
export type Stage = {
|
|
57
|
+
status: StageStatus;
|
|
58
|
+
at?: string;
|
|
59
|
+
error?: string;
|
|
60
|
+
meta?: unknown;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type PublishJournal = {
|
|
64
|
+
publishId: string;
|
|
65
|
+
courseSlug: string;
|
|
66
|
+
startedAt: string;
|
|
67
|
+
updatedAt: string;
|
|
68
|
+
/** Times the sweep has picked this journal up, to bound retries. */
|
|
69
|
+
attempts: number;
|
|
70
|
+
/** Set once retries are exhausted: kept for a human, ignored by the sweep. */
|
|
71
|
+
abandoned?: boolean;
|
|
72
|
+
stages: Record<StageName, Stage>;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export type JournalRef = {
|
|
76
|
+
courseSlug: string;
|
|
77
|
+
publishId: string;
|
|
78
|
+
key: string;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Minimal storage surface the journal needs, so callers can pass the real
|
|
83
|
+
* bucket adapter or an in-memory double. Object-level operations only.
|
|
84
|
+
*/
|
|
85
|
+
export interface JournalStorage {
|
|
86
|
+
save(key: string, contents: string): Promise<void>;
|
|
87
|
+
read(key: string): Promise<string | null>;
|
|
88
|
+
remove(key: string): Promise<void>;
|
|
89
|
+
list(prefix: string): Promise<string[]>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function journalKey(courseSlug: string, publishId: string): string {
|
|
93
|
+
return `${JOURNAL_PREFIX}${courseSlug}__${publishId}.json`
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function coursePrefix(courseSlug: string): string {
|
|
97
|
+
return `${JOURNAL_PREFIX}${courseSlug}__`
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Inverse of `journalKey`; returns null for keys that don't match the shape. */
|
|
101
|
+
export function parseJournalKey(key: string): JournalRef | null {
|
|
102
|
+
if (!key.startsWith(JOURNAL_PREFIX) || !key.endsWith(".json")) {
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const name = key.slice(JOURNAL_PREFIX.length, -".json".length)
|
|
107
|
+
const separator = name.indexOf("__")
|
|
108
|
+
if (separator <= 0) {
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const courseSlug = name.slice(0, separator)
|
|
113
|
+
const publishId = name.slice(separator + 2)
|
|
114
|
+
if (!courseSlug || !publishId) {
|
|
115
|
+
return null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { courseSlug, publishId, key }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function emptyStages(): Record<StageName, Stage> {
|
|
122
|
+
const stages = {} as Record<StageName, Stage>
|
|
123
|
+
for (const name of STAGE_NAMES) {
|
|
124
|
+
stages[name] = { status: "pending" }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return stages
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Persist the journal. Never throws: bookkeeping must not break a publish. */
|
|
131
|
+
async function persist(
|
|
132
|
+
storage: JournalStorage,
|
|
133
|
+
journal: PublishJournal
|
|
134
|
+
): Promise<void> {
|
|
135
|
+
try {
|
|
136
|
+
await storage.save(
|
|
137
|
+
journalKey(journal.courseSlug, journal.publishId),
|
|
138
|
+
JSON.stringify(journal, null, 2)
|
|
139
|
+
)
|
|
140
|
+
} catch (error) {
|
|
141
|
+
console.warn(
|
|
142
|
+
`[publish-journal] Could not persist journal for "${journal.courseSlug}":`,
|
|
143
|
+
(error as Error).message
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Start a journal for a new publication. The generated `publishId` correlates
|
|
150
|
+
* the two publish events and is returned even if persisting failed.
|
|
151
|
+
*/
|
|
152
|
+
export async function createJournal(
|
|
153
|
+
storage: JournalStorage,
|
|
154
|
+
courseSlug: string,
|
|
155
|
+
publishId: string = uuidv4()
|
|
156
|
+
): Promise<PublishJournal> {
|
|
157
|
+
const now = new Date().toISOString()
|
|
158
|
+
const journal: PublishJournal = {
|
|
159
|
+
publishId,
|
|
160
|
+
courseSlug,
|
|
161
|
+
startedAt: now,
|
|
162
|
+
updatedAt: now,
|
|
163
|
+
attempts: 0,
|
|
164
|
+
stages: emptyStages(),
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
await persist(storage, journal)
|
|
168
|
+
return journal
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Update one stage and persist. Mutates and returns the same journal object. */
|
|
172
|
+
export async function markStage(
|
|
173
|
+
storage: JournalStorage,
|
|
174
|
+
journal: PublishJournal,
|
|
175
|
+
stage: StageName,
|
|
176
|
+
status: StageStatus,
|
|
177
|
+
extra: { error?: string; meta?: unknown } = {}
|
|
178
|
+
): Promise<PublishJournal> {
|
|
179
|
+
const next: Stage = { status, at: new Date().toISOString() }
|
|
180
|
+
if (extra.error !== undefined) {
|
|
181
|
+
next.error = extra.error
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (extra.meta !== undefined) {
|
|
185
|
+
next.meta = extra.meta
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
journal.stages[stage] = next
|
|
189
|
+
journal.updatedAt = next.at as string
|
|
190
|
+
await persist(storage, journal)
|
|
191
|
+
return journal
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Read a journal. Returns null when missing or unreadable. */
|
|
195
|
+
export async function readJournal(
|
|
196
|
+
storage: JournalStorage,
|
|
197
|
+
courseSlug: string,
|
|
198
|
+
publishId: string
|
|
199
|
+
): Promise<PublishJournal | null> {
|
|
200
|
+
return readJournalByKey(storage, journalKey(courseSlug, publishId))
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function readJournalByKey(
|
|
204
|
+
storage: JournalStorage,
|
|
205
|
+
key: string
|
|
206
|
+
): Promise<PublishJournal | null> {
|
|
207
|
+
try {
|
|
208
|
+
const raw = await storage.read(key)
|
|
209
|
+
if (!raw) {
|
|
210
|
+
return null
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const parsed = JSON.parse(raw) as PublishJournal
|
|
214
|
+
if (!parsed || !parsed.publishId || !parsed.stages) {
|
|
215
|
+
return null
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Tolerate journals written before a stage was added to STAGE_NAMES.
|
|
219
|
+
for (const name of STAGE_NAMES) {
|
|
220
|
+
if (!parsed.stages[name]) {
|
|
221
|
+
parsed.stages[name] = { status: "pending" }
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return parsed
|
|
226
|
+
} catch (error) {
|
|
227
|
+
console.warn(
|
|
228
|
+
`[publish-journal] Could not read journal "${key}":`,
|
|
229
|
+
(error as Error).message
|
|
230
|
+
)
|
|
231
|
+
return null
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Every journal still stored, i.e. every publication that did not finish
|
|
237
|
+
* cleanly. Returns an empty list on error (the sweep falls back to its deep
|
|
238
|
+
* scan rather than crashing).
|
|
239
|
+
*/
|
|
240
|
+
export async function listJournalRefs(
|
|
241
|
+
storage: JournalStorage
|
|
242
|
+
): Promise<JournalRef[]> {
|
|
243
|
+
return listRefsByPrefix(storage, JOURNAL_PREFIX)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Journals of a single course; backs the concurrent-publish guard. */
|
|
247
|
+
export async function listJournalRefsForCourse(
|
|
248
|
+
storage: JournalStorage,
|
|
249
|
+
courseSlug: string
|
|
250
|
+
): Promise<JournalRef[]> {
|
|
251
|
+
return listRefsByPrefix(storage, coursePrefix(courseSlug))
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function listRefsByPrefix(
|
|
255
|
+
storage: JournalStorage,
|
|
256
|
+
prefix: string
|
|
257
|
+
): Promise<JournalRef[]> {
|
|
258
|
+
try {
|
|
259
|
+
const keys = await storage.list(prefix)
|
|
260
|
+
const refs: JournalRef[] = []
|
|
261
|
+
for (const key of keys) {
|
|
262
|
+
const ref = parseJournalKey(key)
|
|
263
|
+
if (ref) {
|
|
264
|
+
refs.push(ref)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return refs
|
|
269
|
+
} catch (error) {
|
|
270
|
+
console.warn(
|
|
271
|
+
`[publish-journal] Could not list journals "${prefix}":`,
|
|
272
|
+
(error as Error).message
|
|
273
|
+
)
|
|
274
|
+
return []
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function isStageTerminal(stage: Stage): boolean {
|
|
279
|
+
return stage.status === "done" || stage.status === "failed"
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function hasFailedStage(journal: PublishJournal): boolean {
|
|
283
|
+
return STAGE_NAMES.some(name => journal.stages[name].status === "failed")
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** True when no stage is left to run (all done, or done/failed mix). */
|
|
287
|
+
export function isJournalSettled(journal: PublishJournal): boolean {
|
|
288
|
+
return STAGE_NAMES.every(name => isStageTerminal(journal.stages[name]))
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Stages the sweep still has work to do on. */
|
|
292
|
+
export function pendingStages(journal: PublishJournal): StageName[] {
|
|
293
|
+
return STAGE_NAMES.filter(name => !isStageTerminal(journal.stages[name]))
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** A journal untouched for longer than `maxAgeMs` is presumed interrupted. */
|
|
297
|
+
export function isJournalStale(
|
|
298
|
+
journal: PublishJournal,
|
|
299
|
+
maxAgeMs: number,
|
|
300
|
+
now: number = Date.now()
|
|
301
|
+
): boolean {
|
|
302
|
+
const updatedAt = Date.parse(journal.updatedAt)
|
|
303
|
+
if (Number.isNaN(updatedAt)) {
|
|
304
|
+
return true
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return now - updatedAt > maxAgeMs
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export async function incrementAttempts(
|
|
311
|
+
storage: JournalStorage,
|
|
312
|
+
journal: PublishJournal
|
|
313
|
+
): Promise<PublishJournal> {
|
|
314
|
+
journal.attempts += 1
|
|
315
|
+
journal.updatedAt = new Date().toISOString()
|
|
316
|
+
await persist(storage, journal)
|
|
317
|
+
return journal
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Give up on a journal after too many attempts. It is kept, not deleted: a
|
|
322
|
+
* publication that never completed is exactly what someone needs to see.
|
|
323
|
+
*/
|
|
324
|
+
export async function abandonJournal(
|
|
325
|
+
storage: JournalStorage,
|
|
326
|
+
journal: PublishJournal
|
|
327
|
+
): Promise<PublishJournal> {
|
|
328
|
+
journal.abandoned = true
|
|
329
|
+
journal.updatedAt = new Date().toISOString()
|
|
330
|
+
await persist(storage, journal)
|
|
331
|
+
return journal
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Close a journal: delete it when every stage succeeded, keep it otherwise so
|
|
336
|
+
* the sweep can retry and a human can inspect it. Never throws.
|
|
337
|
+
*/
|
|
338
|
+
export async function finalizeJournal(
|
|
339
|
+
storage: JournalStorage,
|
|
340
|
+
journal: PublishJournal
|
|
341
|
+
): Promise<"deleted" | "kept"> {
|
|
342
|
+
if (!isJournalSettled(journal) || hasFailedStage(journal)) {
|
|
343
|
+
await persist(storage, journal)
|
|
344
|
+
return "kept"
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
try {
|
|
348
|
+
await storage.remove(journalKey(journal.courseSlug, journal.publishId))
|
|
349
|
+
return "deleted"
|
|
350
|
+
} catch (error) {
|
|
351
|
+
console.warn(
|
|
352
|
+
`[publish-journal] Could not delete journal for "${journal.courseSlug}":`,
|
|
353
|
+
(error as Error).message
|
|
354
|
+
)
|
|
355
|
+
return "kept"
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** GCS-backed adapter. Object-level operations only, by design. */
|
|
360
|
+
export function createGcsJournalStorage(bucket: Bucket): JournalStorage {
|
|
361
|
+
return {
|
|
362
|
+
async save(key, contents) {
|
|
363
|
+
await bucket.file(key).save(Buffer.from(contents, "utf8"), {
|
|
364
|
+
contentType: "application/json",
|
|
365
|
+
})
|
|
366
|
+
},
|
|
367
|
+
async read(key) {
|
|
368
|
+
try {
|
|
369
|
+
const [buf] = await bucket.file(key).download()
|
|
370
|
+
return buf.toString()
|
|
371
|
+
} catch {
|
|
372
|
+
return null
|
|
373
|
+
}
|
|
374
|
+
},
|
|
375
|
+
async remove(key) {
|
|
376
|
+
await bucket.file(key).delete()
|
|
377
|
+
},
|
|
378
|
+
async list(prefix) {
|
|
379
|
+
const [files] = await bucket.getFiles({ prefix })
|
|
380
|
+
return files.map(file => file.name)
|
|
381
|
+
},
|
|
382
|
+
}
|
|
383
|
+
}
|
package/src/utils/rigoActions.ts
CHANGED
|
@@ -5,6 +5,11 @@ import { PackageInfo } from "./creatorUtilities"
|
|
|
5
5
|
import * as fs from "fs"
|
|
6
6
|
import * as path from "path"
|
|
7
7
|
import { RIGOBOT_HOST } from "./api"
|
|
8
|
+
import { stripMarkdownImages } from "./descriptionHash"
|
|
9
|
+
import {
|
|
10
|
+
DESCRIPTION_PROMPT_VERSION,
|
|
11
|
+
DESCRIPTION_TARGET_WORD_COUNT,
|
|
12
|
+
} from "./packageManifest"
|
|
8
13
|
|
|
9
14
|
type TCreateReadmeInputs = {
|
|
10
15
|
title: string;
|
|
@@ -542,6 +547,131 @@ export const initialContentGenerator = async (
|
|
|
542
547
|
}
|
|
543
548
|
}
|
|
544
549
|
|
|
550
|
+
// Step description generation. Populates initialSyllabus, which is then
|
|
551
|
+
// projected into the package-manifest. Contract (as built) in
|
|
552
|
+
// docs/rigobot-descriptions-contrato.md.
|
|
553
|
+
|
|
554
|
+
export type TGenerateStepDescriptionsParams = {
|
|
555
|
+
/** README of the step, in `sourceLanguage`. Images are stripped before sending. */
|
|
556
|
+
readmeContent: string;
|
|
557
|
+
sourceLanguage: string;
|
|
558
|
+
/** Every language to produce; may or may not include `sourceLanguage`. */
|
|
559
|
+
outputLanguages: string[];
|
|
560
|
+
lessonTitle: string;
|
|
561
|
+
courseTitle?: string;
|
|
562
|
+
targetWordCount?: number;
|
|
563
|
+
promptVersion?: number;
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
export type TGenerateStepDescriptionsResult = {
|
|
567
|
+
/**
|
|
568
|
+
* Descriptions keyed by the `language_code` Rigobot answered with — building
|
|
569
|
+
* this record IS the reassociation, the response order is not contractual.
|
|
570
|
+
* A `null` value is the deliberate fallback for content too thin to describe.
|
|
571
|
+
*/
|
|
572
|
+
descriptionsByLanguage: Record<string, string | null>;
|
|
573
|
+
/** Requested languages absent from the response (a protocol problem, not a
|
|
574
|
+
* "no description" answer: callers should retry these rather than storing a
|
|
575
|
+
* null that would look settled). */
|
|
576
|
+
missingLanguages: string[];
|
|
577
|
+
promptVersion: number;
|
|
578
|
+
/** Seconds the completion took; worth logging to watch the 30s Heroku edge. */
|
|
579
|
+
durationSeconds: number | null;
|
|
580
|
+
completionId: number | null;
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Generate one short description per language for a single step.
|
|
585
|
+
*
|
|
586
|
+
* One request per step: the eye-catching saving is on the *languages* axis (one
|
|
587
|
+
* README in, N descriptions out, ~67% fewer input tokens for a typical course),
|
|
588
|
+
* while batching several steps together would save nothing and would cost cache
|
|
589
|
+
* granularity and partial results.
|
|
590
|
+
*
|
|
591
|
+
* Synchronous by design (`execute_async: false`): the caller owns the write, so
|
|
592
|
+
* every flow — the post-publish stage, the sweep and the backfill — persists
|
|
593
|
+
* its own results instead of relying on a callback. Callers throttle with a
|
|
594
|
+
* concurrency pool.
|
|
595
|
+
*
|
|
596
|
+
* All inputs travel as strings: Rigobot fills the template with plain string
|
|
597
|
+
* replacement and a non-string raises before reaching the LLM.
|
|
598
|
+
*/
|
|
599
|
+
export const generateStepDescriptions = async (
|
|
600
|
+
token: string,
|
|
601
|
+
params: TGenerateStepDescriptionsParams,
|
|
602
|
+
endpointSlug = "generate-step-descriptions"
|
|
603
|
+
): Promise<TGenerateStepDescriptionsResult | null> => {
|
|
604
|
+
const promptVersion = params.promptVersion ?? DESCRIPTION_PROMPT_VERSION
|
|
605
|
+
const targetWordCount =
|
|
606
|
+
params.targetWordCount ?? DESCRIPTION_TARGET_WORD_COUNT
|
|
607
|
+
|
|
608
|
+
const inputs = {
|
|
609
|
+
// Images never help a description and their alt text may be a whole
|
|
610
|
+
// image-generation prompt; markdown structure is kept, it helps the model.
|
|
611
|
+
readme_content: stripMarkdownImages(params.readmeContent),
|
|
612
|
+
source_language: params.sourceLanguage,
|
|
613
|
+
output_languages: params.outputLanguages.join(","),
|
|
614
|
+
lesson_title: params.lessonTitle,
|
|
615
|
+
course_title: params.courseTitle ?? "",
|
|
616
|
+
target_word_count: String(targetWordCount),
|
|
617
|
+
prompt_version: String(promptVersion),
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
try {
|
|
621
|
+
const response = await axios.post(
|
|
622
|
+
`${RIGOBOT_HOST}/v1/prompting/completion/${endpointSlug}/`,
|
|
623
|
+
{ inputs, execute_async: false },
|
|
624
|
+
{
|
|
625
|
+
headers: {
|
|
626
|
+
"Content-Type": "application/json",
|
|
627
|
+
Authorization: "Token " + token.trim(),
|
|
628
|
+
},
|
|
629
|
+
}
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
const data = response.data
|
|
633
|
+
if (!data || data.status === "ERROR") {
|
|
634
|
+
console.error(
|
|
635
|
+
"Error in generateStepDescriptions:",
|
|
636
|
+
data?.status_text || "completion returned ERROR"
|
|
637
|
+
)
|
|
638
|
+
return null
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const entries = Array.isArray(data.parsed?.descriptions) ?
|
|
642
|
+
data.parsed.descriptions :
|
|
643
|
+
[]
|
|
644
|
+
|
|
645
|
+
const descriptionsByLanguage: Record<string, string | null> = {}
|
|
646
|
+
for (const entry of entries) {
|
|
647
|
+
const code = entry?.language_code
|
|
648
|
+
if (typeof code !== "string" || !code) {
|
|
649
|
+
continue
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
descriptionsByLanguage[code] =
|
|
653
|
+
typeof entry.description === "string" ? entry.description : null
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const echoedVersion = Number.parseInt(data.inputs?.prompt_version, 10)
|
|
657
|
+
|
|
658
|
+
return {
|
|
659
|
+
descriptionsByLanguage,
|
|
660
|
+
missingLanguages: params.outputLanguages.filter(
|
|
661
|
+
lang => !(lang in descriptionsByLanguage)
|
|
662
|
+
),
|
|
663
|
+
promptVersion: Number.isFinite(echoedVersion) ?
|
|
664
|
+
echoedVersion :
|
|
665
|
+
promptVersion,
|
|
666
|
+
durationSeconds: typeof data.duration === "number" ? data.duration : null,
|
|
667
|
+
completionId: typeof data.id === "number" ? data.id : null,
|
|
668
|
+
}
|
|
669
|
+
} catch (error) {
|
|
670
|
+
console.error("Error in generateStepDescriptions:", error)
|
|
671
|
+
return null
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
545
675
|
type TAddInteractivityInputs = {
|
|
546
676
|
components: string;
|
|
547
677
|
prev_lesson: string;
|