@learnpack/learnpack 5.0.361 → 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.
- package/lib/commands/serve.js +44 -7
- package/lib/creatorDist/assets/{index-B7s5Nmq8.css → index-Cco3XhDR.css} +96 -0
- package/lib/creatorDist/assets/{index-CqVEKiJR.js → index-o3Cw0bsF.js} +69682 -66808
- package/lib/creatorDist/index.html +2 -2
- package/lib/utils/api.d.ts +0 -9
- package/lib/utils/api.js +1 -65
- package/lib/utils/lessonRecovery.d.ts +18 -0
- package/lib/utils/lessonRecovery.js +31 -0
- package/package.json +1 -1
- package/src/commands/serve.ts +65 -18
- package/src/creator/package-lock.json +297 -3
- package/src/creator/package.json +1 -0
- package/src/creator/src/App.test.tsx +124 -1
- package/src/creator/src/App.tsx +52 -36
- package/src/creator/src/components/FileCard.tsx +63 -19
- package/src/creator/src/components/FileReadListener.test.tsx +128 -0
- package/src/creator/src/components/FileReadListener.tsx +75 -0
- package/src/creator/src/components/FileReviewModal.test.tsx +193 -0
- package/src/creator/src/components/FileReviewModal.tsx +128 -0
- package/src/creator/src/components/FileUploader.test.tsx +229 -0
- package/src/creator/src/components/FileUploader.tsx +212 -58
- package/src/creator/src/components/GenerationErrorCard.tsx +57 -0
- package/src/creator/src/components/MarkdownRenderer.tsx +4 -2
- package/src/creator/src/components/syllabus/SyllabusEditor.tsx +6 -9
- package/src/creator/src/index.css +45 -0
- package/src/creator/src/locales/en.json +27 -5
- package/src/creator/src/locales/es.json +27 -5
- package/src/creator/src/main.tsx +2 -0
- package/src/creator/src/utils/completionResult.test.ts +61 -0
- package/src/creator/src/utils/completionResult.ts +61 -2
- package/src/creator/src/utils/courseLanguage.test.ts +6 -0
- package/src/creator/src/utils/creationRequest.test.ts +53 -18
- package/src/creator/src/utils/creationRequest.ts +17 -12
- package/src/creator/src/utils/fileRead.ts +60 -0
- package/src/creator/src/utils/lib.ts +0 -5
- package/src/creator/src/utils/store.ts +0 -10
- package/src/creatorDist/assets/{index-B7s5Nmq8.css → index-Cco3XhDR.css} +96 -0
- package/src/creatorDist/assets/{index-CqVEKiJR.js → index-o3Cw0bsF.js} +69682 -66808
- package/src/creatorDist/index.html +2 -2
- package/src/utils/api.ts +0 -83
- package/src/utils/lessonRecovery.ts +32 -0
package/src/creator/src/main.tsx
CHANGED
|
@@ -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
|
-
| {
|
|
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 {
|
|
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
|
|
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
|
-
)}.
|
|
20
|
-
|
|
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
|
-
|
|
53
|
-
)
|
|
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,
|
|
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
|
|
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
|
|
43
|
-
//
|
|
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
|
-
)}.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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,
|