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(
|
| 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 =
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 84 |
-
expect(result[0]!.count).toBe(
|
| 85 |
-
expect(result[
|
| 86 |
result.forEach((shard) => {
|
| 87 |
expect(shard.section).toBe("READING");
|
| 88 |
-
expect(shard.shardCount).toBe(
|
| 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/
|
| 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 =
|
| 42 |
const FAST_SHARD_CONCURRENCY = 3;
|
| 43 |
const QUALITY_SECTION_CONCURRENCY = 2;
|
| 44 |
-
|
|
|
|
| 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: "
|
| 763 |
);
|
| 764 |
} else {
|
| 765 |
sectionResult = await generateQuestionsAgentic(
|
| 766 |
subInput,
|
| 767 |
onAgenticProgress,
|
| 768 |
tokenCounter,
|
| 769 |
-
{ strategy: "
|
| 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: "
|
| 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 |
|