rogasper commited on
Commit
62fdec8
·
1 Parent(s): ff49634

feat: enhance question repair and parsing logic by introducing strict explanation checks. Update related functions to support lenient parsing options, improving flexibility in question validation. Adjust tests to verify behavior under different explanation strictness settings.

Browse files
packages/ai/src/__tests__/repair.test.ts CHANGED
@@ -231,6 +231,20 @@ describe("repairAndParseQuestions", () => {
231
  expect(result.invalid).toHaveLength(1);
232
  expect(result.valid).toHaveLength(0);
233
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
234
  });
235
 
236
  describe("tryParseQuestion", () => {
 
231
  expect(result.invalid).toHaveLength(1);
232
  expect(result.valid).toHaveLength(0);
233
  });
234
+
235
+ it("can skip strict explanation language checks for fast paths", () => {
236
+ const raw = [
237
+ {
238
+ ...baseRaw,
239
+ explanation: "This answer is correct because the passage states it explicitly.",
240
+ },
241
+ ];
242
+ const strict = repairAndParseQuestions(raw, fullPassage);
243
+ const lenient = repairAndParseQuestions(raw, fullPassage, { strictExplanation: false });
244
+
245
+ expect(strict.valid).toHaveLength(0);
246
+ expect(lenient.valid).toHaveLength(1);
247
+ });
248
  });
249
 
