@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.
- package/.coderabbit.yaml +4 -1
- package/.vscode/settings.json +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +68 -29
- package/dist/index.js.map +1 -1
- package/dist/lib/fileUpload.d.ts +10 -2
- package/dist/lib/fileUpload.d.ts.map +1 -1
- package/dist/lib/fileUpload.js +38 -6
- package/dist/lib/fileUpload.js.map +1 -1
- package/dist/middleware/auth.d.ts.map +1 -1
- package/dist/middleware/auth.js +18 -5
- package/dist/middleware/auth.js.map +1 -1
- package/dist/models/auth.d.ts +19 -14
- package/dist/models/auth.d.ts.map +1 -1
- package/dist/models/auth.js +53 -30
- package/dist/models/auth.js.map +1 -1
- package/dist/models/class.d.ts +3 -1
- package/dist/models/class.d.ts.map +1 -1
- package/dist/models/class.js +16 -4
- package/dist/models/class.js.map +1 -1
- package/dist/models/notification.d.ts +11 -10
- package/dist/models/notification.d.ts.map +1 -1
- package/dist/models/notification.js +13 -9
- package/dist/models/notification.js.map +1 -1
- package/dist/models/worksheet.d.ts +379 -7
- package/dist/models/worksheet.d.ts.map +1 -1
- package/dist/models/worksheet.js +168 -2
- package/dist/models/worksheet.js.map +1 -1
- package/dist/pipelines/aiLabChat.d.ts.map +1 -1
- package/dist/pipelines/aiLabChat.js +11 -3
- package/dist/pipelines/aiLabChat.js.map +1 -1
- package/dist/pipelines/aiLabChatContract.d.ts +3 -3
- package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
- package/dist/pipelines/aiNewtonChat.js +25 -14
- package/dist/pipelines/aiNewtonChat.js.map +1 -1
- package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
- package/dist/pipelines/gradeWorksheet.js +10 -3
- package/dist/pipelines/gradeWorksheet.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 +264 -16
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/class.d.ts +2 -1
- package/dist/routers/class.d.ts.map +1 -1
- package/dist/routers/class.js +3 -3
- package/dist/routers/class.js.map +1 -1
- package/dist/routers/labChat.d.ts +6 -0
- package/dist/routers/labChat.d.ts.map +1 -1
- package/dist/routers/labChat.js +5 -3
- package/dist/routers/labChat.js.map +1 -1
- package/dist/routers/message.d.ts +31 -0
- package/dist/routers/message.d.ts.map +1 -1
- package/dist/routers/message.js +27 -4
- package/dist/routers/message.js.map +1 -1
- package/dist/routers/newtonChat.d.ts +6 -0
- package/dist/routers/newtonChat.d.ts.map +1 -1
- package/dist/routers/newtonChat.js +5 -4
- package/dist/routers/newtonChat.js.map +1 -1
- package/dist/routers/notifications.d.ts +9 -2
- package/dist/routers/notifications.d.ts.map +1 -1
- package/dist/routers/notifications.js +4 -4
- package/dist/routers/notifications.js.map +1 -1
- package/dist/routers/worksheet.d.ts +78 -5
- package/dist/routers/worksheet.d.ts.map +1 -1
- package/dist/routers/worksheet.js +126 -17
- package/dist/routers/worksheet.js.map +1 -1
- package/dist/services/auth.d.ts +1 -1
- package/dist/services/auth.d.ts.map +1 -1
- package/dist/services/auth.js +19 -12
- package/dist/services/auth.js.map +1 -1
- package/dist/services/class.d.ts +1 -1
- package/dist/services/class.d.ts.map +1 -1
- package/dist/services/class.js +6 -3
- package/dist/services/class.js.map +1 -1
- package/dist/services/file.d.ts.map +1 -1
- package/dist/services/file.js +7 -2
- package/dist/services/file.js.map +1 -1
- package/dist/services/labChat.d.ts +6 -0
- package/dist/services/labChat.d.ts.map +1 -1
- package/dist/services/labChat.js +30 -18
- package/dist/services/labChat.js.map +1 -1
- package/dist/services/message.d.ts +30 -0
- package/dist/services/message.d.ts.map +1 -1
- package/dist/services/message.js +97 -21
- package/dist/services/message.js.map +1 -1
- package/dist/services/newtonChat.d.ts +6 -1
- package/dist/services/newtonChat.d.ts.map +1 -1
- package/dist/services/newtonChat.js +36 -18
- package/dist/services/newtonChat.js.map +1 -1
- package/dist/services/notification.d.ts +12 -5
- package/dist/services/notification.d.ts.map +1 -1
- package/dist/services/notification.js +24 -9
- package/dist/services/notification.js.map +1 -1
- package/dist/services/studentProgress.d.ts.map +1 -1
- package/dist/services/studentProgress.js +6 -3
- package/dist/services/studentProgress.js.map +1 -1
- package/dist/services/worksheet.d.ts +36 -15
- package/dist/services/worksheet.d.ts.map +1 -1
- package/dist/services/worksheet.js +186 -54
- package/dist/services/worksheet.js.map +1 -1
- package/dist/trpc.d.ts.map +1 -1
- package/dist/trpc.js +11 -17
- package/dist/trpc.js.map +1 -1
- package/dist/utils/inference.d.ts +10 -1
- package/dist/utils/inference.d.ts.map +1 -1
- package/dist/utils/inference.js +186 -50
- package/dist/utils/inference.js.map +1 -1
- package/package.json +1 -1
- package/prisma/migrations/20260415103500_add_session_type/migration.sql +33 -0
- package/prisma/migrations/20260415133000_add_ai_usage_logs/migration.sql +19 -0
- package/prisma/schema.prisma +27 -1
- package/scripts/test-pre-push.ts +6 -2
- package/src/index.ts +83 -30
- package/src/lib/fileUpload.ts +55 -5
- package/src/middleware/auth.ts +19 -4
- package/src/models/auth.ts +59 -29
- package/src/models/class.ts +15 -4
- package/src/models/notification.ts +13 -7
- package/src/models/worksheet.ts +203 -0
- package/src/pipelines/aiLabChat.ts +9 -1
- package/src/pipelines/aiNewtonChat.ts +24 -14
- package/src/pipelines/gradeWorksheet.ts +8 -0
- package/src/pipelines/pdfToWorksheet.ts +177 -0
- package/src/routers/class.ts +2 -2
- package/src/routers/labChat.ts +3 -1
- package/src/routers/message.ts +38 -1
- package/src/routers/newtonChat.ts +4 -2
- package/src/routers/notifications.ts +2 -2
- package/src/routers/worksheet.ts +174 -19
- package/src/services/auth.ts +26 -12
- package/src/services/class.ts +4 -1
- package/src/services/file.ts +6 -0
- package/src/services/labChat.ts +42 -18
- package/src/services/message.ts +163 -18
- package/src/services/newtonChat.ts +49 -18
- package/src/services/notification.ts +27 -8
- package/src/services/studentProgress.ts +4 -1
- package/src/services/worksheet.ts +252 -51
- package/src/trpc.ts +12 -18
- package/src/utils/inference.ts +280 -56
- package/tests/globalSetup.ts +13 -4
- package/tests/pipelines/pdfToWorksheet.test.ts +176 -0
- package/tests/routers/auth.test.ts +120 -17
- package/tests/routers/class.test.ts +7 -1
- package/tests/routers/message.test.ts +236 -9
- package/tests/routers/newtonChat.test.ts +81 -0
- package/tests/routers/notification.test.ts +12 -0
- package/tests/routers/worksheet.test.ts +194 -0
- package/tests/setup.ts +22 -5
- package/tests/utils/inference.test.ts +255 -0
package/src/utils/inference.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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:
|
|
143
|
-
|
|
316
|
+
content:
|
|
317
|
+
| string
|
|
318
|
+
| OpenAI.Chat.Completions.ChatCompletionMessageParam[]
|
|
319
|
+
| ResponseInput,
|
|
320
|
+
format?: ZodSchema,
|
|
321
|
+
options?: InferenceOptions,
|
|
144
322
|
): Promise<T> {
|
|
145
|
-
|
|
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
|
|
150
|
-
messages
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
163
|
-
input
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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'];
|
|
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
|
+
}
|
package/tests/globalSetup.ts
CHANGED
|
@@ -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({
|
|
44
|
-
|
|
45
|
-
|
|
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
|
+
});
|