@learnpack/learnpack 5.0.362 → 5.0.363

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 (38) hide show
  1. package/lib/commands/serve.js +0 -6
  2. package/lib/creatorDist/assets/{index-B7s5Nmq8.css → index-Cco3XhDR.css} +96 -0
  3. package/lib/creatorDist/assets/{index-CKg5uuoj.js → index-o3Cw0bsF.js} +69620 -66788
  4. package/lib/creatorDist/index.html +2 -2
  5. package/lib/utils/api.d.ts +0 -9
  6. package/lib/utils/api.js +1 -65
  7. package/package.json +1 -1
  8. package/src/commands/serve.ts +0 -9
  9. package/src/creator/package-lock.json +297 -3
  10. package/src/creator/package.json +1 -0
  11. package/src/creator/src/App.test.tsx +124 -1
  12. package/src/creator/src/App.tsx +52 -36
  13. package/src/creator/src/components/FileCard.tsx +63 -19
  14. package/src/creator/src/components/FileReadListener.test.tsx +128 -0
  15. package/src/creator/src/components/FileReadListener.tsx +75 -0
  16. package/src/creator/src/components/FileReviewModal.test.tsx +193 -0
  17. package/src/creator/src/components/FileReviewModal.tsx +128 -0
  18. package/src/creator/src/components/FileUploader.test.tsx +229 -0
  19. package/src/creator/src/components/FileUploader.tsx +212 -58
  20. package/src/creator/src/components/GenerationErrorCard.tsx +57 -0
  21. package/src/creator/src/components/MarkdownRenderer.tsx +4 -2
  22. package/src/creator/src/components/syllabus/SyllabusEditor.tsx +6 -9
  23. package/src/creator/src/index.css +45 -0
  24. package/src/creator/src/locales/en.json +27 -5
  25. package/src/creator/src/locales/es.json +27 -5
  26. package/src/creator/src/main.tsx +2 -0
  27. package/src/creator/src/utils/completionResult.test.ts +61 -0
  28. package/src/creator/src/utils/completionResult.ts +61 -2
  29. package/src/creator/src/utils/courseLanguage.test.ts +6 -0
  30. package/src/creator/src/utils/creationRequest.test.ts +53 -18
  31. package/src/creator/src/utils/creationRequest.ts +17 -12
  32. package/src/creator/src/utils/fileRead.ts +60 -0
  33. package/src/creator/src/utils/lib.ts +0 -5
  34. package/src/creator/src/utils/store.ts +0 -10
  35. package/src/creatorDist/assets/{index-B7s5Nmq8.css → index-Cco3XhDR.css} +96 -0
  36. package/src/creatorDist/assets/{index-CKg5uuoj.js → index-o3Cw0bsF.js} +69620 -66788
  37. package/src/creatorDist/index.html +2 -2
  38. package/src/utils/api.ts +0 -83
@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client"
3
3
  import { BrowserRouter, Route, Routes } from "react-router"
4
4
  import "./index.css"
5
5
  import App from "./App.tsx"
6
+ import FileReadListener from "./components/FileReadListener.tsx"
6
7
  import SyllabusEditor from "./components/syllabus/SyllabusEditor.tsx"
7
8
  import { Toaster } from "react-hot-toast"
8
9
  import "./i18n"
