feat: refactor AI response handling by introducing parseAiJsonResponse and related utilities to improve JSON parsing and error handling. Streamline response processing in client and pipeline modules, enhancing robustness against HTML error pages and reasoning blocks. Add tests for new parsing functions to ensure reliability.
Browse files- packages/ai/src/__tests__/client.test.ts +91 -18
- packages/ai/src/__tests__/parse-response.test.ts +63 -0
- packages/ai/src/agentic.ts +2 -12
- packages/ai/src/client.ts +39 -15
- packages/ai/src/parse-response.ts +109 -0
- packages/ai/src/pipeline.ts +13 -21
- packages/ai/src/regenerate-questions.ts +2 -10
- packages/api/src/__tests__/generation-outcome.test.ts +45 -1
- packages/api/src/lib/generation-outcome.ts +80 -4
- packages/api/src/queue.ts +4 -0
packages/ai/src/__tests__/client.test.ts
CHANGED
|
@@ -5,6 +5,24 @@ const BASE_URL = "https://api.openai.com/v1";
|
|
| 5 |
const API_KEY = "sk-test-key-12345";
|
| 6 |
const MODEL = "gpt-4";
|
| 7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
function makeMockStream(chunks: string[]): ReadableStream {
|
| 9 |
const encoder = new TextEncoder();
|
| 10 |
return new ReadableStream({
|
|
@@ -17,16 +35,17 @@ function makeMockStream(chunks: string[]): ReadableStream {
|
|
| 17 |
});
|
| 18 |
}
|
| 19 |
|
| 20 |
-
function makeFetchMock(streamChunks: string[], status = 200) {
|
| 21 |
-
return async () =>
|
| 22 |
new Response(makeMockStream(streamChunks), {
|
| 23 |
status,
|
| 24 |
headers: { "content-type": "text/event-stream" },
|
| 25 |
-
})
|
|
|
|
| 26 |
}
|
| 27 |
|
| 28 |
describe("OpenAICompatibleClient", () => {
|
| 29 |
-
let originalFetch:
|
| 30 |
|
| 31 |
beforeAll(() => {
|
| 32 |
originalFetch = globalThis.fetch;
|
|
@@ -87,8 +106,9 @@ describe("OpenAICompatibleClient", () => {
|
|
| 87 |
});
|
| 88 |
|
| 89 |
it("throws on non-OK response", async () => {
|
| 90 |
-
|
| 91 |
-
new Response("Bad Request", { status: 400, headers: { "content-type": "text/plain" } })
|
|
|
|
| 92 |
|
| 93 |
const client = new OpenAICompatibleClient(BASE_URL, API_KEY);
|
| 94 |
expect(
|
|
@@ -100,8 +120,7 @@ describe("OpenAICompatibleClient", () => {
|
|
| 100 |
});
|
| 101 |
|
| 102 |
it("throws on empty response body", async () => {
|
| 103 |
-
|
| 104 |
-
new Response(null, { status: 200 });
|
| 105 |
|
| 106 |
const client = new OpenAICompatibleClient(BASE_URL, API_KEY);
|
| 107 |
expect(
|
|
@@ -134,17 +153,17 @@ describe("OpenAICompatibleClient", () => {
|
|
| 134 |
|
| 135 |
it("retries without response_format on 400 with response_format error", async () => {
|
| 136 |
let callCount = 0;
|
| 137 |
-
|
| 138 |
callCount++;
|
| 139 |
if (callCount === 1) {
|
| 140 |
-
const body =
|
| 141 |
expect(body.response_format).toBeDefined();
|
| 142 |
return new Response("response_format is not supported", {
|
| 143 |
status: 400,
|
| 144 |
headers: { "content-type": "text/plain" },
|
| 145 |
});
|
| 146 |
}
|
| 147 |
-
const body =
|
| 148 |
expect(body.response_format).toBeUndefined();
|
| 149 |
return new Response(makeMockStream([
|
| 150 |
`data: ${JSON.stringify({ choices: [{ delta: { content: "retried" } }] })}\n`,
|
|
@@ -153,7 +172,7 @@ describe("OpenAICompatibleClient", () => {
|
|
| 153 |
status: 200,
|
| 154 |
headers: { "content-type": "text/event-stream" },
|
| 155 |
});
|
| 156 |
-
};
|
| 157 |
|
| 158 |
const client = new OpenAICompatibleClient(BASE_URL, API_KEY);
|
| 159 |
const result = await client.chatCompletion({
|
|
@@ -168,7 +187,7 @@ describe("OpenAICompatibleClient", () => {
|
|
| 168 |
|
| 169 |
it("retries with more tokens on truncated response", async () => {
|
| 170 |
let callCount = 0;
|
| 171 |
-
|
| 172 |
callCount++;
|
| 173 |
const responseContent = callCount === 1
|
| 174 |
? `data: ${JSON.stringify({ choices: [{ delta: { content: '{"incomplete":' } }] })}\n` + "data: [DONE]\n"
|
|
@@ -178,7 +197,7 @@ describe("OpenAICompatibleClient", () => {
|
|
| 178 |
status: 200,
|
| 179 |
headers: { "content-type": "text/event-stream" },
|
| 180 |
});
|
| 181 |
-
};
|
| 182 |
|
| 183 |
const client = new OpenAICompatibleClient(BASE_URL, API_KEY);
|
| 184 |
const result = await client.chatCompletion({
|
|
@@ -191,15 +210,49 @@ describe("OpenAICompatibleClient", () => {
|
|
| 191 |
expect(result.content).toBe('{"complete": true}');
|
| 192 |
});
|
| 193 |
|
| 194 |
-
it("
|
| 195 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
new Response(makeMockStream([
|
| 197 |
-
`data: ${JSON.stringify({ choices: [{ delta: { content: "<html>
|
| 198 |
"data: [DONE]\n",
|
| 199 |
]), {
|
| 200 |
status: 200,
|
| 201 |
headers: { "content-type": "text/event-stream" },
|
| 202 |
-
})
|
|
|
|
| 203 |
|
| 204 |
const client = new OpenAICompatibleClient(BASE_URL, API_KEY);
|
| 205 |
expect(
|
|
@@ -209,4 +262,24 @@ describe("OpenAICompatibleClient", () => {
|
|
| 209 |
}),
|
| 210 |
).rejects.toThrow("HTML");
|
| 211 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
});
|
|
|
|
| 5 |
const API_KEY = "sk-test-key-12345";
|
| 6 |
const MODEL = "gpt-4";
|
| 7 |
|
| 8 |
+
type FetchFn = typeof globalThis.fetch;
|
| 9 |
+
|
| 10 |
+
/** Bun/TS types fetch as an object with static helpers (e.g. preconnect), not just a function. */
|
| 11 |
+
function asFetchMock(fn: (...args: Parameters<FetchFn>) => ReturnType<FetchFn>): FetchFn {
|
| 12 |
+
return fn as FetchFn;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
function setMockFetch(fn: (...args: Parameters<FetchFn>) => ReturnType<FetchFn>): void {
|
| 16 |
+
globalThis.fetch = asFetchMock(fn);
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
function parseRequestBody(opts?: RequestInit): Record<string, unknown> {
|
| 20 |
+
if (opts?.body == null) {
|
| 21 |
+
throw new Error("Expected request body in mock fetch");
|
| 22 |
+
}
|
| 23 |
+
return JSON.parse(String(opts.body));
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
function makeMockStream(chunks: string[]): ReadableStream {
|
| 27 |
const encoder = new TextEncoder();
|
| 28 |
return new ReadableStream({
|
|
|
|
| 35 |
});
|
| 36 |
}
|
| 37 |
|
| 38 |
+
function makeFetchMock(streamChunks: string[], status = 200): FetchFn {
|
| 39 |
+
return asFetchMock(async () =>
|
| 40 |
new Response(makeMockStream(streamChunks), {
|
| 41 |
status,
|
| 42 |
headers: { "content-type": "text/event-stream" },
|
| 43 |
+
}),
|
| 44 |
+
);
|
| 45 |
}
|
| 46 |
|
| 47 |
describe("OpenAICompatibleClient", () => {
|
| 48 |
+
let originalFetch: FetchFn;
|
| 49 |
|
| 50 |
beforeAll(() => {
|
| 51 |
originalFetch = globalThis.fetch;
|
|
|
|
| 106 |
});
|
| 107 |
|
| 108 |
it("throws on non-OK response", async () => {
|
| 109 |
+
setMockFetch(async () =>
|
| 110 |
+
new Response("Bad Request", { status: 400, headers: { "content-type": "text/plain" } }),
|
| 111 |
+
);
|
| 112 |
|
| 113 |
const client = new OpenAICompatibleClient(BASE_URL, API_KEY);
|
| 114 |
expect(
|
|
|
|
| 120 |
});
|
| 121 |
|
| 122 |
it("throws on empty response body", async () => {
|
| 123 |
+
setMockFetch(async () => new Response(null, { status: 200 }));
|
|
|
|
| 124 |
|
| 125 |
const client = new OpenAICompatibleClient(BASE_URL, API_KEY);
|
| 126 |
expect(
|
|
|
|
| 153 |
|
| 154 |
it("retries without response_format on 400 with response_format error", async () => {
|
| 155 |
let callCount = 0;
|
| 156 |
+
setMockFetch(async (_input, opts) => {
|
| 157 |
callCount++;
|
| 158 |
if (callCount === 1) {
|
| 159 |
+
const body = parseRequestBody(opts);
|
| 160 |
expect(body.response_format).toBeDefined();
|
| 161 |
return new Response("response_format is not supported", {
|
| 162 |
status: 400,
|
| 163 |
headers: { "content-type": "text/plain" },
|
| 164 |
});
|
| 165 |
}
|
| 166 |
+
const body = parseRequestBody(opts);
|
| 167 |
expect(body.response_format).toBeUndefined();
|
| 168 |
return new Response(makeMockStream([
|
| 169 |
`data: ${JSON.stringify({ choices: [{ delta: { content: "retried" } }] })}\n`,
|
|
|
|
| 172 |
status: 200,
|
| 173 |
headers: { "content-type": "text/event-stream" },
|
| 174 |
});
|
| 175 |
+
});
|
| 176 |
|
| 177 |
const client = new OpenAICompatibleClient(BASE_URL, API_KEY);
|
| 178 |
const result = await client.chatCompletion({
|
|
|
|
| 187 |
|
| 188 |
it("retries with more tokens on truncated response", async () => {
|
| 189 |
let callCount = 0;
|
| 190 |
+
setMockFetch(async (_input, _opts) => {
|
| 191 |
callCount++;
|
| 192 |
const responseContent = callCount === 1
|
| 193 |
? `data: ${JSON.stringify({ choices: [{ delta: { content: '{"incomplete":' } }] })}\n` + "data: [DONE]\n"
|
|
|
|
| 197 |
status: 200,
|
| 198 |
headers: { "content-type": "text/event-stream" },
|
| 199 |
});
|
| 200 |
+
});
|
| 201 |
|
| 202 |
const client = new OpenAICompatibleClient(BASE_URL, API_KEY);
|
| 203 |
const result = await client.chatCompletion({
|
|
|
|
| 210 |
expect(result.content).toBe('{"complete": true}');
|
| 211 |
});
|
| 212 |
|
| 213 |
+
it("strips reasoning blocks and returns JSON from GLM-style streams", async () => {
|
| 214 |
+
const json = '{"questions":[{"format":"mcq"}]}';
|
| 215 |
+
globalThis.fetch = makeFetchMock([
|
| 216 |
+
`data: ${JSON.stringify({ choices: [{ delta: { content: "<think>\nPlanning questions...\n</think>" } }] })}\n`,
|
| 217 |
+
`data: ${JSON.stringify({ choices: [{ delta: { content: json } }] })}\n`,
|
| 218 |
+
"data: [DONE]\n",
|
| 219 |
+
]);
|
| 220 |
+
|
| 221 |
+
const client = new OpenAICompatibleClient(BASE_URL, API_KEY);
|
| 222 |
+
const result = await client.chatCompletion({
|
| 223 |
+
model: MODEL,
|
| 224 |
+
messages: [{ role: "user", content: "Test" }],
|
| 225 |
+
});
|
| 226 |
+
|
| 227 |
+
expect(result.content).toBe(json);
|
| 228 |
+
});
|
| 229 |
+
|
| 230 |
+
it("ignores reasoning_content delta and keeps final content", async () => {
|
| 231 |
+
globalThis.fetch = makeFetchMock([
|
| 232 |
+
`data: ${JSON.stringify({ choices: [{ delta: { reasoning_content: "internal reasoning only" } }] })}\n`,
|
| 233 |
+
`data: ${JSON.stringify({ choices: [{ delta: { content: '{"ok":true}' } }] })}\n`,
|
| 234 |
+
"data: [DONE]\n",
|
| 235 |
+
]);
|
| 236 |
+
|
| 237 |
+
const client = new OpenAICompatibleClient(BASE_URL, API_KEY);
|
| 238 |
+
const result = await client.chatCompletion({
|
| 239 |
+
model: MODEL,
|
| 240 |
+
messages: [{ role: "user", content: "Test" }],
|
| 241 |
+
});
|
| 242 |
+
|
| 243 |
+
expect(result.content).toBe('{"ok":true}');
|
| 244 |
+
});
|
| 245 |
+
|
| 246 |
+
it("throws on HTML error page in stream", async () => {
|
| 247 |
+
setMockFetch(async () =>
|
| 248 |
new Response(makeMockStream([
|
| 249 |
+
`data: ${JSON.stringify({ choices: [{ delta: { content: "<!DOCTYPE html><html><body>502 Bad Gateway</body></html>" } }] })}\n`,
|
| 250 |
"data: [DONE]\n",
|
| 251 |
]), {
|
| 252 |
status: 200,
|
| 253 |
headers: { "content-type": "text/event-stream" },
|
| 254 |
+
}),
|
| 255 |
+
);
|
| 256 |
|
| 257 |
const client = new OpenAICompatibleClient(BASE_URL, API_KEY);
|
| 258 |
expect(
|
|
|
|
| 262 |
}),
|
| 263 |
).rejects.toThrow("HTML");
|
| 264 |
});
|
| 265 |
+
|
| 266 |
+
it("falls back to non-stream JSON body when SSE yields no tokens", async () => {
|
| 267 |
+
const body = JSON.stringify({
|
| 268 |
+
choices: [{ message: { content: '{"questions":[]}' } }],
|
| 269 |
+
});
|
| 270 |
+
setMockFetch(async () =>
|
| 271 |
+
new Response(body, {
|
| 272 |
+
status: 200,
|
| 273 |
+
headers: { "content-type": "application/json" },
|
| 274 |
+
}),
|
| 275 |
+
);
|
| 276 |
+
|
| 277 |
+
const client = new OpenAICompatibleClient(BASE_URL, API_KEY);
|
| 278 |
+
const result = await client.chatCompletion({
|
| 279 |
+
model: MODEL,
|
| 280 |
+
messages: [{ role: "user", content: "Test" }],
|
| 281 |
+
});
|
| 282 |
+
|
| 283 |
+
expect(result.content).toBe('{"questions":[]}');
|
| 284 |
+
});
|
| 285 |
});
|
packages/ai/src/__tests__/parse-response.test.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it } from "bun:test";
|
| 2 |
+
import {
|
| 3 |
+
extractContentFromCompletionBody,
|
| 4 |
+
parseAiJsonResponse,
|
| 5 |
+
stripReasoningBlocks,
|
| 6 |
+
} from "../parse-response";
|
| 7 |
+
|
| 8 |
+
describe("stripReasoningBlocks", () => {
|
| 9 |
+
it("removes closed redacted_thinking blocks", () => {
|
| 10 |
+
const input = "<think>plan</think>\n{\"ok\":true}";
|
| 11 |
+
expect(stripReasoningBlocks(input)).toBe('{"ok":true}');
|
| 12 |
+
});
|
| 13 |
+
|
| 14 |
+
it("removes think blocks", () => {
|
| 15 |
+
const input = ["<", "think", ">planning</", "think", ">\n", '{"ok":true}'].join("");
|
| 16 |
+
expect(stripReasoningBlocks(input)).toBe('{"ok":true}');
|
| 17 |
+
});
|
| 18 |
+
|
| 19 |
+
it("removes unclosed thinking at start", () => {
|
| 20 |
+
const input = "<think>still streaming...";
|
| 21 |
+
expect(stripReasoningBlocks(input)).toBe("");
|
| 22 |
+
});
|
| 23 |
+
|
| 24 |
+
it("does not treat XML-like tags in JSON as reasoning", () => {
|
| 25 |
+
const input = '{"questionText":"<b>highlight</b>"}';
|
| 26 |
+
expect(stripReasoningBlocks(input)).toBe(input);
|
| 27 |
+
});
|
| 28 |
+
});
|
| 29 |
+
|
| 30 |
+
describe("parseAiJsonResponse", () => {
|
| 31 |
+
it("parses JSON after reasoning preamble", () => {
|
| 32 |
+
const parsed = parseAiJsonResponse(
|
| 33 |
+
"<think>planning</think>\n```json\n{\"questions\":[]}\n```",
|
| 34 |
+
);
|
| 35 |
+
expect(parsed).toEqual({ questions: [] });
|
| 36 |
+
});
|
| 37 |
+
|
| 38 |
+
it("extracts JSON object embedded in prose", () => {
|
| 39 |
+
const parsed = parseAiJsonResponse(
|
| 40 |
+
"Here is the result:\n{\"questions\":[{\"format\":\"multiple_choice\"}]}\nThanks!",
|
| 41 |
+
);
|
| 42 |
+
expect(parsed).toEqual({ questions: [{ format: "multiple_choice" }] });
|
| 43 |
+
});
|
| 44 |
+
|
| 45 |
+
it("throws on empty content after stripping", () => {
|
| 46 |
+
expect(() => parseAiJsonResponse("<think>only thinking")).toThrow(
|
| 47 |
+
"Empty response from AI",
|
| 48 |
+
);
|
| 49 |
+
});
|
| 50 |
+
});
|
| 51 |
+
|
| 52 |
+
describe("extractContentFromCompletionBody", () => {
|
| 53 |
+
it("reads non-stream chat completion JSON", () => {
|
| 54 |
+
const body = JSON.stringify({
|
| 55 |
+
choices: [{ message: { content: '{"questions":[]}' } }],
|
| 56 |
+
});
|
| 57 |
+
expect(extractContentFromCompletionBody(body)).toBe('{"questions":[]}');
|
| 58 |
+
});
|
| 59 |
+
|
| 60 |
+
it("returns null for non-JSON bodies", () => {
|
| 61 |
+
expect(extractContentFromCompletionBody("<html>error</html>")).toBeNull();
|
| 62 |
+
});
|
| 63 |
+
});
|
packages/ai/src/agentic.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import { OpenAICompatibleClient } from "./client";
|
| 2 |
import { GenerationError } from "./errors";
|
|
|
|
| 3 |
import {
|
| 4 |
getPassageJsonSchemaDescription,
|
| 5 |
getValidationJsonSchemaDescription,
|
|
@@ -68,18 +69,7 @@ function calculateMaxTokens(
|
|
| 68 |
}
|
| 69 |
|
| 70 |
function parseJsonResponse(content: string): unknown {
|
| 71 |
-
|
| 72 |
-
let parsed: unknown;
|
| 73 |
-
try {
|
| 74 |
-
parsed = JSON.parse(content);
|
| 75 |
-
} catch {
|
| 76 |
-
const cleaned = content
|
| 77 |
-
.replace(/^```json\s*/, "")
|
| 78 |
-
.replace(/```\s*$/, "")
|
| 79 |
-
.trim();
|
| 80 |
-
parsed = JSON.parse(cleaned);
|
| 81 |
-
}
|
| 82 |
-
return parsed;
|
| 83 |
}
|
| 84 |
|
| 85 |
async function step1GeneratePassage(
|
|
|
|
| 1 |
import { OpenAICompatibleClient } from "./client";
|
| 2 |
import { GenerationError } from "./errors";
|
| 3 |
+
import { parseAiJsonResponse } from "./parse-response";
|
| 4 |
import {
|
| 5 |
getPassageJsonSchemaDescription,
|
| 6 |
getValidationJsonSchemaDescription,
|
|
|
|
| 69 |
}
|
| 70 |
|
| 71 |
function parseJsonResponse(content: string): unknown {
|
| 72 |
+
return parseAiJsonResponse(content);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
}
|
| 74 |
|
| 75 |
async function step1GeneratePassage(
|
packages/ai/src/client.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
export interface ChatMessage {
|
| 2 |
role: "system" | "user" | "assistant";
|
| 3 |
content: string;
|
|
@@ -42,7 +48,8 @@ function parseSSELine(line: string): { content?: string; usage?: ChatCompletionR
|
|
| 42 |
if (data === "[DONE]") return null;
|
| 43 |
try {
|
| 44 |
const chunk = JSON.parse(data);
|
| 45 |
-
const
|
|
|
|
| 46 |
const usage = chunk.usage;
|
| 47 |
return { content: typeof content === "string" ? content : undefined, usage };
|
| 48 |
} catch {
|
|
@@ -53,9 +60,10 @@ function parseSSELine(line: string): { content?: string; usage?: ChatCompletionR
|
|
| 53 |
async function readSSEStream(
|
| 54 |
reader: any,
|
| 55 |
callbacks: StreamCallbacks,
|
| 56 |
-
): Promise<{ content: string; usage?: ChatCompletionResult["usage"] }> {
|
| 57 |
const decoder = new TextDecoder();
|
| 58 |
let buffer = "";
|
|
|
|
| 59 |
let fullContent = "";
|
| 60 |
let lastUsage: ChatCompletionResult["usage"] | undefined;
|
| 61 |
|
|
@@ -63,12 +71,16 @@ async function readSSEStream(
|
|
| 63 |
const { done, value } = await reader.read();
|
| 64 |
if (done) break;
|
| 65 |
|
| 66 |
-
|
|
|
|
|
|
|
| 67 |
const lines = buffer.split("\n");
|
| 68 |
buffer = lines.pop() ?? "";
|
| 69 |
|
| 70 |
for (const line of lines) {
|
| 71 |
-
const
|
|
|
|
|
|
|
| 72 |
if (!parsed) continue;
|
| 73 |
if (parsed.content) {
|
| 74 |
fullContent += parsed.content;
|
|
@@ -94,7 +106,15 @@ async function readSSEStream(
|
|
| 94 |
}
|
| 95 |
}
|
| 96 |
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
}
|
| 99 |
|
| 100 |
function looksTruncated(content: string): boolean {
|
|
@@ -224,7 +244,7 @@ export class OpenAICompatibleClient {
|
|
| 224 |
status: res.status,
|
| 225 |
statusText: res.statusText,
|
| 226 |
preview,
|
| 227 |
-
isHtml:
|
| 228 |
});
|
| 229 |
|
| 230 |
// Retry without response_format if provider doesn't support it
|
|
@@ -237,10 +257,10 @@ export class OpenAICompatibleClient {
|
|
| 237 |
});
|
| 238 |
}
|
| 239 |
|
| 240 |
-
if (
|
| 241 |
throw new Error(
|
| 242 |
-
`
|
| 243 |
-
`
|
| 244 |
`Preview: ${preview.slice(0, 200)}`,
|
| 245 |
);
|
| 246 |
}
|
|
@@ -253,20 +273,24 @@ export class OpenAICompatibleClient {
|
|
| 253 |
}
|
| 254 |
|
| 255 |
const reader = res.body.getReader() as any;
|
| 256 |
-
const
|
| 257 |
reader,
|
| 258 |
callbacks ?? { onToken: () => {} },
|
| 259 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
|
| 261 |
-
// Defense:
|
| 262 |
-
if (result.content
|
| 263 |
const preview = result.content.slice(0, 500);
|
| 264 |
-
log("error", "Stream returned HTML instead of
|
| 265 |
preview: preview.slice(0, 200),
|
| 266 |
});
|
| 267 |
throw new Error(
|
| 268 |
-
`
|
| 269 |
-
`
|
| 270 |
`Preview: ${preview.slice(0, 200)}`,
|
| 271 |
);
|
| 272 |
}
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
extractContentFromCompletionBody,
|
| 3 |
+
looksLikeHtmlErrorPage,
|
| 4 |
+
stripReasoningBlocks,
|
| 5 |
+
} from "./parse-response";
|
| 6 |
+
|
| 7 |
export interface ChatMessage {
|
| 8 |
role: "system" | "user" | "assistant";
|
| 9 |
content: string;
|
|
|
|
| 48 |
if (data === "[DONE]") return null;
|
| 49 |
try {
|
| 50 |
const chunk = JSON.parse(data);
|
| 51 |
+
const choice = chunk.choices?.[0];
|
| 52 |
+
const content = choice?.delta?.content ?? choice?.message?.content;
|
| 53 |
const usage = chunk.usage;
|
| 54 |
return { content: typeof content === "string" ? content : undefined, usage };
|
| 55 |
} catch {
|
|
|
|
| 60 |
async function readSSEStream(
|
| 61 |
reader: any,
|
| 62 |
callbacks: StreamCallbacks,
|
| 63 |
+
): Promise<{ content: string; usage?: ChatCompletionResult["usage"]; rawBody: string }> {
|
| 64 |
const decoder = new TextDecoder();
|
| 65 |
let buffer = "";
|
| 66 |
+
let rawBody = "";
|
| 67 |
let fullContent = "";
|
| 68 |
let lastUsage: ChatCompletionResult["usage"] | undefined;
|
| 69 |
|
|
|
|
| 71 |
const { done, value } = await reader.read();
|
| 72 |
if (done) break;
|
| 73 |
|
| 74 |
+
const chunkText = decoder.decode(value, { stream: true });
|
| 75 |
+
rawBody += chunkText;
|
| 76 |
+
buffer += chunkText;
|
| 77 |
const lines = buffer.split("\n");
|
| 78 |
buffer = lines.pop() ?? "";
|
| 79 |
|
| 80 |
for (const line of lines) {
|
| 81 |
+
const trimmedLine = line.trim();
|
| 82 |
+
if (!trimmedLine || trimmedLine.startsWith(":")) continue;
|
| 83 |
+
const parsed = parseSSELine(trimmedLine);
|
| 84 |
if (!parsed) continue;
|
| 85 |
if (parsed.content) {
|
| 86 |
fullContent += parsed.content;
|
|
|
|
| 106 |
}
|
| 107 |
}
|
| 108 |
|
| 109 |
+
if (!fullContent) {
|
| 110 |
+
const nonStreamContent = extractContentFromCompletionBody(rawBody);
|
| 111 |
+
if (nonStreamContent) {
|
| 112 |
+
fullContent = nonStreamContent;
|
| 113 |
+
callbacks.onToken(nonStreamContent);
|
| 114 |
+
}
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
return { content: fullContent, usage: lastUsage, rawBody };
|
| 118 |
}
|
| 119 |
|
| 120 |
function looksTruncated(content: string): boolean {
|
|
|
|
| 244 |
status: res.status,
|
| 245 |
statusText: res.statusText,
|
| 246 |
preview,
|
| 247 |
+
isHtml: looksLikeHtmlErrorPage(preview),
|
| 248 |
});
|
| 249 |
|
| 250 |
// Retry without response_format if provider doesn't support it
|
|
|
|
| 257 |
});
|
| 258 |
}
|
| 259 |
|
| 260 |
+
if (looksLikeHtmlErrorPage(preview)) {
|
| 261 |
throw new Error(
|
| 262 |
+
`Upstream returned an HTML error page (status ${res.status}), not AI JSON. ` +
|
| 263 |
+
`Check base URL, proxy, or gateway configuration. ` +
|
| 264 |
`Preview: ${preview.slice(0, 200)}`,
|
| 265 |
);
|
| 266 |
}
|
|
|
|
| 273 |
}
|
| 274 |
|
| 275 |
const reader = res.body.getReader() as any;
|
| 276 |
+
const streamResult = await readSSEStream(
|
| 277 |
reader,
|
| 278 |
callbacks ?? { onToken: () => {} },
|
| 279 |
);
|
| 280 |
+
const result = {
|
| 281 |
+
...streamResult,
|
| 282 |
+
content: stripReasoningBlocks(streamResult.content),
|
| 283 |
+
};
|
| 284 |
|
| 285 |
+
// Defense: reject actual HTML error pages, not model thinking tags like <think>
|
| 286 |
+
if (looksLikeHtmlErrorPage(result.content)) {
|
| 287 |
const preview = result.content.slice(0, 500);
|
| 288 |
+
log("error", "Stream returned HTML error page instead of AI content", {
|
| 289 |
preview: preview.slice(0, 200),
|
| 290 |
});
|
| 291 |
throw new Error(
|
| 292 |
+
`Upstream returned an HTML error page in the stream, not AI JSON. ` +
|
| 293 |
+
`Check base URL, proxy, or gateway configuration. ` +
|
| 294 |
`Preview: ${preview.slice(0, 200)}`,
|
| 295 |
);
|
| 296 |
}
|
packages/ai/src/parse-response.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Normalize model output before JSON parsing.
|
| 3 |
+
* Reasoning models (GLM, DeepSeek, etc.) often wrap chain-of-thought in tags
|
| 4 |
+
* that are NOT HTML error pages.
|
| 5 |
+
*/
|
| 6 |
+
|
| 7 |
+
const THINK_TAG = "think";
|
| 8 |
+
|
| 9 |
+
const REASONING_BLOCK_PATTERNS: RegExp[] = [
|
| 10 |
+
/<think>[\s\S]*?<\/redacted_thinking>/gi,
|
| 11 |
+
/<thinking>[\s\S]*?<\/thinking>/gi,
|
| 12 |
+
new RegExp(`<${THINK_TAG}>[\\s\\S]*?</${THINK_TAG}>`, "gi"),
|
| 13 |
+
];
|
| 14 |
+
|
| 15 |
+
const UNCLOSED_REASONING_PREFIXES = [
|
| 16 |
+
/^<think>[\s\S]*/i,
|
| 17 |
+
/^<thinking>[\s\S]*/i,
|
| 18 |
+
new RegExp(`^<${THINK_TAG}>[\\s\\S]*`, "i"),
|
| 19 |
+
];
|
| 20 |
+
|
| 21 |
+
export function stripReasoningBlocks(content: string): string {
|
| 22 |
+
let text = content;
|
| 23 |
+
for (const pattern of REASONING_BLOCK_PATTERNS) {
|
| 24 |
+
text = text.replace(pattern, "");
|
| 25 |
+
}
|
| 26 |
+
for (const pattern of UNCLOSED_REASONING_PREFIXES) {
|
| 27 |
+
text = text.replace(pattern, "");
|
| 28 |
+
}
|
| 29 |
+
return text.trim();
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
function extractJsonCandidate(content: string): string {
|
| 33 |
+
const trimmed = content.trim();
|
| 34 |
+
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
| 35 |
+
if (fenced?.[1]) return fenced[1].trim();
|
| 36 |
+
|
| 37 |
+
const objIdx = trimmed.indexOf("{");
|
| 38 |
+
const arrIdx = trimmed.indexOf("[");
|
| 39 |
+
let start = -1;
|
| 40 |
+
if (objIdx === -1) start = arrIdx;
|
| 41 |
+
else if (arrIdx === -1) start = objIdx;
|
| 42 |
+
else start = Math.min(objIdx, arrIdx);
|
| 43 |
+
|
| 44 |
+
if (start < 0) return trimmed;
|
| 45 |
+
|
| 46 |
+
const slice = trimmed.slice(start);
|
| 47 |
+
const lastBrace = slice.lastIndexOf("}");
|
| 48 |
+
const lastBracket = slice.lastIndexOf("]");
|
| 49 |
+
const end = Math.max(lastBrace, lastBracket);
|
| 50 |
+
if (end === -1) return slice;
|
| 51 |
+
return slice.slice(0, end + 1);
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
export function parseAiJsonResponse(content: string): unknown {
|
| 55 |
+
const normalized = stripReasoningBlocks(content);
|
| 56 |
+
if (!normalized) throw new Error("Empty response from AI");
|
| 57 |
+
|
| 58 |
+
const candidates = [normalized, extractJsonCandidate(normalized)];
|
| 59 |
+
const uniqueCandidates = [...new Set(candidates.filter(Boolean))];
|
| 60 |
+
|
| 61 |
+
let lastError: Error | undefined;
|
| 62 |
+
for (const candidate of uniqueCandidates) {
|
| 63 |
+
try {
|
| 64 |
+
return JSON.parse(candidate);
|
| 65 |
+
} catch (err: unknown) {
|
| 66 |
+
lastError = err instanceof Error ? err : new Error(String(err));
|
| 67 |
+
const markdownCleaned = candidate
|
| 68 |
+
.replace(/^```json\s*/i, "")
|
| 69 |
+
.replace(/```\s*$/, "")
|
| 70 |
+
.trim();
|
| 71 |
+
if (markdownCleaned !== candidate) {
|
| 72 |
+
try {
|
| 73 |
+
return JSON.parse(markdownCleaned);
|
| 74 |
+
} catch (inner: unknown) {
|
| 75 |
+
lastError = inner instanceof Error ? inner : new Error(String(inner));
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
throw new Error(
|
| 82 |
+
`Failed to parse AI response as JSON: ${lastError?.message ?? "unknown error"}. Preview: ${normalized.slice(0, 200)}`,
|
| 83 |
+
);
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
export function extractContentFromCompletionBody(body: string): string | null {
|
| 87 |
+
const trimmed = body.trim();
|
| 88 |
+
if (!trimmed.startsWith("{")) return null;
|
| 89 |
+
try {
|
| 90 |
+
const json = JSON.parse(trimmed) as {
|
| 91 |
+
choices?: Array<{ message?: { content?: string } }>;
|
| 92 |
+
};
|
| 93 |
+
const content = json.choices?.[0]?.message?.content;
|
| 94 |
+
return typeof content === "string" ? content : null;
|
| 95 |
+
} catch {
|
| 96 |
+
return null;
|
| 97 |
+
}
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
export function looksLikeHtmlErrorPage(content: string): boolean {
|
| 101 |
+
const head = content.trim().slice(0, 300).toLowerCase();
|
| 102 |
+
return (
|
| 103 |
+
head.startsWith("<!doctype") ||
|
| 104 |
+
head.startsWith("<html") ||
|
| 105 |
+
head.startsWith("<head") ||
|
| 106 |
+
head.startsWith("<body") ||
|
| 107 |
+
/^<\?(xml|php)/.test(head)
|
| 108 |
+
);
|
| 109 |
+
}
|
packages/ai/src/pipeline.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import { OpenAICompatibleClient } from "./client";
|
| 2 |
import { GenerationError } from "./errors";
|
|
|
|
| 3 |
import { buildQuickModePrompt } from "./prompts";
|
| 4 |
import { repairAndParseQuestions } from "./repair";
|
| 5 |
import { regenerateQuestions, buildRegenerationContext } from "./regenerate-questions";
|
|
@@ -76,28 +77,19 @@ export async function generateQuestionsQuick(
|
|
| 76 |
|
| 77 |
let parsed: unknown;
|
| 78 |
try {
|
| 79 |
-
parsed =
|
| 80 |
-
log("info", "JSON parsed successfully
|
| 81 |
} catch (parseErr: any) {
|
| 82 |
-
log("
|
| 83 |
-
|
| 84 |
-
.
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
error: stripErr.message,
|
| 93 |
-
cleanedPreview: cleaned.slice(0, 500),
|
| 94 |
-
originalPreview: content.slice(0, 500),
|
| 95 |
-
});
|
| 96 |
-
throw new GenerationError(
|
| 97 |
-
`Failed to parse AI response as JSON: ${stripErr.message}. Preview: ${content.slice(0, 200)}`,
|
| 98 |
-
{ tokensUsed },
|
| 99 |
-
);
|
| 100 |
-
}
|
| 101 |
}
|
| 102 |
|
| 103 |
if (!parsed || typeof parsed !== "object") {
|
|
|
|
| 1 |
import { OpenAICompatibleClient } from "./client";
|
| 2 |
import { GenerationError } from "./errors";
|
| 3 |
+
import { parseAiJsonResponse } from "./parse-response";
|
| 4 |
import { buildQuickModePrompt } from "./prompts";
|
| 5 |
import { repairAndParseQuestions } from "./repair";
|
| 6 |
import { regenerateQuestions, buildRegenerationContext } from "./regenerate-questions";
|
|
|
|
| 77 |
|
| 78 |
let parsed: unknown;
|
| 79 |
try {
|
| 80 |
+
parsed = parseAiJsonResponse(content);
|
| 81 |
+
log("info", "JSON parsed successfully");
|
| 82 |
} catch (parseErr: any) {
|
| 83 |
+
log("error", "JSON parse failed", {
|
| 84 |
+
error: parseErr.message,
|
| 85 |
+
originalPreview: content.slice(0, 500),
|
| 86 |
+
});
|
| 87 |
+
throw new GenerationError(
|
| 88 |
+
parseErr.message.includes("Failed to parse AI response")
|
| 89 |
+
? parseErr.message
|
| 90 |
+
: `Failed to parse AI response as JSON: ${parseErr.message}. Preview: ${content.slice(0, 200)}`,
|
| 91 |
+
{ tokensUsed },
|
| 92 |
+
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
}
|
| 94 |
|
| 95 |
if (!parsed || typeof parsed !== "object") {
|
packages/ai/src/regenerate-questions.ts
CHANGED
|
@@ -1,20 +1,12 @@
|
|
| 1 |
import { OpenAICompatibleClient } from "./client";
|
|
|
|
| 2 |
import { getGenericQuestionJsonSchemaDescription } from "./repair";
|
| 3 |
import { OPTION_QUALITY_RULES } from "./prompts";
|
| 4 |
import { buildContentLanguageRules, buildExplanationLanguageRule } from "./language-rules";
|
| 5 |
import type { GenerationInput } from "./schemas";
|
| 6 |
|
| 7 |
function parseJsonResponse(content: string): unknown {
|
| 8 |
-
|
| 9 |
-
try {
|
| 10 |
-
return JSON.parse(content);
|
| 11 |
-
} catch {
|
| 12 |
-
const cleaned = content
|
| 13 |
-
.replace(/^```json\s*/, "")
|
| 14 |
-
.replace(/```\s*$/, "")
|
| 15 |
-
.trim();
|
| 16 |
-
return JSON.parse(cleaned);
|
| 17 |
-
}
|
| 18 |
}
|
| 19 |
|
| 20 |
function calculateRegenerateMaxTokens(userMax: number, count: number): number {
|
|
|
|
| 1 |
import { OpenAICompatibleClient } from "./client";
|
| 2 |
+
import { parseAiJsonResponse } from "./parse-response";
|
| 3 |
import { getGenericQuestionJsonSchemaDescription } from "./repair";
|
| 4 |
import { OPTION_QUALITY_RULES } from "./prompts";
|
| 5 |
import { buildContentLanguageRules, buildExplanationLanguageRule } from "./language-rules";
|
| 6 |
import type { GenerationInput } from "./schemas";
|
| 7 |
|
| 8 |
function parseJsonResponse(content: string): unknown {
|
| 9 |
+
return parseAiJsonResponse(content);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
}
|
| 11 |
|
| 12 |
function calculateRegenerateMaxTokens(userMax: number, count: number): number {
|
packages/api/src/__tests__/generation-outcome.test.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
| 1 |
import { describe, expect, it } from "bun:test";
|
| 2 |
import {
|
|
|
|
|
|
|
| 3 |
isNonRetryableProviderError,
|
| 4 |
resolveGenerationJobOutcome,
|
| 5 |
sectionsWithNoQuestions,
|
|
@@ -23,6 +25,14 @@ describe("isNonRetryableProviderError", () => {
|
|
| 23 |
it("returns false for JSON parse failures", () => {
|
| 24 |
expect(isNonRetryableProviderError(new Error("Failed to parse AI response as JSON"))).toBe(false);
|
| 25 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
});
|
| 27 |
|
| 28 |
describe("resolveGenerationJobOutcome", () => {
|
|
@@ -47,7 +57,7 @@ describe("resolveGenerationJobOutcome", () => {
|
|
| 47 |
expect(outcome.errorMessage).toContain("WRITING");
|
| 48 |
});
|
| 49 |
|
| 50 |
-
it("marks zero questions as failed", () => {
|
| 51 |
const outcome = resolveGenerationJobOutcome({
|
| 52 |
requestedCount: 20,
|
| 53 |
generatedCount: 0,
|
|
@@ -55,6 +65,40 @@ describe("resolveGenerationJobOutcome", () => {
|
|
| 55 |
});
|
| 56 |
expect(outcome.status).toBe("failed");
|
| 57 |
expect(outcome.errorMessage).toContain("Tidak ada soal");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
});
|
| 59 |
});
|
| 60 |
|
|
|
|
| 1 |
import { describe, expect, it } from "bun:test";
|
| 2 |
import {
|
| 3 |
+
collectShardFailureCause,
|
| 4 |
+
formatGenerationErrorMessage,
|
| 5 |
isNonRetryableProviderError,
|
| 6 |
resolveGenerationJobOutcome,
|
| 7 |
sectionsWithNoQuestions,
|
|
|
|
| 25 |
it("returns false for JSON parse failures", () => {
|
| 26 |
expect(isNonRetryableProviderError(new Error("Failed to parse AI response as JSON"))).toBe(false);
|
| 27 |
});
|
| 28 |
+
|
| 29 |
+
it("detects upstream HTML error pages", () => {
|
| 30 |
+
expect(
|
| 31 |
+
isNonRetryableProviderError(
|
| 32 |
+
new Error("Upstream returned an HTML error page in the stream, not AI JSON."),
|
| 33 |
+
),
|
| 34 |
+
).toBe(true);
|
| 35 |
+
});
|
| 36 |
});
|
| 37 |
|
| 38 |
describe("resolveGenerationJobOutcome", () => {
|
|
|
|
| 57 |
expect(outcome.errorMessage).toContain("WRITING");
|
| 58 |
});
|
| 59 |
|
| 60 |
+
it("marks zero questions as failed without blaming provider when cause is unknown", () => {
|
| 61 |
const outcome = resolveGenerationJobOutcome({
|
| 62 |
requestedCount: 20,
|
| 63 |
generatedCount: 0,
|
|
|
|
| 65 |
});
|
| 66 |
expect(outcome.status).toBe("failed");
|
| 67 |
expect(outcome.errorMessage).toContain("Tidak ada soal");
|
| 68 |
+
expect(outcome.errorMessage).not.toContain("Periksa model/API provider");
|
| 69 |
+
});
|
| 70 |
+
|
| 71 |
+
it("surfaces JSON parse cause instead of generic provider hint", () => {
|
| 72 |
+
const outcome = resolveGenerationJobOutcome({
|
| 73 |
+
requestedCount: 5,
|
| 74 |
+
generatedCount: 0,
|
| 75 |
+
failedSections: ["READING"],
|
| 76 |
+
cause: "Failed to parse AI response as JSON: Unexpected token. Preview: {broken",
|
| 77 |
+
});
|
| 78 |
+
expect(outcome.errorMessage).toContain("tidak valid JSON");
|
| 79 |
+
expect(outcome.errorMessage).toContain("Failed to parse AI response");
|
| 80 |
+
expect(outcome.errorMessage).not.toContain("Periksa model/API provider");
|
| 81 |
+
});
|
| 82 |
+
});
|
| 83 |
+
|
| 84 |
+
describe("formatGenerationErrorMessage", () => {
|
| 85 |
+
it("labels upstream provider failures clearly", () => {
|
| 86 |
+
const message = formatGenerationErrorMessage({
|
| 87 |
+
cause: "OpenAI-compatible API error 500: upstream timeout",
|
| 88 |
+
failedSections: ["READING"],
|
| 89 |
+
});
|
| 90 |
+
expect(message).toContain("Gagal menghubungi API provider");
|
| 91 |
+
expect(message).toContain("500");
|
| 92 |
+
});
|
| 93 |
+
|
| 94 |
+
it("detects legacy false-positive HTML guard on reasoning output", () => {
|
| 95 |
+
const message = formatGenerationErrorMessage({
|
| 96 |
+
cause:
|
| 97 |
+
"Provider returned HTML in stream instead of JSON. Preview: <think>planning",
|
| 98 |
+
failedSections: ["READING"],
|
| 99 |
+
});
|
| 100 |
+
expect(message).toContain("reasoning tidak kompatibel");
|
| 101 |
+
expect(message).not.toContain("Gagal menghubungi API provider");
|
| 102 |
});
|
| 103 |
});
|
| 104 |
|
packages/api/src/lib/generation-outcome.ts
CHANGED
|
@@ -6,6 +6,7 @@ export function isNonRetryableProviderError(error: unknown): boolean {
|
|
| 6 |
if (/api error 5\d\d/i.test(message)) return true;
|
| 7 |
if (lower.includes("midstreamfallbackerror")) return true;
|
| 8 |
if (lower.includes("apiconnectionerror")) return true;
|
|
|
|
| 9 |
if (lower.includes("provider returned html")) return true;
|
| 10 |
if (/\berror 502\b/.test(lower) || /\berror 503\b/.test(lower) || /\berror 504\b/.test(lower)) {
|
| 11 |
return true;
|
|
@@ -14,32 +15,97 @@ export function isNonRetryableProviderError(error: unknown): boolean {
|
|
| 14 |
return false;
|
| 15 |
}
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
export function resolveGenerationJobOutcome(input: {
|
| 18 |
requestedCount: number;
|
| 19 |
generatedCount: number;
|
| 20 |
failedSections?: string[];
|
|
|
|
| 21 |
}): {
|
| 22 |
status: "completed" | "completed_partial" | "failed";
|
| 23 |
progressMessage: string;
|
| 24 |
errorMessage: string | null;
|
| 25 |
} {
|
| 26 |
const failedSections = [...new Set(input.failedSections ?? [])];
|
| 27 |
-
const sectionHint =
|
| 28 |
-
failedSections.length > 0 ? ` Section gagal: ${failedSections.join(", ")}.` : "";
|
| 29 |
|
| 30 |
if (input.generatedCount <= 0) {
|
| 31 |
return {
|
| 32 |
status: "failed",
|
| 33 |
progressMessage: "Generation failed",
|
| 34 |
-
errorMessage:
|
|
|
|
|
|
|
|
|
|
| 35 |
};
|
| 36 |
}
|
| 37 |
|
| 38 |
if (input.generatedCount < input.requestedCount) {
|
|
|
|
|
|
|
| 39 |
return {
|
| 40 |
status: "completed_partial",
|
| 41 |
progressMessage: `Selesai sebagian: ${input.generatedCount}/${input.requestedCount} soal`,
|
| 42 |
-
errorMessage: `Hanya ${input.generatedCount} dari ${input.requestedCount} soal berhasil dibuat.${
|
| 43 |
};
|
| 44 |
}
|
| 45 |
|
|
@@ -58,3 +124,13 @@ export function sectionsWithNoQuestions(
|
|
| 58 |
.filter((split) => !questions.some((q) => q.section === split.section))
|
| 59 |
.map((split) => split.section);
|
| 60 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
if (/api error 5\d\d/i.test(message)) return true;
|
| 7 |
if (lower.includes("midstreamfallbackerror")) return true;
|
| 8 |
if (lower.includes("apiconnectionerror")) return true;
|
| 9 |
+
if (lower.includes("upstream returned an html error page")) return true;
|
| 10 |
if (lower.includes("provider returned html")) return true;
|
| 11 |
if (/\berror 502\b/.test(lower) || /\berror 503\b/.test(lower) || /\berror 504\b/.test(lower)) {
|
| 12 |
return true;
|
|
|
|
| 15 |
return false;
|
| 16 |
}
|
| 17 |
|
| 18 |
+
function truncate(text: string, maxLen: number): string {
|
| 19 |
+
return text.length <= maxLen ? text : `${text.slice(0, maxLen)}…`;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
function sectionHint(failedSections: string[]): string {
|
| 23 |
+
return failedSections.length > 0 ? ` Section gagal: ${failedSections.join(", ")}.` : "";
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
/** Turn raw pipeline/client errors into user-facing messages that name the real cause. */
|
| 27 |
+
export function formatGenerationErrorMessage(input: {
|
| 28 |
+
cause?: string;
|
| 29 |
+
failedSections?: string[];
|
| 30 |
+
}): string {
|
| 31 |
+
const failedSections = [...new Set(input.failedSections ?? [])];
|
| 32 |
+
const hint = sectionHint(failedSections);
|
| 33 |
+
const cause = input.cause?.trim();
|
| 34 |
+
|
| 35 |
+
if (!cause) {
|
| 36 |
+
return `Tidak ada soal yang berhasil dibuat.${hint}`;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
const lower = cause.toLowerCase();
|
| 40 |
+
|
| 41 |
+
if (
|
| 42 |
+
lower.includes("redacted_thinking") ||
|
| 43 |
+
lower.includes("<thinking>") ||
|
| 44 |
+
/<\/?think>/.test(lower)
|
| 45 |
+
) {
|
| 46 |
+
return `Model reasoning tidak kompatibel dengan parser Labas.${hint} Coba non-reasoning model, atau update server ke versi terbaru.`;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
if (lower.includes("failed to parse ai response") || lower.includes("invalid json response")) {
|
| 50 |
+
return `Model merespons, tapi output tidak valid JSON.${hint} ${truncate(cause, 280)}`;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
if (lower.includes("empty response from ai") || lower.includes("empty response body")) {
|
| 54 |
+
return `Model tidak mengembalikan konten.${hint} Coba model lain atau kurangi jumlah soal.`;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
if (lower.includes("no valid questions")) {
|
| 58 |
+
return `Model menghasilkan soal, tapi semuanya gagal validasi.${hint} ${truncate(cause, 280)}`;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
if (
|
| 62 |
+
lower.includes("upstream returned an html error page") ||
|
| 63 |
+
lower.includes("provider returned html") ||
|
| 64 |
+
lower.includes("openai-compatible api error") ||
|
| 65 |
+
/api error 5\d\d/i.test(cause) ||
|
| 66 |
+
lower.includes("apiconnectionerror") ||
|
| 67 |
+
lower.includes("midstreamfallbackerror")
|
| 68 |
+
) {
|
| 69 |
+
return `Gagal menghubungi API provider.${hint} ${truncate(cause, 280)}`;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
if (lower.includes("metadata/private network") || lower.includes("invalid base url")) {
|
| 73 |
+
return `Base URL provider tidak valid atau tidak dapat diakses dari server.${hint}`;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
return `Generasi gagal.${hint} ${truncate(cause, 280)}`;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
export function resolveGenerationJobOutcome(input: {
|
| 80 |
requestedCount: number;
|
| 81 |
generatedCount: number;
|
| 82 |
failedSections?: string[];
|
| 83 |
+
cause?: string;
|
| 84 |
}): {
|
| 85 |
status: "completed" | "completed_partial" | "failed";
|
| 86 |
progressMessage: string;
|
| 87 |
errorMessage: string | null;
|
| 88 |
} {
|
| 89 |
const failedSections = [...new Set(input.failedSections ?? [])];
|
|
|
|
|
|
|
| 90 |
|
| 91 |
if (input.generatedCount <= 0) {
|
| 92 |
return {
|
| 93 |
status: "failed",
|
| 94 |
progressMessage: "Generation failed",
|
| 95 |
+
errorMessage: formatGenerationErrorMessage({
|
| 96 |
+
cause: input.cause,
|
| 97 |
+
failedSections,
|
| 98 |
+
}),
|
| 99 |
};
|
| 100 |
}
|
| 101 |
|
| 102 |
if (input.generatedCount < input.requestedCount) {
|
| 103 |
+
const sectionHintText =
|
| 104 |
+
failedSections.length > 0 ? ` Section gagal: ${failedSections.join(", ")}.` : "";
|
| 105 |
return {
|
| 106 |
status: "completed_partial",
|
| 107 |
progressMessage: `Selesai sebagian: ${input.generatedCount}/${input.requestedCount} soal`,
|
| 108 |
+
errorMessage: `Hanya ${input.generatedCount} dari ${input.requestedCount} soal berhasil dibuat.${sectionHintText}`,
|
| 109 |
};
|
| 110 |
}
|
| 111 |
|
|
|
|
| 124 |
.filter((split) => !questions.some((q) => q.section === split.section))
|
| 125 |
.map((split) => split.section);
|
| 126 |
}
|
| 127 |
+
|
| 128 |
+
export function collectShardFailureCause(
|
| 129 |
+
failures: Iterable<{ error: string } | undefined>,
|
| 130 |
+
): string | undefined {
|
| 131 |
+
for (const failure of failures) {
|
| 132 |
+
const message = failure?.error?.trim();
|
| 133 |
+
if (message) return message;
|
| 134 |
+
}
|
| 135 |
+
return undefined;
|
| 136 |
+
}
|
packages/api/src/queue.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
| 27 |
import { and, eq, notInArray } from "drizzle-orm";
|
| 28 |
import { encryptApiKey, decryptApiKey } from "./lib/encryption";
|
| 29 |
import {
|
|
|
|
| 30 |
isNonRetryableProviderError,
|
| 31 |
resolveGenerationJobOutcome,
|
| 32 |
sectionsWithNoQuestions,
|
|
@@ -955,6 +956,9 @@ export const generationWorker = new Worker<FastJobData>(
|
|
| 955 |
requestedCount: input.questionCount,
|
| 956 |
generatedCount: allQuestions.length,
|
| 957 |
failedSections: sectionsWithNoQuestions(sectionSplits, allQuestions),
|
|
|
|
|
|
|
|
|
|
| 958 |
});
|
| 959 |
|
| 960 |
if (outcome.status === "failed") {
|
|
|
|
| 27 |
import { and, eq, notInArray } from "drizzle-orm";
|
| 28 |
import { encryptApiKey, decryptApiKey } from "./lib/encryption";
|
| 29 |
import {
|
| 30 |
+
collectShardFailureCause,
|
| 31 |
isNonRetryableProviderError,
|
| 32 |
resolveGenerationJobOutcome,
|
| 33 |
sectionsWithNoQuestions,
|
|
|
|
| 956 |
requestedCount: input.questionCount,
|
| 957 |
generatedCount: allQuestions.length,
|
| 958 |
failedSections: sectionsWithNoQuestions(sectionSplits, allQuestions),
|
| 959 |
+
cause: collectShardFailureCause(
|
| 960 |
+
failedShards.map((shard) => shardFailureInfo.get(shardKey(shard))),
|
| 961 |
+
),
|
| 962 |
});
|
| 963 |
|
| 964 |
if (outcome.status === "failed") {
|