@studious-lms/server 1.4.2 → 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.
Files changed (152) hide show
  1. package/.coderabbit.yaml +4 -1
  2. package/.vscode/settings.json +3 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +68 -29
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/fileUpload.d.ts +10 -2
  7. package/dist/lib/fileUpload.d.ts.map +1 -1
  8. package/dist/lib/fileUpload.js +38 -6
  9. package/dist/lib/fileUpload.js.map +1 -1
  10. package/dist/middleware/auth.d.ts.map +1 -1
  11. package/dist/middleware/auth.js +18 -5
  12. package/dist/middleware/auth.js.map +1 -1
  13. package/dist/models/auth.d.ts +19 -14
  14. package/dist/models/auth.d.ts.map +1 -1
  15. package/dist/models/auth.js +53 -30
  16. package/dist/models/auth.js.map +1 -1
  17. package/dist/models/class.d.ts +3 -1
  18. package/dist/models/class.d.ts.map +1 -1
  19. package/dist/models/class.js +16 -4
  20. package/dist/models/class.js.map +1 -1
  21. package/dist/models/notification.d.ts +11 -10
  22. package/dist/models/notification.d.ts.map +1 -1
  23. package/dist/models/notification.js +13 -9
  24. package/dist/models/notification.js.map +1 -1
  25. package/dist/models/worksheet.d.ts +379 -7
  26. package/dist/models/worksheet.d.ts.map +1 -1
  27. package/dist/models/worksheet.js +168 -2
  28. package/dist/models/worksheet.js.map +1 -1
  29. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  30. package/dist/pipelines/aiLabChat.js +11 -3
  31. package/dist/pipelines/aiLabChat.js.map +1 -1
  32. package/dist/pipelines/aiLabChatContract.d.ts +3 -3
  33. package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
  34. package/dist/pipelines/aiNewtonChat.js +25 -14
  35. package/dist/pipelines/aiNewtonChat.js.map +1 -1
  36. package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
  37. package/dist/pipelines/gradeWorksheet.js +10 -3
  38. package/dist/pipelines/gradeWorksheet.js.map +1 -1
  39. package/dist/pipelines/pdfToWorksheet.d.ts +12 -0
  40. package/dist/pipelines/pdfToWorksheet.d.ts.map +1 -0
  41. package/dist/pipelines/pdfToWorksheet.js +155 -0
  42. package/dist/pipelines/pdfToWorksheet.js.map +1 -0
  43. package/dist/routers/_app.d.ts +264 -16
  44. package/dist/routers/_app.d.ts.map +1 -1
  45. package/dist/routers/class.d.ts +2 -1
  46. package/dist/routers/class.d.ts.map +1 -1
  47. package/dist/routers/class.js +3 -3
  48. package/dist/routers/class.js.map +1 -1
  49. package/dist/routers/labChat.d.ts +6 -0
  50. package/dist/routers/labChat.d.ts.map +1 -1
  51. package/dist/routers/labChat.js +5 -3
  52. package/dist/routers/labChat.js.map +1 -1
  53. package/dist/routers/message.d.ts +31 -0
  54. package/dist/routers/message.d.ts.map +1 -1
  55. package/dist/routers/message.js +27 -4
  56. package/dist/routers/message.js.map +1 -1
  57. package/dist/routers/newtonChat.d.ts +6 -0
  58. package/dist/routers/newtonChat.d.ts.map +1 -1
  59. package/dist/routers/newtonChat.js +5 -4
  60. package/dist/routers/newtonChat.js.map +1 -1
  61. package/dist/routers/notifications.d.ts +9 -2
  62. package/dist/routers/notifications.d.ts.map +1 -1
  63. package/dist/routers/notifications.js +4 -4
  64. package/dist/routers/notifications.js.map +1 -1
  65. package/dist/routers/worksheet.d.ts +78 -5
  66. package/dist/routers/worksheet.d.ts.map +1 -1
  67. package/dist/routers/worksheet.js +126 -17
  68. package/dist/routers/worksheet.js.map +1 -1
  69. package/dist/services/auth.d.ts +1 -1
  70. package/dist/services/auth.d.ts.map +1 -1
  71. package/dist/services/auth.js +19 -12
  72. package/dist/services/auth.js.map +1 -1
  73. package/dist/services/class.d.ts +1 -1
  74. package/dist/services/class.d.ts.map +1 -1
  75. package/dist/services/class.js +6 -3
  76. package/dist/services/class.js.map +1 -1
  77. package/dist/services/file.d.ts.map +1 -1
  78. package/dist/services/file.js +7 -2
  79. package/dist/services/file.js.map +1 -1
  80. package/dist/services/labChat.d.ts +6 -0
  81. package/dist/services/labChat.d.ts.map +1 -1
  82. package/dist/services/labChat.js +30 -18
  83. package/dist/services/labChat.js.map +1 -1
  84. package/dist/services/message.d.ts +30 -0
  85. package/dist/services/message.d.ts.map +1 -1
  86. package/dist/services/message.js +97 -21
  87. package/dist/services/message.js.map +1 -1
  88. package/dist/services/newtonChat.d.ts +6 -1
  89. package/dist/services/newtonChat.d.ts.map +1 -1
  90. package/dist/services/newtonChat.js +36 -18
  91. package/dist/services/newtonChat.js.map +1 -1
  92. package/dist/services/notification.d.ts +12 -5
  93. package/dist/services/notification.d.ts.map +1 -1
  94. package/dist/services/notification.js +24 -9
  95. package/dist/services/notification.js.map +1 -1
  96. package/dist/services/studentProgress.d.ts.map +1 -1
  97. package/dist/services/studentProgress.js +6 -3
  98. package/dist/services/studentProgress.js.map +1 -1
  99. package/dist/services/worksheet.d.ts +36 -15
  100. package/dist/services/worksheet.d.ts.map +1 -1
  101. package/dist/services/worksheet.js +186 -54
  102. package/dist/services/worksheet.js.map +1 -1
  103. package/dist/trpc.d.ts.map +1 -1
  104. package/dist/trpc.js +11 -17
  105. package/dist/trpc.js.map +1 -1
  106. package/dist/utils/inference.d.ts +10 -1
  107. package/dist/utils/inference.d.ts.map +1 -1
  108. package/dist/utils/inference.js +186 -50
  109. package/dist/utils/inference.js.map +1 -1
  110. package/package.json +1 -1
  111. package/prisma/migrations/20260415103500_add_session_type/migration.sql +33 -0
  112. package/prisma/migrations/20260415133000_add_ai_usage_logs/migration.sql +19 -0
  113. package/prisma/schema.prisma +27 -1
  114. package/scripts/test-pre-push.ts +6 -2
  115. package/src/index.ts +83 -30
  116. package/src/lib/fileUpload.ts +55 -5
  117. package/src/middleware/auth.ts +19 -4
  118. package/src/models/auth.ts +59 -29
  119. package/src/models/class.ts +15 -4
  120. package/src/models/notification.ts +13 -7
  121. package/src/models/worksheet.ts +203 -0
  122. package/src/pipelines/aiLabChat.ts +9 -1
  123. package/src/pipelines/aiNewtonChat.ts +24 -14
  124. package/src/pipelines/gradeWorksheet.ts +8 -0
  125. package/src/pipelines/pdfToWorksheet.ts +177 -0
  126. package/src/routers/class.ts +2 -2
  127. package/src/routers/labChat.ts +3 -1
  128. package/src/routers/message.ts +38 -1
  129. package/src/routers/newtonChat.ts +4 -2
  130. package/src/routers/notifications.ts +2 -2
  131. package/src/routers/worksheet.ts +174 -19
  132. package/src/services/auth.ts +26 -12
  133. package/src/services/class.ts +4 -1
  134. package/src/services/file.ts +6 -0
  135. package/src/services/labChat.ts +42 -18
  136. package/src/services/message.ts +163 -18
  137. package/src/services/newtonChat.ts +49 -18
  138. package/src/services/notification.ts +27 -8
  139. package/src/services/studentProgress.ts +4 -1
  140. package/src/services/worksheet.ts +252 -51
  141. package/src/trpc.ts +12 -18
  142. package/src/utils/inference.ts +280 -56
  143. package/tests/globalSetup.ts +13 -4
  144. package/tests/pipelines/pdfToWorksheet.test.ts +176 -0
  145. package/tests/routers/auth.test.ts +120 -17
  146. package/tests/routers/class.test.ts +7 -1
  147. package/tests/routers/message.test.ts +236 -9
  148. package/tests/routers/newtonChat.test.ts +81 -0
  149. package/tests/routers/notification.test.ts +12 -0
  150. package/tests/routers/worksheet.test.ts +194 -0
  151. package/tests/setup.ts +22 -5
  152. package/tests/utils/inference.test.ts +255 -0
