rogasper commited on
Commit
02ffd8a
·
1 Parent(s): f4209d5

feat: enhance question formats by adding support for matching pairs, error recognition, and text insertion. Update related components and schemas to accommodate new formats, including UI adjustments for displaying options and handling user answers. Implement partial credit scoring for matching pairs and improve answer validation logic in the backend.

Browse files
apps/web/src/components/generate/ResultSection.tsx CHANGED
@@ -60,17 +60,35 @@ export function ResultSection({ result, generatedPackageId }: ResultSectionProps
60
  <p className="font-medium text-[var(--clay-black)] text-lg">{q.questionText}</p>
61
  {"options" in q && q.options && (
62
  <div className="space-y-2">
63
- {q.options.map((opt) => (
64
- <div
65
- key={opt.key}
66
- className="flex items-center p-4 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--oat-light)]"
67
- >
68
- <span className="w-6 h-6 rounded-full border-2 border-[var(--oat-border)] flex items-center justify-center mr-3 text-xs font-bold text-[var(--warm-charcoal)]">
69
- {opt.key}
70
- </span>
71
- <span className="text-[var(--clay-black)]">{opt.text}</span>
72
- </div>
73
- ))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  </div>
75
  )}
76
  <div className="flex gap-2 flex-wrap">
 
60
  <p className="font-medium text-[var(--clay-black)] text-lg">{q.questionText}</p>
61
  {"options" in q && q.options && (
62
  <div className="space-y-2">
63
+ {q.format === "matching_pairs"
64
+ ? q.options.map((opt: { left: string; right?: string }, i: number) => (
65
+ <div
66
+ key={i}
67
+ className="flex items-center p-4 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--oat-light)]"
68
+ >
69
+ <span className="w-6 h-6 rounded-full border-2 border-[var(--oat-border)] flex items-center justify-center mr-3 text-xs font-bold text-[var(--warm-charcoal)]">
70
+ {opt.left?.charAt(0)?.toUpperCase() ?? i + 1}
71
+ </span>
72
+ <span className="text-[var(--clay-black)]">{opt.left}</span>
73
+ {opt.right && (
74
+ <>
75
+ <span className="mx-3 text-[var(--warm-silver)]">→</span>
76
+ <span className="text-[var(--matcha-700)] font-medium">{opt.right}</span>
77
+ </>
78
+ )}
79
+ </div>
80
+ ))
81
+ : q.options.map((opt: { key: string; text: string }) => (
82
+ <div
83
+ key={opt.key}
84
+ className="flex items-center p-4 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--oat-light)]"
85
+ >
86
+ <span className="w-6 h-6 rounded-full border-2 border-[var(--oat-border)] flex items-center justify-center mr-3 text-xs font-bold text-[var(--warm-charcoal)]">
87
+ {opt.key}
88
+ </span>
89
+ <span className="text-[var(--clay-black)]">{opt.text}</span>
90
+ </div>
91
+ ))}
92
  </div>
93
  )}
94
  <div className="flex gap-2 flex-wrap">
apps/web/src/components/test/AccentKeyboard.tsx ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback } from "react";
2
+
3
+ const ACCENT_CHARS: Record<string, string[]> = {
4
+ DELE: ["á", "é", "í", "ó", "ú", "ñ", "¿", "¡"],
5
+ GOETHE: ["ä", "ö", "ü", "ß"],
6
+ TOAFL: ["ا", "ب", "ت", "ث", "ج", "ح", "خ", "د", "ذ", "ر", "ز", "س", "ش", "ص", "ض", "ط", "ظ", "ع", "غ", "ف", "ق", "ك", "ل", "م", "ن", "ه", "و", "ي"],
7
+ };
8
+
9
+ interface AccentKeyboardProps {
10
+ examType: string;
11
+ onInsert: (char: string) => void;
12
+ disabled?: boolean;
13
+ }
14
+
15
+ export function AccentKeyboard({ examType, onInsert, disabled }: AccentKeyboardProps) {
16
+ const chars = ACCENT_CHARS[examType];
17
+ if (!chars || chars.length === 0) return null;
18
+
19
+ return (
20
+ <div className={`flex flex-wrap gap-1.5 p-3 rounded-[var(--radius-lg)] bg-[var(--oat-light)] border border-[var(--oat-border)] ${disabled ? "opacity-50" : ""}`}>
21
+ <span className="w-full text-[10px] font-medium text-[var(--warm-charcoal)] uppercase tracking-wider mb-0.5">
22
+ Special Characters
23
+ </span>
24
+ {chars.map((char) => (
25
+ <button
26
+ key={char}
27
+ type="button"
28
+ onClick={() => onInsert(char)}
29
+ disabled={disabled}
30
+ className="w-8 h-8 flex items-center justify-center rounded-md text-sm font-semibold bg-[var(--pure-white)] border border-[var(--oat-border)] hover:bg-[var(--matcha-300)] hover:border-[var(--matcha-600)] transition-all clay-hover disabled:cursor-not-allowed"
31
+ >
32
+ {char}
33
+ </button>
34
+ ))}
35
+ </div>
36
+ );
37
+ }
apps/web/src/components/test/AttemptTestView.tsx CHANGED
@@ -6,6 +6,8 @@ import { MaterialIcon } from "@/components/ui/MaterialIcon";
6
  import { formatTime } from "@/lib/time";
7
  import { trpc } from "@/utils/trpc";
8
  import { QuestionInput } from "./QuestionInput";
 
 
9
 
10
  interface AttemptTestViewProps {
11
  attemptId: string;
@@ -56,6 +58,15 @@ const QuestionCard = memo(function QuestionCard({
56
  [q.id, sectionResultId, onAnswerChange],
57
  );
58
 
 
 
 
 
 
 
 
 
 
59
  return (
60
  <div
61
  id={`question-${q.id}`}
@@ -85,8 +96,8 @@ const QuestionCard = memo(function QuestionCard({
85
  </button>
86
  </div>
87
 
88
- <p className="text-[var(--warm-charcoal)] mb-6 font-medium leading-relaxed">
89
- {q.questionText}
90
  </p>
91
 
92
  <div className="pl-0">
@@ -102,6 +113,15 @@ const QuestionCard = memo(function QuestionCard({
102
  onChange={handleChange}
103
  disabled={isFinished}
104
  />
 
 
 
 
 
 
 
 
 
105
  </div>
106
  </div>
107
  );
@@ -144,6 +164,9 @@ export function AttemptTestView({
144
  const currentSection = pkg.sections[currentSectionIdx];
145
  const sectionData = attempt?.sections?.[currentSectionIdx];
146
  const sectionResultId = sectionData?.sectionResultId;
 
 
 
147
 
148
  // If user picks an answer before attempt.getById finishes, persist once sectionResultId exists
149
  const flushKeyRef = useRef<string | null>(null);
@@ -186,9 +209,17 @@ export function AttemptTestView({
186
  }
187
  }, [allQuestions]);
188
 
 
 
 
 
 
 
 
189
  const activeQuestion = currentSection?.questions?.find(
190
  (q: any) => q.id === activeQuestionId,
191
  );
 
192
  const passageToShow =
193
  activeQuestion?.passageText ??
194
  currentSection?.questions?.[0]?.passageText ??
@@ -306,8 +337,8 @@ export function AttemptTestView({
306
  <span>Section {currentSectionIdx + 1} dari {pkg.sections.length}</span>
307
  </div>
308
  </header>
309
- <div className="space-y-6 text-lg leading-relaxed text-[var(--warm-charcoal)] font-body whitespace-pre-wrap">
310
- {passageToShow}
311
  </div>
312
  </article>
313
  </section>
@@ -339,16 +370,16 @@ export function AttemptTestView({
339
  </div>
340
 
341
  {/* Active question only */}
342
- {activeQuestion ? (
343
  <QuestionCard
344
- key={activeQuestion.id}
345
- q={activeQuestion}
346
  globalIdx={activeGlobalIdx}
347
- answerValue={answers[activeQuestion.id] ?? ""}
348
  sectionResultId={sectionResultId}
349
- isMarked={isMarked(activeQuestion.id)}
350
  isFinished={isFinished}
351
- isSubmitting={submittingQId === activeQuestion.id}
352
  onAnswerChange={onAnswerChange}
353
  toggleMarkQuestion={toggleMarkQuestion}
354
  />
 
6
  import { formatTime } from "@/lib/time";
7
  import { trpc } from "@/utils/trpc";
8
  import { QuestionInput } from "./QuestionInput";
9
+ import { AccentKeyboard } from "./AccentKeyboard";
10
+ import { parseFurigana } from "@/lib/furigana";
11
 
12
  interface AttemptTestViewProps {
13
  attemptId: string;
 
58
  [q.id, sectionResultId, onAnswerChange],
59
  );
60
 
61
+ const handleAccentInsert = useCallback(
62
+ (char: string) => {
63
+ onAnswerChange(q.id, sectionResultId, answerValue + char);
64
+ },
65
+ [q.id, sectionResultId, answerValue, onAnswerChange],
66
+ );
67
+
68
+ const showAccentKeyboard = q.format === "fill_blank" || q.format === "sentence_completion";
69
+
70
  return (
71
  <div
72
  id={`question-${q.id}`}
 
96
  </button>
97
  </div>
98
 
99
+ <p className="text-[var(--warm-charcoal)] mb-6 font-medium leading-relaxed" dir={q._isRtl ? "rtl" : undefined}>
100
+ {q._useFurigana ? parseFurigana(q.questionText) : q.questionText}
101
  </p>
102
 
103
  <div className="pl-0">
 
113
  onChange={handleChange}
114
  disabled={isFinished}
115
  />
116
+ {showAccentKeyboard && (
117
+ <div className="mt-3">
118
+ <AccentKeyboard
119
+ examType={q._examType}
120
+ onInsert={handleAccentInsert}
121
+ disabled={isFinished}
122
+ />
123
+ </div>
124
+ )}
125
  </div>
126
  </div>
127
  );
 
164
  const currentSection = pkg.sections[currentSectionIdx];
165
  const sectionData = attempt?.sections?.[currentSectionIdx];
166
  const sectionResultId = sectionData?.sectionResultId;
167
+ const examType: string = pkg.examType ?? "";
168
+ const isRtl = examType === "TOAFL";
169
+ const useFurigana = examType === "JLPT" || examType === "TOPIK";
170
 
171
  // If user picks an answer before attempt.getById finishes, persist once sectionResultId exists
172
  const flushKeyRef = useRef<string | null>(null);
 
209
  }
210
  }, [allQuestions]);
211
 
212
+ const addQuestionMeta = (q: any) => ({
213
+ ...q,
214
+ _examType: examType,
215
+ _isRtl: isRtl,
216
+ _useFurigana: useFurigana,
217
+ });
218
+
219
  const activeQuestion = currentSection?.questions?.find(
220
  (q: any) => q.id === activeQuestionId,
221
  );
222
+ const activeQuestionWithMeta = activeQuestion ? addQuestionMeta(activeQuestion) : null;
223
  const passageToShow =
224
  activeQuestion?.passageText ??
225
  currentSection?.questions?.[0]?.passageText ??
 
337
  <span>Section {currentSectionIdx + 1} dari {pkg.sections.length}</span>
338
  </div>
339
  </header>
340
+ <div className="space-y-6 text-lg leading-relaxed text-[var(--warm-charcoal)] font-body whitespace-pre-wrap" dir={isRtl ? "rtl" : undefined}>
341
+ {useFurigana ? parseFurigana(passageToShow) : passageToShow}
342
  </div>
343
  </article>
344
  </section>
 
370
  </div>
371
 
372
  {/* Active question only */}
373
+ {activeQuestionWithMeta ? (
374
  <QuestionCard
375
+ key={activeQuestionWithMeta.id}
376
+ q={activeQuestionWithMeta}
377
  globalIdx={activeGlobalIdx}
378
+ answerValue={answers[activeQuestionWithMeta.id] ?? ""}
379
  sectionResultId={sectionResultId}
380
+ isMarked={isMarked(activeQuestionWithMeta.id)}
381
  isFinished={isFinished}
382
+ isSubmitting={submittingQId === activeQuestionWithMeta.id}
383
  onAnswerChange={onAnswerChange}
384
  toggleMarkQuestion={toggleMarkQuestion}
385
  />
apps/web/src/components/test/QuestionInput.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useState, useEffect, useRef, useCallback } from "react";
2
  import { Input } from "@labas/ui/components/input";
3
 
4
  const MCQ_FORMATS = [
@@ -14,6 +14,9 @@ const MCQ_FORMATS = [
14
  "matching_information",
15
  "summary_completion",
16
  "cloze",
 
 
 
17
  ];
18
 
19
  const TRUE_FALSE_CHOICES = [
@@ -119,6 +122,56 @@ export function QuestionInput({
119
  const format = question.format;
120
  const options = question.options as Array<{ key: string; text: string }> | undefined;
121
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  if (MCQ_FORMATS.includes(format)) {
123
  if (!options || options.length === 0) {
124
  return (
 
1
+ import { useState, useEffect, useRef, useCallback, useMemo } from "react";
2
  import { Input } from "@labas/ui/components/input";
3
 
4
  const MCQ_FORMATS = [
 
14
  "matching_information",
15
  "summary_completion",
16
  "cloze",
17
+ "error_recognition",
18
+ "text_insertion",
19
+ "matching_pairs",
20
  ];
21
 
22
  const TRUE_FALSE_CHOICES = [
 
122
  const format = question.format;
123
  const options = question.options as Array<{ key: string; text: string }> | undefined;
124
 
125
+ if (format === "matching_pairs") {
126
+ if (!options || options.length === 0) {
127
+ return (
128
+ <div className="text-sm text-[var(--warm-silver)] italic">
129
+ Tidak ada opsi tersedia untuk soal ini.
130
+ </div>
131
+ );
132
+ }
133
+ const parseCurrentMapping = (val: string): Map<string, string> => {
134
+ const map = new Map<string, string>();
135
+ val.split(",").forEach((pair) => {
136
+ const [k, v] = pair.split(":").map((s) => s.trim());
137
+ if (k && v) map.set(k, v);
138
+ });
139
+ return map;
140
+ };
141
+ const currentMap = value ? parseCurrentMapping(value) : new Map();
142
+ const updateMapping = (key: string, val: string) => {
143
+ currentMap.set(key, val);
144
+ const serialized = Array.from(currentMap.entries())
145
+ .map(([k, v]) => `${k}:${v}`)
146
+ .join(",");
147
+ onChange(serialized);
148
+ };
149
+ return (
150
+ <div className="space-y-3">
151
+ {options.map((opt) => {
152
+ const matched = currentMap.get(opt.key) || "";
153
+ return (
154
+ <div key={opt.key} className="flex items-center gap-3">
155
+ <span className="w-8 h-8 rounded-full text-sm font-bold flex items-center justify-center shrink-0 bg-[var(--oat-light)] text-[var(--clay-black)]">
156
+ {opt.key}
157
+ </span>
158
+ <span className="flex-1 text-sm text-[var(--clay-black)]">{opt.text}</span>
159
+ <span className="text-[var(--warm-silver)]">→</span>
160
+ <input
161
+ type="text"
162
+ value={matched}
163
+ onChange={(e) => updateMapping(opt.key, e.target.value)}
164
+ disabled={disabled}
165
+ placeholder="Padanan..."
166
+ className="w-24 px-3 py-2 text-sm rounded-[var(--radius-md)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] focus:outline-none focus:border-[var(--matcha-600)]"
167
+ />
168
+ </div>
169
+ );
170
+ })}
171
+ </div>
172
+ );
173
+ }
174
+
175
  if (MCQ_FORMATS.includes(format)) {
176
  if (!options || options.length === 0) {
177
  return (
apps/web/src/lib/difficulty-mapping.ts ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const DIFFICULTY_LABELS: Record<string, string[]> = {
2
+ IELTS: ["Band 4.0", "Band 5.0", "Band 5.5", "Band 6.5", "Band 8.0+"],
3
+ TOEFL: ["30-45", "46-55", "56-65", "66-80", "81-120"],
4
+ JLPT: ["N5", "N4", "N3", "N2", "N1"],
5
+ HSK: ["HSK 1", "HSK 2", "HSK 3", "HSK 4", "HSK 5-6"],
6
+ GOETHE: ["A1", "A2", "B1", "B2", "C1-C2"],
7
+ TOPIK: ["TOPIK I (Lv 1)", "TOPIK I (Lv 2)", "TOPIK II (Lv 3)", "TOPIK II (Lv 4)", "TOPIK II (Lv 5-6)"],
8
+ TOAFL: ["A1", "A2", "B1", "B2", "C1-C2"],
9
+ DELE: ["A1", "A2", "B1", "B2", "C1-C2"],
10
+ };
11
+
12
+ export function getDifficultyLabel(examType: string, level: number): string {
13
+ const labels = DIFFICULTY_LABELS[examType];
14
+ if (!labels) return `Level ${level}`;
15
+ const idx = Math.max(0, Math.min(level - 1, labels.length - 1));
16
+ return labels[idx];
17
+ }
apps/web/src/lib/exam-constants.ts CHANGED
@@ -4,6 +4,9 @@ export const EXAM_TYPES = [
4
  { id: "JLPT", name: "JLPT" },
5
  { id: "HSK", name: "HSK" },
6
  { id: "GOETHE", name: "German" },
 
 
 
7
  ];
8
 
9
  export const SECTIONS = [
@@ -22,6 +25,11 @@ export const FORMATS = [
22
  "reference",
23
  "author_view",
24
  "matching_headings",
 
 
 
 
 
25
  "kanji_reading",
26
  "particle_choice",
27
  "article_case",
 
4
  { id: "JLPT", name: "JLPT" },
5
  { id: "HSK", name: "HSK" },
6
  { id: "GOETHE", name: "German" },
7
+ { id: "TOPIK", name: "Korean" },
8
+ { id: "TOAFL", name: "Arabic" },
9
+ { id: "DELE", name: "Spanish" },
10
  ];
11
 
12
  export const SECTIONS = [
 
25
  "reference",
26
  "author_view",
27
  "matching_headings",
28
+ "matching_information",
29
+ "summary_completion",
30
+ "matching_pairs",
31
+ "error_recognition",
32
+ "text_insertion",
33
  "kanji_reading",
34
  "particle_choice",
35
  "article_case",
apps/web/src/lib/furigana.tsx ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ReactNode } from "react";
2
+
3
+ const FURIGANA_PATTERN = /([\u4e00-\u9fff\u3400-\u4dbf\ua000-\ua4cf\uf900-\ufaff]+)\(([^)]+)\)/g;
4
+
5
+ export function parseFurigana(text: string): ReactNode {
6
+ if (!text) return text;
7
+
8
+ const parts: ReactNode[] = [];
9
+ let lastIndex = 0;
10
+ let match: RegExpExecArray | null;
11
+
12
+ const re = new RegExp(FURIGANA_PATTERN.source, "g");
13
+ while ((match = re.exec(text)) !== null) {
14
+ if (match.index > lastIndex) {
15
+ parts.push(text.slice(lastIndex, match.index));
16
+ }
17
+ parts.push(
18
+ <ruby key={match.index}>
19
+ {match[1]}
20
+ <rt>{match[2]}</rt>
21
+ </ruby>,
22
+ );
23
+ lastIndex = re.lastIndex;
24
+ }
25
+
26
+ if (lastIndex < text.length) {
27
+ parts.push(text.slice(lastIndex));
28
+ }
29
+
30
+ return parts.length > 0 ? parts : text;
31
+ }
apps/web/src/lib/generate-constants.ts CHANGED
@@ -4,6 +4,9 @@ export const EXAM_TYPES = [
4
  { id: "JLPT", name: "JLPT", code: "jp" },
5
  { id: "HSK", name: "HSK", code: "cn" },
6
  { id: "GOETHE", name: "Goethe-Zertifikat", code: "de" },
 
 
 
7
  ];
8
 
9
  export const SECTIONS = [
@@ -12,25 +15,33 @@ export const SECTIONS = [
12
  ];
13
 
14
  export const FORMATS = [
15
- { id: "multiple_choice", name: "Multiple Choice", allowedExams: ["IELTS", "TOEFL", "JLPT", "HSK", "GOETHE"] },
16
- { id: "true_false_not_given", name: "True / False / Not Given", allowedExams: ["IELTS", "TOEFL", "JLPT", "HSK", "GOETHE"] },
17
- { id: "fill_blank", name: "Fill in Blank", allowedExams: ["IELTS", "TOEFL", "JLPT", "HSK", "GOETHE"] },
18
- { id: "synonym", name: "Synonym / Vocabulary", allowedExams: ["IELTS", "TOEFL", "JLPT", "HSK", "GOETHE"] },
19
- { id: "grammar_in_context", name: "Grammar in Context", allowedExams: ["IELTS", "TOEFL", "JLPT", "HSK", "GOETHE"] },
20
- { id: "sentence_completion", name: "Sentence Completion", allowedExams: ["IELTS", "TOEFL", "JLPT", "HSK", "GOETHE"] },
21
- { id: "cloze", name: "Cloze Test", allowedExams: ["IELTS", "TOEFL", "JLPT", "HSK", "GOETHE"] },
22
- { id: "reference", name: "Reference (Pronoun)", allowedExams: ["IELTS", "TOEFL", "JLPT", "HSK", "GOETHE"] },
23
- { id: "author_view", name: "Author's View", allowedExams: ["IELTS", "TOEFL", "JLPT", "HSK", "GOETHE"] },
24
- { id: "matching_headings", name: "Matching Headings", allowedExams: ["IELTS", "TOEFL", "JLPT", "HSK", "GOETHE"] },
 
 
 
 
 
25
  { id: "kanji_reading", name: "Kanji Reading", allowedExams: ["JLPT"] },
26
- { id: "particle_choice", name: "Particle Choice", allowedExams: ["JLPT"] },
27
- { id: "article_case", name: "Article / Case", allowedExams: ["GOETHE"] },
28
- { id: "character_reading", name: "Character Reading", allowedExams: ["HSK"] },
29
- { id: "sentence_arrangement", name: "Sentence Arrangement", allowedExams: ["HSK"] },
30
  ];
31
 
32
- export const TOPICS = ["Science & Tech", "Business", "Sociology", "Arts", "History", "Environment", "Health", "Education"];
33
- export const DIFFICULTIES = ["Beginner", "Intermediate", "Academic", "Expert"];
 
 
 
34
 
35
  export const QUESTION_COUNT_PRESETS = [
36
  { value: 5, label: "5 Soal", desc: "Drill cepat" },
 
4
  { id: "JLPT", name: "JLPT", code: "jp" },
5
  { id: "HSK", name: "HSK", code: "cn" },
6
  { id: "GOETHE", name: "Goethe-Zertifikat", code: "de" },
7
+ { id: "TOPIK", name: "TOPIK", code: "kr" },
8
+ { id: "TOAFL", name: "TOAFL", code: "sa" },
9
+ { id: "DELE", name: "DELE", code: "es" },
10
  ];
11
 
12
  export const SECTIONS = [
 
15
  ];
16
 
17
  export const FORMATS = [
18
+ { id: "multiple_choice", name: "Multiple Choice", allowedExams: ["IELTS", "TOEFL", "JLPT", "HSK", "GOETHE", "TOPIK", "TOAFL", "DELE"] },
19
+ { id: "true_false_not_given", name: "True / False / Not Given", allowedExams: ["IELTS", "TOEFL", "JLPT", "HSK", "GOETHE", "TOPIK", "TOAFL", "DELE"] },
20
+ { id: "fill_blank", name: "Fill in Blank", allowedExams: ["IELTS", "TOEFL", "JLPT", "HSK", "GOETHE", "TOPIK", "TOAFL", "DELE"] },
21
+ { id: "synonym", name: "Synonym / Vocabulary", allowedExams: ["IELTS", "TOEFL", "JLPT", "HSK", "GOETHE", "TOPIK", "TOAFL", "DELE"] },
22
+ { id: "grammar_in_context", name: "Grammar in Context", allowedExams: ["IELTS", "TOEFL", "JLPT", "HSK", "GOETHE", "TOPIK", "TOAFL", "DELE"] },
23
+ { id: "sentence_completion", name: "Sentence Completion", allowedExams: ["IELTS", "TOEFL", "JLPT", "HSK", "GOETHE", "TOPIK", "TOAFL", "DELE"] },
24
+ { id: "cloze", name: "Cloze Test", allowedExams: ["IELTS", "TOEFL", "JLPT", "HSK", "GOETHE", "TOPIK", "TOAFL", "DELE"] },
25
+ { id: "reference", name: "Reference (Pronoun)", allowedExams: ["IELTS", "TOEFL", "JLPT", "HSK", "GOETHE", "TOPIK", "TOAFL", "DELE"] },
26
+ { id: "author_view", name: "Author's View", allowedExams: ["IELTS", "TOEFL", "JLPT", "HSK", "GOETHE", "TOPIK", "TOAFL", "DELE"] },
27
+ { id: "matching_headings", name: "Matching Headings", allowedExams: ["IELTS", "TOEFL", "JLPT", "HSK", "GOETHE", "TOPIK", "TOAFL", "DELE"] },
28
+ { id: "matching_information", name: "Matching Information", allowedExams: ["IELTS", "TOEFL", "JLPT", "HSK", "GOETHE", "TOPIK", "TOAFL", "DELE"] },
29
+ { id: "summary_completion", name: "Summary Completion", allowedExams: ["IELTS", "TOEFL", "JLPT", "HSK", "GOETHE", "TOPIK", "TOAFL", "DELE"] },
30
+ { id: "matching_pairs", name: "Matching Pairs", allowedExams: ["IELTS", "TOEFL", "GOETHE", "DELE", "TOPIK"] },
31
+ { id: "error_recognition", name: "Error Recognition", allowedExams: ["TOEFL", "HSK", "TOPIK", "TOAFL"] },
32
+ { id: "text_insertion", name: "Text Insertion", allowedExams: ["TOEFL", "IELTS"] },
33
  { id: "kanji_reading", name: "Kanji Reading", allowedExams: ["JLPT"] },
34
+ { id: "particle_choice", name: "Particle / Conjunction Choice", allowedExams: ["JLPT", "TOPIK"] },
35
+ { id: "article_case", name: "Article / Gender / Agreement", allowedExams: ["GOETHE", "TOAFL", "DELE"] },
36
+ { id: "character_reading", name: "Character Reading", allowedExams: ["HSK", "TOPIK"] },
37
+ { id: "sentence_arrangement", name: "Sentence Arrangement", allowedExams: ["HSK", "TOPIK", "DELE", "TOAFL"] },
38
  ];
39
 
40
+ export const TOPICS = [
41
+ "Science & Tech", "Business", "Sociology", "Arts", "History", "Environment", "Health", "Education",
42
+ "Daily Interaction", "Social Etiquette", "Workplace",
43
+ ];
44
+ export const DIFFICULTIES = ["Beginner", "Elementary", "Intermediate", "Advanced", "Expert"];
45
 
46
  export const QUESTION_COUNT_PRESETS = [
47
  { value: 5, label: "5 Soal", desc: "Drill cepat" },
apps/web/src/routes/attempt.$id.tsx CHANGED
@@ -412,6 +412,14 @@ function QuestionReviewCard({
412
  {userAnswer}
413
  </span>
414
  </div>
 
 
 
 
 
 
 
 
415
  {(isCorrect === false || isCorrect === null) && (
416
  <div>
417
  <span className="text-[var(--warm-silver)]">Jawaban Benar:</span>{" "}
 
412
  {userAnswer}
413
  </span>
414
  </div>
415
+ {ans?.partialScore != null && ans?.partialScore < 100 && (
416
+ <div>
417
+ <span className="text-[var(--warm-silver)]">Skor Parsial:</span>{" "}
418
+ <span className="font-semibold text-[var(--lemon-700)]">
419
+ {ans.partialScore}%
420
+ </span>
421
+ </div>
422
+ )}
423
  {(isCorrect === false || isCorrect === null) && (
424
  <div>
425
  <span className="text-[var(--warm-silver)]">Jawaban Benar:</span>{" "}
apps/web/src/routes/generate.tsx CHANGED
@@ -25,6 +25,7 @@ import {
25
  DIFFICULTIES,
26
  QUESTION_COUNT_PRESETS,
27
  } from "@/lib/generate-constants";
 
28
  import "flag-icons/css/flag-icons.min.css";
29
  import type { Step } from "react-joyride";
30
 
@@ -395,18 +396,19 @@ function RouteComponent() {
395
  {/* Difficulty */}
396
  <div data-tour="generate-difficulty" className="flex flex-col gap-4">
397
  <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Tingkat Kesulitan</label>
398
- <div className="grid grid-cols-2 md:grid-cols-4 gap-3">
399
  {DIFFICULTIES.map((d, i) => (
400
  <button
401
  key={d}
402
  onClick={() => setDifficulty(i)}
403
- className={`py-4 px-2 rounded-[var(--radius-lg)] text-sm font-semibold transition-all clay-hover min-h-[56px] ${
404
  difficulty === i
405
  ? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
406
  : "bg-[var(--pure-white)] text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] border-2 border-[var(--oat-border)]"
407
  }`}
408
  >
409
- {d}
 
410
  </button>
411
  ))}
412
  </div>
@@ -601,7 +603,7 @@ const generatePageSteps: Step[] = [
601
  {
602
  target: "[data-tour='generate-exam-type']",
603
  title: "Jenis Ujian",
604
- content: "Pilih jenis ujian yang ingin kamu latih. Tersedia IELTS, TOEFL, JLPT, HSK, dan Goethe.",
605
  spotlightPadding: 8,
606
  },
607
  {
@@ -619,7 +621,7 @@ const generatePageSteps: Step[] = [
619
  {
620
  target: "[data-tour='generate-difficulty']",
621
  title: "Tingkat Kesulitan",
622
- content: "Pilih tingkat kesulitan: Beginner, Intermediate, Academic, atau Expert.",
623
  spotlightPadding: 8,
624
  },
625
  {
 
25
  DIFFICULTIES,
26
  QUESTION_COUNT_PRESETS,
27
  } from "@/lib/generate-constants";
28
+ import { getDifficultyLabel } from "@/lib/difficulty-mapping";
29
  import "flag-icons/css/flag-icons.min.css";
30
  import type { Step } from "react-joyride";
31
 
 
396
  {/* Difficulty */}
397
  <div data-tour="generate-difficulty" className="flex flex-col gap-4">
398
  <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Tingkat Kesulitan</label>
399
+ <div className="grid grid-cols-2 md:grid-cols-3 gap-3">
400
  {DIFFICULTIES.map((d, i) => (
401
  <button
402
  key={d}
403
  onClick={() => setDifficulty(i)}
404
+ className={`py-4 px-2 rounded-[var(--radius-lg)] text-sm font-semibold transition-all clay-hover min-h-[56px] flex flex-col items-center ${
405
  difficulty === i
406
  ? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
407
  : "bg-[var(--pure-white)] text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] border-2 border-[var(--oat-border)]"
408
  }`}
409
  >
410
+ <span>{getDifficultyLabel(examType, i + 1)}</span>
411
+ <span className={`text-[10px] mt-0.5 ${difficulty === i ? "text-white/60" : "text-[var(--warm-silver)]"}`}>{d}</span>
412
  </button>
413
  ))}
414
  </div>
 
603
  {
604
  target: "[data-tour='generate-exam-type']",
605
  title: "Jenis Ujian",
606
+ content: "Pilih jenis ujian yang ingin kamu latih. Tersedia IELTS, TOEFL, JLPT, HSK, Goethe, TOPIK (Korea), TOAFL (Arab), dan DELE (Spanyol).",
607
  spotlightPadding: 8,
608
  },
609
  {
 
621
  {
622
  target: "[data-tour='generate-difficulty']",
623
  title: "Tingkat Kesulitan",
624
+ content: "Pilih tingkat kesulitan. Label menyesuaikan dengan jenis ujian yang dipilih (misal: N5-N1 untuk JLPT, Band 4.0-8.0 untuk IELTS).",
625
  spotlightPadding: 8,
626
  },
627
  {
apps/web/src/routes/jobs.tsx CHANGED
@@ -386,19 +386,24 @@ function RouteComponent() {
386
  </p>
387
  {"options" in q && Array.isArray(q.options) && q.options.length > 0 && (
388
  <div className="space-y-1 mb-2">
389
- {q.options.map((opt: any) => (
390
- <div
391
- key={opt.key}
392
- className={`text-sm px-3 py-1.5 rounded-[var(--radius-md)] border ${
393
- // opt.key === q.correctAnswer
394
- // ? "bg-[var(--matcha-300)]/30 border-[var(--matcha-300)] text-[var(--matcha-800)] font-medium"
395
- // : "border-[var(--oat-border)] text-[var(--warm-charcoal)]"
396
- "border-[var(--oat-border)] text-[var(--warm-charcoal)]"
397
- }`}
398
- >
399
- {opt.key}. {opt.text}
400
- </div>
401
- ))}
 
 
 
 
 
402
  </div>
403
  )}
404
  {/* <p className="text-xs text-[var(--warm-charcoal)]">
 
386
  </p>
387
  {"options" in q && Array.isArray(q.options) && q.options.length > 0 && (
388
  <div className="space-y-1 mb-2">
389
+ {q.format === "matching_pairs"
390
+ ? q.options.map((opt: any, i: number) => (
391
+ <div
392
+ key={i}
393
+ className="text-sm px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--oat-border)] text-[var(--warm-charcoal)]"
394
+ >
395
+ {opt.left ?? opt.key}
396
+ {opt.right ? ` → ${opt.right}` : ""}
397
+ </div>
398
+ ))
399
+ : q.options.map((opt: any) => (
400
+ <div
401
+ key={opt.key}
402
+ className="text-sm px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--oat-border)] text-[var(--warm-charcoal)]"
403
+ >
404
+ {opt.key}. {opt.text}
405
+ </div>
406
+ ))}
407
  </div>
408
  )}
409
  {/* <p className="text-xs text-[var(--warm-charcoal)]">
packages/ai/src/agentic.ts CHANGED
@@ -38,6 +38,9 @@ function getTargetLanguage(examType: string): string {
38
  if (examType === "JLPT") return "Japanese";
39
  if (examType === "HSK") return "Chinese";
40
  if (examType === "GOETHE") return "German";
 
 
 
41
  return "English";
42
  }
43
 
@@ -196,6 +199,9 @@ Rules:
196
  - explanation — WAJIB ditulis dalam Bahasa Indonesia. DILARANG menggunakan bahasa asing.
197
  - For true_false_not_given: correctAnswer must be exactly TRUE, FALSE, or NOT_GIVEN (uppercase)
198
  - For author_view: correctAnswer must be exactly YES, NO, or NOT_GIVEN (uppercase)
 
 
 
199
 
200
  Question schema:
201
  ${schema}
@@ -305,6 +311,9 @@ Rules:
305
  - explanation — WAJIB ditulis dalam Bahasa Indonesia. DILARANG menggunakan bahasa asing.
306
  - For true_false_not_given: correctAnswer must be TRUE, FALSE, or NOT_GIVEN (uppercase)
307
  - For author_view: correctAnswer must be YES, NO, or NOT_GIVEN (uppercase)
 
 
 
308
 
309
  Question schema:
310
  ${schema}
 
38
  if (examType === "JLPT") return "Japanese";
39
  if (examType === "HSK") return "Chinese";
40
  if (examType === "GOETHE") return "German";
41
+ if (examType === "TOPIK") return "Korean";
42
+ if (examType === "TOAFL") return "Arabic";
43
+ if (examType === "DELE") return "Spanish";
44
  return "English";
45
  }
46
 
 
199
  - explanation — WAJIB ditulis dalam Bahasa Indonesia. DILARANG menggunakan bahasa asing.
200
  - For true_false_not_given: correctAnswer must be exactly TRUE, FALSE, or NOT_GIVEN (uppercase)
201
  - For author_view: correctAnswer must be exactly YES, NO, or NOT_GIVEN (uppercase)
202
+ - For matching_pairs: options are {key, text} pairs. correctAnswer is serialized mapping like "A:1,B:2".
203
+ - For error_recognition: options are error segments. correctAnswer is key of segment with error.
204
+ - For text_insertion: options are position markers. correctAnswer is best position key.
205
 
206
  Question schema:
207
  ${schema}
 
311
  - explanation — WAJIB ditulis dalam Bahasa Indonesia. DILARANG menggunakan bahasa asing.
312
  - For true_false_not_given: correctAnswer must be TRUE, FALSE, or NOT_GIVEN (uppercase)
313
  - For author_view: correctAnswer must be YES, NO, or NOT_GIVEN (uppercase)
314
+ - For matching_pairs: options are {key, text} pairs. correctAnswer is serialized mapping.
315
+ - For error_recognition: options are error segments. correctAnswer is key of segment with error.
316
+ - For text_insertion: options are position markers. correctAnswer is best position key.
317
 
318
  Question schema:
319
  ${schema}
packages/ai/src/prompts.ts CHANGED
@@ -17,7 +17,11 @@ TOPICS: ${topics.join(", ")}
17
  FORMATS TO GENERATE: ${formats.join(", ")}
18
 
19
  INSTRUCTIONS:
20
- - The reading passage must be written in the target language of the exam (${examType === "JLPT" ? "Japanese" : examType === "HSK" ? "Chinese" : examType === "GOETHE" ? "German" : "English"}).
 
 
 
 
21
  - Passage length should be appropriate for the exam type and difficulty.
22
  - Each question must have:
23
  * a reading passage (passageText)
@@ -29,6 +33,11 @@ INSTRUCTIONS:
29
  - Questions should test real comprehension, not just surface-level recall.
30
  - For multiple choice: always provide 4 options labeled A, B, C, D.
31
  - Options must be plausible distractors — one clearly correct answer.
 
 
 
 
 
32
 
33
  OUTPUT FORMAT:
34
  Return ONLY a valid JSON object with this exact structure (no markdown code blocks, no extra text):
 
17
  FORMATS TO GENERATE: ${formats.join(", ")}
18
 
19
  INSTRUCTIONS:
20
+ - The reading passage must be written in the target language of the exam (${examType === "JLPT" ? "Japanese" : examType === "HSK" ? "Chinese" : examType === "GOETHE" ? "German" : examType === "TOPIK" ? "Korean" : examType === "TOAFL" ? "Arabic" : examType === "DELE" ? "Spanish" : "English"}).
21
+ - For Korean (TOPIK): Focus on particles, honorifics (speech levels), and functional grammar.
22
+ - For Arabic (TOAFL): Support RTL text. Focus on I'rab (case endings/vowel changes) and grammar.
23
+ - For Spanish (DELE): Focus on verb conjugation by subject and agreement.
24
+ - For JLPT/TOPIK kanji/hanja: Include reading annotations in format: 漢字(かんじ) for words that have readings.
25
  - Passage length should be appropriate for the exam type and difficulty.
26
  - Each question must have:
27
  * a reading passage (passageText)
 
33
  - Questions should test real comprehension, not just surface-level recall.
34
  - For multiple choice: always provide 4 options labeled A, B, C, D.
35
  - Options must be plausible distractors — one clearly correct answer.
36
+ - For matching_pairs: Provide options as an array of {key, text} where key is the left item identifier and text is the left item. correctAnswer should be a serialized mapping like "A:1,B:2,C:3" matching each left key to its right pair.
37
+ - For error_recognition: options are error segments (A, B, C, D) and correctAnswer is the key of the segment containing an error.
38
+ - For text_insertion: options are position markers (A, B, C, D) within the passage where a sentence could be inserted. correctAnswer is the best position key.
39
+ - For sentence_arrangement: Provide options as shuffled fragments in random order. correctAnswer is the correct order as comma-separated keys (e.g. "D,A,C,B").
40
+ - For matching_information: options are information items with key and text. correctAnswer is the correct match as serialized mapping.
41
 
42
  OUTPUT FORMAT:
43
  Return ONLY a valid JSON object with this exact structure (no markdown code blocks, no extra text):
packages/ai/src/repair.ts CHANGED
@@ -27,12 +27,15 @@ const FORMATS_WITH_OPTIONS = new Set([
27
  "multiple_choice",
28
  "matching_headings",
29
  "matching_information",
 
30
  "synonym",
31
  "grammar_in_context",
32
  "sentence_completion",
33
  "summary_completion",
34
  "cloze",
35
  "reference",
 
 
36
  "kanji_reading",
37
  "particle_choice",
38
  "article_case",
@@ -60,7 +63,7 @@ function ensureOptions(
60
  if (q.format === "multiple_choice" || q.format === "synonym" || q.format === "grammar_in_context" ||
61
  q.format === "sentence_completion" || q.format === "reference" || q.format === "kanji_reading" ||
62
  q.format === "particle_choice" || q.format === "article_case" || q.format === "character_reading" ||
63
- q.format === "sentence_arrangement") {
64
  return [
65
  { key: "A", text: "Option A" },
66
  { key: "B", text: "Option B" },
@@ -319,6 +322,7 @@ export function getGenericQuestionJsonSchemaDescription(): string {
319
  "true_false_not_given",
320
  "matching_headings",
321
  "matching_information",
 
322
  "fill_blank",
323
  "synonym",
324
  "grammar_in_context",
@@ -327,6 +331,8 @@ export function getGenericQuestionJsonSchemaDescription(): string {
327
  "cloze",
328
  "reference",
329
  "author_view",
 
 
330
  "kanji_reading",
331
  "particle_choice",
332
  "article_case",
@@ -338,7 +344,7 @@ export function getGenericQuestionJsonSchemaDescription(): string {
338
  questionText: { type: "string", description: "The question text" },
339
  options: {
340
  type: "array",
341
- description: "Required for multiple_choice, synonym, matching_*, reference, kanji_reading, particle_choice, article_case, character_reading, sentence_arrangement, summary_completion, cloze. Optional for others.",
342
  items: {
343
  type: "object",
344
  properties: {
 
27
  "multiple_choice",
28
  "matching_headings",
29
  "matching_information",
30
+ "matching_pairs",
31
  "synonym",
32
  "grammar_in_context",
33
  "sentence_completion",
34
  "summary_completion",
35
  "cloze",
36
  "reference",
37
+ "error_recognition",
38
+ "text_insertion",
39
  "kanji_reading",
40
  "particle_choice",
41
  "article_case",
 
63
  if (q.format === "multiple_choice" || q.format === "synonym" || q.format === "grammar_in_context" ||
64
  q.format === "sentence_completion" || q.format === "reference" || q.format === "kanji_reading" ||
65
  q.format === "particle_choice" || q.format === "article_case" || q.format === "character_reading" ||
66
+ q.format === "sentence_arrangement" || q.format === "error_recognition" || q.format === "text_insertion") {
67
  return [
68
  { key: "A", text: "Option A" },
69
  { key: "B", text: "Option B" },
 
322
  "true_false_not_given",
323
  "matching_headings",
324
  "matching_information",
325
+ "matching_pairs",
326
  "fill_blank",
327
  "synonym",
328
  "grammar_in_context",
 
331
  "cloze",
332
  "reference",
333
  "author_view",
334
+ "error_recognition",
335
+ "text_insertion",
336
  "kanji_reading",
337
  "particle_choice",
338
  "article_case",
 
344
  questionText: { type: "string", description: "The question text" },
345
  options: {
346
  type: "array",
347
+ description: "Required for multiple_choice, synonym, matching_*, reference, kanji_reading, particle_choice, article_case, character_reading, sentence_arrangement, error_recognition, text_insertion, summary_completion, cloze, matching_pairs. Optional for others.",
348
  items: {
349
  type: "object",
350
  properties: {
packages/ai/src/schemas.ts CHANGED
@@ -2,7 +2,7 @@ import { z } from "zod";
2
 
3
  // ── Shared Schemas ─────────────────────────────────────────
4
 
5
- export const examTypeSchema = z.enum(["IELTS", "TOEFL", "JLPT", "HSK", "GOETHE"]);
6
  export const sectionTypeSchema = z.enum(["READING", "WRITING", "LISTENING", "SPEAKING"]);
7
 
8
  export const questionFormatSchema = z.enum([
@@ -10,6 +10,7 @@ export const questionFormatSchema = z.enum([
10
  "true_false_not_given",
11
  "matching_headings",
12
  "matching_information",
 
13
  "fill_blank",
14
  "synonym",
15
  "grammar_in_context",
@@ -18,6 +19,8 @@ export const questionFormatSchema = z.enum([
18
  "cloze",
19
  "reference",
20
  "author_view",
 
 
21
  "kanji_reading",
22
  "particle_choice",
23
  "article_case",
@@ -56,6 +59,7 @@ export const baseQuestionSchema = z.object({
56
  explanation: z.string(),
57
  difficulty: difficultySchema,
58
  skillTags: z.array(z.string()).min(1),
 
59
  });
60
 
61
  // ── Format-Specific Question Schemas ───────────────────────
@@ -83,6 +87,24 @@ export const matchingInformationQuestionSchema = baseQuestionSchema.extend({
83
  correctAnswer: z.string(),
84
  });
85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  export const fillBlankQuestionSchema = baseQuestionSchema.extend({
87
  format: z.literal("fill_blank"),
88
  correctAnswer: z.string(), // exact text to fill
@@ -166,6 +188,7 @@ export const questionSchema = z.discriminatedUnion("format", [
166
  trueFalseQuestionSchema,
167
  matchingHeadingsQuestionSchema,
168
  matchingInformationQuestionSchema,
 
169
  fillBlankQuestionSchema,
170
  synonymQuestionSchema,
171
  grammarInContextQuestionSchema,
@@ -174,6 +197,8 @@ export const questionSchema = z.discriminatedUnion("format", [
174
  clozeQuestionSchema,
175
  referenceQuestionSchema,
176
  authorViewQuestionSchema,
 
 
177
  kanjiReadingQuestionSchema,
178
  particleChoiceQuestionSchema,
179
  articleCaseQuestionSchema,
 
2
 
3
  // ── Shared Schemas ─────────────────────────────────────────
4
 
5
+ export const examTypeSchema = z.enum(["IELTS", "TOEFL", "JLPT", "HSK", "GOETHE", "TOPIK", "TOAFL", "DELE"]);
6
  export const sectionTypeSchema = z.enum(["READING", "WRITING", "LISTENING", "SPEAKING"]);
7
 
8
  export const questionFormatSchema = z.enum([
 
10
  "true_false_not_given",
11
  "matching_headings",
12
  "matching_information",
13
+ "matching_pairs",
14
  "fill_blank",
15
  "synonym",
16
  "grammar_in_context",
 
19
  "cloze",
20
  "reference",
21
  "author_view",
22
+ "error_recognition",
23
+ "text_insertion",
24
  "kanji_reading",
25
  "particle_choice",
26
  "article_case",
 
59
  explanation: z.string(),
60
  difficulty: difficultySchema,
61
  skillTags: z.array(z.string()).min(1),
62
+ isCaseSensitive: z.boolean().optional().default(false),
63
  });
64
 
65
  // ── Format-Specific Question Schemas ───────────────────────
 
87
  correctAnswer: z.string(),
88
  });
89
 
90
+ export const matchingPairsQuestionSchema = baseQuestionSchema.extend({
91
+ format: z.literal("matching_pairs"),
92
+ options: z.array(matchingPairSchema), // [{left, right}] pairs to match
93
+ correctAnswer: z.string(), // serialized mapping e.g. "A:1,B:2,C:3"
94
+ });
95
+
96
+ export const errorRecognitionQuestionSchema = baseQuestionSchema.extend({
97
+ format: z.literal("error_recognition"),
98
+ options: z.array(multipleChoiceOptionSchema).min(2).max(6), // error segment choices
99
+ correctAnswer: z.string(), // key of the segment with error
100
+ });
101
+
102
+ export const textInsertionQuestionSchema = baseQuestionSchema.extend({
103
+ format: z.literal("text_insertion"),
104
+ options: z.array(multipleChoiceOptionSchema).min(2).max(6), // position markers
105
+ correctAnswer: z.string(), // key of correct position
106
+ });
107
+
108
  export const fillBlankQuestionSchema = baseQuestionSchema.extend({
109
  format: z.literal("fill_blank"),
110
  correctAnswer: z.string(), // exact text to fill
 
188
  trueFalseQuestionSchema,
189
  matchingHeadingsQuestionSchema,
190
  matchingInformationQuestionSchema,
191
+ matchingPairsQuestionSchema,
192
  fillBlankQuestionSchema,
193
  synonymQuestionSchema,
194
  grammarInContextQuestionSchema,
 
197
  clozeQuestionSchema,
198
  referenceQuestionSchema,
199
  authorViewQuestionSchema,
200
+ errorRecognitionQuestionSchema,
201
+ textInsertionQuestionSchema,
202
  kanjiReadingQuestionSchema,
203
  particleChoiceQuestionSchema,
204
  articleCaseQuestionSchema,
packages/api/src/routers/attempt.ts CHANGED
@@ -16,10 +16,14 @@ import { paginationSchema, paginateDefaults } from "../lib/pagination";
16
  import { assertOwnership } from "../lib/ownership";
17
  import { throwNotFound, throwForbidden, throwBadRequest } from "../lib/errors";
18
 
19
- function normalizeAnswer(format: string, userAnswer: string, correctAnswer: string): boolean {
20
  const ua = userAnswer.trim();
21
  const ca = correctAnswer.trim();
22
 
 
 
 
 
23
  switch (format) {
24
  case "true_false_not_given":
25
  case "author_view":
@@ -40,12 +44,73 @@ function normalizeAnswer(format: string, userAnswer: string, correctAnswer: stri
40
  case "sentence_arrangement":
41
  case "matching_headings":
42
  case "matching_information":
 
 
 
43
  return ua.toUpperCase() === ca.toUpperCase();
44
  default:
45
  return ua === ca;
46
  }
47
  }
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  // ── Simple in-memory rate limiter ──
50
  const rateLimitMap = new Map<string, number>();
51
  let rateLimitCleanup: ReturnType<typeof setInterval> | null = null;
@@ -189,6 +254,7 @@ export const attemptRouter = router({
189
  questionId: answer.questionId,
190
  userAnswer: answer.userAnswer,
191
  isCorrect: answer.isCorrect,
 
192
  timeSpentSec: answer.timeSpentSec,
193
  createdAt: answer.createdAt,
194
  question: {
@@ -246,7 +312,7 @@ export const attemptRouter = router({
246
  }
247
  }
248
 
249
- // Strip correctAnswer & explanation during active attempt
250
  if (isInProgress) {
251
  const sanitizeQuestion = (q: any) => {
252
  if (!q) return q;
@@ -254,8 +320,14 @@ export const attemptRouter = router({
254
  return rest;
255
  };
256
 
 
 
 
 
 
 
257
  answers = answers.map((a) => ({
258
- ...a,
259
  question: sanitizeQuestion(a.question),
260
  }));
261
 
@@ -323,8 +395,6 @@ export const attemptRouter = router({
323
 
324
  if (!q) throwNotFound("Question");
325
 
326
- const isCorrect = normalizeAnswer(q.format, input.userAnswer, q.correctAnswer);
327
-
328
  const [existing] = await db
329
  .select()
330
  .from(answer)
@@ -341,7 +411,7 @@ export const attemptRouter = router({
341
  .update(answer)
342
  .set({
343
  userAnswer: input.userAnswer,
344
- isCorrect,
345
  timeSpentSec: input.timeSpentSec,
346
  })
347
  .where(eq(answer.id, existing.id));
@@ -350,7 +420,7 @@ export const attemptRouter = router({
350
  sectionResultId: input.sectionResultId,
351
  questionId: input.questionId,
352
  userAnswer: input.userAnswer,
353
- isCorrect,
354
  timeSpentSec: input.timeSpentSec,
355
  });
356
  }
@@ -421,22 +491,48 @@ export const attemptRouter = router({
421
  if (!secResult || !pkgSec) continue;
422
 
423
  const secAnswers = await db
424
- .select()
 
 
 
 
 
 
 
 
 
425
  .from(answer)
 
426
  .where(eq(answer.sectionResultId, secResult.id));
427
 
428
  for (const a of secAnswers) {
429
  allQuestionIds.add(a.questionId);
430
  }
431
 
432
- const sectionScore = secAnswers.filter((a) => a.isCorrect).length;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
433
  const sectionMax = questionCounts.get(pkgSec.id) ?? secAnswers.length;
434
  const sectionTimeSpent = secAnswers.reduce((sum, a) => sum + (a.timeSpentSec ?? 0), 0);
435
 
436
  await db
437
  .update(sectionResult)
438
  .set({
439
- score: sectionScore,
440
  maxScore: sectionMax,
441
  timeSpentSec: sectionTimeSpent,
442
  })
 
16
  import { assertOwnership } from "../lib/ownership";
17
  import { throwNotFound, throwForbidden, throwBadRequest } from "../lib/errors";
18
 
19
+ function normalizeAnswer(format: string, userAnswer: string, correctAnswer: string, isCaseSensitive = false): boolean {
20
  const ua = userAnswer.trim();
21
  const ca = correctAnswer.trim();
22
 
23
+ if (isCaseSensitive) {
24
+ return ua === ca;
25
+ }
26
+
27
  switch (format) {
28
  case "true_false_not_given":
29
  case "author_view":
 
44
  case "sentence_arrangement":
45
  case "matching_headings":
46
  case "matching_information":
47
+ case "matching_pairs":
48
+ case "error_recognition":
49
+ case "text_insertion":
50
  return ua.toUpperCase() === ca.toUpperCase();
51
  default:
52
  return ua === ca;
53
  }
54
  }
55
 
56
+ // ── Partial Credit Engine ──
57
+ // For ordering/matching formats, compute a score 0-100 based on per-element correctness.
58
+ function calculatePartialCredit(format: string, userAnswer: string, correctAnswer: string): number | null {
59
+ if (format !== "sentence_arrangement" && format !== "matching_pairs" && format !== "cloze") {
60
+ return null; // not a partial-credit format
61
+ }
62
+
63
+ try {
64
+ const ua = userAnswer.trim();
65
+ const ca = correctAnswer.trim();
66
+ if (!ua || !ca) return null;
67
+
68
+ if (format === "sentence_arrangement") {
69
+ // Both are comma-separated option keys: e.g. "D,A,C,B"
70
+ const userParts = ua.split(",").map((s) => s.trim());
71
+ const correctParts = ca.split(",").map((s) => s.trim());
72
+ if (userParts.length !== correctParts.length) return 0;
73
+ const correctCount = userParts.filter((p, i) => p === correctParts[i]).length;
74
+ return Math.round((correctCount / correctParts.length) * 100);
75
+ }
76
+
77
+ if (format === "matching_pairs") {
78
+ // Serialized mapping: e.g. "A:1,B:2,C:3" or "A-hat,B-shoes,C-scarf"
79
+ const parseMapping = (s: string): Map<string, string> => {
80
+ const map = new Map();
81
+ s.split(",").forEach((pair) => {
82
+ const [k, v] = pair.split(":").map((x) => x.trim());
83
+ if (k && v) map.set(k, v);
84
+ });
85
+ return map;
86
+ };
87
+ const userMap = parseMapping(ua);
88
+ const correctMap = parseMapping(ca);
89
+ let correctCount = 0;
90
+ for (const [key, value] of correctMap) {
91
+ if (userMap.get(key) === value) correctCount++;
92
+ }
93
+ return correctMap.size > 0 ? Math.round((correctCount / correctMap.size) * 100) : null;
94
+ }
95
+
96
+ if (format === "cloze") {
97
+ // Serialized answers per blank: e.g. "A,C,B"
98
+ return calculatePartialCredit("sentence_arrangement", ua, ca);
99
+ }
100
+ } catch {
101
+ return null;
102
+ }
103
+ return null;
104
+ }
105
+
106
+ // ── Word Count Validator ──
107
+ function validateWordCount(answer: string, minWords = 1, maxWords = 500): { isValid: boolean; wordCount: number } {
108
+ const trimmed = answer.trim();
109
+ if (!trimmed) return { isValid: false, wordCount: 0 };
110
+ const words = trimmed.split(/\s+/).filter(Boolean);
111
+ return { isValid: words.length >= minWords && words.length <= maxWords, wordCount: words.length };
112
+ }
113
+
114
  // ── Simple in-memory rate limiter ──
115
  const rateLimitMap = new Map<string, number>();
116
  let rateLimitCleanup: ReturnType<typeof setInterval> | null = null;
 
254
  questionId: answer.questionId,
255
  userAnswer: answer.userAnswer,
256
  isCorrect: answer.isCorrect,
257
+ partialScore: answer.partialScore,
258
  timeSpentSec: answer.timeSpentSec,
259
  createdAt: answer.createdAt,
260
  question: {
 
312
  }
313
  }
314
 
315
+ // Strip sensitive fields during active attempt (anti-cheat)
316
  if (isInProgress) {
317
  const sanitizeQuestion = (q: any) => {
318
  if (!q) return q;
 
320
  return rest;
321
  };
322
 
323
+ const sanitizeAnswer = (a: any) => {
324
+ if (!a) return a;
325
+ const { isCorrect, partialScore, ...rest } = a;
326
+ return rest;
327
+ };
328
+
329
  answers = answers.map((a) => ({
330
+ ...sanitizeAnswer(a),
331
  question: sanitizeQuestion(a.question),
332
  }));
333
 
 
395
 
396
  if (!q) throwNotFound("Question");
397
 
 
 
398
  const [existing] = await db
399
  .select()
400
  .from(answer)
 
411
  .update(answer)
412
  .set({
413
  userAnswer: input.userAnswer,
414
+ // isCorrect & partialScore NOT stored here — recomputed in finish() to prevent cheating
415
  timeSpentSec: input.timeSpentSec,
416
  })
417
  .where(eq(answer.id, existing.id));
 
420
  sectionResultId: input.sectionResultId,
421
  questionId: input.questionId,
422
  userAnswer: input.userAnswer,
423
+ // isCorrect & partialScore NOT stored here — recomputed in finish()
424
  timeSpentSec: input.timeSpentSec,
425
  });
426
  }
 
491
  if (!secResult || !pkgSec) continue;
492
 
493
  const secAnswers = await db
494
+ .select({
495
+ answerId: answer.id,
496
+ questionId: answer.questionId,
497
+ userAnswer: answer.userAnswer,
498
+ partialScore: answer.partialScore,
499
+ timeSpentSec: answer.timeSpentSec,
500
+ questionFormat: question.format,
501
+ questionCorrectAnswer: question.correctAnswer,
502
+ questionIsCaseSensitive: question.isCaseSensitive,
503
+ })
504
  .from(answer)
505
+ .innerJoin(question, eq(answer.questionId, question.id))
506
  .where(eq(answer.sectionResultId, secResult.id));
507
 
508
  for (const a of secAnswers) {
509
  allQuestionIds.add(a.questionId);
510
  }
511
 
512
+ // Recompute scores from raw answers (anti-cheat: ignore stored isCorrect/partialScore)
513
+ let sectionScore = 0;
514
+ for (const a of secAnswers) {
515
+ if (!a.userAnswer) continue;
516
+ const isCaseSensitive = a.questionIsCaseSensitive ?? false;
517
+ const isCorrect = normalizeAnswer(a.questionFormat, a.userAnswer, a.questionCorrectAnswer, isCaseSensitive);
518
+ const partialScore = calculatePartialCredit(a.questionFormat, a.userAnswer, a.questionCorrectAnswer);
519
+ const effectiveScore = isCorrect ? 1 : (partialScore != null ? partialScore / 100 : 0);
520
+ sectionScore += effectiveScore;
521
+
522
+ // Persist computed values for review page display
523
+ await db
524
+ .update(answer)
525
+ .set({ isCorrect, partialScore })
526
+ .where(eq(answer.id, a.answerId));
527
+ }
528
+
529
  const sectionMax = questionCounts.get(pkgSec.id) ?? secAnswers.length;
530
  const sectionTimeSpent = secAnswers.reduce((sum, a) => sum + (a.timeSpentSec ?? 0), 0);
531
 
532
  await db
533
  .update(sectionResult)
534
  .set({
535
+ score: Math.round(sectionScore),
536
  maxScore: sectionMax,
537
  timeSpentSec: sectionTimeSpent,
538
  })
packages/api/src/routers/question.ts CHANGED
@@ -207,6 +207,7 @@ export const questionRouter = router({
207
  correctAnswer: z.string(),
208
  explanation: z.string().optional(),
209
  difficulty: z.number().min(1).max(5).default(3),
 
210
  skillTags: z.array(z.string()).default([]),
211
  isPublic: z.boolean().default(false),
212
  }),
 
207
  correctAnswer: z.string(),
208
  explanation: z.string().optional(),
209
  difficulty: z.number().min(1).max(5).default(3),
210
+ isCaseSensitive: z.boolean().default(false),
211
  skillTags: z.array(z.string()).default([]),
212
  isPublic: z.boolean().default(false),
213
  }),
packages/db/package.json CHANGED
@@ -18,7 +18,8 @@
18
  "db:start": "docker compose up -d",
19
  "db:watch": "docker compose up",
20
  "db:stop": "docker compose stop",
21
- "db:down": "docker compose down"
 
22
  },
23
  "dependencies": {
24
  "@labas/env": "workspace:*",
 
18
  "db:start": "docker compose up -d",
19
  "db:watch": "docker compose up",
20
  "db:stop": "docker compose stop",
21
+ "db:down": "docker compose down",
22
+ "db:seed": "bun run src/seed.ts"
23
  },
24
  "dependencies": {
25
  "@labas/env": "workspace:*",
packages/db/src/schema/app.ts CHANGED
@@ -53,6 +53,7 @@ export const question = pgTable(
53
  difficulty: integer("difficulty").notNull().default(3),
54
  // e.g. ["grammar", "vocabulary", "inference", "main_idea", "detail"]
55
  skillTags: text("skill_tags").array().default([]),
 
56
  source: text("source").notNull().default("manual"), // "ai" | "manual"
57
  aiModel: text("ai_model"),
58
  aiPromptUsed: text("ai_prompt_used"),
@@ -253,6 +254,7 @@ export const answer = pgTable(
253
  .references(() => question.id, { onDelete: "cascade" }),
254
  userAnswer: text("user_answer"),
255
  isCorrect: boolean("is_correct"),
 
256
  timeSpentSec: integer("time_spent_sec"),
257
  createdAt: timestamp("created_at").defaultNow().notNull(),
258
  },
 
53
  difficulty: integer("difficulty").notNull().default(3),
54
  // e.g. ["grammar", "vocabulary", "inference", "main_idea", "detail"]
55
  skillTags: text("skill_tags").array().default([]),
56
+ isCaseSensitive: boolean("is_case_sensitive").default(false).notNull(),
57
  source: text("source").notNull().default("manual"), // "ai" | "manual"
58
  aiModel: text("ai_model"),
59
  aiPromptUsed: text("ai_prompt_used"),
 
254
  .references(() => question.id, { onDelete: "cascade" }),
255
  userAnswer: text("user_answer"),
256
  isCorrect: boolean("is_correct"),
257
+ partialScore: integer("partial_score"), // 0-100 for partial credit formats (ordering, matching)
258
  timeSpentSec: integer("time_spent_sec"),
259
  createdAt: timestamp("created_at").defaultNow().notNull(),
260
  },
packages/db/src/seed.ts ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { db } from "./index";
2
+ import { examType, sectionType } from "./schema";
3
+
4
+ const examTypes = [
5
+ { id: "IELTS", name: "IELTS Academic", language: "English", description: "International English Language Testing System" },
6
+ { id: "TOEFL", name: "TOEFL iBT", language: "English", description: "Test of English as a Foreign Language" },
7
+ { id: "JLPT", name: "JLPT", language: "Japanese", description: "Japanese-Language Proficiency Test" },
8
+ { id: "HSK", name: "HSK", language: "Chinese", description: "Hanyu Shuiping Kaoshi" },
9
+ { id: "GOETHE", name: "Goethe-Zertifikat", language: "German", description: "Goethe-Zertifikat German proficiency test" },
10
+ { id: "TOPIK", name: "TOPIK", language: "Korean", description: "Test of Proficiency in Korean" },
11
+ { id: "TOAFL", name: "TOAFL", language: "Arabic", description: "Test of Arabic as a Foreign Language" },
12
+ { id: "DELE", name: "DELE", language: "Spanish", description: "Diplomas de Español como Lengua Extranjera" },
13
+ ];
14
+
15
+ const sectionTypes = [
16
+ { id: "READING", name: "Reading" },
17
+ { id: "WRITING", name: "Writing" },
18
+ { id: "LISTENING", name: "Listening" },
19
+ { id: "SPEAKING", name: "Speaking" },
20
+ ];
21
+
22
+ async function seed() {
23
+ console.log("Seeding exam_type table...");
24
+ for (const et of examTypes) {
25
+ await db.insert(examType).values(et).onConflictDoNothing();
26
+ }
27
+
28
+ console.log("Seeding section_type table...");
29
+ for (const st of sectionTypes) {
30
+ await db.insert(sectionType).values(st).onConflictDoNothing();
31
+ }
32
+
33
+ console.log("Done.");
34
+ }
35
+
36
+ seed()
37
+ .then(() => process.exit(0))
38
+ .catch((err) => {
39
+ console.error(err);
40
+ process.exit(1);
41
+ });
packages/ui/src/styles/globals.css CHANGED
@@ -347,4 +347,14 @@
347
  text-transform: uppercase;
348
  letter-spacing: 0.0675em;
349
  }
 
 
 
 
 
 
 
 
 
 
350
  }
 
347
  text-transform: uppercase;
348
  letter-spacing: 0.0675em;
349
  }
350
+
351
+ [dir="rtl"] {
352
+ text-align: right;
353
+ }
354
+ [dir="rtl"] .flex-row {
355
+ flex-direction: row-reverse;
356
+ }
357
+ [dir="rtl"] .space-x-* {
358
+ --space-x-reverse: 1;
359
+ }
360
  }