@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.
Files changed (85) hide show
  1. package/lib/commands/publish.js +6 -0
  2. package/lib/commands/serve.js +85 -259
  3. package/lib/models/creator.d.ts +6 -0
  4. package/lib/scripts/descriptionsGcsBackfill.d.ts +1 -0
  5. package/lib/scripts/descriptionsGcsBackfill.js +141 -0
  6. package/lib/scripts/descriptionsS3Backfill.d.ts +1 -0
  7. package/lib/scripts/descriptionsS3Backfill.js +277 -0
  8. package/lib/scripts/descriptionsSweep.d.ts +1 -0
  9. package/lib/scripts/descriptionsSweep.js +142 -0
  10. package/lib/utils/api.d.ts +7 -0
  11. package/lib/utils/api.js +8 -1
  12. package/lib/utils/awsCredentials.d.ts +20 -0
  13. package/lib/utils/awsCredentials.js +43 -0
  14. package/lib/utils/creatorUtilities.js +2 -1
  15. package/lib/utils/descriptionHash.d.ts +66 -0
  16. package/lib/utils/descriptionHash.js +173 -0
  17. package/lib/utils/descriptions/backfillEvents.d.ts +60 -0
  18. package/lib/utils/descriptions/backfillEvents.js +107 -0
  19. package/lib/utils/descriptions/gcsStorage.d.ts +16 -0
  20. package/lib/utils/descriptions/gcsStorage.js +60 -0
  21. package/lib/utils/descriptions/generateCourseDescriptions.d.ts +66 -0
  22. package/lib/utils/descriptions/generateCourseDescriptions.js +176 -0
  23. package/lib/utils/descriptions/mirrorDescriptions.d.ts +49 -0
  24. package/lib/utils/descriptions/mirrorDescriptions.js +109 -0
  25. package/lib/utils/descriptions/publishStage.d.ts +69 -0
  26. package/lib/utils/descriptions/publishStage.js +245 -0
  27. package/lib/utils/descriptions/resumePublication.d.ts +36 -0
  28. package/lib/utils/descriptions/resumePublication.js +128 -0
  29. package/lib/utils/descriptions/s3Storage.d.ts +30 -0
  30. package/lib/utils/descriptions/s3Storage.js +141 -0
  31. package/lib/utils/descriptions/workList.d.ts +75 -0
  32. package/lib/utils/descriptions/workList.js +177 -0
  33. package/lib/utils/gcsBucketName.d.ts +10 -0
  34. package/lib/utils/gcsBucketName.js +19 -0
  35. package/lib/utils/packageManifest.d.ts +22 -0
  36. package/lib/utils/packageManifest.js +57 -5
  37. package/lib/utils/publishEvents.d.ts +66 -0
  38. package/lib/utils/publishEvents.js +111 -0
  39. package/lib/utils/publishJournal.d.ts +119 -0
  40. package/lib/utils/publishJournal.js +275 -0
  41. package/lib/utils/repair/legacyPackageRepair.d.ts +131 -0
  42. package/lib/utils/repair/legacyPackageRepair.js +492 -0
  43. package/lib/utils/repair/repairStorage.d.ts +68 -0
  44. package/lib/utils/repair/repairStorage.js +89 -0
  45. package/lib/utils/rigoActions.d.ts +44 -0
  46. package/lib/utils/rigoActions.js +75 -1
  47. package/lib/utils/s3/packageManifestBackfill.d.ts +2 -0
  48. package/lib/utils/s3/packageManifestBackfill.js +5 -8
  49. package/lib/utils/s3/packageSourcesAudit.d.ts +75 -0
  50. package/lib/utils/s3/packageSourcesAudit.js +184 -0
  51. package/lib/utils/syllabusSync.d.ts +71 -0
  52. package/lib/utils/syllabusSync.js +273 -0
  53. package/package.json +3 -1
  54. package/src/commands/publish.ts +7 -0
  55. package/src/commands/serve.ts +144 -335
  56. package/src/models/creator.ts +9 -0
  57. package/src/scripts/README.md +244 -0
  58. package/src/scripts/descriptionsGcsBackfill.ts +193 -0
  59. package/src/scripts/descriptionsS3Backfill.ts +376 -0
  60. package/src/scripts/descriptionsSweep.ts +185 -0
  61. package/src/ui/_app/app.css +1 -1
  62. package/src/ui/_app/app.js +142 -140
  63. package/src/ui/app.tar.gz +0 -0
  64. package/src/utils/api.ts +9 -0
  65. package/src/utils/awsCredentials.ts +57 -0
  66. package/src/utils/creatorUtilities.ts +2 -1
  67. package/src/utils/descriptionHash.ts +196 -0
  68. package/src/utils/descriptions/backfillEvents.ts +152 -0
  69. package/src/utils/descriptions/gcsStorage.ts +67 -0
  70. package/src/utils/descriptions/generateCourseDescriptions.ts +311 -0
  71. package/src/utils/descriptions/mirrorDescriptions.ts +191 -0
  72. package/src/utils/descriptions/publishStage.ts +394 -0
  73. package/src/utils/descriptions/resumePublication.ts +217 -0
  74. package/src/utils/descriptions/s3Storage.ts +214 -0
  75. package/src/utils/descriptions/workList.ts +283 -0
  76. package/src/utils/gcsBucketName.ts +19 -0
  77. package/src/utils/packageManifest.ts +62 -5
  78. package/src/utils/publishEvents.ts +181 -0
  79. package/src/utils/publishJournal.ts +383 -0
  80. package/src/utils/repair/legacyPackageRepair.ts +731 -0
  81. package/src/utils/repair/repairStorage.ts +168 -0
  82. package/src/utils/rigoActions.ts +130 -0
  83. package/src/utils/s3/packageManifestBackfill.ts +771 -776
  84. package/src/utils/s3/packageSourcesAudit.ts +311 -0
  85. package/src/utils/syllabusSync.ts +390 -0
