@studious-lms/server 1.4.3 → 1.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.vscode/settings.json +3 -0
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/models/worksheet.d.ts +1 -0
- package/dist/models/worksheet.d.ts.map +1 -1
- package/dist/models/worksheet.js +2 -2
- package/dist/models/worksheet.js.map +1 -1
- package/dist/pipelines/pdfToWorksheet.d.ts +12 -0
- package/dist/pipelines/pdfToWorksheet.d.ts.map +1 -0
- package/dist/pipelines/pdfToWorksheet.js +155 -0
- package/dist/pipelines/pdfToWorksheet.js.map +1 -0
- package/dist/routers/_app.d.ts +142 -0
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/worksheet.d.ts +71 -0
- package/dist/routers/worksheet.d.ts.map +1 -1
- package/dist/routers/worksheet.js +110 -3
- package/dist/routers/worksheet.js.map +1 -1
- package/dist/services/worksheet.d.ts +21 -0
- package/dist/services/worksheet.d.ts.map +1 -1
- package/dist/services/worksheet.js +50 -2
- package/dist/services/worksheet.js.map +1 -1
- package/dist/utils/inference.d.ts +2 -1
- package/dist/utils/inference.d.ts.map +1 -1
- package/dist/utils/inference.js +53 -5
- package/dist/utils/inference.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +3 -2
- package/src/models/worksheet.ts +1 -0
- package/src/pipelines/pdfToWorksheet.ts +177 -0
- package/src/routers/worksheet.ts +143 -0
- package/src/services/worksheet.ts +61 -0
- package/src/utils/inference.ts +77 -7
- package/tests/pipelines/pdfToWorksheet.test.ts +176 -0
- package/tests/utils/inference.test.ts +37 -0
package/src/routers/worksheet.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { TRPCError } from "@trpc/server";
|
|
2
|
+
import { prisma } from "../lib/prisma.js";
|
|
3
|
+
import {
|
|
4
|
+
confirmDirectUpload,
|
|
5
|
+
createDirectUploadFiles,
|
|
6
|
+
updateUploadProgress,
|
|
7
|
+
} from "../lib/fileUpload.js";
|
|
2
8
|
import {
|
|
3
9
|
createTRPCRouter,
|
|
4
10
|
protectedClassMemberProcedure,
|
|
@@ -13,6 +19,8 @@ import {
|
|
|
13
19
|
updateWorksheetRecord,
|
|
14
20
|
deleteWorksheetRecord,
|
|
15
21
|
createWorksheetRecord,
|
|
22
|
+
createWorksheetFromPdf,
|
|
23
|
+
createWorksheetFromUploadedPdf,
|
|
16
24
|
addQuestionToWorksheet,
|
|
17
25
|
reorderWorksheetQuestions,
|
|
18
26
|
updateWorksheetQuestionRecord,
|
|
@@ -25,6 +33,40 @@ import {
|
|
|
25
33
|
addCommentToResponse,
|
|
26
34
|
} from "../services/worksheet.js";
|
|
27
35
|
|
|
36
|
+
const directFileSchema = z.object({
|
|
37
|
+
name: z.string(),
|
|
38
|
+
type: z.string(),
|
|
39
|
+
size: z.number(),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const pdfFileSchema = directFileSchema.superRefine((file, ctx) => {
|
|
43
|
+
const isPdf =
|
|
44
|
+
file.type === "application/pdf" && file.name.toLowerCase().endsWith(".pdf");
|
|
45
|
+
if (!isPdf) {
|
|
46
|
+
ctx.addIssue({
|
|
47
|
+
code: z.ZodIssueCode.custom,
|
|
48
|
+
message: "Only PDF files are supported",
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const getWorksheetUploadUrlsSchema = z.object({
|
|
54
|
+
classId: z.string(),
|
|
55
|
+
files: z.array(pdfFileSchema),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const confirmWorksheetUploadSchema = z.object({
|
|
59
|
+
classId: z.string(),
|
|
60
|
+
fileId: z.string(),
|
|
61
|
+
uploadSuccess: z.boolean(),
|
|
62
|
+
errorMessage: z.string().optional(),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const updateUploadProgressSchema = z.object({
|
|
66
|
+
fileId: z.string(),
|
|
67
|
+
progress: z.number().min(0).max(100),
|
|
68
|
+
});
|
|
69
|
+
|
|
28
70
|
export const worksheetRouter = createTRPCRouter({
|
|
29
71
|
getWorksheet: protectedProcedure
|
|
30
72
|
.input(z.object({ worksheetId: z.string() }))
|
|
@@ -66,6 +108,75 @@ export const worksheetRouter = createTRPCRouter({
|
|
|
66
108
|
createWorksheetRecord(input.classId, input.name)
|
|
67
109
|
),
|
|
68
110
|
|
|
111
|
+
createFromPdf: protectedTeacherProcedure
|
|
112
|
+
.input(z.object({ classId: z.string(), url: z.string().url() }))
|
|
113
|
+
.mutation(({ input }) => createWorksheetFromPdf(input.classId, input.url)),
|
|
114
|
+
|
|
115
|
+
getWorksheetUploadUrls: protectedTeacherProcedure
|
|
116
|
+
.input(getWorksheetUploadUrlsSchema)
|
|
117
|
+
.mutation(async ({ ctx, input }) => {
|
|
118
|
+
if (!ctx.user) {
|
|
119
|
+
throw new TRPCError({
|
|
120
|
+
code: "UNAUTHORIZED",
|
|
121
|
+
message: "You must be logged in to upload files",
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const uploadFiles = await createDirectUploadFiles(
|
|
126
|
+
input.files,
|
|
127
|
+
ctx.user.id
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
success: true,
|
|
132
|
+
uploadFiles,
|
|
133
|
+
};
|
|
134
|
+
}),
|
|
135
|
+
|
|
136
|
+
confirmWorksheetUpload: protectedTeacherProcedure
|
|
137
|
+
.input(confirmWorksheetUploadSchema)
|
|
138
|
+
.mutation(async ({ ctx, input }) => {
|
|
139
|
+
if (!ctx.user) {
|
|
140
|
+
throw new TRPCError({
|
|
141
|
+
code: "UNAUTHORIZED",
|
|
142
|
+
message: "You must be logged in",
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const file = await prisma.file.findFirst({
|
|
147
|
+
where: {
|
|
148
|
+
id: input.fileId,
|
|
149
|
+
userId: ctx.user.id,
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
if (!file) {
|
|
154
|
+
throw new TRPCError({
|
|
155
|
+
code: "NOT_FOUND",
|
|
156
|
+
message: "File not found or you don't have permission",
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
await confirmDirectUpload(
|
|
161
|
+
input.fileId,
|
|
162
|
+
input.uploadSuccess,
|
|
163
|
+
input.errorMessage
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
success: true,
|
|
168
|
+
message: input.uploadSuccess
|
|
169
|
+
? "Upload confirmed successfully"
|
|
170
|
+
: "Upload failed",
|
|
171
|
+
};
|
|
172
|
+
}),
|
|
173
|
+
|
|
174
|
+
createFromUploadedPdf: protectedTeacherProcedure
|
|
175
|
+
.input(z.object({ classId: z.string(), fileId: z.string() }))
|
|
176
|
+
.mutation(({ ctx, input }) =>
|
|
177
|
+
createWorksheetFromUploadedPdf(input.classId, input.fileId, ctx.user!.id)
|
|
178
|
+
),
|
|
179
|
+
|
|
69
180
|
addQuestion: protectedProcedure
|
|
70
181
|
.input(
|
|
71
182
|
z.object({
|
|
@@ -261,4 +372,36 @@ export const worksheetRouter = createTRPCRouter({
|
|
|
261
372
|
ctx.user!.id
|
|
262
373
|
)
|
|
263
374
|
),
|
|
375
|
+
|
|
376
|
+
updateUploadProgress: protectedProcedure
|
|
377
|
+
.input(updateUploadProgressSchema)
|
|
378
|
+
.mutation(async ({ ctx, input }) => {
|
|
379
|
+
if (!ctx.user) {
|
|
380
|
+
throw new TRPCError({
|
|
381
|
+
code: "UNAUTHORIZED",
|
|
382
|
+
message: "You must be logged in",
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const file = await prisma.file.findFirst({
|
|
387
|
+
where: {
|
|
388
|
+
id: input.fileId,
|
|
389
|
+
userId: ctx.user.id,
|
|
390
|
+
},
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
if (!file) {
|
|
394
|
+
throw new TRPCError({
|
|
395
|
+
code: "NOT_FOUND",
|
|
396
|
+
message: "File not found or you don't have permission",
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
await updateUploadProgress(input.fileId, input.progress);
|
|
401
|
+
|
|
402
|
+
return {
|
|
403
|
+
success: true,
|
|
404
|
+
progress: input.progress,
|
|
405
|
+
};
|
|
406
|
+
}),
|
|
264
407
|
});
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
import { TRPCError } from "@trpc/server";
|
|
6
6
|
import { GenerationStatus, type WorksheetQuestionType } from "@prisma/client";
|
|
7
7
|
import { prisma } from "../lib/prisma.js";
|
|
8
|
+
import { getFileUrl } from "../lib/fileUpload.js";
|
|
8
9
|
import { cancelGradePipeline, regradeWorksheetPipeline } from "../pipelines/gradeWorksheet.js";
|
|
10
|
+
import { pdfToWorksheet } from "../pipelines/pdfToWorksheet.js";
|
|
9
11
|
import {
|
|
10
12
|
findWorksheetByIdMinimal,
|
|
11
13
|
findWorksheetByIdForClassMember,
|
|
@@ -121,6 +123,65 @@ export async function createWorksheetRecord(classId: string, name: string) {
|
|
|
121
123
|
return createWorksheet({ classId, name });
|
|
122
124
|
}
|
|
123
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Create a worksheet by converting a PDF (URL) into structured questions.
|
|
128
|
+
* Uses the `pdfToWorksheet` pipeline to do inference + persistence.
|
|
129
|
+
*/
|
|
130
|
+
export async function createWorksheetFromPdf(
|
|
131
|
+
classId: string,
|
|
132
|
+
url: string
|
|
133
|
+
) {
|
|
134
|
+
const created = await pdfToWorksheet(url, classId);
|
|
135
|
+
if (!created) {
|
|
136
|
+
throw new TRPCError({
|
|
137
|
+
code: "INTERNAL_SERVER_ERROR",
|
|
138
|
+
message: "Failed to create worksheet from PDF",
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return created;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Create a worksheet from a PDF that was uploaded through the direct-upload flow.
|
|
146
|
+
*/
|
|
147
|
+
export async function createWorksheetFromUploadedPdf(
|
|
148
|
+
classId: string,
|
|
149
|
+
fileId: string,
|
|
150
|
+
userId: string
|
|
151
|
+
) {
|
|
152
|
+
const file = await prisma.file.findFirst({
|
|
153
|
+
where: {
|
|
154
|
+
id: fileId,
|
|
155
|
+
userId,
|
|
156
|
+
uploadStatus: "COMPLETED",
|
|
157
|
+
},
|
|
158
|
+
select: {
|
|
159
|
+
id: true,
|
|
160
|
+
path: true,
|
|
161
|
+
type: true,
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
if (!file) {
|
|
166
|
+
throw new TRPCError({
|
|
167
|
+
code: "NOT_FOUND",
|
|
168
|
+
message: "Uploaded file not found or not ready",
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const isPdf =
|
|
173
|
+
file.type === "application/pdf" || file.path.toLowerCase().endsWith(".pdf");
|
|
174
|
+
if (!isPdf) {
|
|
175
|
+
throw new TRPCError({
|
|
176
|
+
code: "BAD_REQUEST",
|
|
177
|
+
message: "Only PDF files can be converted into worksheets",
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const signedUrl = await getFileUrl(file.path);
|
|
182
|
+
return createWorksheetFromPdf(classId, signedUrl);
|
|
183
|
+
}
|
|
184
|
+
|
|
124
185
|
export async function addQuestionToWorksheet(
|
|
125
186
|
worksheetId: string,
|
|
126
187
|
userId: string,
|
package/src/utils/inference.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Inference util – OpenAI client, inference() for structured output, sendAIMessage for chat.
|
|
3
3
|
*/
|
|
4
4
|
import OpenAI from 'openai';
|
|
5
|
+
import type { ResponseInput } from "openai/resources/responses/responses.js";
|
|
5
6
|
import { logger } from './logger.js';
|
|
6
7
|
import { prisma } from '../lib/prisma.js';
|
|
7
8
|
import { chatChannel, pusher } from '../lib/pusher.js';
|
|
@@ -275,25 +276,93 @@ export async function sendAIMessage(
|
|
|
275
276
|
};
|
|
276
277
|
}
|
|
277
278
|
|
|
279
|
+
/** Responses API user parts use `type: "input_*"`; chat completions use `text` / `image_url` / etc. */
|
|
280
|
+
function isResponseInputWithInputParts(
|
|
281
|
+
content: OpenAI.Chat.Completions.ChatCompletionMessageParam[] | ResponseInput
|
|
282
|
+
): content is ResponseInput {
|
|
283
|
+
if (!Array.isArray(content)) return false;
|
|
284
|
+
for (const msg of content) {
|
|
285
|
+
if (!msg || typeof msg !== "object" || !("content" in msg)) continue;
|
|
286
|
+
const parts = (msg as { content?: unknown }).content;
|
|
287
|
+
if (!Array.isArray(parts)) continue;
|
|
288
|
+
for (const part of parts) {
|
|
289
|
+
if (
|
|
290
|
+
part &&
|
|
291
|
+
typeof part === "object" &&
|
|
292
|
+
"type" in part &&
|
|
293
|
+
typeof (part as { type: string }).type === "string" &&
|
|
294
|
+
(part as { type: string }).type.startsWith("input_")
|
|
295
|
+
) {
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function buildResponsesParseInput(
|
|
304
|
+
content: string | OpenAI.Chat.Completions.ChatCompletionMessageParam[] | ResponseInput
|
|
305
|
+
): ResponseInput {
|
|
306
|
+
if (typeof content === "string") {
|
|
307
|
+
return [{ role: "user", content: [{ type: "input_text", text: content }] }];
|
|
308
|
+
}
|
|
309
|
+
if (isResponseInputWithInputParts(content)) {
|
|
310
|
+
return content;
|
|
311
|
+
}
|
|
312
|
+
return content as unknown as ResponseInput;
|
|
313
|
+
}
|
|
314
|
+
|
|
278
315
|
export async function inference<T>(
|
|
279
|
-
content:
|
|
316
|
+
content:
|
|
317
|
+
| string
|
|
318
|
+
| OpenAI.Chat.Completions.ChatCompletionMessageParam[]
|
|
319
|
+
| ResponseInput,
|
|
280
320
|
format?: ZodSchema,
|
|
281
321
|
options?: InferenceOptions,
|
|
282
322
|
): Promise<T> {
|
|
283
|
-
const input = normalizeInferenceInput(content);
|
|
284
323
|
const reason = getInferenceReason(options);
|
|
285
324
|
const model = options?.model ?? DEFAULT_INFERENCE_MODEL;
|
|
286
|
-
const estimatedInputTokens =
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
325
|
+
const estimatedInputTokens =
|
|
326
|
+
typeof content === 'string'
|
|
327
|
+
? estimateTokenCount(content)
|
|
328
|
+
: isResponseInputWithInputParts(content)
|
|
329
|
+
? estimateTokenCount(
|
|
330
|
+
content
|
|
331
|
+
.flatMap((item) =>
|
|
332
|
+
typeof (item as any)?.content === 'string'
|
|
333
|
+
? [(item as any).content]
|
|
334
|
+
: Array.isArray((item as any)?.content)
|
|
335
|
+
? (item as any).content
|
|
336
|
+
.filter((p: any) => p?.type === 'input_text')
|
|
337
|
+
.map((p: any) => p?.text ?? '')
|
|
338
|
+
: [],
|
|
339
|
+
)
|
|
340
|
+
.join('\n'),
|
|
341
|
+
)
|
|
342
|
+
: (content as OpenAI.Chat.Completions.ChatCompletionMessageParam[]).reduce(
|
|
343
|
+
(total, message) =>
|
|
344
|
+
total +
|
|
345
|
+
estimateTokenCount(
|
|
346
|
+
typeof message.content === 'string' ? message.content : '',
|
|
347
|
+
),
|
|
348
|
+
0,
|
|
349
|
+
);
|
|
290
350
|
const operation = format ? 'responses.parse' : 'chat.completions';
|
|
291
351
|
|
|
292
352
|
try {
|
|
293
353
|
if (!format) {
|
|
354
|
+
if (typeof content !== 'string' && isResponseInputWithInputParts(content)) {
|
|
355
|
+
throw new Error(
|
|
356
|
+
'normalizeInferenceInput cannot handle ResponseInput content with input_* parts when format is undefined; pass a format to use responses.parse.',
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const messages = normalizeInferenceInput(
|
|
361
|
+
content as string | OpenAI.Chat.Completions.ChatCompletionMessageParam[],
|
|
362
|
+
);
|
|
294
363
|
const completion = await openAIClient.chat.completions.create({
|
|
295
364
|
model,
|
|
296
|
-
messages
|
|
365
|
+
messages,
|
|
297
366
|
...(options?.temperature !== undefined
|
|
298
367
|
? { temperature: options.temperature }
|
|
299
368
|
: {}),
|
|
@@ -315,6 +384,7 @@ export async function inference<T>(
|
|
|
315
384
|
return completion.choices[0]?.message?.content as T;
|
|
316
385
|
}
|
|
317
386
|
|
|
387
|
+
const input = buildResponsesParseInput(content);
|
|
318
388
|
const completion = await openAIClient.responses.parse({
|
|
319
389
|
model,
|
|
320
390
|
input,
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pdfToWorksheet` integration-style tests: real DB, mocked inference.
|
|
3
|
+
* CI does not call the model, and the PDF URL is passed through directly.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, test, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
|
|
6
|
+
import { user1Caller } from "../setup";
|
|
7
|
+
import { prisma } from "../../src/lib/prisma";
|
|
8
|
+
import { logger } from "../../src/utils/logger";
|
|
9
|
+
|
|
10
|
+
vi.mock("../../src/utils/inference.js", () => ({
|
|
11
|
+
inference: vi.fn(),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
import { inference } from "../../src/utils/inference.js";
|
|
15
|
+
import { pdfToWorksheet } from "../../src/pipelines/pdfToWorksheet.js";
|
|
16
|
+
|
|
17
|
+
/** Same PDF path represented as a signed Google Cloud Storage URL. */
|
|
18
|
+
export const CONV_TEST_PDF_GCS_URL =
|
|
19
|
+
"https://storage.googleapis.com/studious-test-bucket/worksheets/conv-test.pdf?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Signature=test-signature";
|
|
20
|
+
|
|
21
|
+
export const NON_GCS_PDF_URL =
|
|
22
|
+
"https://drive.google.com/file/d/1BfotteM60Ax0krHzE6rFN1Df8S8kT3kb/view?usp=drive_link";
|
|
23
|
+
|
|
24
|
+
/** Mirrors the 8-question conv-test worksheet shape the model is expected to return. */
|
|
25
|
+
function draftWithEightQuestions(title = "Conv test worksheet") {
|
|
26
|
+
const types = [
|
|
27
|
+
"SHORT_ANSWER",
|
|
28
|
+
"MULTIPLE_CHOICE",
|
|
29
|
+
"TRUE_FALSE",
|
|
30
|
+
"SHORT_ANSWER",
|
|
31
|
+
"LONG_ANSWER",
|
|
32
|
+
"MATH_EXPRESSION",
|
|
33
|
+
"ESSAY",
|
|
34
|
+
"SHORT_ANSWER",
|
|
35
|
+
] as const;
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
title,
|
|
39
|
+
questions: types.map((type, i) => ({
|
|
40
|
+
type,
|
|
41
|
+
question: `Question ${i + 1}`,
|
|
42
|
+
answer: `Answer ${i + 1}`,
|
|
43
|
+
options:
|
|
44
|
+
type === "MULTIPLE_CHOICE"
|
|
45
|
+
? [
|
|
46
|
+
{ id: "a", text: "Wrong", isCorrect: false },
|
|
47
|
+
{ id: "b", text: "Right", isCorrect: true },
|
|
48
|
+
]
|
|
49
|
+
: [],
|
|
50
|
+
markScheme: [] as { id: string; points: number; description: string }[],
|
|
51
|
+
points: i + 1,
|
|
52
|
+
order: i + 1,
|
|
53
|
+
})),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe("pdfToWorksheet", () => {
|
|
58
|
+
let classId: string;
|
|
59
|
+
const createdWorksheetIds: string[] = [];
|
|
60
|
+
|
|
61
|
+
beforeAll(async () => {
|
|
62
|
+
const cls = await user1Caller.class.create({
|
|
63
|
+
name: "pdfToWorksheet pipeline class",
|
|
64
|
+
subject: "Science",
|
|
65
|
+
section: "Test",
|
|
66
|
+
});
|
|
67
|
+
classId = cls.id;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
vi.mocked(inference).mockReset();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
afterEach(async () => {
|
|
75
|
+
for (const id of createdWorksheetIds.splice(0)) {
|
|
76
|
+
await prisma.worksheet.delete({ where: { id } }).catch(() => undefined);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("passes the PDF URL directly to inference and persists a worksheet with 8 questions", async () => {
|
|
81
|
+
const draft = draftWithEightQuestions();
|
|
82
|
+
vi.mocked(inference).mockResolvedValue(draft);
|
|
83
|
+
|
|
84
|
+
const worksheet = await pdfToWorksheet(CONV_TEST_PDF_GCS_URL, classId);
|
|
85
|
+
expect(worksheet).not.toBeNull();
|
|
86
|
+
createdWorksheetIds.push(worksheet!.id);
|
|
87
|
+
|
|
88
|
+
expect(worksheet!.name).toBe(draft.title);
|
|
89
|
+
expect(worksheet!.classId).toBe(classId);
|
|
90
|
+
|
|
91
|
+
const questions = await prisma.worksheetQuestion.findMany({
|
|
92
|
+
where: { worksheetId: worksheet!.id },
|
|
93
|
+
orderBy: { order: "asc" },
|
|
94
|
+
});
|
|
95
|
+
expect(questions).toHaveLength(8);
|
|
96
|
+
expect(questions.map((q) => q.question)).toEqual(
|
|
97
|
+
draft.questions.map((q) => q.question)
|
|
98
|
+
);
|
|
99
|
+
expect(questions.map((q) => q.order)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
|
|
100
|
+
expect(questions.map((q) => q.points)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
|
|
101
|
+
|
|
102
|
+
expect(inference).toHaveBeenCalledTimes(1);
|
|
103
|
+
const input = vi.mocked(inference).mock.calls[0][0];
|
|
104
|
+
expect(Array.isArray(input)).toBe(true);
|
|
105
|
+
type InputPart = { type?: string; file_url?: string; text?: string };
|
|
106
|
+
const content = (input as { role: string; content: InputPart[] }[])[0].content;
|
|
107
|
+
const filePart = content.find((p) => p.type === "input_file");
|
|
108
|
+
expect(filePart).toEqual({
|
|
109
|
+
type: "input_file",
|
|
110
|
+
file_url: CONV_TEST_PDF_GCS_URL,
|
|
111
|
+
});
|
|
112
|
+
expect(vi.mocked(inference)).toHaveBeenCalledWith(
|
|
113
|
+
expect.any(Array),
|
|
114
|
+
expect.anything(),
|
|
115
|
+
expect.objectContaining({
|
|
116
|
+
reason: "pdf_to_worksheet_draft",
|
|
117
|
+
metadata: expect.objectContaining({
|
|
118
|
+
classId,
|
|
119
|
+
filename: "conv-test.pdf",
|
|
120
|
+
}),
|
|
121
|
+
})
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("rejects non-Google Cloud URLs and only logs a sanitized path", async () => {
|
|
126
|
+
const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => undefined);
|
|
127
|
+
|
|
128
|
+
await expect(pdfToWorksheet(NON_GCS_PDF_URL, classId)).rejects.toThrow(
|
|
129
|
+
"Only Google Cloud Storage URLs are supported"
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
133
|
+
"pdfToWorksheet: rejected unsupported PDF URL",
|
|
134
|
+
{ sanitizedUrl: "https://drive.google.com/file/d/1BfotteM60Ax0krHzE6rFN1Df8S8kT3kb/view" }
|
|
135
|
+
);
|
|
136
|
+
expect(inference).not.toHaveBeenCalled();
|
|
137
|
+
warnSpy.mockRestore();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("rounds persisted integer fields from model output", async () => {
|
|
141
|
+
vi.mocked(inference).mockResolvedValue({
|
|
142
|
+
title: "Rounded worksheet",
|
|
143
|
+
questions: [{
|
|
144
|
+
type: "SHORT_ANSWER",
|
|
145
|
+
question: "Rounded question",
|
|
146
|
+
answer: "Rounded answer",
|
|
147
|
+
options: [],
|
|
148
|
+
markScheme: [{ id: "step-1", points: 0.6, description: "Half step" }],
|
|
149
|
+
points: 1.7,
|
|
150
|
+
order: 1.2,
|
|
151
|
+
}],
|
|
152
|
+
} as Awaited<ReturnType<typeof inference>>);
|
|
153
|
+
|
|
154
|
+
const worksheet = await pdfToWorksheet(CONV_TEST_PDF_GCS_URL, classId);
|
|
155
|
+
expect(worksheet).not.toBeNull();
|
|
156
|
+
createdWorksheetIds.push(worksheet!.id);
|
|
157
|
+
|
|
158
|
+
const [question] = await prisma.worksheetQuestion.findMany({
|
|
159
|
+
where: { worksheetId: worksheet!.id },
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
expect(question.points).toBe(2);
|
|
163
|
+
expect(question.order).toBe(1);
|
|
164
|
+
expect(question.markScheme).toEqual([{ id: "step-1", points: 1, description: "Half step" }]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("returns null when inference yields no parsed output", async () => {
|
|
168
|
+
vi.mocked(inference).mockResolvedValue(
|
|
169
|
+
null as unknown as Awaited<ReturnType<typeof inference>>
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const out = await pdfToWorksheet(CONV_TEST_PDF_GCS_URL, classId);
|
|
173
|
+
expect(out).toBeNull();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
});
|
|
@@ -186,6 +186,43 @@ describe('inference', () => {
|
|
|
186
186
|
metadata: { worksheetResponseId: 'response-1' },
|
|
187
187
|
}),
|
|
188
188
|
});
|
|
189
|
+
expect(responseParseMock).toHaveBeenCalledWith({
|
|
190
|
+
model: 'gpt-5-nano',
|
|
191
|
+
input: [{
|
|
192
|
+
role: 'user',
|
|
193
|
+
content: [{ type: 'input_text', text: 'Grade this' }],
|
|
194
|
+
}],
|
|
195
|
+
text: {
|
|
196
|
+
format: expect.any(Object),
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('rejects ResponseInput content with input parts when format is missing', async () => {
|
|
202
|
+
await expect(
|
|
203
|
+
inference<string>([
|
|
204
|
+
{
|
|
205
|
+
role: 'user',
|
|
206
|
+
content: [
|
|
207
|
+
{ type: 'input_text', text: 'Read this PDF' },
|
|
208
|
+
{ type: 'input_file', file_url: 'https://storage.googleapis.com/bucket/file.pdf' },
|
|
209
|
+
],
|
|
210
|
+
},
|
|
211
|
+
] as any, undefined, {
|
|
212
|
+
reason: 'pdf_without_format',
|
|
213
|
+
}),
|
|
214
|
+
).rejects.toThrow(
|
|
215
|
+
'normalizeInferenceInput cannot handle ResponseInput content with input_* parts when format is undefined; pass a format to use responses.parse.',
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
expect(chatCreateMock).not.toHaveBeenCalled();
|
|
219
|
+
expect(prismaMocks.aiUsageLog.create).toHaveBeenCalledWith({
|
|
220
|
+
data: expect.objectContaining({
|
|
221
|
+
reason: 'pdf_without_format',
|
|
222
|
+
operation: 'chat.completions',
|
|
223
|
+
success: false,
|
|
224
|
+
}),
|
|
225
|
+
});
|
|
189
226
|
});
|
|
190
227
|
|
|
191
228
|
test('logs failed attempts before rethrowing', async () => {
|