@learnpack/learnpack 5.0.355 → 5.0.357

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 (120) hide show
  1. package/lib/commands/serve.d.ts +0 -17
  2. package/lib/commands/serve.js +159 -129
  3. package/lib/creatorDist/assets/{index-DnthLsvb.js → index-D6pmbMe9.js} +14030 -13903
  4. package/lib/creatorDist/assets/index-zrPponAn.css +1701 -0
  5. package/lib/creatorDist/index.html +2 -2
  6. package/lib/models/creator.d.ts +1 -1
  7. package/lib/services/ingest/adapters/gcsCourseStorage.d.ts +13 -0
  8. package/lib/services/ingest/adapters/gcsCourseStorage.js +58 -0
  9. package/lib/services/ingest/adapters/rigoUserTokenAuth.d.ts +15 -0
  10. package/lib/services/ingest/adapters/rigoUserTokenAuth.js +46 -0
  11. package/lib/services/ingest/adapters/rigobotPackageRegistry.d.ts +7 -0
  12. package/lib/services/ingest/adapters/rigobotPackageRegistry.js +119 -0
  13. package/lib/services/ingest/core/buildIngestConfig.d.ts +27 -0
  14. package/lib/services/ingest/core/buildIngestConfig.js +40 -0
  15. package/lib/services/ingest/core/buildInitialSyllabus.d.ts +24 -0
  16. package/lib/services/ingest/core/buildInitialSyllabus.js +104 -0
  17. package/lib/services/ingest/core/buildSidebar.d.ts +13 -0
  18. package/lib/services/ingest/core/buildSidebar.js +28 -0
  19. package/lib/services/ingest/core/coursePaths.d.ts +31 -0
  20. package/lib/services/ingest/core/coursePaths.js +50 -0
  21. package/lib/services/ingest/core/ingestCoursePackage.d.ts +52 -0
  22. package/lib/services/ingest/core/ingestCoursePackage.js +217 -0
  23. package/lib/services/ingest/core/normalizeLearnJson.d.ts +25 -0
  24. package/lib/services/ingest/core/normalizeLearnJson.js +29 -0
  25. package/lib/services/ingest/core/pairLessons.d.ts +29 -0
  26. package/lib/services/ingest/core/pairLessons.js +102 -0
  27. package/lib/services/ingest/core/planWrites.d.ts +44 -0
  28. package/lib/services/ingest/core/planWrites.js +59 -0
  29. package/lib/services/ingest/core/resolveSlug.d.ts +53 -0
  30. package/lib/services/ingest/core/resolveSlug.js +93 -0
  31. package/lib/services/ingest/core/types.d.ts +53 -0
  32. package/lib/services/ingest/core/types.js +2 -0
  33. package/lib/services/ingest/core/validatePackage.d.ts +38 -0
  34. package/lib/services/ingest/core/validatePackage.js +97 -0
  35. package/lib/services/ingest/createIngestService.d.ts +34 -0
  36. package/lib/services/ingest/createIngestService.js +38 -0
  37. package/lib/services/ingest/errors.d.ts +22 -0
  38. package/lib/services/ingest/errors.js +34 -0
  39. package/lib/services/ingest/http/errorMapping.d.ts +8 -0
  40. package/lib/services/ingest/http/errorMapping.js +40 -0
  41. package/lib/services/ingest/http/router.d.ts +32 -0
  42. package/lib/services/ingest/http/router.js +124 -0
  43. package/lib/services/ingest/http/zipPackageReader.d.ts +26 -0
  44. package/lib/services/ingest/http/zipPackageReader.js +62 -0
  45. package/lib/services/ingest/ports/courseStorage.d.ts +25 -0
  46. package/lib/services/ingest/ports/courseStorage.js +2 -0
  47. package/lib/services/ingest/ports/packageRegistry.d.ts +58 -0
  48. package/lib/services/ingest/ports/packageRegistry.js +2 -0
  49. package/lib/services/ingest/ports/requestAuthenticator.d.ts +25 -0
  50. package/lib/services/ingest/ports/requestAuthenticator.js +2 -0
  51. package/lib/utils/coursePackage/bucketIo.d.ts +23 -0
  52. package/lib/utils/coursePackage/bucketIo.js +36 -0
  53. package/lib/utils/coursePackage/learnJson.d.ts +26 -0
  54. package/lib/utils/coursePackage/learnJson.js +36 -0
  55. package/lib/utils/coursePackage/sidebar.d.ts +15 -0
  56. package/lib/utils/coursePackage/sidebar.js +26 -0
  57. package/lib/utils/creatorSocket.js +15 -21
  58. package/lib/utils/gcpCredentials.d.ts +18 -0
  59. package/lib/utils/gcpCredentials.js +27 -0
  60. package/lib/utils/rigoActions.d.ts +16 -0
  61. package/lib/utils/rigoActions.js +102 -1
  62. package/lib/utils/socketRegistry.d.ts +46 -0
  63. package/lib/utils/socketRegistry.js +83 -0
  64. package/package.json +1 -1
  65. package/src/commands/serve.ts +162 -114
  66. package/src/creator/README.md +63 -51
  67. package/src/creator/package-lock.json +1188 -8
  68. package/src/creator/package.json +5 -1
  69. package/src/creator/src/App.tsx +120 -54
  70. package/src/creator/src/components/FileUploader.tsx +1 -12
  71. package/src/creator/src/components/NotificationListener.tsx +1 -8
  72. package/src/creator/src/components/syllabus/SyllabusEditor.tsx +101 -36
  73. package/src/creator/src/locales/en.json +8 -0
  74. package/src/creator/src/locales/es.json +8 -0
  75. package/src/creator/src/utils/completionResult.test.ts +144 -0
  76. package/src/creator/src/utils/completionResult.ts +154 -0
  77. package/src/creator/src/utils/constants.ts +17 -4
  78. package/src/creator/src/utils/socket.test.ts +113 -0
  79. package/src/creator/src/utils/socket.ts +60 -37
  80. package/src/creator/src/utils/store.ts +84 -67
  81. package/src/creator/src/utils/useCompletionWatchdog.test.ts +99 -0
  82. package/src/creator/src/utils/useCompletionWatchdog.ts +37 -0
  83. package/src/creator/vitest.config.ts +16 -0
  84. package/src/creatorDist/assets/{index-DnthLsvb.js → index-D6pmbMe9.js} +14030 -13903
  85. package/src/creatorDist/assets/index-zrPponAn.css +1701 -0
  86. package/src/creatorDist/index.html +2 -2
  87. package/src/models/creator.ts +1 -1
  88. package/src/services/ingest/adapters/gcsCourseStorage.ts +70 -0
  89. package/src/services/ingest/adapters/rigoUserTokenAuth.ts +65 -0
  90. package/src/services/ingest/adapters/rigobotPackageRegistry.ts +161 -0
  91. package/src/services/ingest/core/buildIngestConfig.ts +51 -0
  92. package/src/services/ingest/core/buildInitialSyllabus.ts +122 -0
  93. package/src/services/ingest/core/buildSidebar.ts +32 -0
  94. package/src/services/ingest/core/coursePaths.ts +59 -0
  95. package/src/services/ingest/core/ingestCoursePackage.ts +343 -0
  96. package/src/services/ingest/core/normalizeLearnJson.ts +47 -0
  97. package/src/services/ingest/core/pairLessons.ts +132 -0
  98. package/src/services/ingest/core/planWrites.ts +91 -0
  99. package/src/services/ingest/core/resolveSlug.ts +119 -0
  100. package/src/services/ingest/core/types.ts +57 -0
  101. package/src/services/ingest/core/validatePackage.ts +132 -0
  102. package/src/services/ingest/createIngestService.ts +66 -0
  103. package/src/services/ingest/errors.ts +45 -0
  104. package/src/services/ingest/http/errorMapping.ts +49 -0
  105. package/src/services/ingest/http/router.ts +156 -0
  106. package/src/services/ingest/http/zipPackageReader.ts +81 -0
  107. package/src/services/ingest/ports/courseStorage.ts +25 -0
  108. package/src/services/ingest/ports/packageRegistry.ts +60 -0
  109. package/src/services/ingest/ports/requestAuthenticator.ts +28 -0
  110. package/src/ui/_app/app.js +325 -325
  111. package/src/ui/app.tar.gz +0 -0
  112. package/src/utils/coursePackage/bucketIo.ts +46 -0
  113. package/src/utils/coursePackage/learnJson.ts +37 -0
  114. package/src/utils/coursePackage/sidebar.ts +28 -0
  115. package/src/utils/creatorSocket.ts +16 -22
  116. package/src/utils/gcpCredentials.ts +38 -0
  117. package/src/utils/rigoActions.ts +163 -0
  118. package/src/utils/socketRegistry.ts +85 -0
  119. package/lib/creatorDist/assets/index-CjddKHB_.css +0 -1
  120. package/src/creatorDist/assets/index-CjddKHB_.css +0 -1
