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 +42 -22
- apps/web/src/hooks/use-generation-job.ts +3 -0
- apps/web/src/routes/generate.tsx +179 -98
- apps/web/src/routes/jobs.tsx +20 -3
- packages/ai/src/agentic.ts +135 -110
- packages/ai/src/client.ts +69 -3
- packages/ai/src/pipeline.ts +26 -20
- packages/ai/src/repair.ts +360 -0
- packages/ai/src/schemas.ts +1 -0
- packages/api/src/queue.ts +181 -84
- packages/api/src/routers/feedback.ts +1 -1
- packages/api/src/routers/stats.ts +2 -6
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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-
|
| 44 |
<div className="flex justify-between items-center pb-4 border-b border-[var(--oat-border)]">
|
| 45 |
-
<span className="text-[var(--warm-charcoal)]">
|
| 46 |
-
<span className="font-bold text-[var(--clay-black)]">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)]">
|
| 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
|
| 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
|
| 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
|
| 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(--
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 [
|
| 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:
|
|
|
|
| 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
|
| 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
|
| 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(--
|
| 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
|
| 196 |
-
<div className="lg:col-span-8 flex flex-col gap-
|
|
|
|
| 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 |
-
{/*
|
| 219 |
<div className="flex flex-col gap-4">
|
| 220 |
-
<div className="flex
|
| 221 |
-
<label className="font-headline text-xl font-bold text-[var(--clay-black)]">
|
| 222 |
-
<span className="text-
|
| 223 |
-
|
| 224 |
</span>
|
| 225 |
</div>
|
| 226 |
-
<div className="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
<input
|
| 228 |
type="range"
|
| 229 |
-
min=
|
| 230 |
-
max=
|
| 231 |
-
value={
|
| 232 |
-
onChange={(e) =>
|
| 233 |
className="w-full h-2 bg-[var(--warm-silver)] rounded-full appearance-none cursor-pointer accent-[var(--clay-black)]"
|
| 234 |
/>
|
| 235 |
-
<
|
| 236 |
-
<span>Soal Seimbang</span>
|
| 237 |
-
<span>Fokus Kelemahan</span>
|
| 238 |
-
</div>
|
| 239 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
</div>
|
| 241 |
|
| 242 |
-
{/* Difficulty
|
| 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 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
{/*
|
| 343 |
<div className="flex flex-col gap-4">
|
| 344 |
-
<
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 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="
|
| 362 |
-
<span className="text-xs font-medium text-[var(--warm-charcoal)] whitespace-nowrap">Custom:</span>
|
| 363 |
<input
|
| 364 |
type="range"
|
| 365 |
-
min=
|
| 366 |
-
max=
|
| 367 |
-
value={
|
| 368 |
-
onChange={(e) =>
|
| 369 |
className="w-full h-2 bg-[var(--warm-silver)] rounded-full appearance-none cursor-pointer accent-[var(--clay-black)]"
|
| 370 |
/>
|
| 371 |
-
<
|
|
|
|
|
|
|
|
|
|
| 372 |
</div>
|
| 373 |
</div>
|
| 374 |
</div>
|
| 375 |
|
| 376 |
-
{/* Live Preview Card
|
| 377 |
<TestBlueprintCard
|
| 378 |
examType={examType}
|
| 379 |
-
|
| 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
|
| 394 |
-
|
| 395 |
-
|
| 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 |
-
{
|
| 308 |
-
|
| 309 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 146 |
-
const wrapperSchema = getQuestionsArrayJsonSchemaDescription();
|
| 147 |
|
| 148 |
-
const prompt = `Using the following passage, generate ${
|
| 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 |
-
${
|
| 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<{
|
| 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 |
-
|
| 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
|
| 256 |
client: OpenAICompatibleClient,
|
| 257 |
input: GenerationInput,
|
| 258 |
passage: string,
|
| 259 |
-
|
| 260 |
-
|
| 261 |
onToken?: (token: string) => void,
|
| 262 |
-
): Promise<{
|
| 263 |
-
const
|
| 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.
|
| 272 |
|
| 273 |
Passage:
|
| 274 |
"""
|
| 275 |
${passage}
|
| 276 |
"""
|
| 277 |
|
| 278 |
-
|
| 279 |
-
${
|
|
|
|
| 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 |
-
-
|
| 286 |
-
-
|
|
|
|
|
|
|
|
|
|
| 287 |
|
| 288 |
Question schema:
|
| 289 |
-
${
|
| 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)
|
| 310 |
-
throw new Error("
|
| 311 |
}
|
| 312 |
-
|
| 313 |
return {
|
| 314 |
-
|
| 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 |
-
//
|
| 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 |
-
|
| 405 |
-
let
|
|
|
|
|
|
|
| 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 |
-
//
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
|
|
|
| 428 |
accumulatedTokens += regen.tokensUsed;
|
| 429 |
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 433 |
}
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 438 |
}
|
| 439 |
|
| 440 |
steps[3].status = "done";
|
| 441 |
-
steps[3].
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
//
|
| 449 |
-
|
| 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 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
throw new GenerationError("No valid questions generated", { tokensUsed });
|
| 129 |
}
|
| 130 |
|
| 131 |
log("info", "Quick mode generation completed", {
|
| 132 |
-
validCount:
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
const
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
}
|
| 234 |
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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}
|
| 318 |
examTypeId: input.examType,
|
| 319 |
creatorUserId: userId,
|
| 320 |
isPublic: false,
|
| 321 |
totalQuestions: savedQuestionIds.length,
|
| 322 |
-
totalSections:
|
| 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 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 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: { ...
|
| 372 |
-
tokensUsed:
|
| 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
|
| 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,
|
| 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 |
|