@@ -13,6 +14,7 @@ createRoot(document.getElementById("root")!).render(
13
14
  <TooltipProvider>
14
15
  <Toaster />
15
16
  <BrowserRouter>
17
+ <FileReadListener />
16
18
  <Routes>
17
19
  <Route path="/creator" element={<App />} />
18
20
  <Route path="/creator/syllabus" element={<SyllabusEditor />} />
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import {
3
3
  COMPLETION_ERROR_I18N_KEY,
4
+ getFailureGuidance,
4
5
  interpretCompletionPayload,
5
6
  TCompletionErrorCode,
6
7
  } from "./completionResult";
@@ -67,6 +68,19 @@ describe("interpretCompletionPayload", () => {
67
68
  ["Your team has used all available credits", "QUOTA_EXCEEDED"],
68
69
  ["monthly spending limit reached", "QUOTA_EXCEEDED"],
69
70
  ["Error code: 429 - rate limit exceeded", "RATE_LIMITED"],
71
+ [
72
+ "Error code: 400 - Unsupported value: 'temperature' does not support 0 with this model",
73
+ "REQUEST_REJECTED",
74
+ ],
75
+ [
76
+ "The selected model openai/gpt-5.6-luna doesn't support structured outputs",
77
+ "REQUEST_REJECTED",
78
+ ],
79
+ ["Error code: 401 - invalid api key", "REQUEST_REJECTED"],
80
+ [
81
+ "Error code: 404 - model not found, service unavailable",
82
+ "REQUEST_REJECTED",
83
+ ],
70
84
  ["Error code: 503 - service unavailable", "PROVIDER_UNAVAILABLE"],
71
85
  ["The request timed out", "PROVIDER_UNAVAILABLE"],
72
86
  ["Missing key description inside inputs dictionary", "UNKNOWN"],
@@ -86,6 +100,22 @@ describe("interpretCompletionPayload", () => {
86
100
  });
87
101
  });
88
102
 
103
+ it("keeps the job id as the reference for support", () => {
104
+ const result = interpretCompletionPayload(QUOTA_FAILURE);
105
+
106
+ expect(result.ok).toBe(false);
107
+ if (result.ok) return;
108
+ expect(result.reference).toBe("642271");
109
+ });
110
+
111
+ it("has no reference when the payload names no job", () => {
112
+ const result = interpretCompletionPayload(null);
113
+
114
+ expect(result.ok).toBe(false);
115
+ if (result.ok) return;
116
+ expect(result.reference).toBe("");
117
+ });
118
+
89
119
  it("treats a SUCCESS job without a list of steps as malformed", () => {
90
120
  const result = interpretCompletionPayload({
91
121
  status: "SUCCESS",
@@ -133,6 +163,37 @@ describe("interpretCompletionPayload", () => {
133
163
  });
134
164
  });
135
165
 
166
+ describe("getFailureGuidance", () => {
167
+ it("offers a retry for a transient failure, however many there were", () => {
168
+ expect(getFailureGuidance("RATE_LIMITED", 5)).toEqual({
169
+ messageKey: "completionError.rateLimited",
170
+ canRetry: true,
171
+ });
172
+ });
173
+
174
+ it("points to support when the request cannot work", () => {
175
+ expect(getFailureGuidance("REQUEST_REJECTED", 1)).toEqual({
176
+ messageKey: "completionError.contactSupport",
177
+ canRetry: false,
178
+ });
179
+ });
180
+
181
+ it("gives an unknown failure one retry", () => {
182
+ expect(getFailureGuidance("UNKNOWN", 1).canRetry).toBe(true);
183
+ expect(getFailureGuidance("UNKNOWN", 2)).toEqual({
184
+ messageKey: "completionError.contactSupport",
185
+ canRetry: false,
186
+ });
187
+ });
188
+
189
+ it("keeps the quota message, which already explains what to do", () => {
190
+ expect(getFailureGuidance("QUOTA_EXCEEDED", 1)).toEqual({
191
+ messageKey: "completionError.quotaExceeded",
192
+ canRetry: false,
193
+ });
194
+ });
195
+ });
196
+
136
197
  describe("COMPLETION_ERROR_I18N_KEY", () => {
137
198
  it("has a distinct key for every failure code", () => {
138
199
  const keys = Object.values(COMPLETION_ERROR_I18N_KEY);
@@ -16,6 +16,7 @@
16
16
  export type TCompletionErrorCode =
17
17
  | "QUOTA_EXCEEDED"
18
18
  | "RATE_LIMITED"
19
+ | "REQUEST_REJECTED"
19
20
  | "PROVIDER_UNAVAILABLE"
20
21
  | "MALFORMED_RESPONSE"
21
22
  | "TIMEOUT"
@@ -33,7 +34,21 @@ export type TSyllabusPayload = {
33
34
 
34
35
  export type TCompletionResult =
35
36
  | { ok: true; parsed: TSyllabusPayload }
36
- | { ok: false; code: TCompletionErrorCode; detail: string };
37
+ | {
38
+ ok: false;
39
+ code: TCompletionErrorCode;
40
+ detail: string;
41
+ reference: string;
42
+ };
43
+
44
+ /** A failed generation, as the error screen shows it. */
45
+ export type TGenerationFailure = {
46
+ code: TCompletionErrorCode;
47
+ /** The completion job id, which support can look up. Empty if no job exists. */
48
+ reference: string;
49
+ /** Failures in a row, because repeating an unknown one stops being worth a retry. */
50
+ attempts: number;
51
+ };
37
52
 
38
53
  /**
39
54
  * i18n key per failure code.
@@ -45,6 +60,7 @@ export type TCompletionResult =
45
60
  export const COMPLETION_ERROR_I18N_KEY: Record<TCompletionErrorCode, string> = {
46
61
  QUOTA_EXCEEDED: "completionError.quotaExceeded",
47
62
  RATE_LIMITED: "completionError.rateLimited",
63
+ REQUEST_REJECTED: "completionError.contactSupport",
48
64
  PROVIDER_UNAVAILABLE: "completionError.providerUnavailable",
49
65
  MALFORMED_RESPONSE: "completionError.malformedResponse",
50
66
  TIMEOUT: "completionError.timeout",
@@ -77,6 +93,15 @@ const classifyStatusText = (statusText: string): TCompletionErrorCode => {
77
93
  return "RATE_LIMITED";
78
94
  }
79
95
 
96
+ // The provider refused this request as written, so sending it again fails
97
+ // the same way. A 403 about credits already matched the quota case above.
98
+ if (
99
+ text.includes("doesn't support structured outputs") ||
100
+ /error code: (40[0134]|422)\b/.test(text)
101
+ ) {
102
+ return "REQUEST_REJECTED";
103
+ }
104
+
80
105
  if (
81
106
  text.includes("unavailable") ||
82
107
  text.includes("timeout") ||
@@ -89,9 +114,40 @@ const classifyStatusText = (statusText: string): TCompletionErrorCode => {
89
114
  return "UNKNOWN";
90
115
  };
91
116
 
117
+ /**
118
+ * What to tell the user about a failure, and whether a retry can help.
119
+ *
120
+ * Offering a retry that cannot work only makes the user wait for the same
121
+ * error, so these failures point to support instead. An unknown failure gets
122
+ * one retry, since it may be transient.
123
+ * @param code - The failure to report.
124
+ * @param attempts - Failures in a row, including this one.
125
+ * @returns The i18n key of the message, and whether to offer a retry.
126
+ */
127
+ export const getFailureGuidance = (
128
+ code: TCompletionErrorCode,
129
+ attempts: number
130
+ ): { messageKey: string; canRetry: boolean } => {
131
+ if (code === "QUOTA_EXCEEDED") {
132
+ return { messageKey: COMPLETION_ERROR_I18N_KEY[code], canRetry: false };
133
+ }
134
+
135
+ if (code === "REQUEST_REJECTED" || (code === "UNKNOWN" && attempts > 1)) {
136
+ return {
137
+ messageKey: COMPLETION_ERROR_I18N_KEY.REQUEST_REJECTED,
138
+ canRetry: false,
139
+ };
140
+ }
141
+
142
+ return { messageKey: COMPLETION_ERROR_I18N_KEY[code], canRetry: true };
143
+ };
144
+
92
145
  const asString = (value: unknown): string =>
93
146
  typeof value === "string" ? value : "";
94
147
 
148
+ const asReference = (value: unknown): string =>
149
+ typeof value === "number" || typeof value === "string" ? String(value) : "";
150
+
95
151
  const asStringArray = (value: unknown): string[] =>
96
152
  Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
97
153
 
@@ -111,14 +167,16 @@ export function interpretCompletionPayload(
111
167
  ok: false,
112
168
  code: "MALFORMED_RESPONSE",
113
169
  detail: `Expected an object, received ${typeof payload}`,
170
+ reference: "",
114
171
  };
115
172
  }
116
173
 
117
174
  const job = payload as Record<string, unknown>;
175
+ const reference = asReference(job.id);
118
176
 
119
177
  if (job.status === "ERROR") {
120
178
  const detail = asString(job.status_text) || "No status text provided";
121
- return { ok: false, code: classifyStatusText(detail), detail };
179
+ return { ok: false, code: classifyStatusText(detail), detail, reference };
122
180
  }
123
181
 
124
182
  const parsed = job.parsed;
@@ -135,6 +193,7 @@ export function interpretCompletionPayload(
135
193
  ok: false,
136
194
  code: "MALFORMED_RESPONSE",
137
195
  detail: "The completion carried no list of steps",
196
+ reference,
138
197
  };
139
198
  }
140
199
 
@@ -120,6 +120,12 @@ describe("detectSeedLanguage", () => {
120
120
  expect(detectSeedLanguage([es, es])).toBe("es");
121
121
  });
122
122
 
123
+ it("is not thrown off by the markdown of a PDF with tables", () => {
124
+ const read = `\n[Page 1]\n# **Mapa del curso**\n\n${es}\n\n|Campo|Valor|\n|---|---|\n|Idioma|[Español / inglés]|\n|Duración total|[Horas o semanas]|\n`;
125
+
126
+ expect(detectSeedLanguage([read])).toBe("es");
127
+ });
128
+
123
129
  it("reports sources that disagree instead of letting the longest win", () => {
124
130
  expect(detectSeedLanguage([en.repeat(10), es])).toBe("mixed");
125
131
  });
@@ -1,31 +1,28 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import type { ParsedFile } from "../components/FileUploader";
3
- import { buildCourseInfo, toCourseFormState } from "./creationRequest";
3
+ import {
4
+ buildCourseInfo,
5
+ readFiles,
6
+ toCourseFormState,
7
+ } from "./creationRequest";
4
8
  import useStore, { FormState } from "./store";
5
9
 
6
10
  /**
7
11
  * The courseInfo the wizard sent before the course language could be settled,
8
- * copied byte for byte from App.tsx (upstream 68c0bf9). Whitespace inside the
9
- * template is part of the prompt, so it must not be reformatted.
12
+ * copied from App.tsx (upstream 68c0bf9) without its technology list. Whitespace
13
+ * inside the template is part of the prompt, so it must not be reformatted.
10
14
  */
11
- const legacyCourseInfo = (
12
- formState: FormState,
13
- techs: { slug: string }[],
14
- uploadedFiles: ParsedFile[]
15
- ) =>
15
+ const legacyCourseInfo = (formState: FormState, uploadedFiles: ParsedFile[]) =>
16
16
  // prettier-ignore
17
17
  `${JSON.stringify(
18
18
  formState
19
- )}. The following technologies are available, choose up to 3 from the following list: <techs>${techs
20
- .map((t) => t.slug)
21
- .join(", ")}</techs>
22
-
19
+ )}.
20
+
23
21
  The following files have been uploaded by the user: ${uploadedFiles.map((f) => `<FILE name="${f.name}">${f.text}</FILE>`).join("\n")}
24
22
  `;
25
23
 
26
24
  const defaultFormState = () => useStore.getInitialState().formState;
27
25
 
28
- const techs = [{ slug: "python" }, { slug: "sql" }];
29
26
  const uploadedFiles: ParsedFile[] = [
30
27
  {
31
28
  name: "notes.pdf",
@@ -35,7 +32,46 @@ const uploadedFiles: ParsedFile[] = [
35
32
  },
36
33
  ];
37
34
 
35
+ describe("readFiles", () => {
36
+ it("keeps out files that are not read yet or failed", () => {
37
+ const files: ParsedFile[] = [
38
+ {
39
+ name: "ready.pdf",
40
+ text: "Text",
41
+ status: "SUCCESS",
42
+ notificationId: "1",
43
+ },
44
+ {
45
+ name: "reading.pdf",
46
+ text: "",
47
+ status: "PROCESSING",
48
+ notificationId: "2",
49
+ },
50
+ { name: "broken.pdf", text: "", status: "ERROR", notificationId: "3" },
51
+ ];
52
+
53
+ expect(readFiles(files).map((file) => file.name)).toEqual(["ready.pdf"]);
54
+ });
55
+ });
56
+
38
57
  describe("buildCourseInfo", () => {
58
+ it("never sends an empty <FILE> for a file still being read", () => {
59
+ const courseInfo = buildCourseInfo({
60
+ formState: defaultFormState(),
61
+ uploadedFiles: [
62
+ {
63
+ name: "reading.pdf",
64
+ text: "",
65
+ status: "PROCESSING",
66
+ notificationId: "2",
67
+ },
68
+ ],
69
+ language: null,
70
+ });
71
+
72
+ expect(courseInfo).not.toContain("reading.pdf");
73
+ });
74
+
39
75
  it("sends exactly the previous prompt when no language is settled", () => {
40
76
  const formState = {
41
77
  ...defaultFormState(),
@@ -48,16 +84,15 @@ describe("buildCourseInfo", () => {
48
84
  const previousFormState = { ...formState };
49
85
  delete previousFormState.languageChosen;
50
86
 
51
- expect(
52
- buildCourseInfo({ formState, techs, uploadedFiles, language: null })
53
- ).toBe(legacyCourseInfo(previousFormState, techs, uploadedFiles));
87
+ expect(buildCourseInfo({ formState, uploadedFiles, language: null })).toBe(
88
+ legacyCourseInfo(previousFormState, uploadedFiles)
89
+ );
54
90
  });
55
91
 
56
92
  it("asks for the settled language in plain words", () => {
57
93
  const formState = { ...defaultFormState(), language: "en" };
58
94
  const courseInfo = buildCourseInfo({
59
95
  formState,
60
- techs,
61
96
  uploadedFiles,
62
97
  language: "es",
63
98
  });
@@ -79,7 +114,7 @@ describe("buildCourseInfo", () => {
79
114
  };
80
115
  for (const language of [null, "es"] as const) {
81
116
  expect(
82
- buildCourseInfo({ formState, techs, uploadedFiles, language })
117
+ buildCourseInfo({ formState, uploadedFiles, language })
83
118
  ).not.toContain("languageChosen");
84
119
  }
85
120
  });
@@ -7,6 +7,15 @@ const LANGUAGE_NAMES: Record<TCourseLanguage, string> = {
7
7
  es: "Spanish",
8
8
  };
9
9
 
10
+ /**
11
+ * The files whose text is ready. One still being read has no text yet, and
12
+ * would reach the model as an empty document it cannot tell apart from a real one.
13
+ * @param files - The uploaded files.
14
+ * @returns Only the files read successfully.
15
+ */
16
+ export const readFiles = (files: ParsedFile[]): ParsedFile[] =>
17
+ files.filter((file) => file.status === "SUCCESS");
18
+
10
19
  /**
11
20
  * Drops the wizard-only fields from the form state before it leaves the
12
21
  * wizard, in the prompt or in the syllabus sent to the server.
@@ -22,33 +31,29 @@ export const toCourseFormState = (formState: FormState): FormState => {
22
31
  /**
23
32
  * Builds the `courseInfo` text sent to the model to generate the syllabus.
24
33
  *
25
- * Without a settled language the text is exactly what the wizard sent before
26
- * the course language could be settled, so the model keeps choosing.
34
+ * Without a settled language the model keeps choosing it.
27
35
  * @param language - The course language settled before generation, or null.
28
36
  */
29
37
  export const buildCourseInfo = ({
30
38
  formState,
31
- techs,
32
39
  uploadedFiles,
33
40
  language,
34
41
  }: {
35
42
  formState: FormState;
36
- techs: { slug: string }[];
37
43
  uploadedFiles: ParsedFile[];
38
44
  language: TCourseLanguage | null;
39
45
  }): string => {
40
46
  const courseFormState = toCourseFormState(formState);
41
47
 
42
- // Kept byte for byte from the original request, including the spaces on the
43
- // blank line, which .editorconfig would trim. creationRequest.test.ts checks
44
- // it against a copy of the original.
48
+ // Kept byte for byte, including the spaces on the blank line, which
49
+ // .editorconfig would trim. creationRequest.test.ts checks it against a copy.
45
50
  const courseInfo = `${JSON.stringify(
46
51
  language ? { ...courseFormState, language } : courseFormState
47
- )}. The following technologies are available, choose up to 3 from the following list: <techs>${techs
48
- .map((t) => t.slug)
49
- .join(", ")}</techs>
50
-
51
- The following files have been uploaded by the user: ${uploadedFiles
52
+ )}.
53
+
54
+ The following files have been uploaded by the user: ${readFiles(
55
+ uploadedFiles
56
+ )
52
57
  .map((f) => `<FILE name="${f.name}">${f.text}</FILE>`)
53
58
  .join("\n")}
54
59
  `;
@@ -0,0 +1,60 @@
1
+ import type { TFunction } from "i18next";
2
+ import type { ParsedFile } from "../components/FileUploader";
3
+
4
+ /**
5
+ * What the user should know about how a file was read.
6
+ * @param file - The uploaded file.
7
+ * @param t - The translation function.
8
+ * @returns One sentence per concern, empty when the read needs no comment.
9
+ */
10
+ export const readNotices = (file: ParsedFile, t: TFunction): string[] => {
11
+ const { report } = file;
12
+ if (!report) return [];
13
+
14
+ const notices: string[] = [];
15
+
16
+ if (report.ai_pages.length > 0) {
17
+ notices.push(
18
+ t("uploader.files.aiReadNotice", {
19
+ read: report.ai_pages.length,
20
+ total: report.total_pages,
21
+ })
22
+ );
23
+ }
24
+
25
+ const missingPages = [
26
+ ...new Set([...report.unread_pages, ...report.failed_pages]),
27
+ ].sort((a, b) => a - b);
28
+
29
+ if (missingPages.length > 0) {
30
+ notices.push(
31
+ t("uploader.files.missingPagesNotice", {
32
+ pages: missingPages.join(", "),
33
+ })
34
+ );
35
+ }
36
+
37
+ return notices;
38
+ };
39
+
40
+ /**
41
+ * Turns rigobot's page markers into headings for reading. Display only: the
42
+ * model keeps receiving the markers.
43
+ * @param text - The text of the file, in markdown.
44
+ * @param t - The translation function.
45
+ * @returns The same text with a heading per page.
46
+ */
47
+ export const withPageHeadings = (text: string, t: TFunction): string =>
48
+ text.replace(
49
+ /^\[Page (\d+)\]$/gm,
50
+ (_, page) => `\n\n#### ${t("uploader.files.reviewPage", { page })}\n\n`
51
+ );
52
+
53
+ /**
54
+ * Whether the file's text uses markdown. Plain text must not go through the
55
+ * markdown renderer, which would join its lines into one paragraph.
56
+ * @param file - The uploaded file.
57
+ * @returns True when rigobot produced markdown for at least part of the file.
58
+ */
59
+ export const isMarkdownRead = (file: ParsedFile): boolean =>
60
+ Boolean(file.report?.markdown || file.report?.ai_pages.length);
@@ -461,11 +461,6 @@ export const fixTitleLength = (title: string) => {
461
461
  return fixed;
462
462
  };
463
463
 
464
- export const getTechnologies = async () => {
465
- const response = await axios.get(`/technologies`);
466
- return response.data;
467
- };
468
-
469
464
  export const detectLanguage = (text: string) => {
470
465
  const lang = franc(text);
471
466
  if (lang === "spa") return "es";
@@ -43,11 +43,6 @@ type Consumables = {
43
43
  [key: string]: number;
44
44
  };
45
45
 
46
- type TTechnology = {
47
- slug: string;
48
- lang: string;
49
- };
50
-
51
46
  type Store = {
52
47
  auth: Auth;
53
48
  formState: FormState;
@@ -65,8 +60,6 @@ type Store = {
65
60
  ) => void;
66
61
  cleanHistory: () => void;
67
62
  history: Syllabus[];
68
- technologies: TTechnology[];
69
- setTechnologies: (technologies: TTechnology[]) => void;
70
63
  undo: () => void;
71
64
  push: (syllabus: Syllabus) => void;
72
65
  cleanAll: () => void;
@@ -112,8 +105,6 @@ const useStore = create<Store>()(
112
105
  ],
113
106
  },
114
107
  messages: [],
115
- technologies: [],
116
- setTechnologies: (technologies: TTechnology[]) => set({ technologies }),
117
108
  setMessages: (
118
109
  messages: TMessage[] | ((prev: TMessage[]) => TMessage[])
119
110
  ) => {
@@ -178,7 +169,6 @@ const useStore = create<Store>()(
178
169
  uploadedFiles: [],
179
170
  messages: [],
180
171
  history: [],
181
- technologies: [],
182
172
  formState: {
183
173
  description: "",
184
174
  duration: 0,