@@ -6,6 +6,7 @@
6
6
  "scripts": {
7
7
  "dev": "vite",
8
8
  "build": "tsc -b && vite build",
9
+ "test": "vitest run",
9
10
  "lint": "eslint .",
10
11
  "preview": "vite preview",
11
12
  "watch": "vite build --watch"
@@ -41,6 +42,7 @@
41
42
  },
42
43
  "devDependencies": {
43
44
  "@eslint/js": "^9.21.0",
45
+ "@testing-library/react": "^16.3.2",
44
46
  "@types/react": "^19.0.10",
45
47
  "@types/react-dom": "^19.0.4",
46
48
  "@vitejs/plugin-react-swc": "^3.8.0",
@@ -49,10 +51,12 @@
49
51
  "eslint-plugin-react-hooks": "^5.1.0",
50
52
  "eslint-plugin-react-refresh": "^0.4.19",
51
53
  "globals": "^15.15.0",
54
+ "jsdom": "^29.1.1",
52
55
  "postcss": "^8.5.3",
53
56
  "tailwindcss": "^4.1.3",
54
57
  "typescript": "~5.7.2",
55
58
  "typescript-eslint": "^8.24.1",
56
- "vite": "^6.2.0"
59
+ "vite": "^6.2.0",
60
+ "vitest": "^4.1.11"
57
61
  }
