@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.
@@ -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,
@@ -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: string | OpenAI.Chat.Completions.ChatCompletionMessageParam[],
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 = input.reduce(
287
- (total, message) => total + estimateTokenCount(message.content),
288
- 0,
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: input,
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 () => {