rogasper commited on
Commit
c1e702e
·
1 Parent(s): c96f44d

feat: enhance TestBlueprintCard component to support multiple selected sections and improve error handling. Update generation job logic to handle section splits and ensure proper validation of selected formats and sections. Refactor API interactions for better error management and user experience.

Browse files
apps/web/src/components/generate/TestBlueprintCard.tsx CHANGED
@@ -1,11 +1,11 @@
1
  import { Button } from "@labas/ui/components/button";
2
  import { Card, CardContent, CardHeader, CardTitle } from "@labas/ui/components/card";
3
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
4
- import { EXAM_TYPES } from "@/lib/generate-constants";
5
 
6
  interface TestBlueprintCardProps {
7
  examType: string;
8
- section: string;
9
  selectedFormats: string[];
10
  questionCount: number;
11
  weaknessAlign: number;
@@ -20,6 +20,7 @@ interface TestBlueprintCardProps {
20
 
21
  export function TestBlueprintCard({
22
  examType,
 
23
  selectedFormats,
24
  questionCount,
25
  weaknessAlign,
@@ -30,7 +31,12 @@ export function TestBlueprintCard({
30
  hasKey,
31
  error,
32
  onGenerate,
33
- }: TestBlueprintCardProps) {
 
 
 
 
 
34
  return (
35
  <div className="lg:col-span-4 sticky top-8">
36
  <Card className="bg-[var(--pure-white)] border-2 border-[var(--oat-border)] clay-shadow rounded-[var(--radius-xl)]">
@@ -40,10 +46,18 @@ export function TestBlueprintCard({
40
  <CardTitle className="font-headline text-xl text-[var(--clay-black)]">Test Blueprint</CardTitle>
41
  </div>
42
  </CardHeader>
43
- <CardContent className="space-y-6">
44
  <div className="flex justify-between items-center pb-4 border-b border-[var(--oat-border)]">
45
- <span className="text-[var(--warm-charcoal)]">Estimasi Durasi</span>
46
- <span className="font-bold text-[var(--clay-black)]">{questionCount * 2} Menit</span>
 
 
 
 
 
 
 
 
47
  </div>
48
  <div className="flex justify-between items-center pb-4 border-b border-[var(--oat-border)]">
49
  <span className="text-[var(--warm-charcoal)]">Jumlah Soal</span>
@@ -54,13 +68,11 @@ export function TestBlueprintCard({
54
  <span className="font-bold text-[var(--clay-black)]">{selectedFormats.length} Jenis</span>
55
  </div>
56
  <div className="flex justify-between items-center pb-4 border-b border-[var(--oat-border)]">
57
- <span className="text-[var(--warm-charcoal)]">Ujian</span>
58
- <span className="font-bold text-[var(--clay-black)]">
59
- {EXAM_TYPES.find((t) => t.id === examType)?.name}
60
- </span>
61
  </div>
62
 
63
- {/* AI Confidence Score Orbit */}
64
  <div className="bg-[var(--oat-light)] rounded-[var(--radius-lg)] p-6 flex flex-col items-center gap-4 relative overflow-hidden">
65
  <div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-[var(--matcha-600)] to-transparent" />
66
  <span className="text-xs font-label uppercase tracking-widest text-[var(--warm-charcoal)] font-bold">
@@ -92,29 +104,25 @@ export function TestBlueprintCard({
92
  <div className="flex gap-1 p-1 rounded-[var(--radius-lg)] bg-[var(--oat-light)]">
93
  <button
94
  onClick={() => setMode("quick")}
95
- className={`flex-1 py-2 px-3 rounded-[var(--radius-md)] text-sm font-semibold transition-all flex items-center justify-center gap-2 ${
96
  mode === "quick"
97
  ? "bg-[var(--pure-white)] text-[var(--clay-black)] clay-shadow"
98
  : "text-[var(--warm-charcoal)] hover:text-[var(--clay-black)]"
99
  }`}
100
  >
101
- <MaterialIcon name="flash_on" className="text-sm mr-1" />
102
- <span>
103
  Quick
104
- </span>
105
  </button>
106
  <button
107
  onClick={() => setMode("agentic")}
108
- className={`flex-1 py-2 px-3 rounded-[var(--radius-md)] text-sm font-semibold transition-all flex items-center justify-center gap-2 ${
109
  mode === "agentic"
110
  ? "bg-[var(--pure-white)] text-[var(--clay-black)] clay-shadow"
111
  : "text-[var(--warm-charcoal)] hover:text-[var(--clay-black)]"
112
  }`}
113
  >
114
- <MaterialIcon name="psychology" className="text-sm mr-1" />
115
- <span>
116
  Agentic
117
- </span>
118
  </button>
119
  </div>
120
 
@@ -130,7 +138,7 @@ export function TestBlueprintCard({
130
 
131
  {/* Primary CTA */}
132
  <Button
133
- className="w-full py-5 rounded-[var(--radius-lg)] bg-[var(--clay-black)] text-[var(--pure-white)] font-bold text-lg flex items-center justify-center gap-3 clay-shadow clay-hover hover:bg-[var(--warm-charcoal)] transition-all active:scale-95 h-auto"
134
  onClick={onGenerate}
135
  disabled={isGenerating || generatePending || selectedFormats.length === 0 || !hasKey}
136
  >
@@ -143,8 +151,20 @@ export function TestBlueprintCard({
143
  </Button>
144
 
145
  {error && (
146
- <div className="p-4 rounded-[var(--radius-md)] bg-[var(--badge-blue-bg)] text-[var(--badge-blue-text)] text-sm border-2 border-[var(--badge-blue-bg)]">
147
- {error}
 
 
 
 
 
 
 
 
 
 
 
 
148
  </div>
149
  )}
150
  </CardContent>
 
1
  import { Button } from "@labas/ui/components/button";
2
  import { Card, CardContent, CardHeader, CardTitle } from "@labas/ui/components/card";
3
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
4
+ import { EXAM_TYPES, SECTIONS } from "@/lib/generate-constants";
5
 
6
  interface TestBlueprintCardProps {
7
  examType: string;
8
+ selectedSections: string[];
9
  selectedFormats: string[];
10
  questionCount: number;
11
  weaknessAlign: number;
 
20
 
21
  export function TestBlueprintCard({
22
  examType,
23
+ selectedSections,
24
  selectedFormats,
25
  questionCount,
26
  weaknessAlign,
 
31
  hasKey,
32
  error,
33
  onGenerate,
34
+ onDismissError,
35
+ }: TestBlueprintCardProps & { onDismissError?: () => void }) {
36
+ const sectionNames = selectedSections
37
+ .map((id) => SECTIONS.find((s) => s.id === id)?.name)
38
+ .filter(Boolean);
39
+
40
  return (
41
  <div className="lg:col-span-4 sticky top-8">
42
  <Card className="bg-[var(--pure-white)] border-2 border-[var(--oat-border)] clay-shadow rounded-[var(--radius-xl)]">
 
46
  <CardTitle className="font-headline text-xl text-[var(--clay-black)]">Test Blueprint</CardTitle>
47
  </div>
48
  </CardHeader>
49
+ <CardContent className="space-y-5">
50
  <div className="flex justify-between items-center pb-4 border-b border-[var(--oat-border)]">
51
+ <span className="text-[var(--warm-charcoal)]">Ujian</span>
52
+ <span className="font-bold text-[var(--clay-black)]">
53
+ {EXAM_TYPES.find((t) => t.id === examType)?.name}
54
+ </span>
55
+ </div>
56
+ <div className="flex justify-between items-center pb-4 border-b border-[var(--oat-border)]">
57
+ <span className="text-[var(--warm-charcoal)]">Section</span>
58
+ <span className="font-bold text-[var(--clay-black)] text-right">
59
+ {sectionNames.join(" + ")}
60
+ </span>
61
  </div>
62
  <div className="flex justify-between items-center pb-4 border-b border-[var(--oat-border)]">
63
  <span className="text-[var(--warm-charcoal)]">Jumlah Soal</span>
 
68
  <span className="font-bold text-[var(--clay-black)]">{selectedFormats.length} Jenis</span>
69
  </div>
70
  <div className="flex justify-between items-center pb-4 border-b border-[var(--oat-border)]">
71
+ <span className="text-[var(--warm-charcoal)]">Estimasi Durasi</span>
72
+ <span className="font-bold text-[var(--clay-black)]">{questionCount * 2} Menit</span>
 
 
73
  </div>
74
 
75
+ {/* AI Confidence Score */}
76
  <div className="bg-[var(--oat-light)] rounded-[var(--radius-lg)] p-6 flex flex-col items-center gap-4 relative overflow-hidden">
77
  <div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-[var(--matcha-600)] to-transparent" />
78
  <span className="text-xs font-label uppercase tracking-widest text-[var(--warm-charcoal)] font-bold">
 
104
  <div className="flex gap-1 p-1 rounded-[var(--radius-lg)] bg-[var(--oat-light)]">
105
  <button
106
  onClick={() => setMode("quick")}
107
+ className={`flex-1 py-2.5 px-3 rounded-[var(--radius-md)] text-sm font-semibold transition-all flex items-center justify-center gap-2 min-h-[44px] ${
108
  mode === "quick"
109
  ? "bg-[var(--pure-white)] text-[var(--clay-black)] clay-shadow"
110
  : "text-[var(--warm-charcoal)] hover:text-[var(--clay-black)]"
111
  }`}
112
  >
113
+ <MaterialIcon name="flash_on" className="text-sm" />
 
114
  Quick
 
115
  </button>
116
  <button
117
  onClick={() => setMode("agentic")}
118
+ className={`flex-1 py-2.5 px-3 rounded-[var(--radius-md)] text-sm font-semibold transition-all flex items-center justify-center gap-2 min-h-[44px] ${
119
  mode === "agentic"
120
  ? "bg-[var(--pure-white)] text-[var(--clay-black)] clay-shadow"
121
  : "text-[var(--warm-charcoal)] hover:text-[var(--clay-black)]"
122
  }`}
123
  >
124
+ <MaterialIcon name="psychology" className="text-sm" />
 
125
  Agentic
 
126
  </button>
127
  </div>
128
 
 
138
 
139
  {/* Primary CTA */}
140
  <Button
141
+ className="w-full py-5 rounded-[var(--radius-lg)] bg-[var(--clay-black)] text-[var(--pure-white)] font-bold text-lg flex items-center justify-center gap-3 clay-shadow clay-hover hover:bg-[var(--warm-charcoal)] transition-all active:scale-95 h-auto min-h-[56px]"
142
  onClick={onGenerate}
143
  disabled={isGenerating || generatePending || selectedFormats.length === 0 || !hasKey}
144
  >
 
151
  </Button>
152
 
153
  {error && (
154
+ <div className="p-4 rounded-[var(--radius-md)] bg-[var(--pomegranate-400)]/10 text-[var(--pomegranate-400)] text-sm border-2 border-[var(--pomegranate-400)]/20">
155
+ <div className="flex items-start gap-2">
156
+ <MaterialIcon name="error" className="text-sm shrink-0 mt-0.5" />
157
+ <span className="flex-1">{error}</span>
158
+ {onDismissError && (
159
+ <button
160
+ onClick={onDismissError}
161
+ className="shrink-0 p-1 rounded hover:bg-[var(--pomegranate-400)]/10 transition-colors"
162
+ aria-label="Tutup error"
163
+ >
164
+ <MaterialIcon name="close" className="text-sm" />
165
+ </button>
166
+ )}
167
+ </div>
168
  </div>
169
  )}
170
  </CardContent>
apps/web/src/hooks/use-generation-job.ts CHANGED
@@ -84,13 +84,16 @@ export function useGenerationJob() {
84
  const res = jobQuery.data.resultJson as GenerationResult & { generatedPackageId?: string | null };
85
  setResult(res);
86
  setGeneratedPackageId(res.generatedPackageId ?? null);
 
87
  setJobId(null);
88
  }
89
  if (jobQuery.data?.status === "failed") {
 
90
  setError(jobQuery.data.errorMessage ?? "Generation failed");
91
  setJobId(null);
92
  }
93
  if (jobQuery.data?.status === "cancelled") {
 
94
  setError(jobQuery.data.errorMessage ?? "Generasi dibatalkan");
95
  setJobId(null);
96
  }
 
84
  const res = jobQuery.data.resultJson as GenerationResult & { generatedPackageId?: string | null };
85
  setResult(res);
86
  setGeneratedPackageId(res.generatedPackageId ?? null);
87
+ setError(null); // Clear any previous error
88
  setJobId(null);
89
  }
90
  if (jobQuery.data?.status === "failed") {
91
+ setResult(null);
92
  setError(jobQuery.data.errorMessage ?? "Generation failed");
93
  setJobId(null);
94
  }
95
  if (jobQuery.data?.status === "cancelled") {
96
+ setResult(null);
97
  setError(jobQuery.data.errorMessage ?? "Generasi dibatalkan");
98
  setJobId(null);
99
  }
apps/web/src/routes/generate.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useState, useEffect } from "react";
2
  import { useMutation } from "@tanstack/react-query";
3
  import { createFileRoute, redirect, Link } from "@tanstack/react-router";
4
  import { authClient } from "@/lib/auth-client";
@@ -6,7 +6,6 @@ import { trpc } from "@/utils/trpc";
6
  import { useApiKeys } from "@/hooks/use-api-key";
7
  import { useGenerationJob } from "@/hooks/use-generation-job";
8
  import { Button } from "@labas/ui/components/button";
9
- import { Input } from "@labas/ui/components/input";
10
  import {
11
  Select,
12
  SelectContent,
@@ -45,7 +44,6 @@ function RouteComponent() {
45
  configs[0]?.id ?? "",
46
  );
47
 
48
- // keep selectedKeyId in sync when configs load
49
  useEffect(() => {
50
  if (configs.length > 0 && !configs.find((c) => c.id === selectedKeyId)) {
51
  setSelectedKeyId(configs[0].id);
@@ -64,8 +62,16 @@ function RouteComponent() {
64
  reset,
65
  } = useGenerationJob();
66
 
 
 
 
 
 
 
 
 
67
  const [examType, setExamType] = useState("IELTS");
68
- const [section, setSection] = useState("READING");
69
  const [selectedFormats, setSelectedFormats] = useState<string[]>(["multiple_choice"]);
70
  const [difficulty, setDifficulty] = useState(2);
71
  const [selectedTopics, setSelectedTopics] = useState<string[]>(["Science & Tech"]);
@@ -73,7 +79,6 @@ function RouteComponent() {
73
  const [weaknessAlign, setWeaknessAlign] = useState(75);
74
  const [mode, setMode] = useState<"quick" | "agentic">("quick");
75
 
76
- // Reset selected formats when exam type changes to only keep valid ones
77
  useEffect(() => {
78
  setSelectedFormats((prev) => {
79
  const valid = prev.filter((f) =>
@@ -98,6 +103,17 @@ function RouteComponent() {
98
  },
99
  });
100
 
 
 
 
 
 
 
 
 
 
 
 
101
  const toggleFormat = (id: string) => {
102
  setSelectedFormats((prev) =>
103
  prev.includes(id) ? prev.filter((f) => f !== id) : [...prev, id],
@@ -115,12 +131,21 @@ function RouteComponent() {
115
  setError("API key belum dikonfigurasi. Tambahkan di Settings.");
116
  return;
117
  }
 
 
 
 
 
 
 
 
118
 
119
  reset();
120
 
121
  generate.mutate({
122
  examType: examType as any,
123
- section: section as any,
 
124
  formats: selectedFormats as any,
125
  difficulty: difficulty + 1,
126
  topics: selectedTopics,
@@ -135,16 +160,23 @@ function RouteComponent() {
135
  });
136
  };
137
 
 
 
 
 
 
 
 
138
  return (
139
  <div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-7xl mx-auto bg-[var(--warm-cream)]">
140
- {/* Header Section */}
141
  <section className="flex flex-col gap-2 relative mb-10">
142
  <div className="absolute -left-8 -top-8 w-64 h-64 ai-glow pointer-events-none opacity-50" />
143
  <h1 className="text-4xl md:text-5xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">
144
  AI Exam Generator
145
  </h1>
146
  <p className="text-lg text-[var(--warm-charcoal)] max-w-2xl leading-relaxed">
147
- Generate soal latihan reading comprehension dengan AI. Gunakan API key sendiri untuk latihan tanpa batas.
148
  </p>
149
  </section>
150
 
@@ -159,7 +191,7 @@ function RouteComponent() {
159
  )}
160
 
161
  {hasConfigs && (
162
- <div className="mb-8 p-4 rounded-[var(--radius-lg)] bg-[var(--warm-cream)] border-2 border-[var(--oat-border)]">
163
  <label className="text-sm font-medium text-[var(--clay-black)] mb-2 block">
164
  Provider / API Key
165
  </label>
@@ -192,8 +224,9 @@ function RouteComponent() {
192
  )}
193
 
194
  <div className="grid grid-cols-1 lg:grid-cols-12 gap-10 items-start">
195
- {/* Configuration Panel (Left) */}
196
- <div className="lg:col-span-8 flex flex-col gap-8">
 
197
  {/* Exam Type */}
198
  <div className="flex flex-col gap-4">
199
  <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Jenis Ujian</label>
@@ -202,44 +235,121 @@ function RouteComponent() {
202
  <button
203
  key={t.id}
204
  onClick={() => setExamType(t.id)}
205
- className={`flex items-center gap-3 py-4 px-4 rounded-[var(--radius-lg)] border-2 transition-all text-sm font-semibold clay-hover ${
206
  examType === t.id
207
- ? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
208
  : "bg-[var(--pure-white)] text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] border-[var(--oat-border)]"
209
  }`}
210
  >
211
- <span className={`fi fi-${t.code} w-6 h-4 rounded-sm shadow-sm`} />
212
  {t.name}
213
  </button>
214
  ))}
215
  </div>
216
  </div>
217
 
218
- {/* Weakness Alignment */}
219
  <div className="flex flex-col gap-4">
220
- <div className="flex justify-between items-end">
221
- <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Fokus Latihan</label>
222
- <span className="text-sm font-medium text-[var(--matcha-800)] bg-[var(--matcha-300)] px-3 py-1 rounded-full">
223
- Intelligent Focus
224
  </span>
225
  </div>
226
- <div className="relative py-4">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  <input
228
  type="range"
229
- min="0"
230
- max="100"
231
- value={weaknessAlign}
232
- onChange={(e) => setWeaknessAlign(Number(e.target.value))}
233
  className="w-full h-2 bg-[var(--warm-silver)] rounded-full appearance-none cursor-pointer accent-[var(--clay-black)]"
234
  />
235
- <div className="flex justify-between mt-4 text-xs font-label uppercase tracking-widest text-[var(--warm-charcoal)]">
236
- <span>Soal Seimbang</span>
237
- <span>Fokus Kelemahan</span>
238
- </div>
239
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  </div>
241
 
242
- {/* Difficulty Tuning */}
243
  <div className="flex flex-col gap-4">
244
  <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Tingkat Kesulitan</label>
245
  <div className="grid grid-cols-2 md:grid-cols-4 gap-3">
@@ -247,7 +357,7 @@ function RouteComponent() {
247
  <button
248
  key={d}
249
  onClick={() => setDifficulty(i)}
250
- className={`py-4 px-2 rounded-[var(--radius-lg)] text-sm font-semibold transition-all clay-hover ${
251
  difficulty === i
252
  ? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
253
  : "bg-[var(--pure-white)] text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] border-2 border-[var(--oat-border)]"
@@ -259,47 +369,20 @@ function RouteComponent() {
259
  </div>
260
  </div>
261
 
262
- {/* Module Selection */}
263
- <div className="flex flex-col gap-4">
264
- <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Section</label>
265
- <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
266
- {SECTIONS.map((s) => (
267
- <div
268
- key={s.id}
269
- onClick={() => setSection(s.id)}
270
- className={`flex items-center justify-between p-5 rounded-[var(--radius-lg)] border-2 group cursor-pointer transition-all clay-hover ${
271
- section === s.id
272
- ? "bg-[var(--pure-white)] border-[var(--clay-black)] clay-shadow"
273
- : "bg-[var(--pure-white)] border-[var(--oat-border)] hover:bg-[var(--oat-light)]"
274
- }`}
275
- >
276
- <div className="flex items-center gap-4">
277
- <MaterialIcon
278
- name={s.icon}
279
- className={`text-[var(--clay-black)] group-hover:scale-110 transition-transform ${section === s.id ? "text-[var(--clay-black)]" : ""}`}
280
- />
281
- <span className="font-semibold text-[var(--clay-black)]">{s.name}</span>
282
- </div>
283
- <Input
284
- type="checkbox"
285
- checked={section === s.id}
286
- readOnly
287
- className="w-5 h-5 rounded border-[var(--oat-border)] text-[var(--clay-black)] focus:ring-[var(--clay-black)]"
288
- />
289
- </div>
290
- ))}
291
- </div>
292
- </div>
293
-
294
  {/* Format Selection */}
295
  <div className="flex flex-col gap-4">
296
- <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Format Soal</label>
 
 
 
 
 
297
  <div className="flex flex-wrap gap-2">
298
  {FORMATS.filter((f) => f.allowedExams.includes(examType)).map((f) => (
299
  <button
300
  key={f.id}
301
  onClick={() => toggleFormat(f.id)}
302
- className={`px-4 py-2.5 rounded-full text-sm font-medium flex items-center gap-2 cursor-pointer transition-all clay-hover ${
303
  selectedFormats.includes(f.id)
304
  ? "bg-[var(--matcha-300)] text-[var(--matcha-800)]"
305
  : "bg-[var(--oat-light)] text-[var(--warm-charcoal)] hover:bg-[var(--matcha-300)] hover:text-[var(--matcha-800)]"
@@ -316,13 +399,18 @@ function RouteComponent() {
316
 
317
  {/* Topic Focus */}
318
  <div className="flex flex-col gap-4">
319
- <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Topik</label>
 
 
 
 
 
320
  <div className="flex flex-wrap gap-2">
321
  {selectedTopics.map((topic) => (
322
  <span
323
  key={topic}
324
  onClick={() => toggleTopic(topic)}
325
- className="px-4 py-2 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] font-medium flex items-center gap-2 cursor-pointer transition-all hover:brightness-95 clay-hover"
326
  >
327
  {topic} <MaterialIcon name="close" className="text-sm" />
328
  </span>
@@ -331,7 +419,7 @@ function RouteComponent() {
331
  <button
332
  key={topic}
333
  onClick={() => toggleTopic(topic)}
334
- className="px-4 py-2 rounded-full bg-[var(--oat-light)] text-[var(--warm-charcoal)] font-medium hover:bg-[var(--matcha-300)] hover:text-[var(--matcha-800)] transition-all clay-hover"
335
  >
336
  {topic}
337
  </button>
@@ -339,44 +427,35 @@ function RouteComponent() {
339
  </div>
340
  </div>
341
 
342
- {/* Question Count */}
343
  <div className="flex flex-col gap-4">
344
- <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Jumlah Soal: {questionCount}</label>
345
- <div className="grid grid-cols-2 md:grid-cols-4 gap-3">
346
- {QUESTION_COUNT_PRESETS.map((p) => (
347
- <button
348
- key={p.value}
349
- onClick={() => setQuestionCount(p.value)}
350
- className={`py-4 px-2 rounded-[var(--radius-lg)] text-sm font-semibold transition-all clay-hover flex flex-col items-center gap-1 ${
351
- questionCount === p.value
352
- ? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
353
- : "bg-[var(--pure-white)] text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] border-2 border-[var(--oat-border)]"
354
- }`}
355
- >
356
- <span>{p.label}</span>
357
- <span className={`text-xs ${questionCount === p.value ? "text-[var(--pure-white)]/70" : "text-[var(--warm-charcoal)]/70"}`}>{p.desc}</span>
358
- </button>
359
- ))}
360
  </div>
361
- <div className="flex items-center gap-3 mt-2">
362
- <span className="text-xs font-medium text-[var(--warm-charcoal)] whitespace-nowrap">Custom:</span>
363
  <input
364
  type="range"
365
- min={1}
366
- max={40}
367
- value={questionCount}
368
- onChange={(e) => setQuestionCount(Number(e.target.value))}
369
  className="w-full h-2 bg-[var(--warm-silver)] rounded-full appearance-none cursor-pointer accent-[var(--clay-black)]"
370
  />
371
- <span className="text-xs font-bold text-[var(--clay-black)] w-6 text-right">{questionCount}</span>
 
 
 
372
  </div>
373
  </div>
374
  </div>
375
 
376
- {/* Live Preview Card (Right) */}
377
  <TestBlueprintCard
378
  examType={examType}
379
- section={section}
380
  selectedFormats={selectedFormats}
381
  questionCount={questionCount}
382
  weaknessAlign={weaknessAlign}
@@ -387,14 +466,16 @@ function RouteComponent() {
387
  hasKey={hasConfigs}
388
  error={error}
389
  onGenerate={handleGenerate}
 
390
  />
391
  </div>
392
 
393
- {/* Results Section */}
394
- {result && (
395
- <ResultSection result={result} generatedPackageId={generatedPackageId} />
396
- )}
397
-
 
398
  </div>
399
  );
400
  }
 
1
+ import { useState, useEffect, useRef } from "react";
2
  import { useMutation } from "@tanstack/react-query";
3
  import { createFileRoute, redirect, Link } from "@tanstack/react-router";
4
  import { authClient } from "@/lib/auth-client";
 
6
  import { useApiKeys } from "@/hooks/use-api-key";
7
  import { useGenerationJob } from "@/hooks/use-generation-job";
8
  import { Button } from "@labas/ui/components/button";
 
9
  import {
10
  Select,
11
  SelectContent,
 
44
  configs[0]?.id ?? "",
45
  );
46
 
 
47
  useEffect(() => {
48
  if (configs.length > 0 && !configs.find((c) => c.id === selectedKeyId)) {
49
  setSelectedKeyId(configs[0].id);
 
62
  reset,
63
  } = useGenerationJob();
64
 
65
+ // Auto-scroll to results when they appear
66
+ const resultsRef = useRef<HTMLDivElement>(null);
67
+ useEffect(() => {
68
+ if (result && resultsRef.current) {
69
+ resultsRef.current.scrollIntoView({ behavior: "smooth", block: "start" });
70
+ }
71
+ }, [result]);
72
+
73
  const [examType, setExamType] = useState("IELTS");
74
+ const [selectedSections, setSelectedSections] = useState<string[]>(["READING"]);
75
  const [selectedFormats, setSelectedFormats] = useState<string[]>(["multiple_choice"]);
76
  const [difficulty, setDifficulty] = useState(2);
77
  const [selectedTopics, setSelectedTopics] = useState<string[]>(["Science & Tech"]);
 
79
  const [weaknessAlign, setWeaknessAlign] = useState(75);
80
  const [mode, setMode] = useState<"quick" | "agentic">("quick");
81
 
 
82
  useEffect(() => {
83
  setSelectedFormats((prev) => {
84
  const valid = prev.filter((f) =>
 
103
  },
104
  });
105
 
106
+ const toggleSection = (id: string) => {
107
+ setSelectedSections((prev) => {
108
+ if (prev.includes(id)) {
109
+ // Prevent unselecting the last section
110
+ if (prev.length === 1) return prev;
111
+ return prev.filter((s) => s !== id);
112
+ }
113
+ return [...prev, id];
114
+ });
115
+ };
116
+
117
  const toggleFormat = (id: string) => {
118
  setSelectedFormats((prev) =>
119
  prev.includes(id) ? prev.filter((f) => f !== id) : [...prev, id],
 
131
  setError("API key belum dikonfigurasi. Tambahkan di Settings.");
132
  return;
133
  }
134
+ if (selectedSections.length === 0) {
135
+ setError("Pilih minimal 1 section.");
136
+ return;
137
+ }
138
+ if (selectedFormats.length === 0) {
139
+ setError("Pilih minimal 1 format soal.");
140
+ return;
141
+ }
142
 
143
  reset();
144
 
145
  generate.mutate({
146
  examType: examType as any,
147
+ section: selectedSections[0] as any,
148
+ selectedSections: selectedSections as any,
149
  formats: selectedFormats as any,
150
  difficulty: difficulty + 1,
151
  topics: selectedTopics,
 
160
  });
161
  };
162
 
163
+ const sectionSplits = (() => {
164
+ if (mode !== "agentic" || questionCount < 20 || selectedSections.length <= 1) return null;
165
+ const base = Math.floor(questionCount / selectedSections.length);
166
+ const rem = questionCount % selectedSections.length;
167
+ return selectedSections.map((s, i) => ({ section: s, count: base + (i < rem ? 1 : 0) }));
168
+ })();
169
+
170
  return (
171
  <div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-7xl mx-auto bg-[var(--warm-cream)]">
172
+ {/* Header */}
173
  <section className="flex flex-col gap-2 relative mb-10">
174
  <div className="absolute -left-8 -top-8 w-64 h-64 ai-glow pointer-events-none opacity-50" />
175
  <h1 className="text-4xl md:text-5xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">
176
  AI Exam Generator
177
  </h1>
178
  <p className="text-lg text-[var(--warm-charcoal)] max-w-2xl leading-relaxed">
179
+ Generate soal latihan dengan AI. Pilih section, format, dan topik sisanya AI yang kerjakan.
180
  </p>
181
  </section>
182
 
 
191
  )}
192
 
193
  {hasConfigs && (
194
+ <div className="mb-8 p-4 rounded-[var(--radius-lg)] bg-[var(--pure-white)] border-2 border-[var(--oat-border)]">
195
  <label className="text-sm font-medium text-[var(--clay-black)] mb-2 block">
196
  Provider / API Key
197
  </label>
 
224
  )}
225
 
226
  <div className="grid grid-cols-1 lg:grid-cols-12 gap-10 items-start">
227
+ {/* Configuration Panel */}
228
+ <div className="lg:col-span-8 flex flex-col gap-10">
229
+
230
  {/* Exam Type */}
231
  <div className="flex flex-col gap-4">
232
  <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Jenis Ujian</label>
 
235
  <button
236
  key={t.id}
237
  onClick={() => setExamType(t.id)}
238
+ className={`flex items-center gap-3 py-4 px-4 rounded-[var(--radius-lg)] border-2 transition-all text-sm font-semibold clay-hover min-h-[56px] ${
239
  examType === t.id
240
+ ? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow border-[var(--clay-black)]"
241
  : "bg-[var(--pure-white)] text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] border-[var(--oat-border)]"
242
  }`}
243
  >
244
+ <span className={`fi fi-${t.code} w-6 h-4 rounded-sm shadow-sm shrink-0`} />
245
  {t.name}
246
  </button>
247
  ))}
248
  </div>
249
  </div>
250
 
251
+ {/* Section Selection — Multi-select */}
252
  <div className="flex flex-col gap-4">
253
+ <div className="flex items-center justify-between">
254
+ <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Section</label>
255
+ <span className="text-xs text-[var(--warm-charcoal)]">
256
+ {selectedSections.length} dipilih
257
  </span>
258
  </div>
259
+ <div className="flex flex-wrap gap-3">
260
+ {SECTIONS.map((s) => {
261
+ const isSelected = selectedSections.includes(s.id);
262
+ return (
263
+ <button
264
+ key={s.id}
265
+ onClick={() => toggleSection(s.id)}
266
+ className={`flex items-center gap-2.5 px-5 py-3 rounded-[var(--radius-lg)] border-2 transition-all text-sm font-semibold clay-hover min-h-[52px] ${
267
+ isSelected
268
+ ? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow border-[var(--clay-black)]"
269
+ : "bg-[var(--pure-white)] text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] border-[var(--oat-border)]"
270
+ }`}
271
+ >
272
+ <MaterialIcon
273
+ name={isSelected ? "check_circle" : s.icon}
274
+ className={`text-base shrink-0 ${isSelected ? "text-[var(--matcha-400)]" : ""}`}
275
+ />
276
+ {s.name}
277
+ </button>
278
+ );
279
+ })}
280
+ </div>
281
+ {selectedSections.length > 1 && (
282
+ <p className="text-xs text-[var(--matcha-800)] bg-[var(--matcha-300)]/30 px-3 py-2 rounded-[var(--radius-md)]">
283
+ <MaterialIcon name="tips_and_updates" className="text-xs mr-1 inline" />
284
+ Kamu memilih {selectedSections.length} section. Mode Agentic dengan ≥20 soal akan otomatis membagi soal ke section yang dipilih.
285
+ </p>
286
+ )}
287
+ </div>
288
+
289
+ {/* Question Count */}
290
+ <div className="flex flex-col gap-4">
291
+ <label className="font-headline text-xl font-bold text-[var(--clay-black)]">
292
+ Jumlah Soal
293
+ <span className="ml-2 text-sm font-normal text-[var(--warm-charcoal)]">{questionCount} soal</span>
294
+ </label>
295
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-3">
296
+ {QUESTION_COUNT_PRESETS.map((p) => (
297
+ <button
298
+ key={p.value}
299
+ onClick={() => setQuestionCount(p.value)}
300
+ className={`py-4 px-2 rounded-[var(--radius-lg)] text-sm font-semibold transition-all clay-hover flex flex-col items-center gap-1 min-h-[72px] ${
301
+ questionCount === p.value
302
+ ? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
303
+ : "bg-[var(--pure-white)] text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] border-2 border-[var(--oat-border)]"
304
+ }`}
305
+ >
306
+ <span>{p.label}</span>
307
+ <span className={`text-xs ${questionCount === p.value ? "text-[var(--pure-white)]/70" : "text-[var(--warm-charcoal)]/70"}`}>{p.desc}</span>
308
+ </button>
309
+ ))}
310
+ </div>
311
+ <div className="flex items-center gap-3 mt-1">
312
+ <span className="text-xs font-medium text-[var(--warm-charcoal)] whitespace-nowrap">Custom:</span>
313
  <input
314
  type="range"
315
+ min={1}
316
+ max={40}
317
+ value={questionCount}
318
+ onChange={(e) => setQuestionCount(Number(e.target.value))}
319
  className="w-full h-2 bg-[var(--warm-silver)] rounded-full appearance-none cursor-pointer accent-[var(--clay-black)]"
320
  />
321
+ <span className="text-xs font-bold text-[var(--clay-black)] w-6 text-right">{questionCount}</span>
 
 
 
322
  </div>
323
+
324
+ {/* Auto Multi-Section Preview */}
325
+ {sectionSplits && (
326
+ <div className="mt-2 p-4 rounded-[var(--radius-lg)] bg-[var(--matcha-300)]/30 border border-[var(--matcha-400)]">
327
+ <div className="flex items-center gap-2 mb-2 text-[var(--matcha-800)] font-semibold text-sm">
328
+ <MaterialIcon name="auto_awesome" className="text-xs" />
329
+ Auto Multi-Section
330
+ </div>
331
+ <p className="text-[var(--matcha-800)]/80 text-xs mb-3">
332
+ Mode Agentic dengan {questionCount} soal akan dibagi ke {sectionSplits.length} section:
333
+ </p>
334
+ <div className="flex flex-wrap gap-2">
335
+ {sectionSplits.map((s) => {
336
+ const sec = SECTIONS.find((sec) => sec.id === s.section);
337
+ return (
338
+ <span
339
+ key={s.section}
340
+ className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-[var(--pure-white)] text-[var(--matcha-800)] text-xs font-medium border border-[var(--matcha-400)]"
341
+ >
342
+ <MaterialIcon name={sec?.icon ?? "menu_book"} className="text-[10px]" />
343
+ {sec?.name ?? s.section}: {s.count} soal
344
+ </span>
345
+ );
346
+ })}
347
+ </div>
348
+ </div>
349
+ )}
350
  </div>
351
 
352
+ {/* Difficulty */}
353
  <div className="flex flex-col gap-4">
354
  <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Tingkat Kesulitan</label>
355
  <div className="grid grid-cols-2 md:grid-cols-4 gap-3">
 
357
  <button
358
  key={d}
359
  onClick={() => setDifficulty(i)}
360
+ className={`py-4 px-2 rounded-[var(--radius-lg)] text-sm font-semibold transition-all clay-hover min-h-[56px] ${
361
  difficulty === i
362
  ? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
363
  : "bg-[var(--pure-white)] text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] border-2 border-[var(--oat-border)]"
 
369
  </div>
370
  </div>
371
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
372
  {/* Format Selection */}
373
  <div className="flex flex-col gap-4">
374
+ <div className="flex items-center justify-between">
375
+ <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Format Soal</label>
376
+ <span className="text-xs text-[var(--warm-charcoal)]">
377
+ {selectedFormats.length} dipilih
378
+ </span>
379
+ </div>
380
  <div className="flex flex-wrap gap-2">
381
  {FORMATS.filter((f) => f.allowedExams.includes(examType)).map((f) => (
382
  <button
383
  key={f.id}
384
  onClick={() => toggleFormat(f.id)}
385
+ className={`px-4 py-2.5 rounded-full text-sm font-medium flex items-center gap-2 cursor-pointer transition-all clay-hover min-h-[40px] ${
386
  selectedFormats.includes(f.id)
387
  ? "bg-[var(--matcha-300)] text-[var(--matcha-800)]"
388
  : "bg-[var(--oat-light)] text-[var(--warm-charcoal)] hover:bg-[var(--matcha-300)] hover:text-[var(--matcha-800)]"
 
399
 
400
  {/* Topic Focus */}
401
  <div className="flex flex-col gap-4">
402
+ <div className="flex items-center justify-between">
403
+ <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Topik</label>
404
+ <span className="text-xs text-[var(--warm-charcoal)]">
405
+ {selectedTopics.length} dipilih
406
+ </span>
407
+ </div>
408
  <div className="flex flex-wrap gap-2">
409
  {selectedTopics.map((topic) => (
410
  <span
411
  key={topic}
412
  onClick={() => toggleTopic(topic)}
413
+ className="px-4 py-2 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] font-medium flex items-center gap-2 cursor-pointer transition-all hover:brightness-95 clay-hover min-h-[40px]"
414
  >
415
  {topic} <MaterialIcon name="close" className="text-sm" />
416
  </span>
 
419
  <button
420
  key={topic}
421
  onClick={() => toggleTopic(topic)}
422
+ className="px-4 py-2 rounded-full bg-[var(--oat-light)] text-[var(--warm-charcoal)] font-medium hover:bg-[var(--matcha-300)] hover:text-[var(--matcha-800)] transition-all clay-hover min-h-[40px]"
423
  >
424
  {topic}
425
  </button>
 
427
  </div>
428
  </div>
429
 
430
+ {/* Weakness Alignment */}
431
  <div className="flex flex-col gap-4">
432
+ <div className="flex justify-between items-end">
433
+ <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Fokus Latihan</label>
434
+ <span className="text-sm font-medium text-[var(--matcha-800)] bg-[var(--matcha-300)] px-3 py-1 rounded-full">
435
+ Intelligent Focus
436
+ </span>
 
 
 
 
 
 
 
 
 
 
 
437
  </div>
438
+ <div className="relative py-4">
 
439
  <input
440
  type="range"
441
+ min="0"
442
+ max="100"
443
+ value={weaknessAlign}
444
+ onChange={(e) => setWeaknessAlign(Number(e.target.value))}
445
  className="w-full h-2 bg-[var(--warm-silver)] rounded-full appearance-none cursor-pointer accent-[var(--clay-black)]"
446
  />
447
+ <div className="flex justify-between mt-4 text-xs font-label uppercase tracking-widest text-[var(--warm-charcoal)]">
448
+ <span>Soal Seimbang</span>
449
+ <span>Fokus Kelemahan</span>
450
+ </div>
451
  </div>
452
  </div>
453
  </div>
454
 
455
+ {/* Live Preview Card */}
456
  <TestBlueprintCard
457
  examType={examType}
458
+ selectedSections={selectedSections}
459
  selectedFormats={selectedFormats}
460
  questionCount={questionCount}
461
  weaknessAlign={weaknessAlign}
 
466
  hasKey={hasConfigs}
467
  error={error}
468
  onGenerate={handleGenerate}
469
+ onDismissError={() => setError(null)}
470
  />
471
  </div>
472
 
473
+ {/* Results */}
474
+ <div ref={resultsRef}>
475
+ {result && (
476
+ <ResultSection result={result} generatedPackageId={generatedPackageId} />
477
+ )}
478
+ </div>
479
  </div>
480
  );
481
  }
apps/web/src/routes/jobs.tsx CHANGED
@@ -304,9 +304,26 @@ function RouteComponent() {
304
  <CardContent className="pt-0">
305
  <div className="flex items-center justify-between mb-3">
306
  <div className="text-sm text-[var(--warm-charcoal)]">
307
- {result.questions.length} soal dihasilkan · {result.meta.model}
308
- {job.tokensUsed ? ` · ${job.tokensUsed} tokens` : ""}
309
- {result.meta.durationMs ? ` · ${(result.meta.durationMs / 1000).toFixed(1)}s` : ""}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
310
  </div>
311
  <Button
312
  variant="outline"
 
304
  <CardContent className="pt-0">
305
  <div className="flex items-center justify-between mb-3">
306
  <div className="text-sm text-[var(--warm-charcoal)]">
307
+ {(() => {
308
+ const splits = (result as any).sectionSplits as { section: string; count: number }[] | undefined;
309
+ if (splits && splits.length > 1) {
310
+ return (
311
+ <span>
312
+ {result.questions.length} soal · {splits.length} section{" "}
313
+ {splits.map((s) => `${s.section} ${s.count}`).join(", ")} · {result.meta.model}
314
+ {job.tokensUsed ? ` · ${job.tokensUsed} tokens` : ""}
315
+ {result.meta.durationMs ? ` · ${(result.meta.durationMs / 1000).toFixed(1)}s` : ""}
316
+ </span>
317
+ );
318
+ }
319
+ return (
320
+ <span>
321
+ {result.questions.length} soal dihasilkan · {result.meta.model}
322
+ {job.tokensUsed ? ` · ${job.tokensUsed} tokens` : ""}
323
+ {result.meta.durationMs ? ` · ${(result.meta.durationMs / 1000).toFixed(1)}s` : ""}
324
+ </span>
325
+ );
326
+ })()}
327
  </div>
328
  <Button
329
  variant="outline"
packages/ai/src/agentic.ts CHANGED
@@ -1,13 +1,15 @@
1
  import { OpenAICompatibleClient } from "./client";
2
  import { GenerationError } from "./errors";
3
  import {
4
- getQuestionJsonSchemaDescription,
5
  getPassageJsonSchemaDescription,
6
  getValidationJsonSchemaDescription,
7
- getQuestionsArrayJsonSchemaDescription,
8
  getSelfValidationJsonSchemaDescription,
9
  } from "./schema-to-prompt";
10
- import { questionSchema, type GenerationInput, type GenerationResult } from "./schemas";
 
 
 
 
11
 
12
  interface AgenticStep {
13
  step: string;
@@ -32,6 +34,30 @@ function getTargetLanguage(examType: string): string {
32
  return "English";
33
  }
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  function parseJsonResponse(content: string): unknown {
36
  if (!content) throw new Error("Empty response from AI");
37
  let parsed: unknown;
@@ -73,7 +99,7 @@ ${schema}`;
73
  { role: "user", content: prompt },
74
  ],
75
  temperature: 0.7,
76
- max_tokens: input.apiKeyConfig.maxTokens,
77
  response_format: { type: "json_object" },
78
  },
79
  onToken ? { onToken } : undefined,
@@ -122,7 +148,7 @@ ${schema}`;
122
  { role: "user", content: prompt },
123
  ],
124
  temperature: 0.3,
125
- max_tokens: input.apiKeyConfig.maxTokens,
126
  response_format: { type: "json_object" },
127
  },
128
  onToken ? { onToken } : undefined,
@@ -140,12 +166,12 @@ async function step3GenerateQuestions(
140
  client: OpenAICompatibleClient,
141
  input: GenerationInput,
142
  passage: string,
 
143
  onToken?: (token: string) => void,
144
  ): Promise<{ questions: Array<Record<string, unknown>>; tokensUsed: number }> {
145
- const questionSchemaDesc = getQuestionJsonSchemaDescription();
146
- const wrapperSchema = getQuestionsArrayJsonSchemaDescription();
147
 
148
- const prompt = `Using the following passage, generate ${input.questionCount} reading comprehension questions for ${input.examType} exam.
149
 
150
  Passage:
151
  """
@@ -161,12 +187,13 @@ Rules:
161
  - For multiple choice: always provide 4 options (A, B, C, D) with one clearly correct answer
162
  - Options must be plausible distractors
163
  - explanation (explanation) - dijelaskan dengan bahasa Indonesia
 
 
164
 
165
  Question schema:
166
- ${questionSchemaDesc}
167
 
168
- Return ONLY valid JSON conforming to this schema:
169
- ${wrapperSchema}`;
170
 
171
  const result = await client.chatCompletion(
172
  {
@@ -176,7 +203,7 @@ ${wrapperSchema}`;
176
  { role: "user", content: prompt },
177
  ],
178
  temperature: 0.7,
179
- max_tokens: input.apiKeyConfig.maxTokens,
180
  response_format: { type: "json_object" },
181
  },
182
  onToken ? { onToken } : undefined,
@@ -198,7 +225,7 @@ async function step4SelfValidate(
198
  passage: string,
199
  questions: Array<Record<string, unknown>>,
200
  onToken?: (token: string) => void,
201
- ): Promise<{ correctedQuestions: Array<Record<string, unknown>>; confidence: number; tokensUsed: number }> {
202
  const qaPairs = questions
203
  .map((q, i) => `Q${i + 1}: ${q.questionText}\nA: ${q.correctAnswer}`)
204
  .join("\n\n");
@@ -230,7 +257,7 @@ ${schema}`;
230
  { role: "user", content: prompt },
231
  ],
232
  temperature: 0.3,
233
- max_tokens: input.apiKeyConfig.maxTokens,
234
  response_format: { type: "json_object" },
235
  },
236
  onToken ? { onToken } : undefined,
@@ -240,56 +267,42 @@ ${schema}`;
240
  const confidence = typeof parsed.overallConfidence === "number" ? parsed.overallConfidence : 75;
241
  const issues = Array.isArray(parsed.issues) ? parsed.issues : [];
242
 
243
- // Apply fixes if needed
244
- const corrected = questions.map((q, i) => {
245
- const issue = issues.find((iss: any) => iss?.questionIndex === i);
246
- if (issue && issue.suggestedFix) {
247
- return { ...q, explanation: `${q.explanation}\n[Validator note: ${issue.suggestedFix}]` };
248
- }
249
- return q;
250
- });
251
-
252
- return { correctedQuestions: corrected, confidence, tokensUsed: result.usage?.total_tokens ?? 0 };
253
  }
254
 
255
- async function step4RegenerateBadQuestions(
256
  client: OpenAICompatibleClient,
257
  input: GenerationInput,
258
  passage: string,
259
- questions: Array<Record<string, unknown>>,
260
- issueIndices: number[],
261
  onToken?: (token: string) => void,
262
- ): Promise<{ regenerated: Array<Record<string, unknown>>; tokensUsed: number }> {
263
- const badQuestions = issueIndices.map((i) => ({
264
- index: i,
265
- ...questions[i],
266
- }));
267
-
268
- const questionSchemaDesc = getQuestionJsonSchemaDescription();
269
- const wrapperSchema = getQuestionsArrayJsonSchemaDescription();
270
 
271
- const prompt = `You are an expert exam question writer. The following questions were flagged as incorrect or flawed. Regenerate them to fix the issues while keeping the same format and difficulty.
272
 
273
  Passage:
274
  """
275
  ${passage}
276
  """
277
 
278
- Flawed questions (with their original index):
279
- ${JSON.stringify(badQuestions, null, 2)}
 
280
 
281
  Rules:
282
- - Regenerate ONLY the flawed questions
283
- - Maintain the same format, difficulty (${input.difficulty}), and exam style (${input.examType})
284
  - Each question must be directly answerable from the passage
285
- - explanation (explanation) - dijelaskan dengan bahasa Indonesia
286
- - Return the same number of questions in the same order as the input
 
 
 
287
 
288
  Question schema:
289
- ${questionSchemaDesc}
290
 
291
- Return ONLY valid JSON conforming to this schema:
292
- ${wrapperSchema}`;
293
 
294
  const result = await client.chatCompletion(
295
  {
@@ -299,19 +312,18 @@ ${wrapperSchema}`;
299
  { role: "user", content: prompt },
300
  ],
301
  temperature: 0.7,
302
- max_tokens: input.apiKeyConfig.maxTokens,
303
  response_format: { type: "json_object" },
304
  },
305
  onToken ? { onToken } : undefined,
306
  );
307
 
308
  const parsed = parseJsonResponse(result.content) as Record<string, unknown>;
309
- if (!Array.isArray(parsed.questions) || parsed.questions.length !== badQuestions.length) {
310
- throw new Error("Regeneration did not return the expected number of questions");
311
  }
312
-
313
  return {
314
- regenerated: parsed.questions as Array<Record<string, unknown>>,
315
  tokensUsed: result.usage?.total_tokens ?? 0,
316
  };
317
  }
@@ -334,7 +346,10 @@ export async function generateQuestionsAgentic(
334
  { step: "self_validate", status: "pending" as any },
335
  ];
336
 
337
- const report = (current: number) => {
 
 
 
338
  if (onProgress) {
339
  onProgress({ steps, currentStep: current });
340
  }
@@ -342,7 +357,7 @@ export async function generateQuestionsAgentic(
342
 
343
  let accumulatedTokens = 0;
344
 
345
- // Step 1: Generate passage
346
  report(0);
347
  let passage: string;
348
  let title: string;
@@ -360,36 +375,29 @@ export async function generateQuestionsAgentic(
360
  throw new GenerationError(`Step 1 failed: ${err.message}`, { tokensUsed: accumulatedTokens });
361
  }
362
 
363
- // Step 2: Validate passage
364
  steps[1].status = "running";
365
  report(1);
366
- let isValid: boolean;
367
- let feedback: string;
368
  try {
369
  const s2 = await step2ValidatePassage(client, input, passage, onToken);
370
- isValid = s2.isValid;
371
- feedback = s2.feedback;
372
  accumulatedTokens += s2.tokensUsed;
373
- steps[1].status = isValid ? "done" : "error";
374
- steps[1].message = feedback;
375
- steps[1].output = JSON.stringify({ isValid, feedback }, null, 2);
376
  } catch (err: any) {
377
  steps[1].status = "error";
378
  steps[1].message = err.message ?? "Passage validation failed";
379
  throw new GenerationError(`Step 2 failed: ${err.message}`, { tokensUsed: accumulatedTokens });
380
  }
381
 
382
- // Even if not perfect, continue (the feedback is logged)
383
-
384
- // Step 3: Generate questions
385
  steps[2].status = "running";
386
- report(2);
387
  let rawQuestions: Array<Record<string, unknown>>;
388
  try {
389
- const s3 = await step3GenerateQuestions(client, input, passage, onToken);
390
  rawQuestions = s3.questions;
391
  accumulatedTokens += s3.tokensUsed;
392
- steps[2].status = "done";
393
  steps[2].message = `Generated ${rawQuestions.length} questions`;
394
  steps[2].output = rawQuestions.map((q, i) => `${i + 1}. [${q.format}] ${q.questionText}`).join("\n");
395
  } catch (err: any) {
@@ -398,73 +406,90 @@ export async function generateQuestionsAgentic(
398
  throw new GenerationError(`Step 3 failed: ${err.message}`, { tokensUsed: accumulatedTokens });
399
  }
400
 
401
- // Step 4: Self-validate
402
  steps[3].status = "running";
403
- report(3);
404
- let correctedQuestions: Array<Record<string, unknown>>;
405
- let confidence: number;
 
 
406
  try {
407
  const s4 = await step4SelfValidate(client, input, passage, rawQuestions, onToken);
408
- correctedQuestions = s4.correctedQuestions;
409
- confidence = s4.confidence;
410
  accumulatedTokens += s4.tokensUsed;
411
 
412
- // If validation found issues and confidence is low, actually regenerate the bad questions
413
- const issueIndices = (s4.correctedQuestions as any[])
414
- .map((q, i) => (q.explanation?.includes("[Validator note:") ? i : -1))
415
- .filter((i) => i !== -1);
416
-
417
- if (issueIndices.length > 0 && confidence < 85) {
418
- steps[3].message = `Found ${issueIndices.length} issues. Regenerating...`;
419
- report(3);
420
- const regen = await step4RegenerateBadQuestions(
421
- client,
422
- input,
423
- passage,
424
- rawQuestions,
425
- issueIndices,
426
- onToken,
427
- );
 
428
  accumulatedTokens += regen.tokensUsed;
429
 
430
- // Replace the bad questions with regenerated ones
431
- for (let idx = 0; idx < issueIndices.length; idx++) {
432
- correctedQuestions[issueIndices[idx]] = regen.regenerated[idx];
 
 
 
 
 
 
 
433
  }
434
- confidence = Math.min(100, confidence + 10);
435
- steps[3].message = `Regenerated ${issueIndices.length} questions. Confidence: ${confidence}%`;
436
- } else {
437
- steps[3].message = `Confidence score: ${confidence}%`;
 
 
 
 
 
 
 
 
 
 
 
438
  }
439
 
440
  steps[3].status = "done";
441
- steps[3].output = `Overall Confidence: ${confidence}%\nTotal Questions: ${correctedQuestions.length}`;
 
 
 
 
 
 
442
  } catch (err: any) {
443
  steps[3].status = "error";
444
  steps[3].message = err.message ?? "Self-validation failed";
445
  throw new GenerationError(`Step 4 failed: ${err.message}`, { tokensUsed: accumulatedTokens });
446
  }
447
 
448
- // Parse and validate final questions
449
- const questions = correctedQuestions
450
- .map((q) => {
451
- try {
452
- return questionSchema.parse({ ...q, passageText: passage });
453
- } catch (e) {
454
- console.warn("Question validation failed:", e);
455
- return null;
456
- }
457
- })
458
- .filter((q): q is NonNullable<typeof q> => q !== null);
459
-
460
- if (questions.length === 0) {
461
  throw new GenerationError("No valid questions generated after agentic validation", {
462
  tokensUsed: accumulatedTokens,
463
  });
464
  }
465
 
 
 
 
466
  return {
467
- questions,
468
  meta: {
469
  model: input.apiKeyConfig.model,
470
  durationMs: Date.now() - start,
 
1
  import { OpenAICompatibleClient } from "./client";
2
  import { GenerationError } from "./errors";
3
  import {
 
4
  getPassageJsonSchemaDescription,
5
  getValidationJsonSchemaDescription,
 
6
  getSelfValidationJsonSchemaDescription,
7
  } from "./schema-to-prompt";
8
+ import {
9
+ getGenericQuestionJsonSchemaDescription,
10
+ repairAndParseQuestions,
11
+ } from "./repair";
12
+ import { type GenerationInput, type GenerationResult } from "./schemas";
13
 
14
  interface AgenticStep {
15
  step: string;
 
34
  return "English";
35
  }
36
 
37
+ /** Estimate max tokens needed per step to avoid truncation. */
38
+ function calculateMaxTokens(
39
+ userMax: number,
40
+ step: "passage" | "validate" | "questions" | "self_validate" | "regenerate",
41
+ questionCount: number,
42
+ ): number {
43
+ const base = userMax > 0 ? userMax : 16_384;
44
+ switch (step) {
45
+ case "passage":
46
+ return Math.min(base, 8_192);
47
+ case "validate":
48
+ return Math.min(base, 4_096);
49
+ case "self_validate":
50
+ return Math.min(base, 4_096);
51
+ case "questions":
52
+ // Rough estimate: ~500 tokens per question JSON + overhead
53
+ return Math.min(Math.max(base, 2_000 + questionCount * 600), 64_000);
54
+ case "regenerate":
55
+ return Math.min(Math.max(base, 2_000 + questionCount * 600), 64_000);
56
+ default:
57
+ return base;
58
+ }
59
+ }
60
+
61
  function parseJsonResponse(content: string): unknown {
62
  if (!content) throw new Error("Empty response from AI");
63
  let parsed: unknown;
 
99
  { role: "user", content: prompt },
100
  ],
101
  temperature: 0.7,
102
+ max_tokens: calculateMaxTokens(input.apiKeyConfig.maxTokens, "passage", input.questionCount),
103
  response_format: { type: "json_object" },
104
  },
105
  onToken ? { onToken } : undefined,
 
148
  { role: "user", content: prompt },
149
  ],
150
  temperature: 0.3,
151
+ max_tokens: calculateMaxTokens(input.apiKeyConfig.maxTokens, "validate", input.questionCount),
152
  response_format: { type: "json_object" },
153
  },
154
  onToken ? { onToken } : undefined,
 
166
  client: OpenAICompatibleClient,
167
  input: GenerationInput,
168
  passage: string,
169
+ count: number,
170
  onToken?: (token: string) => void,
171
  ): Promise<{ questions: Array<Record<string, unknown>>; tokensUsed: number }> {
172
+ const schema = getGenericQuestionJsonSchemaDescription();
 
173
 
174
+ const prompt = `Using the following passage, generate ${count} reading comprehension questions for ${input.examType} exam.
175
 
176
  Passage:
177
  """
 
187
  - For multiple choice: always provide 4 options (A, B, C, D) with one clearly correct answer
188
  - Options must be plausible distractors
189
  - explanation (explanation) - dijelaskan dengan bahasa Indonesia
190
+ - For true_false_not_given: correctAnswer must be exactly TRUE, FALSE, or NOT_GIVEN (uppercase)
191
+ - For author_view: correctAnswer must be exactly YES, NO, or NOT_GIVEN (uppercase)
192
 
193
  Question schema:
194
+ ${schema}
195
 
196
+ Return ONLY valid JSON conforming to this schema.`;
 
197
 
198
  const result = await client.chatCompletion(
199
  {
 
203
  { role: "user", content: prompt },
204
  ],
205
  temperature: 0.7,
206
+ max_tokens: calculateMaxTokens(input.apiKeyConfig.maxTokens, "questions", count),
207
  response_format: { type: "json_object" },
208
  },
209
  onToken ? { onToken } : undefined,
 
225
  passage: string,
226
  questions: Array<Record<string, unknown>>,
227
  onToken?: (token: string) => void,
228
+ ): Promise<{ confidence: number; issues: any[]; tokensUsed: number }> {
229
  const qaPairs = questions
230
  .map((q, i) => `Q${i + 1}: ${q.questionText}\nA: ${q.correctAnswer}`)
231
  .join("\n\n");
 
257
  { role: "user", content: prompt },
258
  ],
259
  temperature: 0.3,
260
+ max_tokens: calculateMaxTokens(input.apiKeyConfig.maxTokens, "self_validate", input.questionCount),
261
  response_format: { type: "json_object" },
262
  },
263
  onToken ? { onToken } : undefined,
 
267
  const confidence = typeof parsed.overallConfidence === "number" ? parsed.overallConfidence : 75;
268
  const issues = Array.isArray(parsed.issues) ? parsed.issues : [];
269
 
270
+ return { confidence, issues, tokensUsed: result.usage?.total_tokens ?? 0 };
 
 
 
 
 
 
 
 
 
271
  }
272
 
273
+ async function stepRegenerateQuestions(
274
  client: OpenAICompatibleClient,
275
  input: GenerationInput,
276
  passage: string,
277
+ count: number,
278
+ context: string,
279
  onToken?: (token: string) => void,
280
+ ): Promise<{ questions: Array<Record<string, unknown>>; tokensUsed: number }> {
281
+ const schema = getGenericQuestionJsonSchemaDescription();
 
 
 
 
 
 
282
 
283
+ const prompt = `You are an expert exam question writer. ${context}
284
 
285
  Passage:
286
  """
287
  ${passage}
288
  """
289
 
290
+ Generate ${count} new reading comprehension questions for ${input.examType} exam.
291
+ Formats: ${input.formats.join(", ")}
292
+ Difficulty: ${input.difficulty}/5
293
 
294
  Rules:
 
 
295
  - Each question must be directly answerable from the passage
296
+ - Use "passageText" field with relevant excerpt (or full passage)
297
+ - For multiple choice: provide 4 options (A, B, C, D)
298
+ - explanation - dijelaskan dengan bahasa Indonesia
299
+ - For true_false_not_given: correctAnswer must be TRUE, FALSE, or NOT_GIVEN (uppercase)
300
+ - For author_view: correctAnswer must be YES, NO, or NOT_GIVEN (uppercase)
301
 
302
  Question schema:
303
+ ${schema}
304
 
305
+ Return ONLY valid JSON conforming to this schema.`;
 
306
 
307
  const result = await client.chatCompletion(
308
  {
 
312
  { role: "user", content: prompt },
313
  ],
314
  temperature: 0.7,
315
+ max_tokens: calculateMaxTokens(input.apiKeyConfig.maxTokens, "regenerate", count),
316
  response_format: { type: "json_object" },
317
  },
318
  onToken ? { onToken } : undefined,
319
  );
320
 
321
  const parsed = parseJsonResponse(result.content) as Record<string, unknown>;
322
+ if (!Array.isArray(parsed.questions)) {
323
+ throw new Error("Missing questions array in regeneration response");
324
  }
 
325
  return {
326
+ questions: parsed.questions as Array<Record<string, unknown>>,
327
  tokensUsed: result.usage?.total_tokens ?? 0,
328
  };
329
  }
 
346
  { step: "self_validate", status: "pending" as any },
347
  ];
348
 
349
+ const report = (current: number, extraMsg?: string) => {
350
+ if (extraMsg && steps[current]) {
351
+ steps[current]!.message = extraMsg;
352
+ }
353
  if (onProgress) {
354
  onProgress({ steps, currentStep: current });
355
  }
 
357
 
358
  let accumulatedTokens = 0;
359
 
360
+ // ── Step 1: Generate passage ────────────────────────────
361
  report(0);
362
  let passage: string;
363
  let title: string;
 
375
  throw new GenerationError(`Step 1 failed: ${err.message}`, { tokensUsed: accumulatedTokens });
376
  }
377
 
378
+ // ── Step 2: Validate passage ────────────────────────────
379
  steps[1].status = "running";
380
  report(1);
 
 
381
  try {
382
  const s2 = await step2ValidatePassage(client, input, passage, onToken);
 
 
383
  accumulatedTokens += s2.tokensUsed;
384
+ steps[1].status = s2.isValid ? "done" : "error";
385
+ steps[1].message = s2.feedback;
386
+ steps[1].output = JSON.stringify({ isValid: s2.isValid, feedback: s2.feedback }, null, 2);
387
  } catch (err: any) {
388
  steps[1].status = "error";
389
  steps[1].message = err.message ?? "Passage validation failed";
390
  throw new GenerationError(`Step 2 failed: ${err.message}`, { tokensUsed: accumulatedTokens });
391
  }
392
 
393
+ // ── Step 3: Generate questions ──────────────────────────
 
 
394
  steps[2].status = "running";
395
+ report(2, `Generating ${input.questionCount} questions...`);
396
  let rawQuestions: Array<Record<string, unknown>>;
397
  try {
398
+ const s3 = await step3GenerateQuestions(client, input, passage, input.questionCount, onToken);
399
  rawQuestions = s3.questions;
400
  accumulatedTokens += s3.tokensUsed;
 
401
  steps[2].message = `Generated ${rawQuestions.length} questions`;
402
  steps[2].output = rawQuestions.map((q, i) => `${i + 1}. [${q.format}] ${q.questionText}`).join("\n");
403
  } catch (err: any) {
 
406
  throw new GenerationError(`Step 3 failed: ${err.message}`, { tokensUsed: accumulatedTokens });
407
  }
408
 
409
+ // ── Step 4: Self-validate + Repair + Regenerate loop ─────
410
  steps[3].status = "running";
411
+ report(3, "Validating & repairing questions...");
412
+
413
+ let validQuestions: any[] = [];
414
+ let allRepairLogs: string[] = [];
415
+
416
  try {
417
  const s4 = await step4SelfValidate(client, input, passage, rawQuestions, onToken);
 
 
418
  accumulatedTokens += s4.tokensUsed;
419
 
420
+ // Repair & parse
421
+ let { valid, invalid, repairLog } = repairAndParseQuestions(rawQuestions, passage);
422
+ validQuestions = valid;
423
+ allRepairLogs.push(...repairLog);
424
+
425
+ // Regenerate structural-invalid questions (up to 2 attempts)
426
+ let regenerationAttempts = 0;
427
+ const maxRegenAttempts = 2;
428
+
429
+ while (invalid.length > 0 && regenerationAttempts < maxRegenAttempts && validQuestions.length < input.questionCount) {
430
+ regenerationAttempts++;
431
+ const regenCount = Math.min(invalid.length, input.questionCount - validQuestions.length);
432
+ const context = `The previous ${regenCount} question(s) had structural errors: ${invalid.map((i) => `Q${i.index + 1}: ${i.errors.join(", ")}`).join("; ")}.`;
433
+
434
+ report(3, `Regenerating ${regenCount} invalid question(s) (attempt ${regenerationAttempts}/${maxRegenAttempts})...`);
435
+
436
+ const regen = await stepRegenerateQuestions(client, input, passage, regenCount, context, onToken);
437
  accumulatedTokens += regen.tokensUsed;
438
 
439
+ const regenResult = repairAndParseQuestions(regen.questions, passage);
440
+ validQuestions.push(...regenResult.valid);
441
+ allRepairLogs.push(...regenResult.repairLog.map((l) => `[Regen ${regenerationAttempts}] ${l}`));
442
+
443
+ // If regeneration produced valid questions, remove corresponding invalid entries
444
+ if (regenResult.valid.length > 0) {
445
+ invalid = invalid.slice(regenResult.valid.length);
446
+ } else {
447
+ // No progress — break to avoid infinite loop
448
+ break;
449
  }
450
+ }
451
+
452
+ // If still below target, generate additional questions
453
+ if (validQuestions.length < input.questionCount) {
454
+ const needMore = input.questionCount - validQuestions.length;
455
+ report(3, `Generating ${needMore} additional question(s)...`);
456
+ const extra = await stepRegenerateQuestions(
457
+ client, input, passage, needMore,
458
+ `Need ${needMore} more valid questions to reach target of ${input.questionCount}.`,
459
+ onToken,
460
+ );
461
+ accumulatedTokens += extra.tokensUsed;
462
+ const extraResult = repairAndParseQuestions(extra.questions, passage);
463
+ validQuestions.push(...extraResult.valid);
464
+ allRepairLogs.push(...extraResult.repairLog.map((l) => `[Extra] ${l}`));
465
  }
466
 
467
  steps[3].status = "done";
468
+ steps[3].message = `Validated ${validQuestions.length}/${input.questionCount} questions. Confidence: ${s4.confidence}%`;
469
+ steps[3].output = [
470
+ `Overall Confidence: ${s4.confidence}%`,
471
+ `Valid Questions: ${validQuestions.length}/${input.questionCount}`,
472
+ `Repair Log:`,
473
+ ...allRepairLogs,
474
+ ].join("\n");
475
  } catch (err: any) {
476
  steps[3].status = "error";
477
  steps[3].message = err.message ?? "Self-validation failed";
478
  throw new GenerationError(`Step 4 failed: ${err.message}`, { tokensUsed: accumulatedTokens });
479
  }
480
 
481
+ // ── Final check ─────────────────────────────────────────
482
+ if (validQuestions.length === 0) {
 
 
 
 
 
 
 
 
 
 
 
483
  throw new GenerationError("No valid questions generated after agentic validation", {
484
  tokensUsed: accumulatedTokens,
485
  });
486
  }
487
 
488
+ // Trim to requested count (if we overshot)
489
+ const finalQuestions = validQuestions.slice(0, input.questionCount);
490
+
491
  return {
492
+ questions: finalQuestions,
493
  meta: {
494
  model: input.apiKeyConfig.model,
495
  durationMs: Date.now() - start,
packages/ai/src/client.ts CHANGED
@@ -97,6 +97,34 @@ async function readSSEStream(
97
  return { content: fullContent, usage: lastUsage };
98
  }
99
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  export class OpenAICompatibleClient {
101
  constructor(
102
  private baseUrl: string,
@@ -106,17 +134,27 @@ export class OpenAICompatibleClient {
106
  async chatCompletion(
107
  opts: ChatCompletionOptions,
108
  callbacks?: StreamCallbacks,
 
 
 
 
 
 
 
 
109
  ): Promise<ChatCompletionResult> {
110
  const url = `${this.baseUrl.replace(/\/$/, "")}/chat/completions`;
111
  const stream = true;
112
- const body = {
113
  model: opts.model,
114
  messages: opts.messages,
115
  temperature: opts.temperature ?? 0.7,
116
  stream,
117
- ...(opts.max_tokens ? { max_tokens: opts.max_tokens } : {}),
118
- ...(opts.response_format ? { response_format: opts.response_format } : {}),
119
  };
 
 
 
 
120
 
121
  log("info", "Sending chat completion request", {
122
  url,
@@ -124,6 +162,7 @@ export class OpenAICompatibleClient {
124
  messageCount: opts.messages.length,
125
  maxTokens: opts.max_tokens,
126
  stream,
 
127
  });
128
 
129
  const res = await fetch(url, {
@@ -152,6 +191,16 @@ export class OpenAICompatibleClient {
152
  isHtml: preview.trim().startsWith("<"),
153
  });
154
 
 
 
 
 
 
 
 
 
 
 
155
  if (preview.trim().startsWith("<")) {
156
  throw new Error(
157
  `Provider returned HTML instead of JSON (status ${res.status}). ` +
@@ -190,6 +239,23 @@ export class OpenAICompatibleClient {
190
  throw new Error("Empty response from AI");
191
  }
192
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
  log("info", "Chat completion successful", {
194
  contentLength: result.content.length,
195
  usage: result.usage,
 
97
  return { content: fullContent, usage: lastUsage };
98
  }
99
 
100
+ function looksTruncated(content: string): boolean {
101
+ const trimmed = content.trim();
102
+ if (trimmed.length === 0) return false;
103
+ // JSON object/array should end with } or ]
104
+ const lastChar = trimmed[trimmed.length - 1];
105
+ if (lastChar === "}" || lastChar === "]") return false;
106
+ // Check for common truncation signatures
107
+ const unterminated = /Unterminated string|Unexpected end of JSON|Unexpected token/i;
108
+ try {
109
+ JSON.parse(trimmed);
110
+ return false;
111
+ } catch (err: any) {
112
+ if (unterminated.test(err.message)) return true;
113
+ }
114
+ return false;
115
+ }
116
+
117
+ function isResponseFormatError(status: number, text: string): boolean {
118
+ if (status !== 400 && status !== 422) return false;
119
+ const lower = text.toLowerCase();
120
+ return (
121
+ lower.includes("response_format") ||
122
+ lower.includes("json mode") ||
123
+ lower.includes("json_object") ||
124
+ lower.includes("unsupported parameter")
125
+ );
126
+ }
127
+
128
  export class OpenAICompatibleClient {
129
  constructor(
130
  private baseUrl: string,
 
134
  async chatCompletion(
135
  opts: ChatCompletionOptions,
136
  callbacks?: StreamCallbacks,
137
+ ): Promise<ChatCompletionResult> {
138
+ return this._doChatCompletion(opts, callbacks, { attempt: 1 });
139
+ }
140
+
141
+ private async _doChatCompletion(
142
+ opts: ChatCompletionOptions,
143
+ callbacks: StreamCallbacks | undefined,
144
+ ctx: { attempt: number; retriedForTruncation?: boolean; retriedForResponseFormat?: boolean },
145
  ): Promise<ChatCompletionResult> {
146
  const url = `${this.baseUrl.replace(/\/$/, "")}/chat/completions`;
147
  const stream = true;
148
+ const body: Record<string, unknown> = {
149
  model: opts.model,
150
  messages: opts.messages,
151
  temperature: opts.temperature ?? 0.7,
152
  stream,
 
 
153
  };
154
+ if (opts.max_tokens) body.max_tokens = opts.max_tokens;
155
+ if (opts.response_format && !ctx.retriedForResponseFormat) {
156
+ body.response_format = opts.response_format;
157
+ }
158
 
159
  log("info", "Sending chat completion request", {
160
  url,
 
162
  messageCount: opts.messages.length,
163
  maxTokens: opts.max_tokens,
164
  stream,
165
+ attempt: ctx.attempt,
166
  });
167
 
168
  const res = await fetch(url, {
 
191
  isHtml: preview.trim().startsWith("<"),
192
  });
193
 
194
+ // Retry without response_format if provider doesn't support it
195
+ if (!ctx.retriedForResponseFormat && isResponseFormatError(res.status, preview)) {
196
+ log("warn", "Provider rejected response_format, retrying without it");
197
+ return this._doChatCompletion(opts, callbacks, {
198
+ ...ctx,
199
+ attempt: ctx.attempt + 1,
200
+ retriedForResponseFormat: true,
201
+ });
202
+ }
203
+
204
  if (preview.trim().startsWith("<")) {
205
  throw new Error(
206
  `Provider returned HTML instead of JSON (status ${res.status}). ` +
 
239
  throw new Error("Empty response from AI");
240
  }
241
 
242
+ // Truncation detection + retry
243
+ if (!ctx.retriedForTruncation && looksTruncated(result.content)) {
244
+ const newMaxTokens = opts.max_tokens
245
+ ? Math.min(Math.round(opts.max_tokens * 1.5), 128_000)
246
+ : 16_384;
247
+ log("warn", "Response looks truncated, retrying with more tokens", {
248
+ originalLength: result.content.length,
249
+ originalMaxTokens: opts.max_tokens,
250
+ newMaxTokens,
251
+ });
252
+ return this._doChatCompletion(
253
+ { ...opts, max_tokens: newMaxTokens },
254
+ callbacks,
255
+ { ...ctx, attempt: ctx.attempt + 1, retriedForTruncation: true },
256
+ );
257
+ }
258
+
259
  log("info", "Chat completion successful", {
260
  contentLength: result.content.length,
261
  usage: result.usage,
packages/ai/src/pipeline.ts CHANGED
@@ -1,8 +1,8 @@
1
  import { OpenAICompatibleClient } from "./client";
2
  import { GenerationError } from "./errors";
3
  import { buildQuickModePrompt } from "./prompts";
 
4
  import {
5
- questionSchema,
6
  type GenerationInput,
7
  type GenerationResult,
8
  } from "./schemas";
@@ -107,33 +107,39 @@ export async function generateQuestionsQuick(
107
  throw new GenerationError("Missing 'questions' array in AI response", { tokensUsed });
108
  }
109
 
110
- log("info", "Validating questions", { rawCount: raw.questions.length });
111
-
112
- const questions = raw.questions
113
- .map((q: unknown, idx: number) => {
114
- try {
115
- return questionSchema.parse(q);
116
- } catch (e: any) {
117
- log("warn", `Question ${idx} validation failed`, {
118
- error: e.message,
119
- questionPreview: JSON.stringify(q).slice(0, 200),
120
- });
121
- return null;
122
- }
123
- })
124
- .filter((q): q is NonNullable<typeof q> => q !== null);
125
-
126
- if (questions.length === 0) {
127
- log("error", "No valid questions after validation", { rawCount: raw.questions.length });
 
 
 
 
128
  throw new GenerationError("No valid questions generated", { tokensUsed });
129
  }
130
 
131
  log("info", "Quick mode generation completed", {
132
- validCount: questions.length,
133
  rawCount: raw.questions.length,
134
  durationMs: Date.now() - start,
135
  });
136
 
 
 
137
  return {
138
  questions,
139
  meta: {
 
1
  import { OpenAICompatibleClient } from "./client";
2
  import { GenerationError } from "./errors";
3
  import { buildQuickModePrompt } from "./prompts";
4
+ import { repairAndParseQuestions } from "./repair";
5
  import {
 
6
  type GenerationInput,
7
  type GenerationResult,
8
  } from "./schemas";
 
107
  throw new GenerationError("Missing 'questions' array in AI response", { tokensUsed });
108
  }
109
 
110
+ log("info", "Validating & repairing questions", { rawCount: raw.questions.length });
111
+
112
+ // Extract a representative passage text for repair fallback
113
+ const firstWithPassage = raw.questions.find(
114
+ (q: any) => q && typeof q === "object" && typeof q.passageText === "string" && q.passageText.length >= 50,
115
+ ) as any;
116
+ const fallbackPassage = firstWithPassage?.passageText ?? "No passage available.";
117
+
118
+ const { valid, invalid, repairLog } = repairAndParseQuestions(raw.questions, fallbackPassage);
119
+
120
+ if (repairLog.length > 0) {
121
+ log("warn", "Repairs applied", { count: repairLog.length, details: repairLog });
122
+ }
123
+ if (invalid.length > 0) {
124
+ log("warn", "Invalid questions after repair", {
125
+ count: invalid.length,
126
+ details: invalid.map((i) => ({ index: i.index, errors: i.errors })),
127
+ });
128
+ }
129
+
130
+ if (valid.length === 0) {
131
+ log("error", "No valid questions after repair", { rawCount: raw.questions.length });
132
  throw new GenerationError("No valid questions generated", { tokensUsed });
133
  }
134
 
135
  log("info", "Quick mode generation completed", {
136
+ validCount: valid.length,
137
  rawCount: raw.questions.length,
138
  durationMs: Date.now() - start,
139
  });
140
 
141
+ const questions = valid;
142
+
143
  return {
144
  questions,
145
  meta: {
packages/ai/src/repair.ts ADDED
@@ -0,0 +1,360 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { z } from "zod";
2
+ import {
3
+ questionFormatSchema,
4
+ multipleChoiceOptionSchema,
5
+ questionSchema,
6
+ type Question,
7
+ } from "./schemas";
8
+
9
+ // ── Generic Question Schema (for AI prompt) ─────────────────
10
+ // Simplified schema that AI can understand easily.
11
+ // We repair & map to discriminated union afterwards.
12
+
13
+ export const genericQuestionSchema = z.object({
14
+ format: questionFormatSchema,
15
+ passageText: z.string(),
16
+ questionText: z.string().min(1),
17
+ options: z.array(multipleChoiceOptionSchema).optional(),
18
+ correctAnswer: z.string().min(1),
19
+ explanation: z.string().min(1),
20
+ difficulty: z.number().int().min(1).max(5),
21
+ skillTags: z.array(z.string()).min(1),
22
+ });
23
+
24
+ export type GenericQuestion = z.infer<typeof genericQuestionSchema>;
25
+
26
+ 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",
39
+ "character_reading",
40
+ "sentence_arrangement",
41
+ ]);
42
+
43
+ function normalizeOptionKey(key: string): string {
44
+ return key.trim().toUpperCase();
45
+ }
46
+
47
+ function normalizeOptionText(text: string): string {
48
+ return text.trim();
49
+ }
50
+
51
+ function ensureOptions(
52
+ q: GenericQuestion,
53
+ ): Array<{ key: string; text: string }> | undefined {
54
+ if (!FORMATS_WITH_OPTIONS.has(q.format)) return undefined;
55
+
56
+ let opts = q.options;
57
+ if (!Array.isArray(opts) || opts.length === 0) {
58
+ // AI forgot options — inject plausible placeholders so parsing can succeed
59
+ // We'll mark them for later regeneration if needed
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" },
67
+ { key: "C", text: "Option C" },
68
+ { key: "D", text: "Option D" },
69
+ ];
70
+ }
71
+ if (q.format === "matching_headings" || q.format === "matching_information" ||
72
+ q.format === "summary_completion" || q.format === "cloze") {
73
+ return [{ key: "1", text: "Placeholder option" }];
74
+ }
75
+ }
76
+
77
+ opts = opts!.map((o) => ({
78
+ key: normalizeOptionKey(o.key),
79
+ text: normalizeOptionText(o.text),
80
+ }));
81
+
82
+ // Deduplicate by key
83
+ const seen = new Set<string>();
84
+ const deduped = [];
85
+ for (const o of opts) {
86
+ if (!seen.has(o.key)) {
87
+ seen.add(o.key);
88
+ deduped.push(o);
89
+ }
90
+ }
91
+
92
+ return deduped;
93
+ }
94
+
95
+ function coerceCorrectAnswer(q: GenericQuestion): string {
96
+ const ans = String(q.correctAnswer).trim();
97
+
98
+ if (q.format === "true_false_not_given") {
99
+ const upper = ans.toUpperCase();
100
+ if (upper === "T" || upper === "TRUE") return "TRUE";
101
+ if (upper === "F" || upper === "FALSE") return "FALSE";
102
+ if (upper === "NG" || upper === "NOT GIVEN" || upper === "NOT_GIVEN") return "NOT_GIVEN";
103
+ // Fallback — pick the closest
104
+ if (ans.toLowerCase().includes("true")) return "TRUE";
105
+ if (ans.toLowerCase().includes("false")) return "FALSE";
106
+ return "NOT_GIVEN";
107
+ }
108
+
109
+ if (q.format === "author_view") {
110
+ const upper = ans.toUpperCase();
111
+ if (upper === "Y" || upper === "YES") return "YES";
112
+ if (upper === "N" || upper === "NO") return "NO";
113
+ if (upper === "NG" || upper === "NOT GIVEN" || upper === "NOT_GIVEN") return "NOT_GIVEN";
114
+ if (ans.toLowerCase().includes("yes")) return "YES";
115
+ if (ans.toLowerCase().includes("no")) return "NO";
116
+ return "NOT_GIVEN";
117
+ }
118
+
119
+ // For multiple choice / synonym / reference etc: ensure the key exists in options
120
+ if (q.options && q.options.length > 0) {
121
+ const keys = new Set(q.options.map((o) => normalizeOptionKey(o.key)));
122
+ const normalizedAns = normalizeOptionKey(ans);
123
+ if (!keys.has(normalizedAns)) {
124
+ // Answer key doesn't match any option — fallback to first option key
125
+ return q.options[0]!.key;
126
+ }
127
+ return normalizedAns;
128
+ }
129
+
130
+ return ans;
131
+ }
132
+
133
+ function ensurePassageText(q: GenericQuestion, fullPassage: string): string {
134
+ if (!q.passageText || q.passageText.trim().length < 50) {
135
+ return fullPassage;
136
+ }
137
+ return q.passageText.trim();
138
+ }
139
+
140
+ function ensureSkillTags(q: GenericQuestion): string[] {
141
+ if (Array.isArray(q.skillTags) && q.skillTags.length > 0) {
142
+ return q.skillTags.map((s) => String(s).trim()).filter(Boolean);
143
+ }
144
+ return ["comprehension"];
145
+ }
146
+
147
+ function ensureExplanation(q: GenericQuestion): string {
148
+ if (q.explanation && q.explanation.trim().length > 0) {
149
+ return q.explanation.trim();
150
+ }
151
+ return "Penjelasan tidak tersedia.";
152
+ }
153
+
154
+ function ensureQuestionText(q: GenericQuestion): string {
155
+ const text = q.questionText?.trim();
156
+ if (text && text.length >= 10) return text;
157
+ return text || "Soal latihan membaca.";
158
+ }
159
+
160
+ function ensureDifficulty(q: GenericQuestion): number {
161
+ const d = Number(q.difficulty);
162
+ if (Number.isFinite(d) && d >= 1 && d <= 5) return Math.round(d);
163
+ return 3;
164
+ }
165
+
166
+ /**
167
+ * Repair a single raw question to maximize chance of passing Zod validation.
168
+ */
169
+ export function repairQuestion(
170
+ raw: unknown,
171
+ fullPassage: string,
172
+ ): { question: GenericQuestion; wasRepaired: boolean; repairNotes: string[] } {
173
+ const notes: string[] = [];
174
+ let wasRepaired = false;
175
+
176
+ if (!raw || typeof raw !== "object") {
177
+ throw new Error("Question is not an object");
178
+ }
179
+
180
+ const r = raw as Record<string, unknown>;
181
+
182
+ // Build generic question with defaults
183
+ const q: GenericQuestion = {
184
+ format: String(r.format || "multiple_choice").trim().toLowerCase() as any,
185
+ passageText: ensurePassageText(r as GenericQuestion, fullPassage),
186
+ questionText: ensureQuestionText(r as GenericQuestion),
187
+ options: Array.isArray(r.options)
188
+ ? r.options
189
+ .filter((o: any) => o && typeof o === "object")
190
+ .map((o: any) => ({ key: String(o.key ?? ""), text: String(o.text ?? "") }))
191
+ : undefined,
192
+ correctAnswer: String(r.correctAnswer ?? "").trim(),
193
+ explanation: ensureExplanation(r as GenericQuestion),
194
+ difficulty: ensureDifficulty(r as GenericQuestion),
195
+ skillTags: ensureSkillTags(r as GenericQuestion),
196
+ };
197
+
198
+ // Track repairs
199
+ if (!r.passageText || String(r.passageText).trim().length < 50) {
200
+ notes.push("passageText replaced with full passage");
201
+ wasRepaired = true;
202
+ }
203
+ if (!r.questionText || String(r.questionText).trim().length < 10) {
204
+ notes.push("questionText too short, used fallback");
205
+ wasRepaired = true;
206
+ }
207
+ if (!r.explanation || String(r.explanation).trim().length === 0) {
208
+ notes.push("explanation missing, used fallback");
209
+ wasRepaired = true;
210
+ }
211
+ if (!Array.isArray(r.skillTags) || r.skillTags.length === 0) {
212
+ notes.push("skillTags missing, used fallback");
213
+ wasRepaired = true;
214
+ }
215
+
216
+ // Coerce correctAnswer
217
+ const originalAns = q.correctAnswer;
218
+ q.correctAnswer = coerceCorrectAnswer(q);
219
+ if (q.correctAnswer !== originalAns) {
220
+ notes.push(`correctAnswer coerced from "${originalAns}" to "${q.correctAnswer}"`);
221
+ wasRepaired = true;
222
+ }
223
+
224
+ // Ensure options
225
+ const repairedOptions = ensureOptions(q);
226
+ if (repairedOptions !== undefined) {
227
+ if (!q.options || q.options.length === 0) {
228
+ notes.push("options missing, injected placeholders");
229
+ wasRepaired = true;
230
+ }
231
+ q.options = repairedOptions;
232
+ }
233
+
234
+ return { question: q, wasRepaired, repairNotes: notes };
235
+ }
236
+
237
+ /**
238
+ * Try to parse a generic question into the discriminated union schema.
239
+ * Returns null if it still fails.
240
+ */
241
+ export function tryParseQuestion(generic: GenericQuestion): Question | null {
242
+ try {
243
+ return questionSchema.parse(generic as any);
244
+ } catch (err) {
245
+ return null;
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Repair all questions, parse with Zod, separate valid/invalid.
251
+ */
252
+ export function repairAndParseQuestions(
253
+ rawQuestions: unknown[],
254
+ fullPassage: string,
255
+ ): {
256
+ valid: Question[];
257
+ invalid: { index: number; raw: unknown; errors: string[] }[];
258
+ repairLog: string[];
259
+ } {
260
+ const valid: Question[] = [];
261
+ const invalid: { index: number; raw: unknown; errors: string[] }[] = [];
262
+ const repairLog: string[] = [];
263
+
264
+ for (let i = 0; i < rawQuestions.length; i++) {
265
+ const raw = rawQuestions[i];
266
+ try {
267
+ const { question: repaired, wasRepaired, repairNotes } = repairQuestion(raw, fullPassage);
268
+ if (wasRepaired) {
269
+ repairLog.push(`Q${i + 1}: ${repairNotes.join("; ")}`);
270
+ }
271
+ const parsed = tryParseQuestion(repaired);
272
+ if (parsed) {
273
+ valid.push(parsed);
274
+ } else {
275
+ // Try to get Zod errors for diagnostics
276
+ const parseResult = questionSchema.safeParse(repaired as any);
277
+ const errors = parseResult.success
278
+ ? ["Unknown parse failure after repair"]
279
+ : parseResult.error.issues.map((iss) => `${iss.path.join(".")}: ${iss.message}`);
280
+ invalid.push({ index: i, raw, errors });
281
+ repairLog.push(`Q${i + 1}: still invalid after repair — ${errors.join(", ")}`);
282
+ }
283
+ } catch (err: any) {
284
+ invalid.push({ index: i, raw, errors: [err.message ?? "Repair failed"] });
285
+ repairLog.push(`Q${i + 1}: repair threw — ${err.message ?? String(err)}`);
286
+ }
287
+ }
288
+
289
+ return { valid, invalid, repairLog };
290
+ }
291
+
292
+ /**
293
+ * Build a generic JSON schema description for the AI prompt.
294
+ * Much simpler than the discriminated union — reduces AI confusion.
295
+ */
296
+ export function getGenericQuestionJsonSchemaDescription(): string {
297
+ return JSON.stringify(
298
+ {
299
+ type: "object",
300
+ properties: {
301
+ questions: {
302
+ type: "array",
303
+ description: "Array of question objects",
304
+ items: {
305
+ type: "object",
306
+ properties: {
307
+ format: {
308
+ type: "string",
309
+ enum: [
310
+ "multiple_choice",
311
+ "true_false_not_given",
312
+ "matching_headings",
313
+ "matching_information",
314
+ "fill_blank",
315
+ "synonym",
316
+ "grammar_in_context",
317
+ "sentence_completion",
318
+ "summary_completion",
319
+ "cloze",
320
+ "reference",
321
+ "author_view",
322
+ "kanji_reading",
323
+ "particle_choice",
324
+ "article_case",
325
+ "character_reading",
326
+ "sentence_arrangement",
327
+ ],
328
+ },
329
+ passageText: { type: "string", description: "Relevant excerpt from the passage (or full passage)" },
330
+ questionText: { type: "string", description: "The question text" },
331
+ options: {
332
+ type: "array",
333
+ description: "Required for multiple_choice, synonym, matching_*, reference, kanji_reading, particle_choice, article_case, character_reading, sentence_arrangement, summary_completion, cloze. Optional for others.",
334
+ items: {
335
+ type: "object",
336
+ properties: {
337
+ key: { type: "string", description: "Option identifier (e.g. A, B, C, D)" },
338
+ text: { type: "string", description: "Option text" },
339
+ },
340
+ required: ["key", "text"],
341
+ },
342
+ },
343
+ correctAnswer: {
344
+ type: "string",
345
+ description: "For true_false_not_given use TRUE/FALSE/NOT_GIVEN. For author_view use YES/NO/NOT_GIVEN. For multiple choice use the option key (e.g. A).",
346
+ },
347
+ explanation: { type: "string", description: "Explanation in Indonesian" },
348
+ difficulty: { type: "integer", minimum: 1, maximum: 5 },
349
+ skillTags: { type: "array", items: { type: "string" } },
350
+ },
351
+ required: ["format", "passageText", "questionText", "correctAnswer", "explanation", "difficulty", "skillTags"],
352
+ },
353
+ },
354
+ },
355
+ required: ["questions"],
356
+ },
357
+ null,
358
+ 2,
359
+ );
360
+ }
packages/ai/src/schemas.ts CHANGED
@@ -188,6 +188,7 @@ export type Question = z.infer<typeof questionSchema>;
188
  export const generationInputSchema = z.object({
189
  examType: examTypeSchema,
190
  section: sectionTypeSchema,
 
191
  formats: z.array(questionFormatSchema).min(1),
192
  difficulty: difficultySchema,
193
  topics: z.array(z.string()).min(1),
 
188
  export const generationInputSchema = z.object({
189
  examType: examTypeSchema,
190
  section: sectionTypeSchema,
191
+ selectedSections: z.array(sectionTypeSchema).min(1).optional(),
192
  formats: z.array(questionFormatSchema).min(1),
193
  difficulty: difficultySchema,
194
  topics: z.array(z.string()).min(1),
packages/api/src/queue.ts CHANGED
@@ -1,7 +1,7 @@
1
  import { Queue, Worker, type Job } from "bullmq";
2
  import IORedis from "ioredis";
3
  import { env } from "@labas/env/server";
4
- import { generateQuestionsQuick, generateQuestionsAgentic, GenerationError, type GenerationInput } from "@labas/ai";
5
  import { db } from "@labas/db";
6
  import { generationJob, question, testPackage, packageSection, sectionQuestion } from "@labas/db";
7
  import { and, eq, notInArray } from "drizzle-orm";
@@ -92,6 +92,23 @@ export async function cancelGenerationJob(
92
  return { ok: true };
93
  }
94
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  export const generationQueue = new Queue("generation", {
96
  connection: new IORedis(env.REDIS_URL, connectionOptions),
97
  });
@@ -181,7 +198,14 @@ export const generationWorker = new Worker(
181
 
182
  try {
183
  const selectedMode = input.mode;
184
- if (selectedMode === "agentic") {
 
 
 
 
 
 
 
185
  await updateProgress(10, "Generating passage...");
186
  await pushLog("generate_passage", "Starting passage generation...", "running");
187
  }
@@ -201,51 +225,102 @@ export const generationWorker = new Worker(
201
  }
202
  };
203
 
204
- let result;
205
- try {
206
- result =
207
- selectedMode === "agentic"
208
- ? await generateQuestionsAgentic(input, async (p) => {
209
- cancelPoll.check();
210
- const stepProgress = Math.min(
211
- 10 + Math.round((p.currentStep / p.steps.length) * 80),
212
- 90,
213
- );
214
- const step = p.steps[p.currentStep];
215
- const msg = step?.message ?? step?.step ?? "Processing...";
216
- const status = step?.status === "error" ? "error" : step?.status === "done" ? "done" : "running";
217
- await updateProgress(stepProgress, msg);
218
- await pushLog(step?.step ?? "unknown", msg, status, step?.output);
219
- }, tokenCounter)
220
- : await generateQuestionsQuick(input, {
221
- onToken: tokenCounter,
222
- });
223
- } catch (quickErr: any) {
224
- const quickErrorMessage = quickErr?.message ?? String(quickErr);
225
- const shouldFallbackToAgentic =
226
- selectedMode === "quick" &&
227
- (/Failed to parse AI response as JSON/i.test(quickErrorMessage) ||
228
- /Unterminated string/i.test(quickErrorMessage) ||
229
- /Missing 'questions' array/i.test(quickErrorMessage));
230
-
231
- if (!shouldFallbackToAgentic) {
232
- throw quickErr;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  }
234
 
235
- await updateProgress(25, "Quick mode JSON invalid, retrying with agentic mode...");
236
- result = await generateQuestionsAgentic(
237
- { ...input, mode: "agentic" },
238
- async (p) => {
239
- cancelPoll.check();
240
- const stepProgress = Math.min(25 + Math.round((p.currentStep / p.steps.length) * 65), 90);
241
- const msg =
242
- p.steps[p.currentStep]?.message ?? p.steps[p.currentStep]?.step ?? "Processing...";
243
- await updateProgress(stepProgress, msg);
244
- },
245
- tokenCounter,
246
- );
 
 
 
 
 
247
  }
248
 
 
249
  cancelPoll.check();
250
  await updateProgress(95, "Saving to bank...");
251
 
@@ -275,26 +350,26 @@ export const generationWorker = new Worker(
275
 
276
  const userId = jobRow?.userId;
277
  if (userId) {
278
- const toInsert = result.questions.map((q) => ({
279
- examTypeId: input.examType,
280
- sectionTypeId: input.section,
281
- format: q.format,
282
- passageText: q.passageText,
283
- questionText: q.questionText,
284
- options: (q as any).options ?? null,
285
- correctAnswer: q.correctAnswer,
286
- explanation: q.explanation,
287
- difficulty: q.difficulty,
288
- skillTags: q.skillTags,
289
- source: "ai" as const,
290
- aiModel: input.apiKeyConfig.model,
291
- creatorUserId: userId,
292
- isPublic: false,
293
- }));
294
-
295
  const inserted = await db
296
  .insert(question)
297
- .values(toInsert as any)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
298
  .returning({ id: question.id });
299
 
300
  savedQuestionIds = inserted.map((r) => r.id);
@@ -308,18 +383,21 @@ export const generationWorker = new Worker(
308
  month: "short",
309
  year: "numeric",
310
  });
311
- const pkgTitle = `AI Generated - ${input.examType} ${input.section} - ${dateStr}`;
 
 
 
312
 
313
  const [pkg] = await db
314
  .insert(testPackage)
315
  .values({
316
  title: pkgTitle,
317
- description: `Paket latihan AI-generated dengan ${savedQuestionIds.length} soal ${input.examType} ${input.section}.`,
318
  examTypeId: input.examType,
319
  creatorUserId: userId,
320
  isPublic: false,
321
  totalQuestions: savedQuestionIds.length,
322
- totalSections: 1,
323
  estimatedDurationMin: Math.ceil(savedQuestionIds.length * 1.5),
324
  })
325
  .returning();
@@ -327,24 +405,33 @@ export const generationWorker = new Worker(
327
  if (pkg) {
328
  generatedPackageId = pkg.id;
329
 
330
- const [sec] = await db
331
- .insert(packageSection)
332
- .values({
333
- packageId: pkg.id,
334
- sectionTypeId: input.section,
335
- title: `${input.section} Section`,
336
- orderIndex: 0,
337
- })
338
- .returning();
339
-
340
- if (sec) {
341
- await db.insert(sectionQuestion).values(
342
- savedQuestionIds.map((qid, idx) => ({
343
- sectionId: sec.id,
344
- questionId: qid,
345
- orderIndex: idx,
346
- })),
347
- );
 
 
 
 
 
 
 
 
 
348
  }
349
  }
350
  } catch (packageErr: any) {
@@ -363,13 +450,23 @@ export const generationWorker = new Worker(
363
  await updateProgress(100, "Completed");
364
  await pushLog("save", `Saved ${savedQuestionIds.length} questions${generatedPackageId ? ` & created package` : ""}`, "done");
365
 
 
 
 
 
 
 
 
 
 
 
366
  cancelPoll.check();
367
  await db
368
  .update(generationJob)
369
  .set({
370
  status: "completed",
371
- resultJson: { ...result, savedQuestionIds, generatedPackageId } as any,
372
- tokensUsed: result.meta.tokensUsed ?? approxTokens,
373
  durationMs: Date.now() - start,
374
  completedAt: new Date(),
375
  })
 
1
  import { Queue, Worker, type Job } from "bullmq";
2
  import IORedis from "ioredis";
3
  import { env } from "@labas/env/server";
4
+ import { generateQuestionsQuick, generateQuestionsAgentic, GenerationError, type GenerationInput, type GenerationResult } from "@labas/ai";
5
  import { db } from "@labas/db";
6
  import { generationJob, question, testPackage, packageSection, sectionQuestion } from "@labas/db";
7
  import { and, eq, notInArray } from "drizzle-orm";
 
92
  return { ok: true };
93
  }
94
 
95
+ function computeSectionSplit(
96
+ selectedSections: string[],
97
+ count: number,
98
+ ): { section: string; count: number }[] {
99
+ const sections = selectedSections.length > 0 ? selectedSections : ["READING"];
100
+ if (count < 20 || sections.length <= 1) {
101
+ return [{ section: sections[0]!, count }];
102
+ }
103
+ const base = Math.floor(count / sections.length);
104
+ const remainder = count % sections.length;
105
+ const result = sections.map((section, i) => ({
106
+ section,
107
+ count: base + (i < remainder ? 1 : 0),
108
+ }));
109
+ return result;
110
+ }
111
+
112
  export const generationQueue = new Queue("generation", {
113
  connection: new IORedis(env.REDIS_URL, connectionOptions),
114
  });
 
198
 
199
  try {
200
  const selectedMode = input.mode;
201
+ const activeSections = input.selectedSections ?? [input.section];
202
+ const isMultiSection = selectedMode === "agentic" && input.questionCount >= 20 && activeSections.length > 1;
203
+ const sectionSplits = computeSectionSplit(activeSections, input.questionCount);
204
+
205
+ if (isMultiSection) {
206
+ await updateProgress(5, `Preparing ${sectionSplits.length} sections...`);
207
+ await pushLog("plan", `Multi-section plan: ${sectionSplits.map((s) => `${s.section}(${s.count})`).join(", ")}`, "done");
208
+ } else if (selectedMode === "agentic") {
209
  await updateProgress(10, "Generating passage...");
210
  await pushLog("generate_passage", "Starting passage generation...", "running");
211
  }
 
225
  }
226
  };
227
 
228
+ // ── Generation Phase ─────────────────────────────────────
229
+ let allQuestions: Array<{
230
+ section: string;
231
+ format: string;
232
+ passageText: string;
233
+ questionText: string;
234
+ options: any;
235
+ correctAnswer: string;
236
+ explanation: string;
237
+ difficulty: number;
238
+ skillTags: string[];
239
+ aiModel: string;
240
+ }> = [];
241
+ let totalTokens = 0;
242
+ let totalDurationMs = 0;
243
+
244
+ for (let secIdx = 0; secIdx < sectionSplits.length; secIdx++) {
245
+ const split = sectionSplits[secIdx]!;
246
+ const subInput: GenerationInput = { ...input, section: split.section as any, questionCount: split.count };
247
+ const progressSlice = isMultiSection ? 90 / sectionSplits.length : 80;
248
+ const progressOffset = isMultiSection ? 5 + secIdx * progressSlice : 10;
249
+
250
+ let sectionResult: GenerationResult;
251
+ try {
252
+ if (selectedMode === "agentic") {
253
+ sectionResult = await generateQuestionsAgentic(subInput, async (p) => {
254
+ cancelPoll.check();
255
+ const rawProgress = (p.currentStep / p.steps.length) * progressSlice;
256
+ const mappedProgress = Math.min(
257
+ Math.round(progressOffset + rawProgress),
258
+ isMultiSection ? Math.round(5 + (secIdx + 1) * progressSlice) : 90,
259
+ );
260
+ const step = p.steps[p.currentStep];
261
+ const msg = step?.message ?? step?.step ?? "Processing...";
262
+ const status = step?.status === "error" ? "error" : step?.status === "done" ? "done" : "running";
263
+ const prefix = isMultiSection ? `[${split.section}] ` : "";
264
+ await updateProgress(mappedProgress, `${prefix}${msg}`);
265
+ await pushLog(
266
+ step?.step ?? "unknown",
267
+ `${prefix}${msg}`,
268
+ status,
269
+ step?.output,
270
+ );
271
+ }, tokenCounter);
272
+ } else {
273
+ // Quick mode (single section only)
274
+ sectionResult = await generateQuestionsQuick(subInput, {
275
+ onToken: tokenCounter,
276
+ });
277
+ }
278
+ } catch (quickErr: any) {
279
+ const quickErrorMessage = quickErr?.message ?? String(quickErr);
280
+ const shouldFallbackToAgentic =
281
+ selectedMode === "quick" &&
282
+ (/Failed to parse AI response as JSON/i.test(quickErrorMessage) ||
283
+ /Unterminated string/i.test(quickErrorMessage) ||
284
+ /Missing 'questions' array/i.test(quickErrorMessage));
285
+
286
+ if (!shouldFallbackToAgentic) {
287
+ throw quickErr;
288
+ }
289
+
290
+ await updateProgress(25, "Quick mode JSON invalid, retrying with agentic mode...");
291
+ sectionResult = await generateQuestionsAgentic(
292
+ { ...subInput, mode: "agentic" },
293
+ async (p) => {
294
+ cancelPoll.check();
295
+ const stepProgress = Math.min(25 + Math.round((p.currentStep / p.steps.length) * 65), 90);
296
+ const msg =
297
+ p.steps[p.currentStep]?.message ?? p.steps[p.currentStep]?.step ?? "Processing...";
298
+ await updateProgress(stepProgress, msg);
299
+ },
300
+ tokenCounter,
301
+ );
302
  }
303
 
304
+ for (const q of sectionResult.questions) {
305
+ allQuestions.push({
306
+ section: split.section,
307
+ format: q.format,
308
+ passageText: q.passageText,
309
+ questionText: q.questionText,
310
+ options: (q as any).options ?? null,
311
+ correctAnswer: q.correctAnswer,
312
+ explanation: q.explanation,
313
+ difficulty: q.difficulty,
314
+ skillTags: q.skillTags,
315
+ aiModel: input.apiKeyConfig.model,
316
+ });
317
+ }
318
+
319
+ totalTokens += sectionResult.meta.tokensUsed ?? 0;
320
+ totalDurationMs += sectionResult.meta.durationMs;
321
  }
322
 
323
+ // ── Saving Phase ─────────────────────────────────────────
324
  cancelPoll.check();
325
  await updateProgress(95, "Saving to bank...");
326
 
 
350
 
351
  const userId = jobRow?.userId;
352
  if (userId) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
353
  const inserted = await db
354
  .insert(question)
355
+ .values(
356
+ allQuestions.map((q) => ({
357
+ examTypeId: input.examType,
358
+ sectionTypeId: q.section,
359
+ format: q.format,
360
+ passageText: q.passageText,
361
+ questionText: q.questionText,
362
+ options: q.options,
363
+ correctAnswer: q.correctAnswer,
364
+ explanation: q.explanation,
365
+ difficulty: q.difficulty,
366
+ skillTags: q.skillTags,
367
+ source: "ai" as const,
368
+ aiModel: q.aiModel,
369
+ creatorUserId: userId,
370
+ isPublic: false,
371
+ })) as any,
372
+ )
373
  .returning({ id: question.id });
374
 
375
  savedQuestionIds = inserted.map((r) => r.id);
 
383
  month: "short",
384
  year: "numeric",
385
  });
386
+ const sectionLabel = isMultiSection
387
+ ? `${sectionSplits.length} sections`
388
+ : input.section;
389
+ const pkgTitle = `AI Generated - ${input.examType} ${sectionLabel} - ${dateStr}`;
390
 
391
  const [pkg] = await db
392
  .insert(testPackage)
393
  .values({
394
  title: pkgTitle,
395
+ description: `Paket latihan AI-generated dengan ${savedQuestionIds.length} soal ${input.examType}.`,
396
  examTypeId: input.examType,
397
  creatorUserId: userId,
398
  isPublic: false,
399
  totalQuestions: savedQuestionIds.length,
400
+ totalSections: sectionSplits.length,
401
  estimatedDurationMin: Math.ceil(savedQuestionIds.length * 1.5),
402
  })
403
  .returning();
 
405
  if (pkg) {
406
  generatedPackageId = pkg.id;
407
 
408
+ for (let i = 0; i < sectionSplits.length; i++) {
409
+ const split = sectionSplits[i]!;
410
+ const sectionQuestions = allQuestions
411
+ .map((q, idx) => ({ ...q, _globalIndex: idx }))
412
+ .filter((q) => q.section === split.section);
413
+
414
+ const [sec] = await db
415
+ .insert(packageSection)
416
+ .values({
417
+ packageId: pkg.id,
418
+ sectionTypeId: split.section,
419
+ title: `${split.section} Section`,
420
+ orderIndex: i,
421
+ })
422
+ .returning();
423
+
424
+ if (sec) {
425
+ await db.insert(sectionQuestion).values(
426
+ sectionQuestions
427
+ .map((q, idx) => ({
428
+ sectionId: sec.id,
429
+ questionId: savedQuestionIds[q._globalIndex],
430
+ orderIndex: idx,
431
+ }))
432
+ .filter((q) => q.questionId != null) as any,
433
+ );
434
+ }
435
  }
436
  }
437
  } catch (packageErr: any) {
 
450
  await updateProgress(100, "Completed");
451
  await pushLog("save", `Saved ${savedQuestionIds.length} questions${generatedPackageId ? ` & created package` : ""}`, "done");
452
 
453
+ const combinedResult: GenerationResult = {
454
+ questions: allQuestions as any,
455
+ meta: {
456
+ model: input.apiKeyConfig.model,
457
+ tokensUsed: totalTokens || approxTokens,
458
+ durationMs: totalDurationMs || Date.now() - start,
459
+ mode: selectedMode,
460
+ },
461
+ };
462
+
463
  cancelPoll.check();
464
  await db
465
  .update(generationJob)
466
  .set({
467
  status: "completed",
468
+ resultJson: { ...combinedResult, savedQuestionIds, generatedPackageId, sectionSplits } as any,
469
+ tokensUsed: totalTokens || approxTokens,
470
  durationMs: Date.now() - start,
471
  completedAt: new Date(),
472
  })
packages/api/src/routers/feedback.ts CHANGED
@@ -2,7 +2,7 @@ import { z } from "zod";
2
  import { eq, and, sql } from "drizzle-orm";
3
  import { router, protectedProcedure, publicProcedure } from "../index";
4
  import { db } from "@labas/db";
5
- import { questionFeedback, question } from "@labas/db";
6
 
7
  export const feedbackRouter = router({
8
  getQuestionFeedback: publicProcedure
 
2
  import { eq, and, sql } from "drizzle-orm";
3
  import { router, protectedProcedure, publicProcedure } from "../index";
4
  import { db } from "@labas/db";
5
+ import { questionFeedback } from "@labas/db";
6
 
7
  export const feedbackRouter = router({
8
  getQuestionFeedback: publicProcedure
packages/api/src/routers/stats.ts CHANGED
@@ -1,5 +1,5 @@
1
  import { z } from "zod";
2
- import { eq, and, desc, sql, gte } from "drizzle-orm";
3
  import { router, protectedProcedure } from "../index";
4
  import { db } from "@labas/db";
5
  import {
@@ -12,10 +12,6 @@ import {
12
  sectionType,
13
  } from "@labas/db";
14
 
15
- function clamp(n: number, min: number, max: number) {
16
- return Math.max(min, Math.min(max, n));
17
- }
18
-
19
  export const statsRouter = router({
20
  overview: protectedProcedure.query(async ({ ctx }) => {
21
  const userId = ctx.session.user.id;
@@ -257,7 +253,7 @@ export const statsRouter = router({
257
  const filled: { date: string; attempts: number; avgScorePct: number }[] = [];
258
  for (let i = days - 1; i >= 0; i--) {
259
  const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
260
- const key = d.toISOString().split("T")[0];
261
  filled.push(resultMap.get(key) ?? { date: key, attempts: 0, avgScorePct: 0 });
262
  }
263
 
 
1
  import { z } from "zod";
2
+ import { eq, and, sql, gte } from "drizzle-orm";
3
  import { router, protectedProcedure } from "../index";
4
  import { db } from "@labas/db";
5
  import {
 
12
  sectionType,
13
  } from "@labas/db";
14
 
 
 
 
 
15
  export const statsRouter = router({
16
  overview: protectedProcedure.query(async ({ ctx }) => {
17
  const userId = ctx.session.user.id;
 
253
  const filled: { date: string; attempts: number; avgScorePct: number }[] = [];
254
  for (let i = days - 1; i >= 0; i--) {
255
  const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
256
+ const key = d.toISOString().split("T")[0]!;
257
  filled.push(resultMap.get(key) ?? { date: key, attempts: 0, avgScorePct: 0 });
258
  }
259