58
62
  }
@@ -28,9 +28,16 @@ import ResumeCourseModal from "./components/ResumeCourseModal"
28
28
  import { possiblePurposes, PurposeSelector } from "./components/PurposeSelector"
29
29
  import { useTranslation } from "react-i18next"
30
30
  import NotificationListener from "./components/NotificationListener"
31
+ import {
32
+ COMPLETION_ERROR_I18N_KEY,
33
+ interpretCompletionPayload,
34
+ TCompletionErrorCode,
35
+ } from "./utils/completionResult"
36
+ import { useCompletionWatchdog } from "./utils/useCompletionWatchdog"
31
37
  import { slugify } from "./utils/creatorUtils"
32
38
  import TurnstileModal from "./components/TurnstileModal"
33
39
  import { TMessage } from "./components/Message"
40
+ import { Lesson } from "./components/LessonItem"
34
41
  import LanguageDetectionModal from "./components/LanguageDetectionModal"
35
42
 
36
43
  function App() {
@@ -88,6 +95,13 @@ function App() {
88
95
  checkTechs()
89
96
  }, [])
90
97
 
98
+ useCompletionWatchdog(notificationId, () => {
99
+ handleCompletionFailure(
100
+ "TIMEOUT",
101
+ `No notification arrived for ${notificationId}`
102
+ )
103
+ })
104
+
91
105
  const verifyToken = async () => {
92
106
  const { token } = checkParams(["token"])
93
107
  if (token) {
@@ -267,6 +281,111 @@ function App() {
267
281
  }
268
282
  }
269
283
 
284
+ /**
285
+ * Leaves the loading screen and puts the user back on the last wizard step.
286
+ *
287
+ * Their answers stay in the store, so retrying costs them nothing. The raw
288
+ * detail goes to the console rather than the toast: it carries provider
289
+ * internals, such as the Rigobot team id, that mean nothing to the user.
290
+ * @param code - The failure to report.
291
+ * @param detail - The underlying message, for the console.
292
+ */
293
+ const handleCompletionFailure = (
294
+ code: TCompletionErrorCode,
295
+ detail: string
296
+ ) => {
297
+ console.error("COURSE GENERATION FAILED", code, detail)
298
+ toast.error(t(COMPLETION_ERROR_I18N_KEY[code]))
299
+ setNotificationId("")
300
+ setFormState({
301
+ isCompleted: false,
302
+ currentStep: "hasContentIndex",
303
+ })
304
+ }
305
+
306
+ const handleNotification = (payload: unknown) => {
307
+ try {
308
+ const result = interpretCompletionPayload(payload)
309
+
310
+ if (!result.ok) {
311
+ handleCompletionFailure(result.code, result.detail)
312
+ return
313
+ }
314
+
315
+ const { parsed } = result
316
+ // parseLesson returns null for a step that does not match the expected
317
+ // "01.0 - Title [TYPE: description]" format. Those nulls used to slip into
318
+ // the syllabus unnoticed, because listOfSteps was typed as any.
319
+ const lessons = parsed.listOfSteps
320
+ .map((lesson) => parseLesson(lesson, []))
321
+ .filter((lesson): lesson is Lesson => lesson !== null)
322
+
323
+ if (lessons.length < parsed.listOfSteps.length) {
324
+ console.warn(
325
+ `Dropped ${
326
+ parsed.listOfSteps.length - lessons.length
327
+ } unparseable step(s)`,
328
+ parsed.listOfSteps
329
+ )
330
+ }
331
+
332
+ push({
333
+ lessons,
334
+ courseInfo: {
335
+ ...formState,
336
+ title: parsed.title,
337
+ slug: slugify(fixTitleLength(parsed.title)),
338
+ description: parsed.description,
339
+ language: parsed.languageCode || formState.language || "en",
340
+ technologies:
341
+ parsed.technologies.length > 0
342
+ ? parsed.technologies
343
+ : ["education", "quizzes"],
344
+ },
345
+ })
346
+
347
+ if (parsed.languageCode) {
348
+ i18n.changeLanguage(parsed.languageCode)
349
+ }
350
+
351
+ const initialMessages: TMessage[] = [
352
+ {
353
+ type: "user",
354
+ content: formState.description,
355
+ },
356
+ {
357
+ type: "assistant",
358
+ content: parsed.aiMessage,
359
+ },
360
+ ]
361
+
362
+ if (lessons.length > 0) {
363
+ initialMessages.push({
364
+ type: "assistant",
365
+ content: t("contentIndex.okMessage"),
366
+ })
367
+ initialMessages.push({
368
+ type: "assistant",
369
+ content: t("contentIndex.instructionsMessage"),
370
+ })
371
+ }
372
+
373
+ setMessages(initialMessages)
374
+ setNotificationId("")
375
+ navigate("/creator/syllabus")
376
+ setFormState({
377
+ isCompleted: false,
378
+ currentStep: "description",
379
+ })
380
+ } catch (error) {
381
+ // Nothing catches a throw from inside a socket.io listener, so a bug in
382
+ // this handler would strand the UI on the loading screen. Fail loudly
383
+ // into the wizard instead.
384
+ console.error(error, "ERROR HANDLING COURSE NOTIFICATION")
385
+ handleCompletionFailure("MALFORMED_RESPONSE", String(error))
386
+ }
387
+ }
388
+
270
389
  const checkTechs = async () => {
271
390
  if (technologies.length === 0) {
272
391
  const technologies = await getTechnologies()
@@ -518,60 +637,7 @@ function App() {
518
637
  />
519
638
  {notificationId && (
520
639
  <NotificationListener
521
- onNotification={(res) => {
522
- const lessons = res.parsed.listOfSteps.map((lesson: any) => {
523
- return parseLesson(lesson, [])
524
- })
525
-
526
- push({
527
- lessons,
528
- courseInfo: {
529
- ...formState,
530
- title: res.parsed.title,
531
- slug: slugify(fixTitleLength(res.parsed.title)),
532
- description: res.parsed.description,
533
- language:
534
- res.parsed.languageCode || formState.language || "en",
535
- technologies:
536
- res.parsed.technologies.length > 0
537
- ? res.parsed.technologies
538
- : ["education", "quizzes"],
539
- },
540
- })
541
-
542
- if (res.parsed.languageCode) {
543
- i18n.changeLanguage(res.parsed.languageCode)
544
- }
545
-
546
- let initialMessages: TMessage[] = [
547
- {
548
- type: "user",
549
- content: formState.description,
550
- },
551
- {
552
- type: "assistant",
553
- content: res.parsed.aiMessage,
554
- },
555
- ]
556
-
557
- if (lessons.length > 0) {
558
- initialMessages.push({
559
- type: "assistant",
560
- content: t("contentIndex.okMessage"),
561
- })
562
- initialMessages.push({
563
- type: "assistant",
564
- content: t("contentIndex.instructionsMessage"),
565
- })
566
- }
567
-
568
- setMessages(initialMessages)
569
- navigate("/creator/syllabus")
570
- setFormState({
571
- isCompleted: false,
572
- currentStep: "description",
573
- })
574
- }}
640
+ onNotification={handleNotification}
575
641
  notificationId={notificationId}
576
642
  />
577
643
  )}
@@ -70,18 +70,7 @@ const UploadedFileCard = ({ idx, file }: { idx: number; file: ParsedFile }) => {
70
70
  useEffect(() => {
71
71
  if (file.status === "SUCCESS") return
72
72
 
73
- console.log("CONNECTING TO SOCKET", file.notificationId)
74
- socketClient.connect()
75
- socketClient.on(file.notificationId, handleUpdate)
76
-
77
- socketClient.emit("registerNotification", {
78
- notificationId: file.notificationId,
79
- })
80
-
81
- return () => {
82
- socketClient.off(file.notificationId, handleUpdate)
83
- socketClient.disconnect()
84
- }
73
+ return socketClient.subscribe(file.notificationId, handleUpdate)
85
74
  }, [])
86
75
 
87
76
  return (
@@ -16,14 +16,7 @@ const NotificationListener: React.FC<NotificationListenerProps> = ({
16
16
  useEffect(() => {
17
17
  if (!notificationId) return
18
18
 
19
- socketClient.connect()
20
- socketClient.on(notificationId, onNotification)
21
- socketClient.emit("registerNotification", { notificationId })
22
-
23
- return () => {
24
- socketClient.off(notificationId, onNotification)
25
- socketClient.disconnect()
26
- }
19
+ return socketClient.subscribe(notificationId, onNotification)
27
20
  }, [notificationId, onNotification])
28
21
 
29
22
  return null
@@ -30,10 +30,16 @@ import { randomUUID } from "../../utils/creatorUtils"
30
30
  import { RIGO_FLOAT_GIF } from "../../utils/constants"
31
31
  import { useTranslation } from "react-i18next"
32
32
  import NotificationListener from "../NotificationListener"
33
+ import {
34
+ COMPLETION_ERROR_I18N_KEY,
35
+ interpretCompletionPayload,
36
+ TCompletionErrorCode,
37
+ } from "../../utils/completionResult"
38
+ import { useCompletionWatchdog } from "../../utils/useCompletionWatchdog"
33
39
 
34
40
  const SyllabusEditor: React.FC = () => {
35
41
  const navigate = useNavigate()
36
- const { i18n } = useTranslation()
42
+ const { t, i18n } = useTranslation()
37
43
 
38
44
  const {
39
45
  history,
@@ -155,6 +161,9 @@ const SyllabusEditor: React.FC = () => {
155
161
  }
156
162
 
157
163
  if (!isAuthenticated) {
164
+ // Without this the chat stays on "Thinking..." forever, because nothing
165
+ // downstream ever runs to turn it off.
166
+ setIsThinking(false)
158
167
  setShowLoginModal(true)
159
168
  return
160
169
  }
@@ -200,6 +209,96 @@ const SyllabusEditor: React.FC = () => {
200
209
  }
201
210
  }
202
211
 
212
+ /**
213
+ * Ends the "Thinking..." state and tells the user why.
214
+ *
215
+ * Drops the empty assistant placeholder that `sendPrompt` optimistically
216
+ * pushed, so the chat does not keep a blank bubble. The syllabus itself is
217
+ * untouched: a failed refinement leaves the previous version in place.
218
+ * @param code - The failure to report.
219
+ * @param detail - The underlying message, for the console.
220
+ */
221
+ const handleCompletionFailure = (
222
+ code: TCompletionErrorCode,
223
+ detail: string
224
+ ) => {
225
+ console.error("SYLLABUS UPDATE FAILED", code, detail)
226
+ toast.error(t(COMPLETION_ERROR_I18N_KEY[code]))
227
+ setMessages((prev) => prev.filter((m) => Boolean(m.content)))
228
+ setIsThinking(false)
229
+ setNotificationId("")
230
+ }
231
+
232
+ const handleNotification = (payload: unknown) => {
233
+ try {
234
+ const result = interpretCompletionPayload(payload)
235
+
236
+ if (!result.ok) {
237
+ handleCompletionFailure(result.code, result.detail)
238
+ return
239
+ }
240
+
241
+ const { parsed } = result
242
+ // parseLesson returns null for a step that does not match the expected
243
+ // "01.0 - Title [TYPE: description]" format. Those nulls used to slip into
244
+ // the syllabus unnoticed, because listOfSteps was typed as any.
245
+ const lessons: Lesson[] = parsed.listOfSteps
246
+ .map((step) => parseLesson(step, syllabus.lessons))
247
+ .filter((lesson): lesson is Lesson => lesson !== null)
248
+
249
+ if (lessons.length < parsed.listOfSteps.length) {
250
+ console.warn(
251
+ `Dropped ${
252
+ parsed.listOfSteps.length - lessons.length
253
+ } unparseable step(s)`,
254
+ parsed.listOfSteps
255
+ )
256
+ }
257
+
258
+ push({
259
+ ...syllabus,
260
+ lessons: lessons,
261
+ courseInfo: {
262
+ ...syllabus.courseInfo,
263
+ title: parsed.title || syllabus.courseInfo.title,
264
+ description: parsed.description || syllabus.courseInfo.description,
265
+ language:
266
+ parsed.languageCode || syllabus.courseInfo.language || "en",
267
+ technologies:
268
+ parsed.technologies.length > 0
269
+ ? parsed.technologies
270
+ : syllabus.courseInfo.technologies || [],
271
+ },
272
+ })
273
+
274
+ if (parsed.languageCode) {
275
+ i18n.changeLanguage(parsed.languageCode)
276
+ }
277
+
278
+ setMessages((prev) => [
279
+ ...prev.filter((m) => Boolean(m.content)),
280
+ {
281
+ type: "assistant",
282
+ content: parsed.aiMessage,
283
+ },
284
+ ])
285
+ setIsThinking(false)
286
+ setNotificationId("")
287
+ } catch (error) {
288
+ // A throw inside a socket.io listener is caught by nobody and would leave
289
+ // the chat stuck on "Thinking...".
290
+ console.error(error, "ERROR HANDLING SYLLABUS NOTIFICATION")
291
+ handleCompletionFailure("MALFORMED_RESPONSE", String(error))
292
+ }
293
+ }
294
+
295
+ useCompletionWatchdog(notificationId, () => {
296
+ handleCompletionFailure(
297
+ "TIMEOUT",
298
+ `No notification arrived for ${notificationId}`
299
+ )
300
+ })
301
+
203
302
  const handleSubmit = async () => {
204
303
  if (!auth.bcToken || !auth.rigoToken) {
205
304
  setShowLoginModal(true)
@@ -284,41 +383,7 @@ It may take a moment..."
284
383
  <div className="flex w-full bg-white rounded-md shadow-md overflow-hidden h-screen ">
285
384
  <ParamsChecker />
286
385
  <NotificationListener
287
- onNotification={(res) => {
288
- const lessons: Lesson[] = res.parsed.listOfSteps.map((step: any) =>
289
- parseLesson(step, syllabus.lessons)
290
- )
291
- push({
292
- ...syllabus,
293
- lessons: lessons,
294
- courseInfo: {
295
- ...syllabus.courseInfo,
296
- title: res.parsed.title || syllabus.courseInfo.title,
297
- description:
298
- res.parsed.description || syllabus.courseInfo.description,
299
- language:
300
- res.parsed.languageCode || syllabus.courseInfo.language || "en",
301
- technologies:
302
- res.parsed.technologies.length > 0
303
- ? res.parsed.technologies
304
- : syllabus.courseInfo.technologies || [],
305
- },
306
- })
307
-
308
- if (res.parsed.languageCode) {
309
- i18n.changeLanguage(res.parsed.languageCode)
310
- }
311
-
312
- setMessages((prev) => [
313
- ...prev.filter((m) => Boolean(m.content)),
314
- {
315
- type: "assistant",
316
- content: res.parsed.aiMessage,
317
- },
318
- ])
319
- setIsThinking(false)
320
- setNotificationId("")
321
- }}
386
+ onNotification={handleNotification}
322
387
  notificationId={notificationId}
323
388
  />
324
389
  {showLoginModal && (
@@ -87,6 +87,14 @@
87
87
  "text": "Learnpack is setting up your tutorial. It may take a moment...",
88
88
  "thinking": "Thinking..."
89
89
  },
90
+ "completionError": {
91
+ "quotaExceeded": "The course generation service has run out of capacity right now. Please try again later or contact support.",
92
+ "rateLimited": "Too many requests at the moment. Please wait a few seconds and try again.",
93
+ "providerUnavailable": "The course generation service is temporarily unavailable. Please try again in a few minutes.",
94
+ "malformedResponse": "We couldn't read the generated syllabus. Please try again.",
95
+ "timeout": "This is taking longer than expected, so we stopped waiting. Your answers were kept, please try again.",
96
+ "unknown": "Something went wrong while generating the course. Please try again."
97
+ },
90
98
  "sidebar": {
91
99
  "chatWithMe": "Chat with me to update the course content",
92
100
  "howCanLearnPackHelpYou": "How can LearnPack help you?"
@@ -87,6 +87,14 @@
87
87
  "text": "Learnpack está configurando tu tutorial. Puede que tarde un momento...",
88
88
  "thinking": "Pensando..."
89
89
  },
90
+ "completionError": {
91
+ "quotaExceeded": "El servicio de generación de cursos no tiene capacidad disponible en este momento. Inténtalo más tarde o contacta con soporte.",
92
+ "rateLimited": "Demasiadas solicitudes en este momento. Espera unos segundos e inténtalo de nuevo.",
93
+ "providerUnavailable": "El servicio de generación de cursos no está disponible temporalmente. Inténtalo de nuevo en unos minutos.",
94
+ "malformedResponse": "No pudimos leer el temario generado. Inténtalo de nuevo.",
95
+ "timeout": "Esto está tardando más de lo esperado y dejamos de esperar. Guardamos tus respuestas, inténtalo de nuevo.",
96
+ "unknown": "Algo salió mal al generar el curso. Inténtalo de nuevo."
97
+ },
90
98
  "sidebar": {
91
99
  "chatWithMe": "Chatea conmigo para actualizar el contenido del curso",
92
100
  "howCanLearnPackHelpYou": "¿Cómo puede LearnPack ayudarte?"
@@ -0,0 +1,144 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ COMPLETION_ERROR_I18N_KEY,
4
+ interpretCompletionPayload,
5
+ TCompletionErrorCode,
6
+ } from "./completionResult";
7
+
8
+ /**
9
+ * The payload that actually stranded the creator: a job Rigobot ran and failed,
10
+ * with `parsed: null`. Reading `parsed.listOfSteps` on this is what threw inside
11
+ * the socket listener.
12
+ */
13
+ const QUOTA_FAILURE = {
14
+ id: 642271,
15
+ status: "ERROR",
16
+ status_text:
17
+ "Error code: 403 - {'code': 'permission-denied', 'error': 'Your team " +
18
+ "fde60f91-0622-41c0-a31a-566d8df3497b has either used all available " +
19
+ "credits or reached its monthly spending limit. To continue making API " +
20
+ "requests, please purchase more credits or raise your spending limit.'}",
21
+ answer: null,
22
+ parsed: null,
23
+ };
24
+
25
+ const SUCCESSFUL_JOB = {
26
+ id: 1,
27
+ status: "SUCCESS",
28
+ status_text: "",
29
+ parsed: {
30
+ title: "Claude Code Skills",
31
+ description: "A short course about writing skills for Claude Code.",
32
+ languageCode: "en",
33
+ technologies: ["agent-skills", "ai"],
34
+ aiMessage: "Here is the syllabus I drafted.",
35
+ listOfSteps: ["00.0 - Welcome [READ: ...]", "01.0 - Skills [READ: ...]"],
36
+ },
37
+ };
38
+
39
+ describe("interpretCompletionPayload", () => {
40
+ it("returns the parsed syllabus for a successful job", () => {
41
+ const result = interpretCompletionPayload(SUCCESSFUL_JOB);
42
+
43
+ expect(result.ok).toBe(true);
44
+ if (!result.ok) return;
45
+
46
+ expect(result.parsed.listOfSteps).toHaveLength(2);
47
+ expect(result.parsed.title).toBe("Claude Code Skills");
48
+ expect(result.parsed.languageCode).toBe("en");
49
+ expect(result.parsed.technologies).toEqual(["agent-skills", "ai"]);
50
+ });
51
+
52
+ it("reports the credits failure from the incident as a quota problem", () => {
53
+ const result = interpretCompletionPayload(QUOTA_FAILURE);
54
+
55
+ expect(result.ok).toBe(false);
56
+ if (result.ok) return;
57
+
58
+ expect(result.code).toBe("QUOTA_EXCEEDED");
59
+ expect(result.detail).toContain("permission-denied");
60
+ });
61
+
62
+ it("does not throw on the payload shape that used to crash the listener", () => {
63
+ expect(() => interpretCompletionPayload(QUOTA_FAILURE)).not.toThrow();
64
+ });
65
+
66
+ const statusTexts: Array<[string, TCompletionErrorCode]> = [
67
+ ["Your team has used all available credits", "QUOTA_EXCEEDED"],
68
+ ["monthly spending limit reached", "QUOTA_EXCEEDED"],
69
+ ["Error code: 429 - rate limit exceeded", "RATE_LIMITED"],
70
+ ["Error code: 503 - service unavailable", "PROVIDER_UNAVAILABLE"],
71
+ ["The request timed out", "PROVIDER_UNAVAILABLE"],
72
+ ["Missing key description inside inputs dictionary", "UNKNOWN"],
73
+ ];
74
+
75
+ statusTexts.forEach(([statusText, expected]) => {
76
+ it(`classifies "${statusText.slice(0, 40)}..." as ${expected}`, () => {
77
+ const result = interpretCompletionPayload({
78
+ status: "ERROR",
79
+ status_text: statusText,
80
+ parsed: null,
81
+ });
82
+
83
+ expect(result.ok).toBe(false);
84
+ if (result.ok) return;
85
+ expect(result.code).toBe(expected);
86
+ });
87
+ });
88
+
89
+ it("treats a SUCCESS job without a list of steps as malformed", () => {
90
+ const result = interpretCompletionPayload({
91
+ status: "SUCCESS",
92
+ parsed: { title: "Something", listOfSteps: null },
93
+ });
94
+
95
+ expect(result.ok).toBe(false);
96
+ if (result.ok) return;
97
+ expect(result.code).toBe("MALFORMED_RESPONSE");
98
+ });
99
+
100
+ it("fills in the optional fields the syllabus screens read", () => {
101
+ const result = interpretCompletionPayload({
102
+ status: "SUCCESS",
103
+ parsed: { listOfSteps: ["00.0 - Welcome [READ: ...]"] },
104
+ });
105
+
106
+ expect(result.ok).toBe(true);
107
+ if (!result.ok) return;
108
+
109
+ // The screens do `parsed.technologies.length > 0` and `parsed.title || ...`,
110
+ // so these have to exist even when Rigobot omits them.
111
+ expect(result.parsed.technologies).toEqual([]);
112
+ expect(result.parsed.title).toBe("");
113
+ expect(result.parsed.aiMessage).toBe("");
114
+ });
115
+
116
+ const junk: Array<[string, unknown]> = [
117
+ ["null", null],
118
+ ["undefined", undefined],
119
+ ["a string", "not a job"],
120
+ ["a number", 42],
121
+ ["an empty object", {}],
122
+ ["a job with no parsed field", { status: "SUCCESS" }],
123
+ ];
124
+
125
+ junk.forEach(([label, payload]) => {
126
+ it(`reports ${label} as malformed instead of throwing`, () => {
127
+ const result = interpretCompletionPayload(payload);
128
+
129
+ expect(result.ok).toBe(false);
130
+ if (result.ok) return;
131
+ expect(result.code).toBe("MALFORMED_RESPONSE");
132
+ });
133
+ });
134
+ });
135
+
136
+ describe("COMPLETION_ERROR_I18N_KEY", () => {
137
+ it("has a distinct key for every failure code", () => {
138
+ const keys = Object.values(COMPLETION_ERROR_I18N_KEY);
139
+
140
+ expect(keys.length).toBeGreaterThan(0);
141
+ expect(new Set(keys).size).toBe(keys.length);
142
+ keys.forEach((key) => expect(key).toMatch(/^completionError\./));
143
+ });
144
+ });