feat: add job retry functionality and enhance question generation with JSON schema descriptions. Update UI to allow job retries for failed or canceled jobs, and improve API handling for job input configurations. Include new utility functions for generating JSON schema descriptions for questions and passages.
Browse files- apps/web/src/routes/jobs.tsx +28 -7
- bun.lock +1 -0
- packages/ai/package.json +2 -1
- packages/ai/src/agentic.ts +183 -100
- packages/ai/src/index.ts +7 -0
- packages/ai/src/pipeline.ts +1 -0
- packages/ai/src/prompts.ts +5 -154
- packages/ai/src/schema-to-prompt.ts +106 -0
- packages/api/src/queue.ts +17 -14
- packages/api/src/routers/ai.ts +29 -0
- packages/db/src/schema/app.ts +1 -0
apps/web/src/routes/jobs.tsx
CHANGED
|
@@ -157,6 +157,14 @@ function RouteComponent() {
|
|
| 157 |
},
|
| 158 |
});
|
| 159 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
const toggleExpand = (id: string) => {
|
| 161 |
setExpandedJobId((prev) => (prev === id ? null : id));
|
| 162 |
};
|
|
@@ -241,6 +249,19 @@ function RouteComponent() {
|
|
| 241 |
Batalkan
|
| 242 |
</Button>
|
| 243 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
<div className="text-xs text-[var(--warm-charcoal)]">
|
| 245 |
{formatDate(job.createdAt)}
|
| 246 |
</div>
|
|
@@ -249,14 +270,14 @@ function RouteComponent() {
|
|
| 249 |
|
| 250 |
{(job.status === "running" || job.status === "pending") && (
|
| 251 |
<div className="mt-3">
|
| 252 |
-
<div className="flex justify-between text-xs text-[var(--warm-charcoal)] mb-1">
|
| 253 |
-
<span>{job.progressMessage ?? "Processing..."}</span>
|
| 254 |
-
<span>{job.progress}%</span>
|
| 255 |
</div>
|
| 256 |
-
<div className="w-full h-2 bg-[var(--oat-
|
| 257 |
<div
|
| 258 |
-
className="h-full bg-[var(--matcha-600)] transition-all duration-500"
|
| 259 |
-
style={{ width: `${job.progress}%` }}
|
| 260 |
/>
|
| 261 |
</div>
|
| 262 |
</div>
|
|
@@ -284,7 +305,7 @@ function RouteComponent() {
|
|
| 284 |
<div className="flex items-center justify-between mb-3">
|
| 285 |
<div className="text-sm text-[var(--warm-charcoal)]">
|
| 286 |
{result.questions.length} soal dihasilkan · {result.meta.model}
|
| 287 |
-
{
|
| 288 |
{result.meta.durationMs ? ` · ${(result.meta.durationMs / 1000).toFixed(1)}s` : ""}
|
| 289 |
</div>
|
| 290 |
<Button
|
|
|
|
| 157 |
},
|
| 158 |
});
|
| 159 |
|
| 160 |
+
const retryJob = useMutation({
|
| 161 |
+
...trpc.ai.retryJob.mutationOptions(),
|
| 162 |
+
onSuccess: async () => {
|
| 163 |
+
await queryClient.invalidateQueries({ queryKey: trpc.ai.myJobs.queryKey() });
|
| 164 |
+
await queryClient.invalidateQueries({ queryKey: trpc.ai.getJobStatus.queryKey() });
|
| 165 |
+
},
|
| 166 |
+
});
|
| 167 |
+
|
| 168 |
const toggleExpand = (id: string) => {
|
| 169 |
setExpandedJobId((prev) => (prev === id ? null : id));
|
| 170 |
};
|
|
|
|
| 249 |
Batalkan
|
| 250 |
</Button>
|
| 251 |
)}
|
| 252 |
+
{(job.status === "failed" || job.status === "cancelled") && (
|
| 253 |
+
<Button
|
| 254 |
+
type="button"
|
| 255 |
+
variant="outline"
|
| 256 |
+
size="sm"
|
| 257 |
+
className="rounded-[var(--radius-lg)] border-2 border-[var(--matcha-400)] text-xs shrink-0 text-[var(--matcha-800)]"
|
| 258 |
+
disabled={retryJob.isPending}
|
| 259 |
+
onClick={() => retryJob.mutate({ jobId: job.id })}
|
| 260 |
+
>
|
| 261 |
+
<MaterialIcon name="refresh" className="text-sm mr-1" />
|
| 262 |
+
Retry
|
| 263 |
+
</Button>
|
| 264 |
+
)}
|
| 265 |
<div className="text-xs text-[var(--warm-charcoal)]">
|
| 266 |
{formatDate(job.createdAt)}
|
| 267 |
</div>
|
|
|
|
| 270 |
|
| 271 |
{(job.status === "running" || job.status === "pending") && (
|
| 272 |
<div className="mt-3">
|
| 273 |
+
<div className="flex justify-between text-xs text-[var(--warm-charcoal)] mb-1.5">
|
| 274 |
+
<span className="truncate mr-2">{job.progressMessage ?? "Processing..."}</span>
|
| 275 |
+
<span className="shrink-0 font-semibold">{job.progress}%</span>
|
| 276 |
</div>
|
| 277 |
+
<div className="w-full h-2.5 bg-[var(--oat-light)] rounded-full overflow-hidden border border-[var(--oat-border)]">
|
| 278 |
<div
|
| 279 |
+
className="h-full bg-[var(--matcha-600)] transition-all duration-500 rounded-full"
|
| 280 |
+
style={{ width: `${Math.max(2, job.progress ?? 0)}%` }}
|
| 281 |
/>
|
| 282 |
</div>
|
| 283 |
</div>
|
|
|
|
| 305 |
<div className="flex items-center justify-between mb-3">
|
| 306 |
<div className="text-sm text-[var(--warm-charcoal)]">
|
| 307 |
{result.questions.length} soal dihasilkan · {result.meta.model}
|
| 308 |
+
{job.tokensUsed ? ` · ${job.tokensUsed} tokens` : ""}
|
| 309 |
{result.meta.durationMs ? ` · ${(result.meta.durationMs / 1000).toFixed(1)}s` : ""}
|
| 310 |
</div>
|
| 311 |
<Button
|
bun.lock
CHANGED
|
@@ -86,6 +86,7 @@
|
|
| 86 |
"name": "@labas/ai",
|
| 87 |
"dependencies": {
|
| 88 |
"zod": "catalog:",
|
|
|
|
| 89 |
},
|
| 90 |
"devDependencies": {
|
| 91 |
"@labas/config": "workspace:*",
|
|
|
|
| 86 |
"name": "@labas/ai",
|
| 87 |
"dependencies": {
|
| 88 |
"zod": "catalog:",
|
| 89 |
+
"zod-to-json-schema": "^3.25.2",
|
| 90 |
},
|
| 91 |
"devDependencies": {
|
| 92 |
"@labas/config": "workspace:*",
|
packages/ai/package.json
CHANGED
|
@@ -10,7 +10,8 @@
|
|
| 10 |
}
|
| 11 |
},
|
| 12 |
"dependencies": {
|
| 13 |
-
"zod": "catalog:"
|
|
|
|
| 14 |
},
|
| 15 |
"devDependencies": {
|
| 16 |
"@labas/config": "workspace:*",
|
|
|
|
| 10 |
}
|
| 11 |
},
|
| 12 |
"dependencies": {
|
| 13 |
+
"zod": "catalog:",
|
| 14 |
+
"zod-to-json-schema": "^3.25.2"
|
| 15 |
},
|
| 16 |
"devDependencies": {
|
| 17 |
"@labas/config": "workspace:*",
|
packages/ai/src/agentic.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
| 1 |
import { OpenAICompatibleClient } from "./client";
|
| 2 |
import { GenerationError } from "./errors";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
import { questionSchema, type GenerationInput, type GenerationResult } from "./schemas";
|
| 4 |
|
| 5 |
interface AgenticStep {
|
|
@@ -43,7 +50,9 @@ function parseJsonResponse(content: string): unknown {
|
|
| 43 |
async function step1GeneratePassage(
|
| 44 |
client: OpenAICompatibleClient,
|
| 45 |
input: GenerationInput,
|
|
|
|
| 46 |
): Promise<{ passage: string; title: string; tokensUsed: number }> {
|
|
|
|
| 47 |
const prompt = `Generate an authentic, high-quality reading passage for ${input.examType} ${input.section.toLowerCase()} section at difficulty level ${input.difficulty}/5.
|
| 48 |
|
| 49 |
Requirements:
|
|
@@ -53,21 +62,22 @@ Requirements:
|
|
| 53 |
- The passage should be natural, well-structured, and appropriate for the exam level
|
| 54 |
- Length should be suitable for ${input.questionCount} comprehension questions
|
| 55 |
|
| 56 |
-
Return ONLY valid JSON:
|
| 57 |
-
{
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
|
|
|
| 71 |
|
| 72 |
const parsed = parseJsonResponse(result.content) as Record<string, unknown>;
|
| 73 |
if (!parsed.passage || typeof parsed.passage !== "string") {
|
|
@@ -84,7 +94,9 @@ async function step2ValidatePassage(
|
|
| 84 |
client: OpenAICompatibleClient,
|
| 85 |
input: GenerationInput,
|
| 86 |
passage: string,
|
|
|
|
| 87 |
): Promise<{ isValid: boolean; feedback: string; tokensUsed: number }> {
|
|
|
|
| 88 |
const prompt = `Validate this reading passage for a ${input.examType} exam at difficulty ${input.difficulty}/5.
|
| 89 |
|
| 90 |
Passage:
|
|
@@ -99,22 +111,22 @@ Check:
|
|
| 99 |
4. Topic relevance: ${input.topics.join(", ")}
|
| 100 |
5. Natural flow and coherence
|
| 101 |
|
| 102 |
-
Return ONLY valid JSON:
|
| 103 |
-
{
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
|
| 119 |
const parsed = parseJsonResponse(result.content) as Record<string, unknown>;
|
| 120 |
return {
|
|
@@ -128,30 +140,10 @@ async function step3GenerateQuestions(
|
|
| 128 |
client: OpenAICompatibleClient,
|
| 129 |
input: GenerationInput,
|
| 130 |
passage: string,
|
|
|
|
| 131 |
): Promise<{ questions: Array<Record<string, unknown>>; tokensUsed: number }> {
|
| 132 |
-
const
|
| 133 |
-
const
|
| 134 |
-
.map((f) => {
|
| 135 |
-
const schemas: Record<string, string> = {
|
| 136 |
-
multiple_choice: `{"format":"multiple_choice","questionText":"...","options":[{"key":"A","text":"..."},...],"correctAnswer":"A","explanation":"...","difficulty":${input.difficulty},"skillTags":["..."]`,
|
| 137 |
-
true_false_not_given: `{"format":"true_false_not_given","questionText":"...","correctAnswer":"TRUE|FALSE|NOT_GIVEN","explanation":"...","difficulty":${input.difficulty},"skillTags":["..."]`,
|
| 138 |
-
fill_blank: `{"format":"fill_blank","questionText":"...","correctAnswer":"exact text","explanation":"...","difficulty":${input.difficulty},"skillTags":["..."]`,
|
| 139 |
-
synonym: `{"format":"synonym","questionText":"...","options":[{"key":"A","text":"..."},...],"correctAnswer":"A","explanation":"...","difficulty":${input.difficulty},"skillTags":["vocabulary","synonym"]`,
|
| 140 |
-
grammar_in_context: `{"format":"grammar_in_context","questionText":"...","options":[{"key":"A","text":"..."},...],"correctAnswer":"A","explanation":"...","difficulty":${input.difficulty},"skillTags":["grammar"]`,
|
| 141 |
-
sentence_completion: `{"format":"sentence_completion","questionText":"...","options":[{"key":"A","text":"..."},...],"correctAnswer":"A","explanation":"...","difficulty":${input.difficulty},"skillTags":["..."]`,
|
| 142 |
-
cloze: `{"format":"cloze","questionText":"...","options":[{"key":"A","text":"..."},...],"correctAnswer":"serialized mapping","explanation":"...","difficulty":${input.difficulty},"skillTags":["grammar","vocabulary"]`,
|
| 143 |
-
reference: `{"format":"reference","questionText":"...","options":[{"key":"A","text":"..."},...],"correctAnswer":"A","explanation":"...","difficulty":${input.difficulty},"skillTags":["reference","inference"]`,
|
| 144 |
-
author_view: `{"format":"author_view","questionText":"...","correctAnswer":"YES|NO|NOT_GIVEN","explanation":"...","difficulty":${input.difficulty},"skillTags":["inference","author_view"]`,
|
| 145 |
-
matching_headings: `{"format":"matching_headings","questionText":"Match each paragraph to a heading:","options":[{"key":"i","text":"..."},...],"correctAnswer":"serialized mapping","explanation":"...","difficulty":${input.difficulty},"skillTags":["main_idea","matching"]`,
|
| 146 |
-
kanji_reading: `{"format":"kanji_reading","questionText":"...","options":[{"key":"A","text":"..."},...],"correctAnswer":"A","explanation":"...","difficulty":${input.difficulty},"skillTags":["kanji","reading"]`,
|
| 147 |
-
particle_choice: `{"format":"particle_choice","questionText":"...","options":[{"key":"A","text":"..."},...],"correctAnswer":"A","explanation":"...","difficulty":${input.difficulty},"skillTags":["grammar","particle"]`,
|
| 148 |
-
article_case: `{"format":"article_case","questionText":"...","options":[{"key":"A","text":"..."},...],"correctAnswer":"A","explanation":"...","difficulty":${input.difficulty},"skillTags":["grammar","article","case"]`,
|
| 149 |
-
character_reading: `{"format":"character_reading","questionText":"...","options":[{"key":"A","text":"..."},...],"correctAnswer":"A","explanation":"...","difficulty":${input.difficulty},"skillTags":["character","reading"]`,
|
| 150 |
-
sentence_arrangement: `{"format":"sentence_arrangement","questionText":"...","options":[{"key":"A","text":"..."},...],"correctAnswer":"A","explanation":"...","difficulty":${input.difficulty},"skillTags":["reading","sentence_structure"]`,
|
| 151 |
-
};
|
| 152 |
-
return schemas[f] || schemas["multiple_choice"];
|
| 153 |
-
})
|
| 154 |
-
.join("\n---\n");
|
| 155 |
|
| 156 |
const prompt = `Using the following passage, generate ${input.questionCount} reading comprehension questions for ${input.examType} exam.
|
| 157 |
|
|
@@ -160,8 +152,7 @@ Passage:
|
|
| 160 |
${passage}
|
| 161 |
"""
|
| 162 |
|
| 163 |
-
Formats to generate:
|
| 164 |
-
${formatInstructions}
|
| 165 |
|
| 166 |
Rules:
|
| 167 |
- Each question must be directly answerable from the passage
|
|
@@ -169,24 +160,27 @@ Rules:
|
|
| 169 |
- Questions should test real comprehension, not surface recall
|
| 170 |
- For multiple choice: always provide 4 options (A, B, C, D) with one clearly correct answer
|
| 171 |
- Options must be plausible distractors
|
| 172 |
-
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
{
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
|
|
|
|
|
|
|
|
|
| 190 |
|
| 191 |
const parsed = parseJsonResponse(result.content) as Record<string, unknown>;
|
| 192 |
if (!Array.isArray(parsed.questions)) {
|
|
@@ -203,11 +197,13 @@ async function step4SelfValidate(
|
|
| 203 |
input: GenerationInput,
|
| 204 |
passage: string,
|
| 205 |
questions: Array<Record<string, unknown>>,
|
|
|
|
| 206 |
): Promise<{ correctedQuestions: Array<Record<string, unknown>>; confidence: number; tokensUsed: number }> {
|
| 207 |
const qaPairs = questions
|
| 208 |
.map((q, i) => `Q${i + 1}: ${q.questionText}\nA: ${q.correctAnswer}`)
|
| 209 |
.join("\n\n");
|
| 210 |
|
|
|
|
| 211 |
const prompt = `You are a strict exam validator. Review these questions against the passage and identify any errors.
|
| 212 |
|
| 213 |
Passage:
|
|
@@ -223,28 +219,22 @@ For each question, verify:
|
|
| 223 |
2. Are there any ambiguous questions?
|
| 224 |
3. Are distractors plausible but clearly wrong?
|
| 225 |
|
| 226 |
-
Return ONLY valid JSON:
|
| 227 |
-
{
|
| 228 |
-
|
| 229 |
-
|
| 230 |
{
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
{ role: "system", content: getSystemPrompt() },
|
| 243 |
-
{ role: "user", content: prompt },
|
| 244 |
-
],
|
| 245 |
-
temperature: 0.3,
|
| 246 |
-
max_tokens: input.apiKeyConfig.maxTokens,
|
| 247 |
-
});
|
| 248 |
|
| 249 |
const parsed = parseJsonResponse(result.content) as Record<string, unknown>;
|
| 250 |
const confidence = typeof parsed.overallConfidence === "number" ? parsed.overallConfidence : 75;
|
|
@@ -262,9 +252,74 @@ Return ONLY valid JSON:
|
|
| 262 |
return { correctedQuestions: corrected, confidence, tokensUsed: result.usage?.total_tokens ?? 0 };
|
| 263 |
}
|
| 264 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
export async function generateQuestionsAgentic(
|
| 266 |
input: GenerationInput,
|
| 267 |
onProgress?: (progress: AgenticProgress) => void,
|
|
|
|
| 268 |
): Promise<GenerationResult> {
|
| 269 |
const start = Date.now();
|
| 270 |
const client = new OpenAICompatibleClient(
|
|
@@ -292,7 +347,7 @@ export async function generateQuestionsAgentic(
|
|
| 292 |
let passage: string;
|
| 293 |
let title: string;
|
| 294 |
try {
|
| 295 |
-
const s1 = await step1GeneratePassage(client, input);
|
| 296 |
passage = s1.passage;
|
| 297 |
title = s1.title;
|
| 298 |
accumulatedTokens += s1.tokensUsed;
|
|
@@ -311,7 +366,7 @@ export async function generateQuestionsAgentic(
|
|
| 311 |
let isValid: boolean;
|
| 312 |
let feedback: string;
|
| 313 |
try {
|
| 314 |
-
const s2 = await step2ValidatePassage(client, input, passage);
|
| 315 |
isValid = s2.isValid;
|
| 316 |
feedback = s2.feedback;
|
| 317 |
accumulatedTokens += s2.tokensUsed;
|
|
@@ -331,7 +386,7 @@ export async function generateQuestionsAgentic(
|
|
| 331 |
report(2);
|
| 332 |
let rawQuestions: Array<Record<string, unknown>>;
|
| 333 |
try {
|
| 334 |
-
const s3 = await step3GenerateQuestions(client, input, passage);
|
| 335 |
rawQuestions = s3.questions;
|
| 336 |
accumulatedTokens += s3.tokensUsed;
|
| 337 |
steps[2].status = "done";
|
|
@@ -349,12 +404,40 @@ export async function generateQuestionsAgentic(
|
|
| 349 |
let correctedQuestions: Array<Record<string, unknown>>;
|
| 350 |
let confidence: number;
|
| 351 |
try {
|
| 352 |
-
const s4 = await step4SelfValidate(client, input, passage, rawQuestions);
|
| 353 |
correctedQuestions = s4.correctedQuestions;
|
| 354 |
confidence = s4.confidence;
|
| 355 |
accumulatedTokens += s4.tokensUsed;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
steps[3].status = "done";
|
| 357 |
-
steps[3].message = `Confidence score: ${confidence}%`;
|
| 358 |
steps[3].output = `Overall Confidence: ${confidence}%\nTotal Questions: ${correctedQuestions.length}`;
|
| 359 |
} catch (err: any) {
|
| 360 |
steps[3].status = "error";
|
|
|
|
| 1 |
import { OpenAICompatibleClient } from "./client";
|
| 2 |
import { GenerationError } from "./errors";
|
| 3 |
+
import {
|
| 4 |
+
getQuestionJsonSchemaDescription,
|
| 5 |
+
getPassageJsonSchemaDescription,
|
| 6 |
+
getValidationJsonSchemaDescription,
|
| 7 |
+
getQuestionsArrayJsonSchemaDescription,
|
| 8 |
+
getSelfValidationJsonSchemaDescription,
|
| 9 |
+
} from "./schema-to-prompt";
|
| 10 |
import { questionSchema, type GenerationInput, type GenerationResult } from "./schemas";
|
| 11 |
|
| 12 |
interface AgenticStep {
|
|
|
|
| 50 |
async function step1GeneratePassage(
|
| 51 |
client: OpenAICompatibleClient,
|
| 52 |
input: GenerationInput,
|
| 53 |
+
onToken?: (token: string) => void,
|
| 54 |
): Promise<{ passage: string; title: string; tokensUsed: number }> {
|
| 55 |
+
const schema = getPassageJsonSchemaDescription();
|
| 56 |
const prompt = `Generate an authentic, high-quality reading passage for ${input.examType} ${input.section.toLowerCase()} section at difficulty level ${input.difficulty}/5.
|
| 57 |
|
| 58 |
Requirements:
|
|
|
|
| 62 |
- The passage should be natural, well-structured, and appropriate for the exam level
|
| 63 |
- Length should be suitable for ${input.questionCount} comprehension questions
|
| 64 |
|
| 65 |
+
Return ONLY valid JSON conforming to this schema:
|
| 66 |
+
${schema}`;
|
| 67 |
+
|
| 68 |
+
const result = await client.chatCompletion(
|
| 69 |
+
{
|
| 70 |
+
model: input.apiKeyConfig.model,
|
| 71 |
+
messages: [
|
| 72 |
+
{ role: "system", content: getSystemPrompt() },
|
| 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,
|
| 80 |
+
);
|
| 81 |
|
| 82 |
const parsed = parseJsonResponse(result.content) as Record<string, unknown>;
|
| 83 |
if (!parsed.passage || typeof parsed.passage !== "string") {
|
|
|
|
| 94 |
client: OpenAICompatibleClient,
|
| 95 |
input: GenerationInput,
|
| 96 |
passage: string,
|
| 97 |
+
onToken?: (token: string) => void,
|
| 98 |
): Promise<{ isValid: boolean; feedback: string; tokensUsed: number }> {
|
| 99 |
+
const schema = getValidationJsonSchemaDescription();
|
| 100 |
const prompt = `Validate this reading passage for a ${input.examType} exam at difficulty ${input.difficulty}/5.
|
| 101 |
|
| 102 |
Passage:
|
|
|
|
| 111 |
4. Topic relevance: ${input.topics.join(", ")}
|
| 112 |
5. Natural flow and coherence
|
| 113 |
|
| 114 |
+
Return ONLY valid JSON conforming to this schema:
|
| 115 |
+
${schema}`;
|
| 116 |
+
|
| 117 |
+
const result = await client.chatCompletion(
|
| 118 |
+
{
|
| 119 |
+
model: input.apiKeyConfig.model,
|
| 120 |
+
messages: [
|
| 121 |
+
{ role: "system", content: getSystemPrompt() },
|
| 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,
|
| 129 |
+
);
|
| 130 |
|
| 131 |
const parsed = parseJsonResponse(result.content) as Record<string, unknown>;
|
| 132 |
return {
|
|
|
|
| 140 |
client: OpenAICompatibleClient,
|
| 141 |
input: GenerationInput,
|
| 142 |
passage: string,
|
| 143 |
+
onToken?: (token: string) => void,
|
| 144 |
): Promise<{ questions: Array<Record<string, unknown>>; tokensUsed: number }> {
|
| 145 |
+
const questionSchemaDesc = getQuestionJsonSchemaDescription();
|
| 146 |
+
const wrapperSchema = getQuestionsArrayJsonSchemaDescription();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
|
| 148 |
const prompt = `Using the following passage, generate ${input.questionCount} reading comprehension questions for ${input.examType} exam.
|
| 149 |
|
|
|
|
| 152 |
${passage}
|
| 153 |
"""
|
| 154 |
|
| 155 |
+
Formats to generate: ${input.formats.join(", ")}
|
|
|
|
| 156 |
|
| 157 |
Rules:
|
| 158 |
- Each question must be directly answerable from the passage
|
|
|
|
| 160 |
- Questions should test real comprehension, not surface recall
|
| 161 |
- For multiple choice: always provide 4 options (A, B, C, D) with one clearly correct answer
|
| 162 |
- Options must be plausible distractors
|
| 163 |
+
- explanation (explanation) - dijelaskan dengan bahasa Indonesia
|
| 164 |
+
|
| 165 |
+
Question schema:
|
| 166 |
+
${questionSchemaDesc}
|
| 167 |
+
|
| 168 |
+
Return ONLY valid JSON conforming to this schema:
|
| 169 |
+
${wrapperSchema}`;
|
| 170 |
+
|
| 171 |
+
const result = await client.chatCompletion(
|
| 172 |
+
{
|
| 173 |
+
model: input.apiKeyConfig.model,
|
| 174 |
+
messages: [
|
| 175 |
+
{ role: "system", content: getSystemPrompt() },
|
| 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,
|
| 183 |
+
);
|
| 184 |
|
| 185 |
const parsed = parseJsonResponse(result.content) as Record<string, unknown>;
|
| 186 |
if (!Array.isArray(parsed.questions)) {
|
|
|
|
| 197 |
input: GenerationInput,
|
| 198 |
passage: string,
|
| 199 |
questions: Array<Record<string, unknown>>,
|
| 200 |
+
onToken?: (token: string) => void,
|
| 201 |
): Promise<{ correctedQuestions: Array<Record<string, unknown>>; confidence: number; tokensUsed: number }> {
|
| 202 |
const qaPairs = questions
|
| 203 |
.map((q, i) => `Q${i + 1}: ${q.questionText}\nA: ${q.correctAnswer}`)
|
| 204 |
.join("\n\n");
|
| 205 |
|
| 206 |
+
const schema = getSelfValidationJsonSchemaDescription();
|
| 207 |
const prompt = `You are a strict exam validator. Review these questions against the passage and identify any errors.
|
| 208 |
|
| 209 |
Passage:
|
|
|
|
| 219 |
2. Are there any ambiguous questions?
|
| 220 |
3. Are distractors plausible but clearly wrong?
|
| 221 |
|
| 222 |
+
Return ONLY valid JSON conforming to this schema:
|
| 223 |
+
${schema}`;
|
| 224 |
+
|
| 225 |
+
const result = await client.chatCompletion(
|
| 226 |
{
|
| 227 |
+
model: input.apiKeyConfig.model,
|
| 228 |
+
messages: [
|
| 229 |
+
{ role: "system", content: getSystemPrompt() },
|
| 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,
|
| 237 |
+
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
|
| 239 |
const parsed = parseJsonResponse(result.content) as Record<string, unknown>;
|
| 240 |
const confidence = typeof parsed.overallConfidence === "number" ? parsed.overallConfidence : 75;
|
|
|
|
| 252 |
return { correctedQuestions: corrected, confidence, tokensUsed: result.usage?.total_tokens ?? 0 };
|
| 253 |
}
|
| 254 |
|
| 255 |
+
async function step4RegenerateBadQuestions(
|
| 256 |
+
client: OpenAICompatibleClient,
|
| 257 |
+
input: GenerationInput,
|
| 258 |
+
passage: string,
|
| 259 |
+
questions: Array<Record<string, unknown>>,
|
| 260 |
+
issueIndices: number[],
|
| 261 |
+
onToken?: (token: string) => void,
|
| 262 |
+
): Promise<{ regenerated: Array<Record<string, unknown>>; tokensUsed: number }> {
|
| 263 |
+
const badQuestions = issueIndices.map((i) => ({
|
| 264 |
+
index: i,
|
| 265 |
+
...questions[i],
|
| 266 |
+
}));
|
| 267 |
+
|
| 268 |
+
const questionSchemaDesc = getQuestionJsonSchemaDescription();
|
| 269 |
+
const wrapperSchema = getQuestionsArrayJsonSchemaDescription();
|
| 270 |
+
|
| 271 |
+
const prompt = `You are an expert exam question writer. The following questions were flagged as incorrect or flawed. Regenerate them to fix the issues while keeping the same format and difficulty.
|
| 272 |
+
|
| 273 |
+
Passage:
|
| 274 |
+
"""
|
| 275 |
+
${passage}
|
| 276 |
+
"""
|
| 277 |
+
|
| 278 |
+
Flawed questions (with their original index):
|
| 279 |
+
${JSON.stringify(badQuestions, null, 2)}
|
| 280 |
+
|
| 281 |
+
Rules:
|
| 282 |
+
- Regenerate ONLY the flawed questions
|
| 283 |
+
- Maintain the same format, difficulty (${input.difficulty}), and exam style (${input.examType})
|
| 284 |
+
- Each question must be directly answerable from the passage
|
| 285 |
+
- explanation (explanation) - dijelaskan dengan bahasa Indonesia
|
| 286 |
+
- Return the same number of questions in the same order as the input
|
| 287 |
+
|
| 288 |
+
Question schema:
|
| 289 |
+
${questionSchemaDesc}
|
| 290 |
+
|
| 291 |
+
Return ONLY valid JSON conforming to this schema:
|
| 292 |
+
${wrapperSchema}`;
|
| 293 |
+
|
| 294 |
+
const result = await client.chatCompletion(
|
| 295 |
+
{
|
| 296 |
+
model: input.apiKeyConfig.model,
|
| 297 |
+
messages: [
|
| 298 |
+
{ role: "system", content: getSystemPrompt() },
|
| 299 |
+
{ role: "user", content: prompt },
|
| 300 |
+
],
|
| 301 |
+
temperature: 0.7,
|
| 302 |
+
max_tokens: input.apiKeyConfig.maxTokens,
|
| 303 |
+
response_format: { type: "json_object" },
|
| 304 |
+
},
|
| 305 |
+
onToken ? { onToken } : undefined,
|
| 306 |
+
);
|
| 307 |
+
|
| 308 |
+
const parsed = parseJsonResponse(result.content) as Record<string, unknown>;
|
| 309 |
+
if (!Array.isArray(parsed.questions) || parsed.questions.length !== badQuestions.length) {
|
| 310 |
+
throw new Error("Regeneration did not return the expected number of questions");
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
return {
|
| 314 |
+
regenerated: parsed.questions as Array<Record<string, unknown>>,
|
| 315 |
+
tokensUsed: result.usage?.total_tokens ?? 0,
|
| 316 |
+
};
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
export async function generateQuestionsAgentic(
|
| 320 |
input: GenerationInput,
|
| 321 |
onProgress?: (progress: AgenticProgress) => void,
|
| 322 |
+
onToken?: (token: string) => void,
|
| 323 |
): Promise<GenerationResult> {
|
| 324 |
const start = Date.now();
|
| 325 |
const client = new OpenAICompatibleClient(
|
|
|
|
| 347 |
let passage: string;
|
| 348 |
let title: string;
|
| 349 |
try {
|
| 350 |
+
const s1 = await step1GeneratePassage(client, input, onToken);
|
| 351 |
passage = s1.passage;
|
| 352 |
title = s1.title;
|
| 353 |
accumulatedTokens += s1.tokensUsed;
|
|
|
|
| 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;
|
|
|
|
| 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";
|
|
|
|
| 404 |
let correctedQuestions: Array<Record<string, unknown>>;
|
| 405 |
let confidence: number;
|
| 406 |
try {
|
| 407 |
+
const s4 = await step4SelfValidate(client, input, passage, rawQuestions, onToken);
|
| 408 |
correctedQuestions = s4.correctedQuestions;
|
| 409 |
confidence = s4.confidence;
|
| 410 |
accumulatedTokens += s4.tokensUsed;
|
| 411 |
+
|
| 412 |
+
// If validation found issues and confidence is low, actually regenerate the bad questions
|
| 413 |
+
const issueIndices = (s4.correctedQuestions as any[])
|
| 414 |
+
.map((q, i) => (q.explanation?.includes("[Validator note:") ? i : -1))
|
| 415 |
+
.filter((i) => i !== -1);
|
| 416 |
+
|
| 417 |
+
if (issueIndices.length > 0 && confidence < 85) {
|
| 418 |
+
steps[3].message = `Found ${issueIndices.length} issues. Regenerating...`;
|
| 419 |
+
report(3);
|
| 420 |
+
const regen = await step4RegenerateBadQuestions(
|
| 421 |
+
client,
|
| 422 |
+
input,
|
| 423 |
+
passage,
|
| 424 |
+
rawQuestions,
|
| 425 |
+
issueIndices,
|
| 426 |
+
onToken,
|
| 427 |
+
);
|
| 428 |
+
accumulatedTokens += regen.tokensUsed;
|
| 429 |
+
|
| 430 |
+
// Replace the bad questions with regenerated ones
|
| 431 |
+
for (let idx = 0; idx < issueIndices.length; idx++) {
|
| 432 |
+
correctedQuestions[issueIndices[idx]] = regen.regenerated[idx];
|
| 433 |
+
}
|
| 434 |
+
confidence = Math.min(100, confidence + 10);
|
| 435 |
+
steps[3].message = `Regenerated ${issueIndices.length} questions. Confidence: ${confidence}%`;
|
| 436 |
+
} else {
|
| 437 |
+
steps[3].message = `Confidence score: ${confidence}%`;
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
steps[3].status = "done";
|
|
|
|
| 441 |
steps[3].output = `Overall Confidence: ${confidence}%\nTotal Questions: ${correctedQuestions.length}`;
|
| 442 |
} catch (err: any) {
|
| 443 |
steps[3].status = "error";
|
packages/ai/src/index.ts
CHANGED
|
@@ -5,3 +5,10 @@ export type { AgenticProgress } from "./agentic";
|
|
| 5 |
export { buildQuickModePrompt } from "./prompts";
|
| 6 |
export * from "./schemas";
|
| 7 |
export { GenerationError } from "./errors";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
export { buildQuickModePrompt } from "./prompts";
|
| 6 |
export * from "./schemas";
|
| 7 |
export { GenerationError } from "./errors";
|
| 8 |
+
export {
|
| 9 |
+
getQuestionJsonSchemaDescription,
|
| 10 |
+
getPassageJsonSchemaDescription,
|
| 11 |
+
getValidationJsonSchemaDescription,
|
| 12 |
+
getQuestionsArrayJsonSchemaDescription,
|
| 13 |
+
getSelfValidationJsonSchemaDescription,
|
| 14 |
+
} from "./schema-to-prompt";
|
packages/ai/src/pipeline.ts
CHANGED
|
@@ -56,6 +56,7 @@ export async function generateQuestionsQuick(
|
|
| 56 |
],
|
| 57 |
temperature: 0.7,
|
| 58 |
max_tokens: input.apiKeyConfig.maxTokens,
|
|
|
|
| 59 |
},
|
| 60 |
callbacks?.onToken
|
| 61 |
? { onToken: callbacks.onToken }
|
|
|
|
| 56 |
],
|
| 57 |
temperature: 0.7,
|
| 58 |
max_tokens: input.apiKeyConfig.maxTokens,
|
| 59 |
+
response_format: { type: "json_object" },
|
| 60 |
},
|
| 61 |
callbacks?.onToken
|
| 62 |
? { onToken: callbacks.onToken }
|
packages/ai/src/prompts.ts
CHANGED
|
@@ -1,161 +1,10 @@
|
|
| 1 |
import type { GenerationInput } from "./schemas";
|
|
|
|
| 2 |
|
| 3 |
export function buildQuickModePrompt(input: GenerationInput): string {
|
| 4 |
const { examType, section, formats, difficulty, topics, questionCount } = input;
|
| 5 |
|
| 6 |
-
const
|
| 7 |
-
multiple_choice: `{
|
| 8 |
-
"format": "multiple_choice",
|
| 9 |
-
"passageText": "...",
|
| 10 |
-
"questionText": "...",
|
| 11 |
-
"options": [{"key": "A", "text": "..."}, {"key": "B", "text": "..."}, ...],
|
| 12 |
-
"correctAnswer": "A",
|
| 13 |
-
"explanation": "...",
|
| 14 |
-
"difficulty": ${difficulty},
|
| 15 |
-
"skillTags": ["..."]
|
| 16 |
-
}`,
|
| 17 |
-
true_false_not_given: `{
|
| 18 |
-
"format": "true_false_not_given",
|
| 19 |
-
"passageText": "...",
|
| 20 |
-
"questionText": "...",
|
| 21 |
-
"correctAnswer": "TRUE" | "FALSE" | "NOT_GIVEN",
|
| 22 |
-
"explanation": "...",
|
| 23 |
-
"difficulty": ${difficulty},
|
| 24 |
-
"skillTags": ["..."]
|
| 25 |
-
}`,
|
| 26 |
-
fill_blank: `{
|
| 27 |
-
"format": "fill_blank",
|
| 28 |
-
"passageText": "...",
|
| 29 |
-
"questionText": "Fill in the blank: ...",
|
| 30 |
-
"correctAnswer": "exact text",
|
| 31 |
-
"explanation": "...",
|
| 32 |
-
"difficulty": ${difficulty},
|
| 33 |
-
"skillTags": ["..."]
|
| 34 |
-
}`,
|
| 35 |
-
synonym: `{
|
| 36 |
-
"format": "synonym",
|
| 37 |
-
"passageText": "...",
|
| 38 |
-
"questionText": "The word '___' in the passage is closest in meaning to:",
|
| 39 |
-
"options": [{"key": "A", "text": "..."}, ...],
|
| 40 |
-
"correctAnswer": "A",
|
| 41 |
-
"explanation": "...",
|
| 42 |
-
"difficulty": ${difficulty},
|
| 43 |
-
"skillTags": ["vocabulary", "synonym"]
|
| 44 |
-
}`,
|
| 45 |
-
grammar_in_context: `{
|
| 46 |
-
"format": "grammar_in_context",
|
| 47 |
-
"passageText": "...",
|
| 48 |
-
"questionText": "...",
|
| 49 |
-
"options": [{"key": "A", "text": "..."}, ...],
|
| 50 |
-
"correctAnswer": "A",
|
| 51 |
-
"explanation": "...",
|
| 52 |
-
"difficulty": ${difficulty},
|
| 53 |
-
"skillTags": ["grammar"]
|
| 54 |
-
}`,
|
| 55 |
-
sentence_completion: `{
|
| 56 |
-
"format": "sentence_completion",
|
| 57 |
-
"passageText": "...",
|
| 58 |
-
"questionText": "...",
|
| 59 |
-
"options": [{"key": "A", "text": "..."}, ...],
|
| 60 |
-
"correctAnswer": "A",
|
| 61 |
-
"explanation": "...",
|
| 62 |
-
"difficulty": ${difficulty},
|
| 63 |
-
"skillTags": ["..."]
|
| 64 |
-
}`,
|
| 65 |
-
cloze: `{
|
| 66 |
-
"format": "cloze",
|
| 67 |
-
"passageText": "...",
|
| 68 |
-
"questionText": "Fill each blank with the correct option:",
|
| 69 |
-
"options": [{"key": "A", "text": "..."}, ...],
|
| 70 |
-
"correctAnswer": "serialized mapping of blank index to option key",
|
| 71 |
-
"explanation": "...",
|
| 72 |
-
"difficulty": ${difficulty},
|
| 73 |
-
"skillTags": ["grammar", "vocabulary"]
|
| 74 |
-
}`,
|
| 75 |
-
reference: `{
|
| 76 |
-
"format": "reference",
|
| 77 |
-
"passageText": "...",
|
| 78 |
-
"questionText": "The word 'it' in paragraph X refers to:",
|
| 79 |
-
"options": [{"key": "A", "text": "..."}, ...],
|
| 80 |
-
"correctAnswer": "A",
|
| 81 |
-
"explanation": "...",
|
| 82 |
-
"difficulty": ${difficulty},
|
| 83 |
-
"skillTags": ["reference", "inference"]
|
| 84 |
-
}`,
|
| 85 |
-
author_view: `{
|
| 86 |
-
"format": "author_view",
|
| 87 |
-
"passageText": "...",
|
| 88 |
-
"questionText": "...",
|
| 89 |
-
"correctAnswer": "YES" | "NO" | "NOT_GIVEN",
|
| 90 |
-
"explanation": "...",
|
| 91 |
-
"difficulty": ${difficulty},
|
| 92 |
-
"skillTags": ["inference", "author_view"]
|
| 93 |
-
}`,
|
| 94 |
-
matching_headings: `{
|
| 95 |
-
"format": "matching_headings",
|
| 96 |
-
"passageText": "...",
|
| 97 |
-
"questionText": "Match each paragraph to a heading:",
|
| 98 |
-
"options": [{"key": "i", "text": "..."}, ...],
|
| 99 |
-
"correctAnswer": "serialized mapping of paragraph to heading key",
|
| 100 |
-
"explanation": "...",
|
| 101 |
-
"difficulty": ${difficulty},
|
| 102 |
-
"skillTags": ["main_idea", "matching"]
|
| 103 |
-
}`,
|
| 104 |
-
kanji_reading: `{
|
| 105 |
-
"format": "kanji_reading",
|
| 106 |
-
"passageText": "...",
|
| 107 |
-
"questionText": "How is the kanji '...' read in this context?",
|
| 108 |
-
"options": [{"key": "A", "text": "..."}, ...],
|
| 109 |
-
"correctAnswer": "A",
|
| 110 |
-
"explanation": "...",
|
| 111 |
-
"difficulty": ${difficulty},
|
| 112 |
-
"skillTags": ["kanji", "reading"]
|
| 113 |
-
}`,
|
| 114 |
-
particle_choice: `{
|
| 115 |
-
"format": "particle_choice",
|
| 116 |
-
"passageText": "...",
|
| 117 |
-
"questionText": "Which particle fits the blank?",
|
| 118 |
-
"options": [{"key": "A", "text": "..."}, ...],
|
| 119 |
-
"correctAnswer": "A",
|
| 120 |
-
"explanation": "...",
|
| 121 |
-
"difficulty": ${difficulty},
|
| 122 |
-
"skillTags": ["grammar", "particle"]
|
| 123 |
-
}`,
|
| 124 |
-
article_case: `{
|
| 125 |
-
"format": "article_case",
|
| 126 |
-
"passageText": "...",
|
| 127 |
-
"questionText": "Which article/case fits the blank?",
|
| 128 |
-
"options": [{"key": "A", "text": "..."}, ...],
|
| 129 |
-
"correctAnswer": "A",
|
| 130 |
-
"explanation": "...",
|
| 131 |
-
"difficulty": ${difficulty},
|
| 132 |
-
"skillTags": ["grammar", "article", "case"]
|
| 133 |
-
}`,
|
| 134 |
-
character_reading: `{
|
| 135 |
-
"format": "character_reading",
|
| 136 |
-
"passageText": "...",
|
| 137 |
-
"questionText": "How is the character '...' read in this context?",
|
| 138 |
-
"options": [{"key": "A", "text": "..."}, ...],
|
| 139 |
-
"correctAnswer": "A",
|
| 140 |
-
"explanation": "...",
|
| 141 |
-
"difficulty": ${difficulty},
|
| 142 |
-
"skillTags": ["character", "reading"]
|
| 143 |
-
}`,
|
| 144 |
-
sentence_arrangement: `{
|
| 145 |
-
"format": "sentence_arrangement",
|
| 146 |
-
"passageText": "...",
|
| 147 |
-
"questionText": "Arrange the following sentences into the correct order:",
|
| 148 |
-
"options": [{"key": "A", "text": "..."}, ...],
|
| 149 |
-
"correctAnswer": "A",
|
| 150 |
-
"explanation": "...",
|
| 151 |
-
"difficulty": ${difficulty},
|
| 152 |
-
"skillTags": ["reading", "sentence_structure"]
|
| 153 |
-
}`,
|
| 154 |
-
};
|
| 155 |
-
|
| 156 |
-
const formatExamples = formats
|
| 157 |
-
.map((f) => formatDescriptions[f] || formatDescriptions["multiple_choice"])
|
| 158 |
-
.join("\n\n---\n\n");
|
| 159 |
|
| 160 |
return `You are an expert exam question writer for ${examType} ${section.toLowerCase()} section.
|
| 161 |
|
|
@@ -165,6 +14,7 @@ EXAM: ${examType}
|
|
| 165 |
SECTION: ${section}
|
| 166 |
DIFFICULTY: ${difficulty}/5
|
| 167 |
TOPICS: ${topics.join(", ")}
|
|
|
|
| 168 |
|
| 169 |
INSTRUCTIONS:
|
| 170 |
- The reading passage must be written in the target language of the exam (${examType === "JLPT" ? "Japanese" : examType === "HSK" ? "Chinese" : examType === "GOETHE" ? "German" : "English"}).
|
|
@@ -185,7 +35,8 @@ Return ONLY a valid JSON object with this exact structure (no markdown code bloc
|
|
| 185 |
|
| 186 |
{
|
| 187 |
"questions": [
|
| 188 |
-
|
|
|
|
| 189 |
]
|
| 190 |
}
|
| 191 |
|
|
|
|
| 1 |
import type { GenerationInput } from "./schemas";
|
| 2 |
+
import { getQuestionJsonSchemaDescription } from "./schema-to-prompt";
|
| 3 |
|
| 4 |
export function buildQuickModePrompt(input: GenerationInput): string {
|
| 5 |
const { examType, section, formats, difficulty, topics, questionCount } = input;
|
| 6 |
|
| 7 |
+
const questionSchemaJson = getQuestionJsonSchemaDescription();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
return `You are an expert exam question writer for ${examType} ${section.toLowerCase()} section.
|
| 10 |
|
|
|
|
| 14 |
SECTION: ${section}
|
| 15 |
DIFFICULTY: ${difficulty}/5
|
| 16 |
TOPICS: ${topics.join(", ")}
|
| 17 |
+
FORMATS TO GENERATE: ${formats.join(", ")}
|
| 18 |
|
| 19 |
INSTRUCTIONS:
|
| 20 |
- The reading passage must be written in the target language of the exam (${examType === "JLPT" ? "Japanese" : examType === "HSK" ? "Chinese" : examType === "GOETHE" ? "German" : "English"}).
|
|
|
|
| 35 |
|
| 36 |
{
|
| 37 |
"questions": [
|
| 38 |
+
// array of question objects. Schema:
|
| 39 |
+
${questionSchemaJson.split("\n").map((l) => " " + l).join("\n")}
|
| 40 |
]
|
| 41 |
}
|
| 42 |
|
packages/ai/src/schema-to-prompt.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { zodToJsonSchema } from "zod-to-json-schema";
|
| 2 |
+
import { questionSchema } from "./schemas";
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* Generate a concise JSON-schema description of the question schema
|
| 6 |
+
* suitable for embedding into an LLM prompt.
|
| 7 |
+
*/
|
| 8 |
+
export function getQuestionJsonSchemaDescription(): string {
|
| 9 |
+
const jsonSchema = zodToJsonSchema(questionSchema as any, {
|
| 10 |
+
name: "Question",
|
| 11 |
+
$refStrategy: "none",
|
| 12 |
+
});
|
| 13 |
+
|
| 14 |
+
// Strip the top-level wrapper so the AI sees just the object shape
|
| 15 |
+
const defs = (jsonSchema as any).definitions?.Question ?? jsonSchema;
|
| 16 |
+
|
| 17 |
+
return JSON.stringify(defs, null, 2);
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
/**
|
| 21 |
+
* Generate a JSON schema description for a passage response.
|
| 22 |
+
*/
|
| 23 |
+
export function getPassageJsonSchemaDescription(): string {
|
| 24 |
+
return JSON.stringify(
|
| 25 |
+
{
|
| 26 |
+
type: "object",
|
| 27 |
+
properties: {
|
| 28 |
+
title: { type: "string", description: "Brief title describing the passage topic" },
|
| 29 |
+
passage: { type: "string", description: "The full reading passage text" },
|
| 30 |
+
},
|
| 31 |
+
required: ["title", "passage"],
|
| 32 |
+
},
|
| 33 |
+
null,
|
| 34 |
+
2,
|
| 35 |
+
);
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
/**
|
| 39 |
+
* Generate a JSON schema description for a validation response.
|
| 40 |
+
*/
|
| 41 |
+
export function getValidationJsonSchemaDescription(): string {
|
| 42 |
+
return JSON.stringify(
|
| 43 |
+
{
|
| 44 |
+
type: "object",
|
| 45 |
+
properties: {
|
| 46 |
+
isValid: { type: "boolean" },
|
| 47 |
+
feedback: { type: "string", description: "Brief assessment. If invalid, explain why." },
|
| 48 |
+
score: { type: "number", minimum: 1, maximum: 10 },
|
| 49 |
+
},
|
| 50 |
+
required: ["isValid", "feedback", "score"],
|
| 51 |
+
},
|
| 52 |
+
null,
|
| 53 |
+
2,
|
| 54 |
+
);
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
/**
|
| 58 |
+
* Generate a JSON schema description for the questions array wrapper.
|
| 59 |
+
*/
|
| 60 |
+
export function getQuestionsArrayJsonSchemaDescription(): string {
|
| 61 |
+
return JSON.stringify(
|
| 62 |
+
{
|
| 63 |
+
type: "object",
|
| 64 |
+
properties: {
|
| 65 |
+
questions: {
|
| 66 |
+
type: "array",
|
| 67 |
+
description: "Array of question objects",
|
| 68 |
+
items: { $ref: "#/$defs/Question" },
|
| 69 |
+
},
|
| 70 |
+
},
|
| 71 |
+
required: ["questions"],
|
| 72 |
+
},
|
| 73 |
+
null,
|
| 74 |
+
2,
|
| 75 |
+
);
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
/**
|
| 79 |
+
* Generate a JSON schema description for the self-validation response.
|
| 80 |
+
*/
|
| 81 |
+
export function getSelfValidationJsonSchemaDescription(): string {
|
| 82 |
+
return JSON.stringify(
|
| 83 |
+
{
|
| 84 |
+
type: "object",
|
| 85 |
+
properties: {
|
| 86 |
+
overallConfidence: { type: "number", minimum: 0, maximum: 100 },
|
| 87 |
+
issues: {
|
| 88 |
+
type: "array",
|
| 89 |
+
items: {
|
| 90 |
+
type: "object",
|
| 91 |
+
properties: {
|
| 92 |
+
questionIndex: { type: "number" },
|
| 93 |
+
issue: { type: "string" },
|
| 94 |
+
suggestedFix: { type: "string" },
|
| 95 |
+
},
|
| 96 |
+
required: ["questionIndex", "issue", "suggestedFix"],
|
| 97 |
+
},
|
| 98 |
+
},
|
| 99 |
+
needsRevision: { type: "boolean" },
|
| 100 |
+
},
|
| 101 |
+
required: ["overallConfidence", "issues", "needsRevision"],
|
| 102 |
+
},
|
| 103 |
+
null,
|
| 104 |
+
2,
|
| 105 |
+
);
|
| 106 |
+
}
|
packages/api/src/queue.ts
CHANGED
|
@@ -188,6 +188,19 @@ export const generationWorker = new Worker(
|
|
| 188 |
|
| 189 |
startHeartbeat();
|
| 190 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
let result;
|
| 192 |
try {
|
| 193 |
result =
|
|
@@ -203,21 +216,9 @@ export const generationWorker = new Worker(
|
|
| 203 |
const status = step?.status === "error" ? "error" : step?.status === "done" ? "done" : "running";
|
| 204 |
await updateProgress(stepProgress, msg);
|
| 205 |
await pushLog(step?.step ?? "unknown", msg, status, step?.output);
|
| 206 |
-
})
|
| 207 |
: await generateQuestionsQuick(input, {
|
| 208 |
-
onToken:
|
| 209 |
-
cancelPoll.check();
|
| 210 |
-
countToken(token);
|
| 211 |
-
// Update progress message with token count every ~500 chars
|
| 212 |
-
if (approxTokens % 20 === 0) {
|
| 213 |
-
job.updateProgress(job.progress ?? 5).catch(() => {});
|
| 214 |
-
db
|
| 215 |
-
.update(generationJob)
|
| 216 |
-
.set({ progressMessage: `Generating... (~${approxTokens} tokens)` })
|
| 217 |
-
.where(eq(generationJob.id, jobId))
|
| 218 |
-
.catch(() => {});
|
| 219 |
-
}
|
| 220 |
-
},
|
| 221 |
});
|
| 222 |
} catch (quickErr: any) {
|
| 223 |
const quickErrorMessage = quickErr?.message ?? String(quickErr);
|
|
@@ -241,6 +242,7 @@ export const generationWorker = new Worker(
|
|
| 241 |
p.steps[p.currentStep]?.message ?? p.steps[p.currentStep]?.step ?? "Processing...";
|
| 242 |
await updateProgress(stepProgress, msg);
|
| 243 |
},
|
|
|
|
| 244 |
);
|
| 245 |
}
|
| 246 |
|
|
@@ -435,6 +437,7 @@ export async function enqueueGeneration(
|
|
| 435 |
questionCount: input.questionCount,
|
| 436 |
status: "pending",
|
| 437 |
progress: 0,
|
|
|
|
| 438 |
})
|
| 439 |
.returning();
|
| 440 |
|
|
|
|
| 188 |
|
| 189 |
startHeartbeat();
|
| 190 |
|
| 191 |
+
const tokenCounter = (token: string) => {
|
| 192 |
+
cancelPoll.check();
|
| 193 |
+
countToken(token);
|
| 194 |
+
if (approxTokens % 20 === 0) {
|
| 195 |
+
job.updateProgress(job.progress ?? 5).catch(() => {});
|
| 196 |
+
db
|
| 197 |
+
.update(generationJob)
|
| 198 |
+
.set({ progressMessage: `Generating... (~${approxTokens} tokens)` })
|
| 199 |
+
.where(eq(generationJob.id, jobId))
|
| 200 |
+
.catch(() => {});
|
| 201 |
+
}
|
| 202 |
+
};
|
| 203 |
+
|
| 204 |
let result;
|
| 205 |
try {
|
| 206 |
result =
|
|
|
|
| 216 |
const status = step?.status === "error" ? "error" : step?.status === "done" ? "done" : "running";
|
| 217 |
await updateProgress(stepProgress, msg);
|
| 218 |
await pushLog(step?.step ?? "unknown", msg, status, step?.output);
|
| 219 |
+
}, tokenCounter)
|
| 220 |
: await generateQuestionsQuick(input, {
|
| 221 |
+
onToken: tokenCounter,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
});
|
| 223 |
} catch (quickErr: any) {
|
| 224 |
const quickErrorMessage = quickErr?.message ?? String(quickErr);
|
|
|
|
| 242 |
p.steps[p.currentStep]?.message ?? p.steps[p.currentStep]?.step ?? "Processing...";
|
| 243 |
await updateProgress(stepProgress, msg);
|
| 244 |
},
|
| 245 |
+
tokenCounter,
|
| 246 |
);
|
| 247 |
}
|
| 248 |
|
|
|
|
| 437 |
questionCount: input.questionCount,
|
| 438 |
status: "pending",
|
| 439 |
progress: 0,
|
| 440 |
+
inputJson: input as any,
|
| 441 |
})
|
| 442 |
.returning();
|
| 443 |
|
packages/api/src/routers/ai.ts
CHANGED
|
@@ -109,6 +109,35 @@ export const aiRouter = router({
|
|
| 109 |
};
|
| 110 |
}),
|
| 111 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
saveQuestions: protectedProcedure
|
| 113 |
.input(
|
| 114 |
z.object({
|
|
|
|
| 109 |
};
|
| 110 |
}),
|
| 111 |
|
| 112 |
+
retryJob: protectedProcedure
|
| 113 |
+
.input(z.object({ jobId: z.string().uuid() }))
|
| 114 |
+
.mutation(async ({ ctx, input }) => {
|
| 115 |
+
const [job] = await db
|
| 116 |
+
.select()
|
| 117 |
+
.from(generationJob)
|
| 118 |
+
.where(eq(generationJob.id, input.jobId))
|
| 119 |
+
.limit(1);
|
| 120 |
+
|
| 121 |
+
if (!job) throw new Error("Job tidak ditemukan");
|
| 122 |
+
if (job.userId !== ctx.session.user.id) throw new Error("Tidak diizinkan");
|
| 123 |
+
if (job.status !== "failed" && job.status !== "cancelled") {
|
| 124 |
+
throw new Error("Hanya job yang gagal atau dibatalkan yang bisa di-retry");
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
if (!job.inputJson || typeof job.inputJson !== "object") {
|
| 128 |
+
throw new Error("Data input job tidak tersedia untuk retry");
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
const jobInput = job.inputJson as any;
|
| 132 |
+
// Ensure the parsed input has the apiKeyConfig shape the pipeline expects
|
| 133 |
+
if (!jobInput.apiKeyConfig?.baseUrl || !jobInput.apiKeyConfig?.apiKey || !jobInput.apiKeyConfig?.model) {
|
| 134 |
+
throw new Error("Konfigurasi API key tidak valid untuk retry");
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
const newJobId = await enqueueGeneration(ctx.session.user.id, jobInput);
|
| 138 |
+
return { jobId: newJobId };
|
| 139 |
+
}),
|
| 140 |
+
|
| 141 |
saveQuestions: protectedProcedure
|
| 142 |
.input(
|
| 143 |
z.object({
|
packages/db/src/schema/app.ts
CHANGED
|
@@ -513,6 +513,7 @@ export const generationJob = pgTable(
|
|
| 513 |
progressMessage: text("progress_message"),
|
| 514 |
logs: jsonb("logs"), // Array of {step, message, timestamp, status}
|
| 515 |
resultJson: jsonb("result_json"), // GenerationResult
|
|
|
|
| 516 |
errorMessage: text("error_message"),
|
| 517 |
tokensUsed: integer("tokens_used"),
|
| 518 |
durationMs: integer("duration_ms"),
|
|
|
|
| 513 |
progressMessage: text("progress_message"),
|
| 514 |
logs: jsonb("logs"), // Array of {step, message, timestamp, status}
|
| 515 |
resultJson: jsonb("result_json"), // GenerationResult
|
| 516 |
+
inputJson: jsonb("input_json"), // GenerationInput (stored for retry)
|
| 517 |
errorMessage: text("error_message"),
|
| 518 |
tokensUsed: integer("tokens_used"),
|
| 519 |
durationMs: integer("duration_ms"),
|