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 +29 -11
- apps/web/src/components/test/AccentKeyboard.tsx +37 -0
- apps/web/src/components/test/AttemptTestView.tsx +41 -10
- apps/web/src/components/test/QuestionInput.tsx +54 -1
- apps/web/src/lib/difficulty-mapping.ts +17 -0
- apps/web/src/lib/exam-constants.ts +8 -0
- apps/web/src/lib/furigana.tsx +31 -0
- apps/web/src/lib/generate-constants.ts +27 -16
- apps/web/src/routes/attempt.$id.tsx +8 -0
- apps/web/src/routes/generate.tsx +7 -5
- apps/web/src/routes/jobs.tsx +18 -13
- packages/ai/src/agentic.ts +9 -0
- packages/ai/src/prompts.ts +10 -1
- packages/ai/src/repair.ts +8 -2
- packages/ai/src/schemas.ts +26 -1
- packages/api/src/routers/attempt.ts +106 -10
- packages/api/src/routers/question.ts +1 -0
- packages/db/package.json +2 -1
- packages/db/src/schema/app.ts +2 -0
- packages/db/src/seed.ts +41 -0
- packages/ui/src/styles/globals.css +10 -0
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.
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 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 |
-
{
|
| 343 |
<QuestionCard
|
| 344 |
-
key={
|
| 345 |
-
q={
|
| 346 |
globalIdx={activeGlobalIdx}
|
| 347 |
-
answerValue={answers[
|
| 348 |
sectionResultId={sectionResultId}
|
| 349 |
-
isMarked={isMarked(
|
| 350 |
isFinished={isFinished}
|
| 351 |
-
isSubmitting={submittingQId ===
|
| 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 /
|
| 28 |
-
{ id: "character_reading", name: "Character Reading", allowedExams: ["HSK"] },
|
| 29 |
-
{ id: "sentence_arrangement", name: "Sentence Arrangement", allowedExams: ["HSK"] },
|
| 30 |
];
|
| 31 |
|
| 32 |
-
export const TOPICS = [
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
| 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-
|
| 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 |
-
{
|
|
|
|
| 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
|
| 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:
|
| 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.
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
}
|