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

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 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-border)] rounded-full overflow-hidden">
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
- {result.meta.tokensUsed ? ` · ${result.meta.tokensUsed} tokens` : ""}
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
- "title": "Brief title describing the passage topic",
59
- "passage": "The full reading passage text..."
60
- }`;
61
-
62
- const result = await client.chatCompletion({
63
- model: input.apiKeyConfig.model,
64
- messages: [
65
- { role: "system", content: getSystemPrompt() },
66
- { role: "user", content: prompt },
67
- ],
68
- temperature: 0.7,
69
- max_tokens: input.apiKeyConfig.maxTokens,
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
- "isValid": true/false,
105
- "feedback": "Brief assessment. If invalid, explain why.",
106
- "score": number from 1-10
107
- }`;
108
-
109
- const result = await client.chatCompletion({
110
- model: input.apiKeyConfig.model,
111
- messages: [
112
- { role: "system", content: getSystemPrompt() },
113
- { role: "user", content: prompt },
114
- ],
115
- temperature: 0.3,
116
- max_tokens: input.apiKeyConfig.maxTokens,
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 formats = input.formats;
133
- const formatInstructions = formats
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
- - an explanation (explanation) - dijelaskan dengan bahasa Indonesia
173
-
174
- Return ONLY valid JSON:
175
- {
176
- "questions": [
177
- // array of question objects matching the format schemas above
178
- ]
179
- }`;
180
-
181
- const result = await client.chatCompletion({
182
- model: input.apiKeyConfig.model,
183
- messages: [
184
- { role: "system", content: getSystemPrompt() },
185
- { role: "user", content: prompt },
186
- ],
187
- temperature: 0.7,
188
- max_tokens: input.apiKeyConfig.maxTokens,
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
- "overallConfidence": number from 0-100,
229
- "issues": [
230
  {
231
- "questionIndex": 0-based index,
232
- "issue": "description of problem",
233
- "suggestedFix": "corrected answer or explanation"
234
- }
235
- ],
236
- "needsRevision": true/false
237
- }`;
238
-
239
- const result = await client.chatCompletion({
240
- model: input.apiKeyConfig.model,
241
- messages: [
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 formatDescriptions: Record<string, string> = {
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
- ${formatExamples}
 
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: (token) => {
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"),