@@ -2,15 +2,15 @@
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';
8
9
  import { ensureAIUserExists, getAIUserId } from './aiUser.js';
9
10
  import { env } from '../lib/config/env.js';
11
+ import { Prisma } from '@prisma/client';
10
12
  import { ZodSchema } from 'zod';
11
- import { zodTextFormat } from "openai/helpers/zod";
12
-
13
-
13
+ import { zodTextFormat } from 'openai/helpers/zod';
14
14
 
15
15
  export const inferenceClient = new OpenAI({
16
16
  apiKey: env.INFERENCE_API_KEY,
@@ -19,6 +19,38 @@ export const inferenceClient = new OpenAI({
19
19
 
20
20
  export const openAIClient = new OpenAI();
21
21
 
22
+ const DEFAULT_INFERENCE_MODEL = 'gpt-5-nano';
23
+
24
+ type InferenceMessage = {
25
+ role: 'user' | 'assistant' | 'system';
26
+ content: string;
27
+ };
28
+
29
+ export interface InferenceOptions {
30
+ reason?: string;
31
+ metadata?: Prisma.InputJsonValue;
32
+ model?: string;
33
+ temperature?: number;
34
+ maxTokens?: number;
35
+ }
36
+
37
+ interface InferenceUsage {
38
+ promptTokens?: number;
39
+ completionTokens?: number;
40
+ totalTokens?: number;
41
+ }
42
+
43
+ interface InferenceAuditLog {
44
+ model: string;
45
+ operation: 'chat.completions' | 'responses.parse';
46
+ success: boolean;
47
+ estimatedInputTokens: number;
48
+ reason: string;
49
+ metadata?: Prisma.InputJsonValue;
50
+ usage?: InferenceUsage;
51
+ error?: unknown;
52
+ }
53
+
22
54
  // Types for lab chat context
23
55
  export interface LabChatContext {
24
56
  subject: string;
@@ -39,6 +71,116 @@ export interface InferenceResponse {
39
71
  finishReason: string;
40
72
  }
41
73
 
74
+ function normalizeInferenceInput(
75
+ content: string | OpenAI.Chat.Completions.ChatCompletionMessageParam[],
76
+ ): InferenceMessage[] {
77
+ if (typeof content === 'string') {
78
+ return [{ role: 'user', content }];
79
+ }
80
+
81
+ return content.map((message) => ({
82
+ role: message.role as InferenceMessage['role'],
83
+ content: typeof message.content === 'string' ? message.content : JSON.stringify(message.content),
84
+ }));
85
+ }
86
+
87
+ function getInferenceReason(options?: InferenceOptions) {
88
+ return options?.reason?.trim() || 'unspecified';
89
+ }
90
+
91
+ function serializeInferenceError(error: unknown) {
92
+ if (error instanceof Error) {
93
+ return error.message;
94
+ }
95
+
96
+ if (typeof error === 'string') {
97
+ return error;
98
+ }
99
+
100
+ try {
101
+ return JSON.stringify(error);
102
+ } catch {
103
+ return 'Unknown inference error';
104
+ }
105
+ }
106
+
107
+ async function logInferenceUsage({
108
+ model,
109
+ operation,
110
+ success,
111
+ estimatedInputTokens,
112
+ reason,
113
+ metadata,
114
+ usage,
115
+ error,
116
+ }: InferenceAuditLog) {
117
+ const context = {
118
+ reason,
119
+ model,
120
+ operation,
121
+ success,
122
+ estimatedInputTokens,
123
+ promptTokens: usage?.promptTokens,
124
+ completionTokens: usage?.completionTokens,
125
+ totalTokens: usage?.totalTokens,
126
+ metadata,
127
+ ...(error ? { error: serializeInferenceError(error) } : {}),
128
+ };
129
+
130
+ try {
131
+ await prisma.aiUsageLog.create({
132
+ data: {
133
+ reason,
134
+ model,
135
+ operation,
136
+ success,
137
+ estimatedInputTokens,
138
+ promptTokens: usage?.promptTokens,
139
+ completionTokens: usage?.completionTokens,
140
+ totalTokens: usage?.totalTokens,
141
+ ...(metadata !== undefined ? { metadata } : {}),
142
+ ...(error ? { error: serializeInferenceError(error) } : {}),
143
+ },
144
+ });
145
+ } catch (loggingError) {
146
+ logger.error('Failed to persist AI usage log', {
147
+ ...context,
148
+ loggingError: serializeInferenceError(loggingError),
149
+ });
150
+ return;
151
+ }
152
+
153
+ logger.info('AI usage logged', context);
154
+ }
155
+
156
+ function getChatCompletionUsage(completion: {
157
+ usage?: {
158
+ prompt_tokens?: number;
159
+ completion_tokens?: number;
160
+ total_tokens?: number;
161
+ };
162
+ }): InferenceUsage {
163
+ return {
164
+ promptTokens: completion.usage?.prompt_tokens,
165
+ completionTokens: completion.usage?.completion_tokens,
166
+ totalTokens: completion.usage?.total_tokens,
167
+ };
168
+ }
169
+
170
+ function getResponseUsage(completion: {
171
+ usage?: {
172
+ input_tokens?: number;
173
+ output_tokens?: number;
174
+ total_tokens?: number;
175
+ };
176
+ }): InferenceUsage {
177
+ return {
178
+ promptTokens: completion.usage?.input_tokens,
179
+ completionTokens: completion.usage?.output_tokens,
180
+ totalTokens: completion.usage?.total_tokens,
181
+ };
182
+ }
183
+
42
184
  /**
43
185
  * Centralized function to send AI messages to conversations
44
186
  * Handles database storage and Pusher broadcasting
@@ -65,10 +207,8 @@ export async function sendAIMessage(
65
207
  createdAt: Date;
66
208
  meta?: Record<string, any>;
67
209
  }> {
68
- // Ensure AI user exists
69
210
  await ensureAIUserExists();
70
211
 
71
- // Create message in database
72
212
  const aiMessage = await prisma.message.create({
73
213
  data: {
74
214
  content,
@@ -94,17 +234,15 @@ export async function sendAIMessage(
94
234
  contentLength: content.length,
95
235
  });
96
236
 
97
- // Prepare sender info
98
237
  const senderInfo = {
99
238
  id: getAIUserId(),
100
239
  username: 'Newton_AI',
101
240
  profile: {
102
- displayName: "Newton AI",
241
+ displayName: 'Newton AI',
103
242
  profilePicture: options.customSender?.profilePicture || null,
104
243
  },
105
244
  };
106
245
 
107
- // Broadcast via Pusher
108
246
  try {
109
247
  await pusher.trigger(chatChannel(conversationId), 'new-message', {
110
248
  id: aiMessage.id,
@@ -115,7 +253,7 @@ export async function sendAIMessage(
115
253
  sender: senderInfo,
116
254
  mentionedUserIds: [],
117
255
  meta: aiMessage.meta,
118
- attachments: aiMessage.attachments.map(attachment => ({
256
+ attachments: aiMessage.attachments.map((attachment) => ({
119
257
  id: attachment.id,
120
258
  attachmentId: attachment.id,
121
259
  name: attachment.name,
@@ -138,69 +276,156 @@ export async function sendAIMessage(
138
276
  };
139
277
  }
140
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
+
141
315
  export async function inference<T>(
142
- content: string | OpenAI.Chat.Completions.ChatCompletionMessageParam[],
143
- format?: ZodSchema
316
+ content:
317
+ | string
318
+ | OpenAI.Chat.Completions.ChatCompletionMessageParam[]
319
+ | ResponseInput,
320
+ format?: ZodSchema,
321
+ options?: InferenceOptions,
144
322
  ): Promise<T> {
145
- try {
323
+ const reason = getInferenceReason(options);
324
+ const model = options?.model ?? DEFAULT_INFERENCE_MODEL;
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
+ );
350
+ const operation = format ? 'responses.parse' : 'chat.completions';
146
351
 
352
+ try {
147
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
+ );
148
363
  const completion = await openAIClient.chat.completions.create({
149
- model: 'gpt-5-nano',
150
- messages: typeof content === 'string' ? [
151
- {
152
- role: 'user',
153
- content: content,
154
- },
155
- ] : content as Array<{ role: 'user' | 'assistant' | 'system'; content: string }>,
364
+ model,
365
+ messages,
366
+ ...(options?.temperature !== undefined
367
+ ? { temperature: options.temperature }
368
+ : {}),
369
+ ...(options?.maxTokens !== undefined
370
+ ? { max_tokens: options.maxTokens }
371
+ : {}),
372
+ });
373
+
374
+ await logInferenceUsage({
375
+ model,
376
+ operation,
377
+ success: true,
378
+ estimatedInputTokens,
379
+ reason,
380
+ metadata: options?.metadata,
381
+ usage: getChatCompletionUsage(completion),
156
382
  });
157
383
 
158
384
  return completion.choices[0]?.message?.content as T;
159
385
  }
160
386
 
387
+ const input = buildResponsesParseInput(content);
161
388
  const completion = await openAIClient.responses.parse({
162
- model: 'gpt-5-nano',
163
- input: typeof content === 'string' ? [
164
- {
165
- role: 'user',
166
- content: content,
167
- },
168
- ] : content as Array<{ role: 'user' | 'assistant' | 'system'; content: string }>,
169
- ...(format ? { text: {
170
- format: zodTextFormat(format, "newton_response_format"),
171
- },
172
- } : {}),
173
- });
174
-
175
- console.log({
176
- model: 'gpt-5-nano',
177
- input: typeof content === 'string' ? [
178
- {
179
- role: 'user',
180
- content: content,
181
- },
182
- ] : content as Array<{ role: 'user' | 'assistant' | 'system'; content: string }>,
183
- ...(format ? { text: {
184
- format: zodTextFormat(format, "newton_response_format"),
185
- },
186
- } : {}),
389
+ model,
390
+ input,
391
+ text: {
392
+ format: zodTextFormat(format, 'newton_response_format'),
393
+ },
187
394
  });
188
395
 
189
-
190
396
  if (!completion) {
191
397
  throw new Error('No response generated from inference API');
192
398
  }
193
399
 
194
- // if (format) {
195
- // if (typeof completion.output === 'string') {
196
- // return JSON.parse(completion.output);
197
- // }
198
- // return JSON.parse(completion.output);
199
- // }
400
+ await logInferenceUsage({
401
+ model,
402
+ operation,
403
+ success: true,
404
+ estimatedInputTokens,
405
+ reason,
406
+ metadata: options?.metadata,
407
+ usage: getResponseUsage(completion),
408
+ });
200
409
 
201
- return completion.output_parsed;
410
+ return completion.output_parsed as T;
202
411
  } catch (error) {
203
- logger.error('Failed to generate inference response', { error });
412
+ await logInferenceUsage({
413
+ model,
414
+ operation,
415
+ success: false,
416
+ estimatedInputTokens,
417
+ reason,
418
+ metadata: options?.metadata,
419
+ error,
420
+ });
421
+
422
+ logger.error('Failed to generate inference response', {
423
+ error,
424
+ reason,
425
+ model,
426
+ operation,
427
+ metadata: options?.metadata,
428
+ });
204
429
  throw error;
205
430
  }
206
431
  }
@@ -217,7 +442,7 @@ export async function getAvailableModels(): Promise<string[]> {
217
442
  .sort();
218
443
  } catch (error) {
219
444
  logger.error('Failed to fetch inference models', { error });
220
- return ['command-r-plus', 'command-r', 'command-light']; // Fallback Cohere models
445
+ return ['command-r-plus', 'command-r', 'command-light'];
221
446
  }
222
447
  }
223
448
 
@@ -225,6 +450,5 @@ export async function getAvailableModels(): Promise<string[]> {
225
450
  * Estimate token count for a message (rough approximation)
226
451
  */
227
452
  export function estimateTokenCount(text: string): number {
228
- // Rough approximation: 1 token ≈ 4 characters for English text
229
453
  return Math.ceil(text.length / 4);
230
- }
454
+ }
@@ -9,6 +9,7 @@ import { appRouter } from '../src/routers/_app';
9
9
  import { createTRPCContext } from '../src/trpc';
10
10
  import { clearDatabase } from '../src/seedDatabase';
11
11
  import { execSync } from 'child_process';
12
+ import { SessionType } from '@prisma/client';
12
13
 
13
14
  async function runSetup() {
14
15
  const ctx = await createTRPCContext({
@@ -40,9 +41,18 @@ async function runSetup() {
40
41
  confirmPassword: 'password_is_1234',
41
42
  });
42
43
 
43
- const session1 = await prisma.session.findFirst({ where: { userId: user1.user.id } });
44
- const session2 = await prisma.session.findFirst({ where: { userId: user2.user.id } });
45
- const session3 = await prisma.session.findFirst({ where: { userId: user3.user.id } });
44
+ const session1 = await prisma.session.findFirst({
45
+ where: { userId: user1.user.id, type: SessionType.EMAIL_VERIFICATION },
46
+ orderBy: { createdAt: 'desc' },
47
+ });
48
+ const session2 = await prisma.session.findFirst({
49
+ where: { userId: user2.user.id, type: SessionType.EMAIL_VERIFICATION },
50
+ orderBy: { createdAt: 'desc' },
51
+ });
52
+ const session3 = await prisma.session.findFirst({
53
+ where: { userId: user3.user.id, type: SessionType.EMAIL_VERIFICATION },
54
+ orderBy: { createdAt: 'desc' },
55
+ });
46
56
 
47
57
  if (!session1 || !session2 || !session3) {
48
58
  throw new Error('Failed to create sessions for test users');
@@ -59,4 +69,3 @@ export default async function setup(project: TestProject) {
59
69
  project.onTestsRerun(runSetup);
60
70
  await runSetup();
61
71
  }
62
-
@@ -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
+ });