@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/models/worksheet.ts
CHANGED
|
@@ -4,6 +4,21 @@
|
|
|
4
4
|
import { prisma } from "../lib/prisma.js";
|
|
5
5
|
import type { WorksheetQuestionType, GenerationStatus } from "@prisma/client";
|
|
6
6
|
|
|
7
|
+
const worksheetClassMemberWhere = (userId: string) => ({
|
|
8
|
+
class: {
|
|
9
|
+
OR: [
|
|
10
|
+
{ teachers: { some: { id: userId } } },
|
|
11
|
+
{ students: { some: { id: userId } } },
|
|
12
|
+
],
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const worksheetTeacherWhere = (userId: string) => ({
|
|
17
|
+
class: {
|
|
18
|
+
teachers: { some: { id: userId } },
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
7
22
|
/** @returns Worksheet with questions and class. */
|
|
8
23
|
export function findWorksheetById(id: string) {
|
|
9
24
|
return prisma.worksheet.findUnique({
|
|
@@ -22,6 +37,34 @@ export function findWorksheetByIdMinimal(id: string) {
|
|
|
22
37
|
});
|
|
23
38
|
}
|
|
24
39
|
|
|
40
|
+
/** @returns Worksheet by ID for an authorized class member. */
|
|
41
|
+
export function findWorksheetByIdForClassMember(id: string, userId: string) {
|
|
42
|
+
return prisma.worksheet.findFirst({
|
|
43
|
+
where: {
|
|
44
|
+
id,
|
|
45
|
+
...worksheetClassMemberWhere(userId),
|
|
46
|
+
},
|
|
47
|
+
include: {
|
|
48
|
+
questions: { orderBy: { createdAt: "asc" as const } },
|
|
49
|
+
class: true,
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** @returns Worksheet by ID for an authorized teacher. */
|
|
55
|
+
export function findWorksheetByIdForTeacher(id: string, userId: string) {
|
|
56
|
+
return prisma.worksheet.findFirst({
|
|
57
|
+
where: {
|
|
58
|
+
id,
|
|
59
|
+
...worksheetTeacherWhere(userId),
|
|
60
|
+
},
|
|
61
|
+
include: {
|
|
62
|
+
questions: { orderBy: { createdAt: "asc" as const } },
|
|
63
|
+
class: true,
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
25
68
|
/** @returns Worksheets for a class. */
|
|
26
69
|
export function findWorksheetsByClassId(classId: string) {
|
|
27
70
|
return prisma.worksheet.findMany({
|
|
@@ -33,6 +76,26 @@ export function findWorksheetsByClassId(classId: string) {
|
|
|
33
76
|
});
|
|
34
77
|
}
|
|
35
78
|
|
|
79
|
+
/** @returns Worksheet question by ID for an authorized teacher. */
|
|
80
|
+
export function findWorksheetQuestionByIdForTeacher(id: string, userId: string) {
|
|
81
|
+
return prisma.worksheetQuestion.findFirst({
|
|
82
|
+
where: {
|
|
83
|
+
id,
|
|
84
|
+
worksheet: worksheetTeacherWhere(userId),
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** @returns Worksheet question scoped to a worksheet. */
|
|
90
|
+
export function findWorksheetQuestionByIdInWorksheet(
|
|
91
|
+
id: string,
|
|
92
|
+
worksheetId: string
|
|
93
|
+
) {
|
|
94
|
+
return prisma.worksheetQuestion.findFirst({
|
|
95
|
+
where: { id, worksheetId },
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
36
99
|
/** Create a worksheet. */
|
|
37
100
|
export function createWorksheet(data: { classId: string; name: string }) {
|
|
38
101
|
return prisma.worksheet.create({
|
|
@@ -72,6 +135,7 @@ export function createWorksheetQuestion(data: {
|
|
|
72
135
|
points?: number;
|
|
73
136
|
options?: object;
|
|
74
137
|
markScheme?: object;
|
|
138
|
+
order?: number;
|
|
75
139
|
}) {
|
|
76
140
|
return prisma.worksheetQuestion.create({
|
|
77
141
|
data,
|
|
@@ -118,6 +182,45 @@ export function findSubmissionById(id: string) {
|
|
|
118
182
|
});
|
|
119
183
|
}
|
|
120
184
|
|
|
185
|
+
/** @returns Submission if requester is the owning student or a class teacher for the worksheet. */
|
|
186
|
+
export function findSubmissionByIdForWorksheetAccess(
|
|
187
|
+
id: string,
|
|
188
|
+
worksheetId: string,
|
|
189
|
+
worksheetClassId: string,
|
|
190
|
+
userId: string
|
|
191
|
+
) {
|
|
192
|
+
return prisma.submission.findFirst({
|
|
193
|
+
where: {
|
|
194
|
+
id,
|
|
195
|
+
assignment: {
|
|
196
|
+
classId: worksheetClassId,
|
|
197
|
+
worksheets: { some: { id: worksheetId } },
|
|
198
|
+
},
|
|
199
|
+
OR: [
|
|
200
|
+
{
|
|
201
|
+
studentId: userId,
|
|
202
|
+
assignment: {
|
|
203
|
+
class: {
|
|
204
|
+
students: { some: { id: userId } },
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
assignment: {
|
|
210
|
+
class: {
|
|
211
|
+
teachers: { some: { id: userId } },
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
},
|
|
217
|
+
select: {
|
|
218
|
+
id: true,
|
|
219
|
+
studentId: true,
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
121
224
|
const worksheetResponseInclude = {
|
|
122
225
|
responses: {
|
|
123
226
|
include: {
|
|
@@ -182,6 +285,29 @@ export function findWorksheetResponseById(id: string) {
|
|
|
182
285
|
});
|
|
183
286
|
}
|
|
184
287
|
|
|
288
|
+
/** @returns Worksheet response if requester is the owning student. */
|
|
289
|
+
export function findWorksheetResponseByIdForStudent(id: string, userId: string) {
|
|
290
|
+
return prisma.studentWorksheetResponse.findFirst({
|
|
291
|
+
where: {
|
|
292
|
+
id,
|
|
293
|
+
studentId: userId,
|
|
294
|
+
worksheet: worksheetClassMemberWhere(userId),
|
|
295
|
+
},
|
|
296
|
+
include: { responses: true, worksheet: true },
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** @returns Worksheet response if requester is a class teacher. */
|
|
301
|
+
export function findWorksheetResponseByIdForTeacher(id: string, userId: string) {
|
|
302
|
+
return prisma.studentWorksheetResponse.findFirst({
|
|
303
|
+
where: {
|
|
304
|
+
id,
|
|
305
|
+
worksheet: worksheetTeacherWhere(userId),
|
|
306
|
+
},
|
|
307
|
+
include: { responses: true, worksheet: true },
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
185
311
|
/** @returns Worksheet response with question responses. */
|
|
186
312
|
export function findWorksheetResponseWithResponses(
|
|
187
313
|
id: string,
|
|
@@ -197,6 +323,43 @@ export function findWorksheetResponseWithResponses(
|
|
|
197
323
|
});
|
|
198
324
|
}
|
|
199
325
|
|
|
326
|
+
/** @returns Worksheet response with scoped responses if requester is the owning student. */
|
|
327
|
+
export function findWorksheetResponseWithResponsesForStudent(
|
|
328
|
+
id: string,
|
|
329
|
+
userId: string,
|
|
330
|
+
questionId?: string
|
|
331
|
+
) {
|
|
332
|
+
return prisma.studentWorksheetResponse.findFirst({
|
|
333
|
+
where: {
|
|
334
|
+
id,
|
|
335
|
+
studentId: userId,
|
|
336
|
+
worksheet: worksheetClassMemberWhere(userId),
|
|
337
|
+
},
|
|
338
|
+
include: {
|
|
339
|
+
worksheet: true,
|
|
340
|
+
responses: questionId ? { where: { questionId } } : true,
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** @returns Worksheet response with scoped responses if requester is a class teacher. */
|
|
346
|
+
export function findWorksheetResponseWithResponsesForTeacher(
|
|
347
|
+
id: string,
|
|
348
|
+
userId: string,
|
|
349
|
+
questionId?: string
|
|
350
|
+
) {
|
|
351
|
+
return prisma.studentWorksheetResponse.findFirst({
|
|
352
|
+
where: {
|
|
353
|
+
id,
|
|
354
|
+
worksheet: worksheetTeacherWhere(userId),
|
|
355
|
+
},
|
|
356
|
+
include: {
|
|
357
|
+
worksheet: true,
|
|
358
|
+
responses: questionId ? { where: { questionId } } : true,
|
|
359
|
+
},
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
200
363
|
/** @returns Worksheet question by ID. */
|
|
201
364
|
export function findWorksheetQuestionById(id: string) {
|
|
202
365
|
return prisma.worksheetQuestion.findUnique({
|
|
@@ -204,6 +367,46 @@ export function findWorksheetQuestionById(id: string) {
|
|
|
204
367
|
});
|
|
205
368
|
}
|
|
206
369
|
|
|
370
|
+
/** @returns Student question progress if requester is a class teacher. */
|
|
371
|
+
export function findStudentQuestionProgressByIdForTeacher(
|
|
372
|
+
id: string,
|
|
373
|
+
userId: string
|
|
374
|
+
) {
|
|
375
|
+
return prisma.studentQuestionProgress.findFirst({
|
|
376
|
+
where: {
|
|
377
|
+
id,
|
|
378
|
+
question: {
|
|
379
|
+
worksheet: worksheetTeacherWhere(userId),
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
include: {
|
|
383
|
+
question: true,
|
|
384
|
+
studentWorksheetResponse: true,
|
|
385
|
+
},
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** @returns Student question progress by id and worksheet response if requester is a class teacher. */
|
|
390
|
+
export function findStudentQuestionProgressByIdForTeacherAndResponse(
|
|
391
|
+
id: string,
|
|
392
|
+
studentWorksheetResponseId: string,
|
|
393
|
+
userId: string
|
|
394
|
+
) {
|
|
395
|
+
return prisma.studentQuestionProgress.findFirst({
|
|
396
|
+
where: {
|
|
397
|
+
id,
|
|
398
|
+
studentWorksheetResponseId,
|
|
399
|
+
question: {
|
|
400
|
+
worksheet: worksheetTeacherWhere(userId),
|
|
401
|
+
},
|
|
402
|
+
},
|
|
403
|
+
include: {
|
|
404
|
+
question: true,
|
|
405
|
+
studentWorksheetResponse: true,
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
207
410
|
/** Update student's question progress. */
|
|
208
411
|
export function updateStudentQuestionProgress(
|
|
209
412
|
id: string,
|
|
@@ -479,7 +479,15 @@ export const generateAndSendLabIntroduction = async (
|
|
|
479
479
|
// });
|
|
480
480
|
|
|
481
481
|
const response = await withTimeout(
|
|
482
|
-
inference<LabChatResponse>(messages, labChatResponseSchema
|
|
482
|
+
inference<LabChatResponse>(messages, labChatResponseSchema, {
|
|
483
|
+
reason: 'lab_chat_response',
|
|
484
|
+
metadata: {
|
|
485
|
+
labChatId,
|
|
486
|
+
conversationId,
|
|
487
|
+
classId: fullLabChat.classId,
|
|
488
|
+
...(emitOptions?.messageId ? { messageId: emitOptions.messageId } : {}),
|
|
489
|
+
},
|
|
490
|
+
}),
|
|
483
491
|
LAB_CHAT_RESPONSE_TIMEOUT_MS,
|
|
484
492
|
"Lab chat response generation",
|
|
485
493
|
);
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Uses assignment context and AI policy levels for tutoring.
|
|
4
4
|
*/
|
|
5
5
|
import { prisma } from "../lib/prisma.js";
|
|
6
|
-
import { inference
|
|
6
|
+
import { inference } from "../utils/inference.js";
|
|
7
7
|
import { logger } from "../utils/logger.js";
|
|
8
8
|
import { sendAIMessage } from "../utils/inference.js";
|
|
9
9
|
import { isAIUser } from "../utils/aiUser.js";
|
|
@@ -132,20 +132,23 @@ export const generateAndSendNewtonIntroduction = async (
|
|
|
132
132
|
|
|
133
133
|
Do not use markdown formatting in your responses - use plain text only.`;
|
|
134
134
|
|
|
135
|
-
const
|
|
135
|
+
const response = await inference<string>([
|
|
136
|
+
{ role: 'system', content: systemPrompt },
|
|
137
|
+
{
|
|
138
|
+
role: 'user',
|
|
139
|
+
content: 'Please introduce yourself to the student. Explain that you are Newton, their AI tutor, and you are here to help them with their assignment. Ask them what they would like help with.'
|
|
140
|
+
},
|
|
141
|
+
], undefined, {
|
|
142
|
+
reason: 'newton_chat_introduction',
|
|
143
|
+
metadata: {
|
|
144
|
+
newtonChatId,
|
|
145
|
+
conversationId,
|
|
146
|
+
submissionId: submission.id,
|
|
147
|
+
},
|
|
136
148
|
model: 'command-a-03-2025',
|
|
137
|
-
|
|
138
|
-
{ role: 'system', content: systemPrompt },
|
|
139
|
-
{
|
|
140
|
-
role: 'user',
|
|
141
|
-
content: 'Please introduce yourself to the student. Explain that you are Newton, their AI tutor, and you are here to help them with their assignment. Ask them what they would like help with.'
|
|
142
|
-
},
|
|
143
|
-
],
|
|
144
|
-
max_tokens: 300,
|
|
149
|
+
maxTokens: 300,
|
|
145
150
|
temperature: 0.8,
|
|
146
151
|
});
|
|
147
|
-
|
|
148
|
-
const response = completion.choices[0]?.message?.content;
|
|
149
152
|
|
|
150
153
|
if (!response) {
|
|
151
154
|
throw new Error('No response generated from inference API');
|
|
@@ -320,7 +323,14 @@ export const generateAndSendNewtonIntroduction = async (
|
|
|
320
323
|
content: `SYSTEM: ${formatAssignmentString(assignmentData)}`,
|
|
321
324
|
});
|
|
322
325
|
|
|
323
|
-
const response = await inference<string>(messages
|
|
326
|
+
const response = await inference<string>(messages, undefined, {
|
|
327
|
+
reason: 'newton_chat_response',
|
|
328
|
+
metadata: {
|
|
329
|
+
newtonChatId,
|
|
330
|
+
conversationId,
|
|
331
|
+
submissionId: submission.id,
|
|
332
|
+
},
|
|
333
|
+
});
|
|
324
334
|
|
|
325
335
|
if (!response) {
|
|
326
336
|
throw new Error('No response generated from inference API');
|
|
@@ -344,4 +354,4 @@ export const generateAndSendNewtonIntroduction = async (
|
|
|
344
354
|
});
|
|
345
355
|
}
|
|
346
356
|
}
|
|
347
|
-
|
|
357
|
+
|
|
@@ -97,6 +97,14 @@ const gradeWorksheetQuestion = async (worksheetResponseId: string, worksheetQues
|
|
|
97
97
|
})), // @note: this has to be converted to [id: string]: correct boolean
|
|
98
98
|
comments: z.array(z.string()),
|
|
99
99
|
}),
|
|
100
|
+
{
|
|
101
|
+
reason: 'worksheet_autograde',
|
|
102
|
+
metadata: {
|
|
103
|
+
worksheetResponseId: worksheetResponse.id,
|
|
104
|
+
questionId: worksheetQuestionProgressId,
|
|
105
|
+
studentId: worksheetResponse.studentId,
|
|
106
|
+
},
|
|
107
|
+
},
|
|
100
108
|
).catch((error) => {
|
|
101
109
|
logger.error('Failed to grade worksheet response', { error });
|
|
102
110
|
throw error;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PDF URL → worksheet: send a remote PDF URL to structured inference,
|
|
3
|
+
* then persist a Worksheet and WorksheetQuestion rows.
|
|
4
|
+
*/
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import type { ResponseInput } from "openai/resources/responses/responses.js";
|
|
7
|
+
import { prisma } from "../lib/prisma.js";
|
|
8
|
+
import { inference } from "../utils/inference.js";
|
|
9
|
+
import { logger } from "../utils/logger.js";
|
|
10
|
+
|
|
11
|
+
/** Restrict remote fetches to Google-hosted object URLs. */
|
|
12
|
+
function isProbablyGoogleCloudUrl(url: string): boolean {
|
|
13
|
+
try {
|
|
14
|
+
const host = new URL(url).hostname;
|
|
15
|
+
return (
|
|
16
|
+
host.endsWith("storage.googleapis.com") ||
|
|
17
|
+
host.endsWith("googleusercontent.com") ||
|
|
18
|
+
host.includes("storage.cloud.google.com")
|
|
19
|
+
);
|
|
20
|
+
} catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getFilenameFromUrl(url: string): string {
|
|
26
|
+
try {
|
|
27
|
+
const parsed = new URL(url);
|
|
28
|
+
const lastSegment = parsed.pathname.split("/").filter(Boolean).pop();
|
|
29
|
+
return lastSegment && lastSegment.includes(".") ? lastSegment : "document.pdf";
|
|
30
|
+
} catch {
|
|
31
|
+
return "document.pdf";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function sanitizeUrlForLogs(url: string): string {
|
|
36
|
+
try {
|
|
37
|
+
const parsed = new URL(url);
|
|
38
|
+
return `${parsed.origin}${parsed.pathname}`;
|
|
39
|
+
} catch {
|
|
40
|
+
return 'invalid-url';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** LLM output shape (validated by inference). */
|
|
45
|
+
const worksheetDraftSchema = z.object({
|
|
46
|
+
title: z.string(),
|
|
47
|
+
questions: z
|
|
48
|
+
.array(
|
|
49
|
+
z.object({
|
|
50
|
+
type: z.enum([
|
|
51
|
+
"MULTIPLE_CHOICE",
|
|
52
|
+
"TRUE_FALSE",
|
|
53
|
+
"SHORT_ANSWER",
|
|
54
|
+
"LONG_ANSWER",
|
|
55
|
+
"MATH_EXPRESSION",
|
|
56
|
+
"ESSAY",
|
|
57
|
+
]),
|
|
58
|
+
question: z.string(),
|
|
59
|
+
answer: z.string(),
|
|
60
|
+
options: z
|
|
61
|
+
.array(
|
|
62
|
+
z.object({
|
|
63
|
+
id: z.string(),
|
|
64
|
+
text: z.string(),
|
|
65
|
+
isCorrect: z.boolean(),
|
|
66
|
+
})
|
|
67
|
+
)
|
|
68
|
+
.optional()
|
|
69
|
+
.default([]),
|
|
70
|
+
markScheme: z
|
|
71
|
+
.array(
|
|
72
|
+
z.object({
|
|
73
|
+
id: z.string(),
|
|
74
|
+
points: z.number().transform((value) => Math.round(value)),
|
|
75
|
+
description: z.string(),
|
|
76
|
+
})
|
|
77
|
+
)
|
|
78
|
+
.optional()
|
|
79
|
+
.default([]),
|
|
80
|
+
points: z.number().optional().default(0),
|
|
81
|
+
order: z.number().transform((value) => Math.round(value)),
|
|
82
|
+
})
|
|
83
|
+
)
|
|
84
|
+
.default([]),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
type WorksheetDraft = z.infer<typeof worksheetDraftSchema>;
|
|
88
|
+
|
|
89
|
+
const schemaInstructions = `Return ONLY valid JSON matching this schema:
|
|
90
|
+
{
|
|
91
|
+
"title": string,
|
|
92
|
+
"questions": [
|
|
93
|
+
{
|
|
94
|
+
"type": "MULTIPLE_CHOICE" | "TRUE_FALSE" | "SHORT_ANSWER" | "LONG_ANSWER" | "MATH_EXPRESSION" | "ESSAY",
|
|
95
|
+
"question": string,
|
|
96
|
+
"answer": string,
|
|
97
|
+
"options": [ { "id": string, "text": string, "isCorrect": boolean } ],
|
|
98
|
+
"markScheme": [ { "id": string, "points": number, "description": string } ],
|
|
99
|
+
"points": number,
|
|
100
|
+
"order": number
|
|
101
|
+
}
|
|
102
|
+
]
|
|
103
|
+
}
|
|
104
|
+
Rules: "questions" is an array ([] if none). Non-MCQ "options" can be []. Use "order" 1,2,3,… in reading order. No markdown or extra keys.
|
|
105
|
+
If no answer is provided for a multiple-choice or true/false question, determine the answer from the options.`;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Sends a PDF URL to the model for remote retrieval, extracts a worksheet via
|
|
109
|
+
* structured inference, and inserts worksheet + questions in one DB transaction.
|
|
110
|
+
*/
|
|
111
|
+
export async function pdfToWorksheet(gcsUrl: string, classId: string) {
|
|
112
|
+
const sanitizedUrl = sanitizeUrlForLogs(gcsUrl);
|
|
113
|
+
|
|
114
|
+
if (!isProbablyGoogleCloudUrl(gcsUrl)) {
|
|
115
|
+
logger.warn("pdfToWorksheet: rejected unsupported PDF URL", {
|
|
116
|
+
sanitizedUrl,
|
|
117
|
+
});
|
|
118
|
+
throw new Error("Only Google Cloud Storage URLs are supported");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const filename = getFilenameFromUrl(gcsUrl);
|
|
122
|
+
const prompt = `Read the attached PDF and convert it into a single worksheet JSON object.\n\n${schemaInstructions}`;
|
|
123
|
+
|
|
124
|
+
// Force Responses API input types so we can pass a remote PDF via `file_url`.
|
|
125
|
+
const input: ResponseInput = [
|
|
126
|
+
{
|
|
127
|
+
role: "user",
|
|
128
|
+
content: [
|
|
129
|
+
{ type: "input_text", text: prompt },
|
|
130
|
+
{ type: "input_file", file_url: gcsUrl },
|
|
131
|
+
],
|
|
132
|
+
},
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
const draft = await inference<WorksheetDraft>(
|
|
136
|
+
input,
|
|
137
|
+
worksheetDraftSchema,
|
|
138
|
+
{
|
|
139
|
+
reason: 'pdf_to_worksheet_draft',
|
|
140
|
+
metadata: {
|
|
141
|
+
classId,
|
|
142
|
+
filename,
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
if (!draft) {
|
|
148
|
+
logger.warn("pdfToWorksheet: no model output", { filename });
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return prisma.$transaction(async (tx) => {
|
|
153
|
+
const worksheet = await tx.worksheet.create({
|
|
154
|
+
data: { classId, name: draft.title },
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
if (draft.questions.length > 0) {
|
|
158
|
+
await tx.worksheetQuestion.createMany({
|
|
159
|
+
data: draft.questions.map((question) => ({
|
|
160
|
+
worksheetId: worksheet.id,
|
|
161
|
+
type: question.type,
|
|
162
|
+
question: question.question,
|
|
163
|
+
answer: question.answer,
|
|
164
|
+
points: Math.round(question.points ?? 0),
|
|
165
|
+
options: question.options ?? [],
|
|
166
|
+
markScheme: (question.markScheme ?? []).map((item) => ({
|
|
167
|
+
...item,
|
|
168
|
+
points: Math.round(item.points),
|
|
169
|
+
})),
|
|
170
|
+
order: Math.round(question.order),
|
|
171
|
+
})),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return worksheet;
|
|
176
|
+
});
|
|
177
|
+
}
|
package/src/routers/class.ts
CHANGED
|
@@ -46,7 +46,7 @@ export const classRouter = createTRPCRouter({
|
|
|
46
46
|
getAllClasses(ctx.user?.id ?? "")
|
|
47
47
|
),
|
|
48
48
|
|
|
49
|
-
get:
|
|
49
|
+
get: protectedClassMemberProcedure
|
|
50
50
|
.input(z.object({ classId: z.string() }))
|
|
51
51
|
.query(({ ctx, input }) =>
|
|
52
52
|
getClass(ctx.user?.id ?? "", input.classId)
|
|
@@ -329,4 +329,4 @@ export const classRouter = createTRPCRouter({
|
|
|
329
329
|
.mutation(({ ctx, input }) =>
|
|
330
330
|
importClass(input.classId, ctx.user?.id ?? "", input.year, input.classData)
|
|
331
331
|
),
|
|
332
|
-
});
|
|
332
|
+
});
|
package/src/routers/labChat.ts
CHANGED
|
@@ -38,8 +38,9 @@ export const labChatRouter = createTRPCRouter({
|
|
|
38
38
|
.input(
|
|
39
39
|
z.object({
|
|
40
40
|
labChatId: z.string(),
|
|
41
|
-
content: z.string().
|
|
41
|
+
content: z.string().max(4000),
|
|
42
42
|
mentionedUserIds: z.array(z.string()).optional(),
|
|
43
|
+
fileIds: z.array(z.string()).max(10).optional(),
|
|
43
44
|
})
|
|
44
45
|
)
|
|
45
46
|
.mutation(({ ctx, input }) =>
|
|
@@ -47,6 +48,7 @@ export const labChatRouter = createTRPCRouter({
|
|
|
47
48
|
labChatId: input.labChatId,
|
|
48
49
|
content: input.content,
|
|
49
50
|
mentionedUserIds: input.mentionedUserIds,
|
|
51
|
+
fileIds: input.fileIds,
|
|
50
52
|
})
|
|
51
53
|
),
|
|
52
54
|
|
package/src/routers/message.ts
CHANGED
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
sendMessage,
|
|
6
6
|
updateMessage,
|
|
7
7
|
deleteMessage,
|
|
8
|
+
getMessageUploadUrls,
|
|
9
|
+
confirmMessageUpload,
|
|
8
10
|
markAsRead,
|
|
9
11
|
markMentionsAsRead,
|
|
10
12
|
markSuggestionCreated,
|
|
@@ -32,8 +34,9 @@ export const messageRouter = createTRPCRouter({
|
|
|
32
34
|
.input(
|
|
33
35
|
z.object({
|
|
34
36
|
conversationId: z.string(),
|
|
35
|
-
content: z.string().
|
|
37
|
+
content: z.string().max(4000),
|
|
36
38
|
mentionedUserIds: z.array(z.string()).optional(),
|
|
39
|
+
fileIds: z.array(z.string()).max(10).optional(),
|
|
37
40
|
})
|
|
38
41
|
)
|
|
39
42
|
.mutation(({ ctx, input }) =>
|
|
@@ -41,9 +44,43 @@ export const messageRouter = createTRPCRouter({
|
|
|
41
44
|
conversationId: input.conversationId,
|
|
42
45
|
content: input.content,
|
|
43
46
|
mentionedUserIds: input.mentionedUserIds,
|
|
47
|
+
fileIds: input.fileIds,
|
|
44
48
|
})
|
|
45
49
|
),
|
|
46
50
|
|
|
51
|
+
getUploadUrls: protectedProcedure
|
|
52
|
+
.input(
|
|
53
|
+
z.object({
|
|
54
|
+
conversationId: z.string(),
|
|
55
|
+
files: z
|
|
56
|
+
.array(
|
|
57
|
+
z.object({
|
|
58
|
+
name: z.string().min(1),
|
|
59
|
+
type: z.string().min(1),
|
|
60
|
+
size: z.number().int().nonnegative(),
|
|
61
|
+
})
|
|
62
|
+
)
|
|
63
|
+
.min(1)
|
|
64
|
+
.max(10),
|
|
65
|
+
})
|
|
66
|
+
)
|
|
67
|
+
.mutation(({ ctx, input }) =>
|
|
68
|
+
getMessageUploadUrls(ctx.user!.id, input)
|
|
69
|
+
),
|
|
70
|
+
|
|
71
|
+
confirmUpload: protectedProcedure
|
|
72
|
+
.input(
|
|
73
|
+
z.object({
|
|
74
|
+
conversationId: z.string(),
|
|
75
|
+
fileId: z.string(),
|
|
76
|
+
uploadSuccess: z.boolean(),
|
|
77
|
+
errorMessage: z.string().optional(),
|
|
78
|
+
})
|
|
79
|
+
)
|
|
80
|
+
.mutation(({ ctx, input }) =>
|
|
81
|
+
confirmMessageUpload(ctx.user!.id, input)
|
|
82
|
+
),
|
|
83
|
+
|
|
47
84
|
update: protectedProcedure
|
|
48
85
|
.input(
|
|
49
86
|
z.object({
|
|
@@ -21,8 +21,9 @@ export const newtonChatRouter = createTRPCRouter({
|
|
|
21
21
|
.input(
|
|
22
22
|
z.object({
|
|
23
23
|
newtonChatId: z.string(),
|
|
24
|
-
content: z.string().
|
|
24
|
+
content: z.string().max(4000),
|
|
25
25
|
mentionedUserIds: z.array(z.string()).optional(),
|
|
26
|
+
fileIds: z.array(z.string()).max(10).optional(),
|
|
26
27
|
})
|
|
27
28
|
)
|
|
28
29
|
.mutation(({ ctx, input }) =>
|
|
@@ -30,7 +31,8 @@ export const newtonChatRouter = createTRPCRouter({
|
|
|
30
31
|
ctx.user!.id,
|
|
31
32
|
input.newtonChatId,
|
|
32
33
|
input.content,
|
|
33
|
-
input.mentionedUserIds ?? []
|
|
34
|
+
input.mentionedUserIds ?? [],
|
|
35
|
+
input.fileIds ?? []
|
|
34
36
|
)
|
|
35
37
|
),
|
|
36
38
|
});
|
|
@@ -15,7 +15,7 @@ export const notificationRouter = createTRPCRouter({
|
|
|
15
15
|
|
|
16
16
|
get: protectedProcedure
|
|
17
17
|
.input(z.object({ id: z.string() }))
|
|
18
|
-
.query(({ input }) => getNotification(input.id)),
|
|
18
|
+
.query(({ ctx, input }) => getNotification(ctx.user!.id, input.id)),
|
|
19
19
|
|
|
20
20
|
sendTo: protectedProcedure
|
|
21
21
|
.input(
|
|
@@ -39,5 +39,5 @@ export const notificationRouter = createTRPCRouter({
|
|
|
39
39
|
|
|
40
40
|
markAsRead: protectedProcedure
|
|
41
41
|
.input(z.object({ id: z.string() }))
|
|
42
|
-
.mutation(({ input }) => markAsRead(input.id)),
|
|
42
|
+
.mutation(({ ctx, input }) => markAsRead(ctx.user!.id, input.id)),
|
|
43
43
|
});
|