@learnpack/learnpack 5.0.359 → 5.0.360

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.
@@ -0,0 +1,173 @@
1
+ import { franc } from "franc";
2
+
3
+ export type TCourseLanguage = "en" | "es";
4
+
5
+ /**
6
+ * Narrows a language code to one the creator can build a course in.
7
+ * @param value - A code such as "es", "EN" or an i18next resolved language.
8
+ * @returns "en" or "es", or null for anything else (including "es-MX").
9
+ */
10
+ export const toCourseLanguage = (
11
+ value: string | null | undefined
12
+ ): TCourseLanguage | null => {
13
+ const code = value?.trim().toLowerCase();
14
+ return code === "en" || code === "es" ? code : null;
15
+ };
16
+
17
+ // franc only reads the first 2048 characters of whatever it is given, so long
18
+ // text is judged window by window.
19
+ const WINDOW_LENGTH = 2048;
20
+ // Enough windows that a cover page or front matter cannot decide alone, few
21
+ // enough that an English bibliography at the end of a Spanish document cannot
22
+ // outvote its body.
23
+ const MAX_WINDOWS = 5;
24
+ // Below this many letters, titles, code and tables misclassify. Digits and
25
+ // symbols are not counted because they carry no language.
26
+ const MIN_LETTERS = 200;
27
+ // A window shorter than this at the end of the text is too small to vote.
28
+ const MIN_WINDOW_LENGTH = 200;
29
+ const MAJORITY = 2 / 3;
30
+
31
+ const countLetters = (text: string) => (text.match(/\p{L}/gu) || []).length;
32
+
33
+ /**
34
+ * Detects whether a piece of seed text is English or Spanish.
35
+ *
36
+ * Votes over the first few 2048-character windows and only answers on a
37
+ * two-thirds majority, so mixed or ambiguous text comes back as null rather
38
+ * than as a guess. Windows in any other language vote too, so a Portuguese
39
+ * or French document is not passed off as Spanish or English.
40
+ * @param text - Text extracted from an uploaded file, pasted text or a
41
+ * transcript.
42
+ * @returns "en" or "es", "other" when the text is mostly in a language the
43
+ * creator does not support, or null when there is too little text or no clear
44
+ * majority.
45
+ */
46
+ export const detectTextLanguage = (
47
+ text: string
48
+ ): TCourseLanguage | "other" | null => {
49
+ if (countLetters(text) < MIN_LETTERS) return null;
50
+
51
+ let english = 0;
52
+ let spanish = 0;
53
+ let other = 0;
54
+ let windows = 0;
55
+ for (
56
+ let start = 0;
57
+ start < text.length && windows < MAX_WINDOWS;
58
+ start += WINDOW_LENGTH
59
+ ) {
60
+ const window = text.slice(start, start + WINDOW_LENGTH);
61
+ if (start > 0 && window.length < MIN_WINDOW_LENGTH) continue;
62
+ windows++;
63
+ const lang = franc(window);
64
+ if (lang === "eng") english++;
65
+ else if (lang === "spa") spanish++;
66
+ else if (lang !== "und") other++;
67
+ }
68
+
69
+ const votes = english + spanish + other;
70
+ if (votes === 0) return null;
71
+ if (english / votes >= MAJORITY) return "en";
72
+ if (spanish / votes >= MAJORITY) return "es";
73
+ if (other / votes >= MAJORITY) return "other";
74
+ return null;
75
+ };
76
+
77
+ export type TSeedLanguage = TCourseLanguage | "mixed" | null;
78
+
79
+ /**
80
+ * Detects the language of everything the user gave as course content, judging
81
+ * each source separately so one long file cannot outvote a short one.
82
+ * @param seeds - The text of each uploaded file, pasted text or transcript.
83
+ * @returns The shared language, "mixed" when confidently detected sources
84
+ * disagree, or null when none could be judged or any source is in a language
85
+ * the creator does not support (the model then picks, as before).
86
+ */
87
+ export const detectSeedLanguage = (seeds: string[]): TSeedLanguage => {
88
+ const detected = seeds.map(detectTextLanguage);
89
+ if (detected.includes("other")) return null;
90
+ const languages = new Set(
91
+ detected.filter((lang): lang is TCourseLanguage => lang !== null)
92
+ );
93
+ if (languages.size === 0) return null;
94
+ if (languages.size > 1) return "mixed";
95
+ return [...languages][0];
96
+ };
97
+
98
+ // Words that exist in only one of the two languages. Ambiguous ones such as
99
+ // "a", "me", "no" or "son" are left out on purpose.
100
+ const ENGLISH_ONLY_WORDS = new Set(
101
+ "the to and of for with how what why when your my our this that these those is are was be into from about on in an i we you want learn teach build create make use using write explain help course lesson beginners introduction basics guide".split(
102
+ " "
103
+ )
104
+ );
105
+ const SPANISH_ONLY_WORDS = new Set(
106
+ "el la los las del de para con por sobre cómo como qué que y en un una unos unas mi mis nuestro nuestra nuestros es son está quiero aprender aprende enseña enseñar crear crea construir usar usando escribir explica ayúdame curso lección principiantes introducción básico básica guía desde".split(
107
+ " "
108
+ )
109
+ );
110
+ const SPANISH_CHARACTERS = /[ñáéíóú¿¡]/i;
111
+
112
+ /**
113
+ * Detects the language a course description was written in.
114
+ *
115
+ * Descriptions are a few words long and full of technical terms, which
116
+ * statistical detection misreads ("Learn TypeScript generics" comes out as
117
+ * Spanish). Counting words that only exist in one language is reliable at
118
+ * that length, and answering only when the other language shows no sign at
119
+ * all keeps mixed descriptions out.
120
+ * @param description - The course description from the wizard.
121
+ * @returns "en" or "es", or null when the description is mixed or has no
122
+ * telling words.
123
+ */
124
+ export const detectDescriptionLanguage = (
125
+ description: string
126
+ ): TCourseLanguage | null => {
127
+ const words = description.toLowerCase().match(/\p{L}+/gu) || [];
128
+ let english = 0;
129
+ let spanish = 0;
130
+ for (const word of words) {
131
+ if (ENGLISH_ONLY_WORDS.has(word)) english++;
132
+ if (SPANISH_ONLY_WORDS.has(word)) spanish++;
133
+ }
134
+ if (SPANISH_CHARACTERS.test(description)) spanish++;
135
+
136
+ if (english > 0 && spanish === 0) return "en";
137
+ if (spanish > 0 && english === 0) return "es";
138
+ return null;
139
+ };
140
+
141
+ export type TCourseLanguageDecision =
142
+ // Nothing settles the language: the request goes out exactly as before and
143
+ // the model picks.
144
+ | { kind: "model" }
145
+ | { kind: "use"; language: TCourseLanguage }
146
+ // Only the user can say which language they meant.
147
+ | { kind: "ask" };
148
+
149
+ /**
150
+ * Decides the course language before generation.
151
+ *
152
+ * A language the user chose always wins. Otherwise the seed's language is used
153
+ * when the description agrees or cannot be judged, and any disagreement is put
154
+ * to the user. A misread description can therefore only cause a question,
155
+ * never a course in the wrong language.
156
+ */
157
+ export const decideCourseLanguage = ({
158
+ chosen,
159
+ seed,
160
+ description,
161
+ }: {
162
+ chosen: TCourseLanguage | null;
163
+ seed: TSeedLanguage;
164
+ description: TCourseLanguage | null;
165
+ }): TCourseLanguageDecision => {
166
+ if (chosen) return { kind: "use", language: chosen };
167
+ if (seed === null) return { kind: "model" };
168
+ if (seed === "mixed") return { kind: "ask" };
169
+ if (description === null || description === seed) {
170
+ return { kind: "use", language: seed };
171
+ }
172
+ return { kind: "ask" };
173
+ };
@@ -0,0 +1,104 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import type { ParsedFile } from "../components/FileUploader";
3
+ import { buildCourseInfo, toCourseFormState } from "./creationRequest";
4
+ import useStore, { FormState } from "./store";
5
+
6
+ /**
7
+ * 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.
10
+ */
11
+ const legacyCourseInfo = (
12
+ formState: FormState,
13
+ techs: { slug: string }[],
14
+ uploadedFiles: ParsedFile[]
15
+ ) =>
16
+ // prettier-ignore
17
+ `${JSON.stringify(
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
+
23
+ The following files have been uploaded by the user: ${uploadedFiles.map((f) => `<FILE name="${f.name}">${f.text}</FILE>`).join("\n")}
24
+ `;
25
+
26
+ const defaultFormState = () => useStore.getInitialState().formState;
27
+
28
+ const techs = [{ slug: "python" }, { slug: "sql" }];
29
+ const uploadedFiles: ParsedFile[] = [
30
+ {
31
+ name: "notes.pdf",
32
+ text: "Relational databases",
33
+ status: "SUCCESS",
34
+ notificationId: "n1",
35
+ },
36
+ ];
37
+
38
+ describe("buildCourseInfo", () => {
39
+ it("sends exactly the previous prompt when no language is settled", () => {
40
+ const formState = {
41
+ ...defaultFormState(),
42
+ description: "Learn SQL joins",
43
+ purpose: "learnpack-lesson-writer",
44
+ duration: 30,
45
+ hasContentIndex: true,
46
+ };
47
+ // The wizard state before this change had no languageChosen key.
48
+ const previousFormState = { ...formState };
49
+ delete previousFormState.languageChosen;
50
+
51
+ expect(
52
+ buildCourseInfo({ formState, techs, uploadedFiles, language: null })
53
+ ).toBe(legacyCourseInfo(previousFormState, techs, uploadedFiles));
54
+ });
55
+
56
+ it("asks for the settled language in plain words", () => {
57
+ const formState = { ...defaultFormState(), language: "en" };
58
+ const courseInfo = buildCourseInfo({
59
+ formState,
60
+ techs,
61
+ uploadedFiles,
62
+ language: "es",
63
+ });
64
+
65
+ expect(courseInfo).toContain('"language":"es"');
66
+ expect(courseInfo).not.toContain('"language":"en"');
67
+ expect(
68
+ courseInfo.endsWith(
69
+ "The user chose Spanish as the course language. This replaces the rule to write in the language of the user messages: write every field in Spanish, including the title, the description, and each step's title and description in listOfSteps, even if the user's messages or the uploaded files are in another language. Set languageCode to \"es\"."
70
+ )
71
+ ).toBe(true);
72
+ });
73
+
74
+ it("never sends languageChosen to the model", () => {
75
+ const formState = {
76
+ ...defaultFormState(),
77
+ language: "es",
78
+ languageChosen: true,
79
+ };
80
+ for (const language of [null, "es"] as const) {
81
+ expect(
82
+ buildCourseInfo({ formState, techs, uploadedFiles, language })
83
+ ).not.toContain("languageChosen");
84
+ }
85
+ });
86
+ });
87
+
88
+ describe("toCourseFormState", () => {
89
+ it("drops languageChosen and keeps every other key in order", () => {
90
+ const formState = { ...defaultFormState(), languageChosen: true };
91
+ const courseFormState = toCourseFormState(formState);
92
+
93
+ expect("languageChosen" in courseFormState).toBe(false);
94
+ expect(Object.keys(courseFormState)).toEqual(
95
+ Object.keys(formState).filter((key) => key !== "languageChosen")
96
+ );
97
+ });
98
+
99
+ it("does not change the form state it was given", () => {
100
+ const formState = { ...defaultFormState(), languageChosen: true };
101
+ toCourseFormState(formState);
102
+ expect(formState.languageChosen).toBe(true);
103
+ });
104
+ });
@@ -0,0 +1,63 @@
1
+ import type { ParsedFile } from "../components/FileUploader";
2
+ import type { TCourseLanguage } from "./courseLanguage";
3
+ import type { FormState } from "./store";
4
+
5
+ const LANGUAGE_NAMES: Record<TCourseLanguage, string> = {
6
+ en: "English",
7
+ es: "Spanish",
8
+ };
9
+
10
+ /**
11
+ * Drops the wizard-only fields from the form state before it leaves the
12
+ * wizard, in the prompt or in the syllabus sent to the server.
13
+ * @param formState - The wizard's form state.
14
+ * @returns A copy without `languageChosen`, keys otherwise in the same order.
15
+ */
16
+ export const toCourseFormState = (formState: FormState): FormState => {
17
+ const courseFormState = { ...formState };
18
+ delete courseFormState.languageChosen;
19
+ return courseFormState;
20
+ };
21
+
22
+ /**
23
+ * Builds the `courseInfo` text sent to the model to generate the syllabus.
24
+ *
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.
27
+ * @param language - The course language settled before generation, or null.
28
+ */
29
+ export const buildCourseInfo = ({
30
+ formState,
31
+ techs,
32
+ uploadedFiles,
33
+ language,
34
+ }: {
35
+ formState: FormState;
36
+ techs: { slug: string }[];
37
+ uploadedFiles: ParsedFile[];
38
+ language: TCourseLanguage | null;
39
+ }): string => {
40
+ const courseFormState = toCourseFormState(formState);
41
+
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.
45
+ const courseInfo = `${JSON.stringify(
46
+ 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
+ .map((f) => `<FILE name="${f.name}">${f.text}</FILE>`)
53
+ .join("\n")}
54
+ `;
55
+
56
+ if (!language) return courseInfo;
57
+
58
+ const name = LANGUAGE_NAMES[language];
59
+ // Prompt 390 keys on the first sentence to override its rule of writing in
60
+ // the language of the user messages, so keep its wording in sync.
61
+ return `${courseInfo}
62
+ The user chose ${name} as the course language. This replaces the rule to write in the language of the user messages: write every field in ${name}, including the title, the description, and each step's title and description in listOfSteps, even if the user's messages or the uploaded files are in another language. Set languageCode to "${language}".`;
63
+ };
@@ -15,6 +15,10 @@ export type FormState = {
15
15
  difficulty: TDifficulty;
16
16
  slug: string;
17
17
  language?: string;
18
+ // True once the user picked the course language themselves (the language
19
+ // picker, the `language` URL param or a language modal). Never sent to the
20
+ // model or the server.
21
+ languageChosen?: boolean;
18
22
  isCompleted: boolean;
19
23
  variables: string[];
20
24
  currentStep: string;
@@ -93,6 +97,7 @@ const useStore = create<Store>()(
93
97
  contentIndex: "",
94
98
  purpose: "",
95
99
  language: "en",
100
+ languageChosen: false,
96
101
  technologies: [],
97
102
  difficulty: "beginner",
98
103
  // sources: [],
@@ -129,6 +134,7 @@ const useStore = create<Store>()(
129
134
  description: "",
130
135
  duration: 0,
131
136
  language: "en",
137
+ languageChosen: false,
132
138
  difficulty: "beginner",
133
139
  technologies: [],
134
140
  hasContentIndex: false,
@@ -179,6 +185,7 @@ const useStore = create<Store>()(
179
185
  slug: "",
180
186
  difficulty: "beginner",
181
187
  language: "en",
188
+ languageChosen: false,
182
189
  technologies: [],
183
190
  hasContentIndex: false,
184
191
  contentIndex: "",
@@ -65,6 +65,7 @@
65
65
  --color-blue-50: oklch(97% 0.014 254.604);
66
66
  --color-blue-100: oklch(93.2% 0.032 255.585);
67
67
  --color-blue-200: oklch(88.2% 0.059 254.128);
68
+ --color-blue-300: oklch(80.9% 0.105 251.813);
68
69
  --color-blue-400: oklch(70.7% 0.165 254.624);
69
70
  --color-blue-500: oklch(62.3% 0.214 259.815);
70
71
  --color-blue-600: oklch(54.6% 0.245 262.881);
@@ -439,6 +440,12 @@
439
440
  .z-1000 {
440
441
  z-index: 1000;
441
442
  }
443
+ .col-span-2 {
444
+ grid-column: span 2 / span 2;
445
+ }
446
+ .row-start-2 {
447
+ grid-row-start: 2;
448
+ }
442
449
  .container {
443
450
  width: 100%;
444
451
  }
@@ -536,6 +543,9 @@
536
543
  .flex {
537
544
  display: flex;
538
545
  }
546
+ .grid {
547
+ display: grid;
548
+ }
539
549
  .hidden {
540
550
  display: none;
541
551
  }
@@ -548,6 +558,9 @@
548
558
  .table {
549
559
  display: table;
550
560
  }
561
+ .field-sizing-content {
562
+ field-sizing: content;
563
+ }
551
564
  .h-2 {
552
565
  height: calc(var(--spacing) * 2);
553
566
  }
@@ -644,6 +657,9 @@
644
657
  .w-full {
645
658
  width: 100%;
646
659
  }
660
+ .max-w-64 {
661
+ max-width: calc(var(--spacing) * 64);
662
+ }
647
663
  .max-w-\[700px\] {
648
664
  max-width: 700px;
649
665
  }
@@ -705,6 +721,9 @@
705
721
  .list-disc {
706
722
  list-style-type: disc;
707
723
  }
724
+ .grid-cols-2 {
725
+ grid-template-columns: repeat(2, minmax(0, 1fr));
726
+ }
708
727
  .flex-col {
709
728
  flex-direction: column;
710
729
  }
@@ -780,6 +799,15 @@
780
799
  calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse))
781
800
  );
782
801
  }