250
  describe("tryParseQuestion", () => {
packages/ai/src/agentic.ts CHANGED
@@ -408,6 +408,7 @@ async function runAgenticQuestionPipeline(ctx: {
408
 
409
  let { valid, invalid, repairLog } = repairAndParseQuestions(rawQuestions, passage, {
410
  examType: input.examType,
 
411
  });
412
 
413
  validQuestions = valid;
@@ -428,6 +429,7 @@ async function runAgenticQuestionPipeline(ctx: {
428
 
429
  const regenResult = repairAndParseQuestions(regen.questions, passage, {
430
  examType: input.examType,
 
431
  });
432
  validQuestions.push(...regenResult.valid);
433
  allRepairLogs.push(...regenResult.repairLog.map((l) => `[Regen ${regenerationAttempts}] ${l}`));
@@ -450,6 +452,7 @@ async function runAgenticQuestionPipeline(ctx: {
450
  accumulatedTokens += extra.tokensUsed;
451
  const extraResult = repairAndParseQuestions(extra.questions, passage, {
452
  examType: input.examType,
 
453
  });
454
  validQuestions.push(...extraResult.valid);
455
  allRepairLogs.push(...extraResult.repairLog.map((l) => `[Extra] ${l}`));
 
408
 
409
  let { valid, invalid, repairLog } = repairAndParseQuestions(rawQuestions, passage, {
410
  examType: input.examType,
411
+ strictExplanation: strategy === "full",
412
  });
413
 
414
  validQuestions = valid;
 
429
 
430
  const regenResult = repairAndParseQuestions(regen.questions, passage, {
431
  examType: input.examType,
432
+ strictExplanation: strategy === "full",
433
  });
434
  validQuestions.push(...regenResult.valid);
435
  allRepairLogs.push(...regenResult.repairLog.map((l) => `[Regen ${regenerationAttempts}] ${l}`));
 
452
  accumulatedTokens += extra.tokensUsed;
453
  const extraResult = repairAndParseQuestions(extra.questions, passage, {
454
  examType: input.examType,
455
+ strictExplanation: strategy === "full",
456
  });
457
  validQuestions.push(...extraResult.valid);
458
  allRepairLogs.push(...extraResult.repairLog.map((l) => `[Extra] ${l}`));
packages/ai/src/client.ts CHANGED
@@ -228,7 +228,7 @@ export class OpenAICompatibleClient {
228
  Authorization: `Bearer ${this.apiKey}`,
229
  },
230
  body: JSON.stringify(body),
231
- signal: AbortSignal.timeout(300_000),
232
  });
233
 
234
  log("info", "Received response", {
 
228
  Authorization: `Bearer ${this.apiKey}`,
229
  },
230
  body: JSON.stringify(body),
231
+ signal: AbortSignal.timeout(180_000),
232
  });
233
 
234
  log("info", "Received response", {
packages/ai/src/pipeline.ts CHANGED
@@ -9,7 +9,7 @@ import {
9
  type GenerationResult,
10
  } from "./schemas";
11
 
12
- const MAX_QUICK_REGEN_ATTEMPTS = 2;
13
 
14
  export interface QuickModeCallbacks {
15
  onToken?: (token: string) => void;
@@ -113,7 +113,7 @@ export async function generateQuestionsQuick(
113
  let { valid: validQuestions, invalid: pendingInvalid, repairLog } = repairAndParseQuestions(
114
  raw.questions,
115
  fallbackPassage,
116
- { examType: input.examType },
117
  );
118
 
119
  let accumulatedTokens = tokensUsed ?? 0;
@@ -137,6 +137,7 @@ export async function generateQuestionsQuick(
137
 
138
  const regenResult = repairAndParseQuestions(regen.questions, fallbackPassage, {
139
  examType: input.examType,
 
140
  });
141
  validQuestions = [...validQuestions, ...regenResult.valid];
142
  pendingInvalid = regenResult.invalid;
 
9
  type GenerationResult,
10
  } from "./schemas";
11
 
12
+ const MAX_QUICK_REGEN_ATTEMPTS = 1;
13
 
14
  export interface QuickModeCallbacks {
15
  onToken?: (token: string) => void;
 
113
  let { valid: validQuestions, invalid: pendingInvalid, repairLog } = repairAndParseQuestions(
114
  raw.questions,
115
  fallbackPassage,
116
+ { examType: input.examType, strictExplanation: false },
117
  );
118
 
119
  let accumulatedTokens = tokensUsed ?? 0;
 
137
 
138
  const regenResult = repairAndParseQuestions(regen.questions, fallbackPassage, {
139
  examType: input.examType,
140
+ strictExplanation: false,
141
  });
142
  validQuestions = [...validQuestions, ...regenResult.valid];
143
  pendingInvalid = regenResult.invalid;
packages/ai/src/repair.ts CHANGED
@@ -187,10 +187,6 @@ function ensureSkillTags(q: GenericQuestion): string[] {
187
  return ["comprehension"];
188
  }
189
 
190
- function hasCJK(text: string): boolean {
191
- return /[\u4E00-\u9FFF\u3400-\u4DBF\u3040-\u309F\u30A0-\u30FF\uAC00-\uD7AF]/.test(text);
192
- }
193
-
194
  function ensureExplanation(q: GenericQuestion): string {
195
  if (q.explanation && q.explanation.trim().length > 0) {
196
  return q.explanation.trim();
@@ -220,6 +216,7 @@ export function repairQuestion(
220
  raw: unknown,
221
  fullPassage: string,
222
  examType?: string,
 
223
  ): { question: GenericQuestion; wasRepaired: boolean; repairNotes: string[] } {
224
  const notes: string[] = [];
225
  let wasRepaired = false;
@@ -259,7 +256,7 @@ export function repairQuestion(
259
  if (explanationText.trim().length === 0) {
260
  notes.push("explanation missing, used fallback");
261
  wasRepaired = true;
262
- } else if (getExplanationLanguageErrors(explanationText).length > 0) {
263
  notes.push("explanation not in Bahasa Indonesia, marked for regeneration");
264
  wasRepaired = true;
265
  }
@@ -305,7 +302,7 @@ export function tryParseQuestion(generic: GenericQuestion): Question | null {
305
  export function repairAndParseQuestions(
306
  rawQuestions: unknown[],
307
  fullPassage: string,
308
- options?: { examType?: string },
309
  ): {
310
  valid: Question[];
311
  invalid: { index: number; raw: unknown; errors: string[] }[];
@@ -316,11 +313,17 @@ export function repairAndParseQuestions(
316
  const repairLog: string[] = [];
317
 
318
  const examType = options?.examType;
 
319
 
320
  for (let i = 0; i < rawQuestions.length; i++) {
321
  const raw = rawQuestions[i];
322
  try {
323
- const { question: repaired, wasRepaired, repairNotes } = repairQuestion(raw, fullPassage, examType);
 
 
 
 
 
324
  if (wasRepaired) {
325
  repairLog.push(`Q${i + 1}: ${repairNotes.join("; ")}`);
326
  }
@@ -328,7 +331,7 @@ export function repairAndParseQuestions(
328
  const semanticErrors = [
329
  ...getQuestionSemanticErrors(repaired),
330
  ...(examType ? getQuestionLanguageErrors(repaired, examType) : []),
331
- ...getExplanationLanguageErrors(repaired.explanation),
332
  ];
333
  if (semanticErrors.length > 0) {
334
  invalid.push({ index: i, raw, errors: semanticErrors });
 
187
  return ["comprehension"];
188
  }
189
 
 
 
 
 
190
  function ensureExplanation(q: GenericQuestion): string {
191
  if (q.explanation && q.explanation.trim().length > 0) {
192
  return q.explanation.trim();
 
216
  raw: unknown,
217
  fullPassage: string,
218
  examType?: string,
219
+ strictExplanation = true,
220
  ): { question: GenericQuestion; wasRepaired: boolean; repairNotes: string[] } {
221
  const notes: string[] = [];
222
  let wasRepaired = false;
 
256
  if (explanationText.trim().length === 0) {
257
  notes.push("explanation missing, used fallback");
258
  wasRepaired = true;
259
+ } else if (strictExplanation && getExplanationLanguageErrors(explanationText).length > 0) {
260
  notes.push("explanation not in Bahasa Indonesia, marked for regeneration");
261
  wasRepaired = true;
262
  }
 
302
  export function repairAndParseQuestions(
303
  rawQuestions: unknown[],
304
  fullPassage: string,
305
+ options?: { examType?: string; strictExplanation?: boolean },
306
  ): {
307
  valid: Question[];
308
  invalid: { index: number; raw: unknown; errors: string[] }[];
 
313
  const repairLog: string[] = [];
314
 
315
  const examType = options?.examType;
316
+ const strictExplanation = options?.strictExplanation ?? true;
317
 
318
  for (let i = 0; i < rawQuestions.length; i++) {
319
  const raw = rawQuestions[i];
320
  try {
321
+ const { question: repaired, wasRepaired, repairNotes } = repairQuestion(
322
+ raw,
323
+ fullPassage,
324
+ examType,
325
+ strictExplanation,
326
+ );
327
  if (wasRepaired) {
328
  repairLog.push(`Q${i + 1}: ${repairNotes.join("; ")}`);
329
  }
 
331
  const semanticErrors = [
332
  ...getQuestionSemanticErrors(repaired),
333
  ...(examType ? getQuestionLanguageErrors(repaired, examType) : []),
334
+ ...(strictExplanation ? getExplanationLanguageErrors(repaired.explanation) : []),
335
  ];
336
  if (semanticErrors.length > 0) {
337
  invalid.push({ index: i, raw, errors: semanticErrors });
packages/api/src/__tests__/queue.test.ts CHANGED
@@ -80,12 +80,12 @@ describe("splitIntoShards", () => {
80
 
81
  it("splits into multiple shards when count exceeds max", () => {
82
  const result = splitIntoShards([{ section: "READING", count: 20 }]);
83
- expect(result).toHaveLength(4); // ceil(20/6) = 4
84
- expect(result[0]!.count).toBe(6);
85
- expect(result[3]!.count).toBe(2); // last shard has remainder
86
  result.forEach((shard) => {
87
  expect(shard.section).toBe("READING");
88
- expect(shard.shardCount).toBe(4);
89
  });
90
  });
91
 
@@ -94,7 +94,7 @@ describe("splitIntoShards", () => {
94
  { section: "READING", count: 10 },
95
  { section: "LISTENING", count: 10 },
96
  ]);
97
- expect(result).toHaveLength(4); // ceil(10/6) + ceil(10/6) = 2 + 2
98
  expect(result[0]!.section).toBe("READING");
99
  expect(result[0]!.sectionIndex).toBe(0);
100
  expect(result[2]!.section).toBe("LISTENING");
 
80
 
81
  it("splits into multiple shards when count exceeds max", () => {
82
  const result = splitIntoShards([{ section: "READING", count: 20 }]);
83
+ expect(result).toHaveLength(3); // ceil(20/8) = 3
84
+ expect(result[0]!.count).toBe(8);
85
+ expect(result[2]!.count).toBe(4); // last shard has remainder
86
  result.forEach((shard) => {
87
  expect(shard.section).toBe("READING");
88
+ expect(shard.shardCount).toBe(3);
89
  });
90
  });
91
 
 
94
  { section: "READING", count: 10 },
95
  { section: "LISTENING", count: 10 },
96
  ]);
97
+ expect(result).toHaveLength(4); // ceil(10/8) + ceil(10/8) = 2 + 2
98
  expect(result[0]!.section).toBe("READING");
99
  expect(result[0]!.sectionIndex).toBe(0);
100
  expect(result[2]!.section).toBe("LISTENING");
packages/api/src/queue.ts CHANGED
@@ -38,10 +38,11 @@ const FAST_QUEUE_NAME = "generation-fast";
38
  const QUALITY_QUEUE_NAME = "generation-quality";
39
  const CANCEL_POLL_MS = 500;
40
  const HEARTBEAT_MS = 10_000;
41
- const MAX_QUESTIONS_PER_SHARD = 6;
42
  const FAST_SHARD_CONCURRENCY = 3;
43
  const QUALITY_SECTION_CONCURRENCY = 2;
44
- export const MAX_SHARD_RETRIES = 2;
 
45
 
46
  interface SectionSplit {
47
  section: string;
@@ -759,14 +760,14 @@ export const generationWorker = new Worker<FastJobData>(
759
  sharedPassage,
760
  onAgenticProgress,
761
  tokenCounter,
762
- { strategy: "full", maxRegenerateAttempts: 1 },
763
  );
764
  } else {
765
  sectionResult = await generateQuestionsAgentic(
766
  subInput,
767
  onAgenticProgress,
768
  tokenCounter,
769
- { strategy: "full", maxRegenerateAttempts: 1 },
770
  );
771
  }
772
  } else {
@@ -777,6 +778,7 @@ export const generationWorker = new Worker<FastJobData>(
777
  } catch (quickErr: any) {
778
  const quickErrorMessage = quickErr?.message ?? String(quickErr);
779
  const shouldFallbackToAgentic =
 
780
  selectedMode === "quick" &&
781
  (/Failed to parse AI response as JSON/i.test(quickErrorMessage) ||
782
  /Unterminated string/i.test(quickErrorMessage) ||
@@ -790,7 +792,7 @@ export const generationWorker = new Worker<FastJobData>(
790
  { ...subInput, mode: "agentic" },
791
  onAgenticProgress,
792
  tokenCounter,
793
- { strategy: "full", maxRegenerateAttempts: 1 },
794
  );
795
  }
796
 
 
38
  const QUALITY_QUEUE_NAME = "generation-quality";
39
  const CANCEL_POLL_MS = 500;
40
  const HEARTBEAT_MS = 10_000;
41
+ const MAX_QUESTIONS_PER_SHARD = 8;
42
  const FAST_SHARD_CONCURRENCY = 3;
43
  const QUALITY_SECTION_CONCURRENCY = 2;
44
+ const QUICK_PARSE_FALLBACK_TO_AGENTIC = false;
45
+ export const MAX_SHARD_RETRIES = 1;
46
 
47
  interface SectionSplit {
48
  section: string;
 
760
  sharedPassage,
761
  onAgenticProgress,
762
  tokenCounter,
763
+ { strategy: "lean", maxRegenerateAttempts: 1 },
764
  );
765
  } else {
766
  sectionResult = await generateQuestionsAgentic(
767
  subInput,
768
  onAgenticProgress,
769
  tokenCounter,
770
+ { strategy: "lean", maxRegenerateAttempts: 1 },
771
  );
772
  }
773
  } else {
 
778
  } catch (quickErr: any) {
779
  const quickErrorMessage = quickErr?.message ?? String(quickErr);
780
  const shouldFallbackToAgentic =
781
+ QUICK_PARSE_FALLBACK_TO_AGENTIC &&
782
  selectedMode === "quick" &&
783
  (/Failed to parse AI response as JSON/i.test(quickErrorMessage) ||
784
  /Unterminated string/i.test(quickErrorMessage) ||
 
792
  { ...subInput, mode: "agentic" },
793
  onAgenticProgress,
794
  tokenCounter,
795
+ { strategy: "lean", maxRegenerateAttempts: 1 },
796
  );
797
  }
798