@@ -0,0 +1,168 @@
1
+ import { Bucket } from "@google-cloud/storage"
2
+ import { PutObjectCommand } from "@aws-sdk/client-s3"
3
+ import {
4
+ AwsClient,
5
+ fetchJsonObject,
6
+ fetchTextObject,
7
+ listObjectKeys,
8
+ withRetry,
9
+ } from "../s3/packageManifestBackfill"
10
+
11
+ /**
12
+ * The two buckets a package lives in, behind one interface.
13
+ *
14
+ * A legacy package is repaired **twice, independently** — once against the
15
+ * published snapshot in S3 and once against the draft in GCS — never by copying
16
+ * one into the other. The two hold different content (the draft keeps moving
17
+ * after a publication) and the synthesized structure is a projection of that
18
+ * content: a syllabus derived from the snapshot could name steps the draft does
19
+ * not have. This is the same rule `mirrorDescriptions` enforces with its content
20
+ * hash; deriving from each bucket needs no guard at all.
21
+ *
22
+ * What crosses between them is only the descriptions, through
23
+ * `descriptionsS3Backfill --mirror-draft`, which is hash-guarded per step.
24
+ */
25
+
26
+ export type RepairTarget = "s3" | "gcs";
27
+
28
+ export const ALL_REPAIR_TARGETS: RepairTarget[] = ["s3", "gcs"]
29
+
30
+ /**
31
+ * Where each input lives. The two buckets do not agree on this, and the
32
+ * disagreement is load-bearing:
33
+ *
34
+ * - S3 reads `{slug}/config.json` first (`fetchPackageSources`), which is where
35
+ * the publish writes it into the zip.
36
+ * - GCS reads `courses/{slug}/.learn/config.json` — the publish route downloads
37
+ * that one before anything else, so a config written anywhere else in the
38
+ * draft would not make the package publishable.
39
+ */
40
+ export type PackageLayout = {
41
+ learnJsonKey(slug: string): string;
42
+ /** Config locations in resolution order; the first is the write target. */
43
+ configKeys(slug: string): string[];
44
+ syllabusKey(slug: string): string;
45
+ /** Sidebar locations in resolution order; the first is the write target. */
46
+ sidebarKeys(slug: string): string[];
47
+ exercisesPrefix(slug: string): string;
48
+ /**
49
+ * Whether `config.json` is the authority on the exercise list, or the folder
50
+ * listing is.
51
+ *
52
+ * This decides the **language keys** of the synthesized syllabus, and the two
53
+ * buckets genuinely disagree:
54
+ *
55
+ * - S3 reads the published `config.json` (`fetchPackageSources`), where these
56
+ * legacy packages carry `us` for the unsuffixed README.
57
+ * - GCS has no such reader. Both the publish-time manifest and the
58
+ * descriptions pipeline go through `configBuilder.buildConfig`, which
59
+ * derives exercises from the listing and maps an unsuffixed `README.md` to
60
+ * `en` — regardless of what the draft's `.learn/config.json` says.
61
+ *
62
+ * A syllabus keyed the other way is not read at all: the manifest would look
63
+ * up `translations["en"]`, find nothing, and publish `description: null` for
64
+ * every step. So each side is built the way its own reader will read it.
65
+ */
66
+ exercisesFromConfig: boolean;
67
+ };
68
+
69
+ export type RepairStorage = {
70
+ target: RepairTarget;
71
+ layout: PackageLayout;
72
+ readJson<T>(key: string): Promise<T | null>;
73
+ readText(key: string): Promise<string | null>;
74
+ listKeys(prefix: string): Promise<string[]>;
75
+ writeJson(key: string, value: unknown): Promise<void>;
76
+ };
77
+
78
+ export const s3Layout: PackageLayout = {
79
+ learnJsonKey: slug => `${slug}/learn.json`,
80
+ configKeys: slug => [`${slug}/config.json`, `${slug}/.learn/config.json`],
81
+ syllabusKey: slug => `${slug}/.learn/initialSyllabus.json`,
82
+ // 25 of the published packages keep their sidebar at the root. It has to be
83
+ // resolved before writing: `fetchPackageSources` prefers the `.learn/` copy,
84
+ // so a synthesized one would shadow the published titles.
85
+ sidebarKeys: slug => [
86
+ `${slug}/.learn/sidebar.json`,
87
+ `${slug}/sidebar.json`,
88
+ ],
89
+ exercisesPrefix: slug => `${slug}/exercises/`,
90
+ exercisesFromConfig: true,
91
+ }
92
+
93
+ export const gcsLayout: PackageLayout = {
94
+ learnJsonKey: slug => `courses/${slug}/learn.json`,
95
+ configKeys: slug => [
96
+ `courses/${slug}/.learn/config.json`,
97
+ `courses/${slug}/config.json`,
98
+ ],
99
+ syllabusKey: slug => `courses/${slug}/.learn/initialSyllabus.json`,
100
+ sidebarKeys: slug => [`courses/${slug}/.learn/sidebar.json`],
101
+ exercisesPrefix: slug => `courses/${slug}/exercises/`,
102
+ exercisesFromConfig: false,
103
+ }
104
+
105
+ export function createS3RepairStorage(
106
+ s3: AwsClient,
107
+ bucket: string
108
+ ): RepairStorage {
109
+ return {
110
+ target: "s3",
111
+ layout: s3Layout,
112
+ readJson: key => fetchJsonObject(s3, bucket, key),
113
+ readText: key => fetchTextObject(s3, bucket, key),
114
+ listKeys: prefix => listObjectKeys(s3, bucket, prefix),
115
+ async writeJson(key, value) {
116
+ await withRetry(() =>
117
+ s3.send(
118
+ new PutObjectCommand({
119
+ Bucket: bucket,
120
+ Key: key,
121
+ Body: JSON.stringify(value, null, 2),
122
+ ContentType: "application/json",
123
+ })
124
+ )
125
+ )
126
+ },
127
+ }
128
+ }
129
+
130
+ export function createGcsRepairStorage(bucket: Bucket): RepairStorage {
131
+ const download = async (key: string): Promise<string | null> => {
132
+ try {
133
+ const [buf] = await bucket.file(key).download()
134
+ return buf.toString()
135
+ } catch {
136
+ return null
137
+ }
138
+ }
139
+
140
+ return {
141
+ target: "gcs",
142
+ layout: gcsLayout,
143
+ async readJson<T>(key: string): Promise<T | null> {
144
+ const content = await download(key)
145
+ if (content === null) {
146
+ return null
147
+ }
148
+
149
+ try {
150
+ return JSON.parse(content) as T
151
+ } catch {
152
+ return null
153
+ }
154
+ },
155
+ readText: download,
156
+ async listKeys(prefix) {
157
+ const [files] = await bucket.getFiles({ prefix })
158
+ return files.map(file => file.name)
159
+ },
160
+ async writeJson(key, value) {
161
+ await bucket
162
+ .file(key)
163
+ .save(Buffer.from(JSON.stringify(value, null, 2), "utf8"), {
164
+ contentType: "application/json",
165
+ })
166
+ },
167
+ }
168
+ }
@@ -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;