802
+ .justify-self-center {
803
+ justify-self: center;
804
+ }
805
+ .justify-self-end {
806
+ justify-self: flex-end;
807
+ }
808
+ .justify-self-start {
809
+ justify-self: flex-start;
810
+ }
783
811
  .truncate {
784
812
  text-overflow: ellipsis;
785
813
  white-space: nowrap;
@@ -905,6 +933,9 @@
905
933
  .bg-red-100 {
906
934
  background-color: var(--color-red-100);
907
935
  }
936
+ .bg-transparent {
937
+ background-color: #0000;
938
+ }
908
939
  .bg-white {
909
940
  background-color: var(--color-white);
910
941
  }
@@ -1046,6 +1077,9 @@
1046
1077
  .break-words {
1047
1078
  overflow-wrap: break-word;
1048
1079
  }
1080
+ .whitespace-nowrap {
1081
+ white-space: nowrap;
1082
+ }
1049
1083
  .whitespace-pre-line {
1050
1084
  white-space: pre-line;
1051
1085
  }
@@ -1244,6 +1278,19 @@
1244
1278
  .focus\:ring-blue-200:focus {
1245
1279
  --tw-ring-color: var(--color-blue-200);
1246
1280
  }
1281
+ .focus\:outline-none:focus {
1282
+ --tw-outline-style: none;
1283
+ outline-style: none;
1284
+ }
1285
+ .focus-visible\:ring-2:focus-visible {
1286
+ --tw-ring-shadow: var(--tw-ring-inset) 0 0 0
1287
+ calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
1288
+ box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow),
1289
+ var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
1290
+ }
1291
+ .focus-visible\:ring-blue-300:focus-visible {
1292
+ --tw-ring-color: var(--color-blue-300);
1293
+ }
1247
1294
  .disabled\:cursor-not-allowed:disabled {
1248
1295
  cursor: not-allowed;
1249
1296
  }
@@ -1257,9 +1304,21 @@
1257
1304
  opacity: 0.5;
1258
1305
  }
1259
1306
  @media (min-width: 40rem) {
1307
+ .sm\:col-span-1 {
1308
+ grid-column: span 1 / span 1;
1309
+ }
1310
+ .sm\:col-start-2 {
1311
+ grid-column-start: 2;
1312
+ }
1313
+ .sm\:row-start-1 {
1314
+ grid-row-start: 1;
1315
+ }
1260
1316
  .sm\:w-auto {
1261
1317
  width: auto;
1262
1318
  }
1319
+ .sm\:grid-cols-\[1fr_auto_1fr\] {
1320
+ grid-template-columns: 1fr auto 1fr;
1321
+ }
1263
1322
  .sm\:flex-row {
1264
1323
  flex-direction: row;
1265
1324
  }