@studious-lms/server 1.19.0 → 1.21.0
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/Dockerfile +5 -2
- package/dist/index.js +8 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/pusher.d.ts +3 -1
- package/dist/lib/pusher.d.ts.map +1 -1
- package/dist/lib/pusher.js +5 -3
- package/dist/lib/pusher.js.map +1 -1
- package/dist/middleware/security.d.ts.map +1 -1
- package/dist/middleware/security.js +4 -4
- package/dist/middleware/security.js.map +1 -1
- package/dist/models/class.d.ts +1 -1
- package/dist/models/class.d.ts.map +1 -1
- package/dist/models/class.js +5 -4
- package/dist/models/class.js.map +1 -1
- package/dist/models/conversation.d.ts +17 -17
- package/dist/models/notification.d.ts +63 -11
- package/dist/models/notification.d.ts.map +1 -1
- package/dist/models/notification.js +24 -10
- package/dist/models/notification.js.map +1 -1
- package/dist/pipelines/aiLabChat.d.ts +1 -0
- package/dist/pipelines/aiLabChat.d.ts.map +1 -1
- package/dist/pipelines/aiLabChat.js +7 -2
- package/dist/pipelines/aiLabChat.js.map +1 -1
- package/dist/pipelines/aiNewtonChat.d.ts +2 -2
- package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
- package/dist/pipelines/aiNewtonChat.js +12 -4
- package/dist/pipelines/aiNewtonChat.js.map +1 -1
- package/dist/pipelines/gradeWorksheet.d.ts +3 -3
- package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
- package/dist/pipelines/gradeWorksheet.js +13 -9
- package/dist/pipelines/gradeWorksheet.js.map +1 -1
- package/dist/pipelines/pdfToWorksheet.d.ts +2 -0
- package/dist/pipelines/pdfToWorksheet.d.ts.map +1 -1
- package/dist/pipelines/pdfToWorksheet.js +3 -2
- package/dist/pipelines/pdfToWorksheet.js.map +1 -1
- package/dist/routers/_app.d.ts +322 -82
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/announcement.d.ts.map +1 -1
- package/dist/routers/announcement.js +2 -2
- package/dist/routers/announcement.js.map +1 -1
- package/dist/routers/assignment.d.ts +74 -0
- package/dist/routers/assignment.d.ts.map +1 -1
- package/dist/routers/assignment.js +190 -2
- package/dist/routers/assignment.js.map +1 -1
- package/dist/routers/notifications.d.ts +51 -41
- package/dist/routers/notifications.d.ts.map +1 -1
- package/dist/routers/notifications.js +22 -18
- package/dist/routers/notifications.js.map +1 -1
- package/dist/routers/school.d.ts +30 -0
- package/dist/routers/school.d.ts.map +1 -1
- package/dist/routers/school.js +11 -2
- package/dist/routers/school.js.map +1 -1
- package/dist/routers/user.d.ts +12 -0
- package/dist/routers/user.d.ts.map +1 -1
- package/dist/routers/user.js +11 -2
- package/dist/routers/user.js.map +1 -1
- package/dist/seedDatabase.d.ts +4 -0
- package/dist/seedDatabase.d.ts.map +1 -1
- package/dist/services/aiChatStream.d.ts +5 -0
- package/dist/services/aiChatStream.d.ts.map +1 -1
- package/dist/services/aiChatStream.js +32 -3
- package/dist/services/aiChatStream.js.map +1 -1
- package/dist/services/aiStream.d.ts +5 -0
- package/dist/services/aiStream.d.ts.map +1 -1
- package/dist/services/aiStream.js +29 -3
- package/dist/services/aiStream.js.map +1 -1
- package/dist/services/aiUsage.d.ts +72 -0
- package/dist/services/aiUsage.d.ts.map +1 -0
- package/dist/services/aiUsage.js +150 -0
- package/dist/services/aiUsage.js.map +1 -0
- package/dist/services/announcement.d.ts.map +1 -1
- package/dist/services/announcement.js +7 -2
- package/dist/services/announcement.js.map +1 -1
- package/dist/services/assignment.d.ts.map +1 -1
- package/dist/services/assignment.js +29 -4
- package/dist/services/assignment.js.map +1 -1
- package/dist/services/attendance.js +10 -2
- package/dist/services/attendance.js.map +1 -1
- package/dist/services/class.d.ts.map +1 -1
- package/dist/services/class.js +6 -2
- package/dist/services/class.js.map +1 -1
- package/dist/services/discussion.d.ts.map +1 -1
- package/dist/services/discussion.js +41 -12
- package/dist/services/discussion.js.map +1 -1
- package/dist/services/discussionSummary.d.ts.map +1 -1
- package/dist/services/discussionSummary.js +3 -2
- package/dist/services/discussionSummary.js.map +1 -1
- package/dist/services/notification.d.ts +79 -16
- package/dist/services/notification.d.ts.map +1 -1
- package/dist/services/notification.js +64 -16
- package/dist/services/notification.js.map +1 -1
- package/dist/services/studentProgress.d.ts.map +1 -1
- package/dist/services/studentProgress.js +6 -2
- package/dist/services/studentProgress.js.map +1 -1
- package/dist/services/userPrivacy.d.ts +4 -0
- package/dist/services/userPrivacy.d.ts.map +1 -1
- package/dist/services/userPrivacy.js +6 -2
- package/dist/services/userPrivacy.js.map +1 -1
- package/dist/services/worksheet.js +4 -4
- package/dist/services/worksheet.js.map +1 -1
- package/dist/utils/inference.d.ts +27 -0
- package/dist/utils/inference.d.ts.map +1 -1
- package/dist/utils/inference.js +23 -3
- package/dist/utils/inference.js.map +1 -1
- package/dist/workers/processors/aiLabChat.d.ts.map +1 -1
- package/dist/workers/processors/aiLabChat.js +4 -3
- package/dist/workers/processors/aiLabChat.js.map +1 -1
- package/dist/workers/processors/aiNewtonChat.d.ts.map +1 -1
- package/dist/workers/processors/aiNewtonChat.js +5 -5
- package/dist/workers/processors/aiNewtonChat.js.map +1 -1
- package/dist/workers/processors/gradeWorksheet.js +4 -4
- package/dist/workers/processors/gradeWorksheet.js.map +1 -1
- package/dist/workers/processors/pdfToWorksheet.d.ts.map +1 -1
- package/dist/workers/processors/pdfToWorksheet.js +7 -4
- package/dist/workers/processors/pdfToWorksheet.js.map +1 -1
- package/docker-entrypoint.sh +7 -0
- package/package.json +1 -1
- package/prisma/migrations/20260708011456_add_file_annotations/migration.sql +35 -0
- package/prisma/migrations/20260708011500_add_ai_usage_attribution/migration.sql +13 -0
- package/prisma/migrations/20260708013000_add_notification_type_and_entity/migration.sql +14 -0
- package/prisma/schema.prisma +99 -43
- package/src/index.ts +7 -0
- package/src/lib/pusher.ts +3 -1
- package/src/middleware/security.ts +2 -2
- package/src/models/class.ts +3 -2
- package/src/models/notification.ts +42 -13
- package/src/pipelines/aiLabChat.ts +6 -1
- package/src/pipelines/aiNewtonChat.ts +10 -0
- package/src/pipelines/gradeWorksheet.ts +25 -7
- package/src/pipelines/pdfToWorksheet.ts +3 -0
- package/src/routers/announcement.ts +0 -1
- package/src/routers/assignment.ts +214 -0
- package/src/routers/notifications.ts +26 -24
- package/src/routers/school.ts +12 -0
- package/src/routers/user.ts +12 -0
- package/src/services/aiChatStream.ts +35 -0
- package/src/services/aiStream.ts +34 -0
- package/src/services/aiUsage.ts +202 -0
- package/src/services/announcement.ts +5 -0
- package/src/services/assignment.ts +29 -2
- package/src/services/attendance.ts +12 -0
- package/src/services/class.ts +4 -0
- package/src/services/discussion.ts +50 -11
- package/src/services/discussionSummary.ts +1 -0
- package/src/services/notification.ts +83 -20
- package/src/services/studentProgress.ts +4 -0
- package/src/services/userPrivacy.ts +4 -0
- package/src/services/worksheet.ts +2 -2
- package/src/utils/inference.ts +40 -2
- package/src/workers/processors/aiLabChat.ts +2 -1
- package/src/workers/processors/aiNewtonChat.ts +3 -1
- package/src/workers/processors/gradeWorksheet.ts +2 -2
- package/src/workers/processors/pdfToWorksheet.ts +5 -2
- package/tests/integration/routers/notification.test.ts +68 -20
- package/tests/unit/services/aiUsage.test.ts +105 -0
|
@@ -79,7 +79,11 @@ INSTRUCTIONS:
|
|
|
79
79
|
- Mark isCorrect=true only if the student earned full or near-full marks.`;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
const gradeWorksheetQuestion = async (
|
|
82
|
+
const gradeWorksheetQuestion = async (
|
|
83
|
+
worksheetResponseId: string,
|
|
84
|
+
worksheetQuestionProgressId: string,
|
|
85
|
+
actingUserId?: string | null,
|
|
86
|
+
) => {
|
|
83
87
|
|
|
84
88
|
await removeAllPreviousAIComments(worksheetQuestionProgressId);
|
|
85
89
|
|
|
@@ -155,6 +159,10 @@ const gradeWorksheetQuestion = async (worksheetResponseId: string, worksheetQues
|
|
|
155
159
|
questionId: worksheetQuestionProgressId,
|
|
156
160
|
studentId: worksheetResponse.studentId,
|
|
157
161
|
},
|
|
162
|
+
attribution: {
|
|
163
|
+
userId: actingUserId ?? worksheetResponse.studentId,
|
|
164
|
+
classId: worksheetResponse.worksheet.classId,
|
|
165
|
+
},
|
|
158
166
|
},
|
|
159
167
|
).catch((error) => {
|
|
160
168
|
logger.error('Failed to grade worksheet response', { error });
|
|
@@ -259,7 +267,10 @@ const gradeWorksheetQuestion = async (worksheetResponseId: string, worksheetQues
|
|
|
259
267
|
|
|
260
268
|
const DO_NOT_INFERENCE_STATUSES = [JobStatus.CANCELLED, JobStatus.PENDING, JobStatus.COMPLETED];
|
|
261
269
|
|
|
262
|
-
export const gradeWorksheetPipeline = async (
|
|
270
|
+
export const gradeWorksheetPipeline = async (
|
|
271
|
+
worksheetResponseId: string,
|
|
272
|
+
actingUserId?: string | null,
|
|
273
|
+
) => {
|
|
263
274
|
logger.info('Grading worksheet response', { worksheetResponseId });
|
|
264
275
|
const worksheetResponse = await prisma.studentWorksheetResponse.findUnique({
|
|
265
276
|
where: { id: worksheetResponseId },
|
|
@@ -349,7 +360,7 @@ export const gradeWorksheetPipeline = async (worksheetResponseId: string) => {
|
|
|
349
360
|
}
|
|
350
361
|
|
|
351
362
|
try {
|
|
352
|
-
await gradeWorksheetQuestion(worksheetResponseId, response.id);
|
|
363
|
+
await gradeWorksheetQuestion(worksheetResponseId, response.id, actingUserId);
|
|
353
364
|
} catch (questionError) {
|
|
354
365
|
logger.error('Failed to grade individual question, continuing with remaining questions', {
|
|
355
366
|
questionId: response.questionId,
|
|
@@ -387,7 +398,10 @@ export const cancelGradePipeline = async (worksheetResponseId: string, worksheet
|
|
|
387
398
|
return updatedStudentQuestionProgress;
|
|
388
399
|
};
|
|
389
400
|
|
|
390
|
-
export const regradeAllPipeline = async (
|
|
401
|
+
export const regradeAllPipeline = async (
|
|
402
|
+
worksheetResponseId: string,
|
|
403
|
+
actingUserId?: string | null,
|
|
404
|
+
) => {
|
|
391
405
|
logger.info('Regrading all questions for worksheet response', { worksheetResponseId });
|
|
392
406
|
const worksheetResponse = await prisma.studentWorksheetResponse.findUnique({
|
|
393
407
|
where: { id: worksheetResponseId },
|
|
@@ -434,7 +448,7 @@ export const regradeAllPipeline = async (worksheetResponseId: string) => {
|
|
|
434
448
|
// Re-grade each question sequentially
|
|
435
449
|
for (const response of worksheetResponse.responses) {
|
|
436
450
|
try {
|
|
437
|
-
await gradeWorksheetQuestion(worksheetResponseId, response.id);
|
|
451
|
+
await gradeWorksheetQuestion(worksheetResponseId, response.id, actingUserId);
|
|
438
452
|
} catch (questionError) {
|
|
439
453
|
logger.error('Failed to regrade question, continuing with remaining', {
|
|
440
454
|
questionId: response.questionId,
|
|
@@ -444,7 +458,11 @@ export const regradeAllPipeline = async (worksheetResponseId: string) => {
|
|
|
444
458
|
}
|
|
445
459
|
};
|
|
446
460
|
|
|
447
|
-
export const regradeWorksheetPipeline = async (
|
|
461
|
+
export const regradeWorksheetPipeline = async (
|
|
462
|
+
worksheetResponseId: string,
|
|
463
|
+
worksheetQuestionProgressId: string,
|
|
464
|
+
actingUserId?: string | null,
|
|
465
|
+
) => {
|
|
448
466
|
logger.info('Regrading worksheet response', { worksheetResponseId, worksheetQuestionProgressId });
|
|
449
467
|
try {
|
|
450
468
|
const worksheetResponse = await prisma.studentWorksheetResponse.findUnique({
|
|
@@ -479,7 +497,7 @@ export const regradeWorksheetPipeline = async (worksheetResponseId: string, work
|
|
|
479
497
|
where: { id: worksheetQuestionProgressId },
|
|
480
498
|
});
|
|
481
499
|
|
|
482
|
-
await gradeWorksheetQuestion(worksheetResponseId, worksheetQuestionProgressId);
|
|
500
|
+
await gradeWorksheetQuestion(worksheetResponseId, worksheetQuestionProgressId, actingUserId);
|
|
483
501
|
|
|
484
502
|
return updatedStudentQuestionProgress;
|
|
485
503
|
} catch (error) {
|
|
@@ -179,6 +179,8 @@ export interface PdfToWorksheetOptions {
|
|
|
179
179
|
* channel. This is the path used by the BullMQ worker.
|
|
180
180
|
*/
|
|
181
181
|
worksheetId?: string;
|
|
182
|
+
/** Acting user for AI usage attribution. */
|
|
183
|
+
userId?: string | null;
|
|
182
184
|
}
|
|
183
185
|
|
|
184
186
|
/**
|
|
@@ -265,6 +267,7 @@ export async function pdfToWorksheet(
|
|
|
265
267
|
classId,
|
|
266
268
|
filename,
|
|
267
269
|
},
|
|
270
|
+
attribution: { userId: options.userId, classId },
|
|
268
271
|
}
|
|
269
272
|
);
|
|
270
273
|
} catch (error) {
|
|
@@ -21,7 +21,6 @@ import { getReactions as getCommentReactions } from "../services/comment.js";
|
|
|
21
21
|
import { logger } from "../utils/logger.js";
|
|
22
22
|
import { createDirectUploadFiles, type UploadedFile, type DirectUploadFile, confirmDirectUpload } from "../lib/fileUpload.js";
|
|
23
23
|
import { deleteFile } from "../lib/googleCloudStorage.js";
|
|
24
|
-
import { sendToMultiple } from "../services/notification.js";
|
|
25
24
|
|
|
26
25
|
// Schema for direct file uploads (no base64 data)
|
|
27
26
|
const directFileSchema = z.object({
|
|
@@ -180,6 +180,30 @@ const confirmAnnotationUploadSchema = z.object({
|
|
|
180
180
|
errorMessage: z.string().optional(),
|
|
181
181
|
});
|
|
182
182
|
|
|
183
|
+
const listFileAnnotationsSchema = z.object({
|
|
184
|
+
classId: z.string(),
|
|
185
|
+
submissionId: z.string(),
|
|
186
|
+
fileId: z.string(),
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const upsertFileAnnotationSchema = z.object({
|
|
190
|
+
classId: z.string(),
|
|
191
|
+
submissionId: z.string(),
|
|
192
|
+
fileId: z.string(),
|
|
193
|
+
id: z.string().optional(),
|
|
194
|
+
page: z.number().int().min(1),
|
|
195
|
+
type: z.enum(["HIGHLIGHT", "INK", "COMMENT", "TEXT"]),
|
|
196
|
+
geometry: z.record(z.string(), z.unknown()),
|
|
197
|
+
text: z.string().optional(),
|
|
198
|
+
color: z.string().optional(),
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const deleteFileAnnotationSchema = z.object({
|
|
202
|
+
classId: z.string(),
|
|
203
|
+
submissionId: z.string(),
|
|
204
|
+
id: z.string(),
|
|
205
|
+
});
|
|
206
|
+
|
|
183
207
|
const updateUploadProgressSchema = z.object({
|
|
184
208
|
fileId: z.string(),
|
|
185
209
|
progress: z.number().min(0).max(100),
|
|
@@ -437,6 +461,11 @@ export const assignmentRouter = createTRPCRouter({
|
|
|
437
461
|
receiverIds,
|
|
438
462
|
title: `New comment on ${assignment.title}`,
|
|
439
463
|
content: `${getCommenterName(comment)} commented in ${assignment.class.name}:\n\n${input.content}\n\n[View assignment](/class/${assignment.class.id}/assignments/${assignment.id})`,
|
|
464
|
+
senderId: authorId,
|
|
465
|
+
type: "COMMENT_REPLY",
|
|
466
|
+
entityType: "ASSIGNMENT",
|
|
467
|
+
entityId: assignment.id,
|
|
468
|
+
classId: assignment.class.id,
|
|
440
469
|
}).catch((error) => {
|
|
441
470
|
logger.error("Failed to send assignment comment notifications:", error);
|
|
442
471
|
});
|
|
@@ -559,6 +588,11 @@ export const assignmentRouter = createTRPCRouter({
|
|
|
559
588
|
receiverIds,
|
|
560
589
|
title: `New private comment on ${submission.assignment.title}`,
|
|
561
590
|
content: `${getCommenterName(comment)} commented on a submission in ${submission.assignment.class.name}:\n\n${input.content}\n\n[View submission](/class/${submission.assignment.class.id}/assignments/${submission.assignment.id}/submissions/${submission.id})`,
|
|
591
|
+
senderId: authorId,
|
|
592
|
+
type: "COMMENT_REPLY",
|
|
593
|
+
entityType: "ASSIGNMENT",
|
|
594
|
+
entityId: submission.assignment.id,
|
|
595
|
+
classId: submission.assignment.class.id,
|
|
562
596
|
}).catch((error) => {
|
|
563
597
|
logger.error("Failed to send submission comment notifications:", error);
|
|
564
598
|
});
|
|
@@ -956,6 +990,186 @@ export const assignmentRouter = createTRPCRouter({
|
|
|
956
990
|
};
|
|
957
991
|
}),
|
|
958
992
|
|
|
993
|
+
listFileAnnotations: protectedClassMemberProcedure
|
|
994
|
+
.input(listFileAnnotationsSchema)
|
|
995
|
+
.query(async ({ ctx, input }) => {
|
|
996
|
+
const { classId, submissionId, fileId } = input;
|
|
997
|
+
|
|
998
|
+
const submission = await prisma.submission.findFirst({
|
|
999
|
+
where: {
|
|
1000
|
+
id: submissionId,
|
|
1001
|
+
assignment: { classId },
|
|
1002
|
+
},
|
|
1003
|
+
select: {
|
|
1004
|
+
id: true,
|
|
1005
|
+
studentId: true,
|
|
1006
|
+
returned: true,
|
|
1007
|
+
assignment: {
|
|
1008
|
+
select: {
|
|
1009
|
+
class: {
|
|
1010
|
+
select: {
|
|
1011
|
+
teachers: { where: { id: ctx.user!.id }, select: { id: true } },
|
|
1012
|
+
},
|
|
1013
|
+
},
|
|
1014
|
+
},
|
|
1015
|
+
},
|
|
1016
|
+
},
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
if (!submission) {
|
|
1020
|
+
throw new TRPCError({
|
|
1021
|
+
code: "NOT_FOUND",
|
|
1022
|
+
message: "Submission not found",
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
const isTeacher = submission.assignment.class.teachers.length > 0;
|
|
1027
|
+
const isOwningStudent = submission.studentId === ctx.user!.id;
|
|
1028
|
+
|
|
1029
|
+
if (!isTeacher && !(isOwningStudent && submission.returned)) {
|
|
1030
|
+
throw new TRPCError({
|
|
1031
|
+
code: "FORBIDDEN",
|
|
1032
|
+
message: "You don't have access to these annotations",
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
const annotations = await prisma.fileAnnotation.findMany({
|
|
1037
|
+
where: { submissionId, fileId },
|
|
1038
|
+
orderBy: { createdAt: "asc" },
|
|
1039
|
+
include: {
|
|
1040
|
+
author: {
|
|
1041
|
+
select: {
|
|
1042
|
+
id: true,
|
|
1043
|
+
username: true,
|
|
1044
|
+
profile: { select: { displayName: true } },
|
|
1045
|
+
},
|
|
1046
|
+
},
|
|
1047
|
+
},
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
return { annotations };
|
|
1051
|
+
}),
|
|
1052
|
+
|
|
1053
|
+
upsertFileAnnotation: protectedTeacherProcedure
|
|
1054
|
+
.input(upsertFileAnnotationSchema)
|
|
1055
|
+
.mutation(async ({ ctx, input }) => {
|
|
1056
|
+
const { classId, submissionId, fileId, id, page, type, geometry, text, color } = input;
|
|
1057
|
+
|
|
1058
|
+
const submission = await prisma.submission.findFirst({
|
|
1059
|
+
where: {
|
|
1060
|
+
id: submissionId,
|
|
1061
|
+
assignment: {
|
|
1062
|
+
classId,
|
|
1063
|
+
class: {
|
|
1064
|
+
teachers: {
|
|
1065
|
+
some: { id: ctx.user!.id },
|
|
1066
|
+
},
|
|
1067
|
+
},
|
|
1068
|
+
},
|
|
1069
|
+
},
|
|
1070
|
+
select: { id: true },
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
if (!submission) {
|
|
1074
|
+
throw new TRPCError({
|
|
1075
|
+
code: "NOT_FOUND",
|
|
1076
|
+
message: "Submission not found or you are not a teacher of this class",
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
const file = await prisma.file.findFirst({
|
|
1081
|
+
where: {
|
|
1082
|
+
id: fileId,
|
|
1083
|
+
OR: [{ submissionId }, { annotationId: submissionId }],
|
|
1084
|
+
},
|
|
1085
|
+
select: { id: true },
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
if (!file) {
|
|
1089
|
+
throw new TRPCError({
|
|
1090
|
+
code: "NOT_FOUND",
|
|
1091
|
+
message: "File does not belong to this submission",
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
if (id) {
|
|
1096
|
+
const existing = await prisma.fileAnnotation.findFirst({
|
|
1097
|
+
where: { id, submissionId, fileId },
|
|
1098
|
+
select: { id: true },
|
|
1099
|
+
});
|
|
1100
|
+
|
|
1101
|
+
if (!existing) {
|
|
1102
|
+
throw new TRPCError({
|
|
1103
|
+
code: "NOT_FOUND",
|
|
1104
|
+
message: "Annotation not found",
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
const annotation = await prisma.fileAnnotation.update({
|
|
1109
|
+
where: { id },
|
|
1110
|
+
data: {
|
|
1111
|
+
page,
|
|
1112
|
+
type,
|
|
1113
|
+
geometry: geometry as object,
|
|
1114
|
+
text: text ?? null,
|
|
1115
|
+
color: color ?? null,
|
|
1116
|
+
},
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1119
|
+
return { annotation };
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
const annotation = await prisma.fileAnnotation.create({
|
|
1123
|
+
data: {
|
|
1124
|
+
fileId,
|
|
1125
|
+
submissionId,
|
|
1126
|
+
authorId: ctx.user!.id,
|
|
1127
|
+
page,
|
|
1128
|
+
type,
|
|
1129
|
+
geometry: geometry as object,
|
|
1130
|
+
text: text ?? null,
|
|
1131
|
+
color: color ?? null,
|
|
1132
|
+
},
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
return { annotation };
|
|
1136
|
+
}),
|
|
1137
|
+
|
|
1138
|
+
deleteFileAnnotation: protectedTeacherProcedure
|
|
1139
|
+
.input(deleteFileAnnotationSchema)
|
|
1140
|
+
.mutation(async ({ ctx, input }) => {
|
|
1141
|
+
const { classId, submissionId, id } = input;
|
|
1142
|
+
|
|
1143
|
+
const annotation = await prisma.fileAnnotation.findFirst({
|
|
1144
|
+
where: {
|
|
1145
|
+
id,
|
|
1146
|
+
submissionId,
|
|
1147
|
+
submission: {
|
|
1148
|
+
assignment: {
|
|
1149
|
+
classId,
|
|
1150
|
+
class: {
|
|
1151
|
+
teachers: {
|
|
1152
|
+
some: { id: ctx.user!.id },
|
|
1153
|
+
},
|
|
1154
|
+
},
|
|
1155
|
+
},
|
|
1156
|
+
},
|
|
1157
|
+
},
|
|
1158
|
+
select: { id: true },
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
if (!annotation) {
|
|
1162
|
+
throw new TRPCError({
|
|
1163
|
+
code: "NOT_FOUND",
|
|
1164
|
+
message: "Annotation not found or you are not a teacher of this class",
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
await prisma.fileAnnotation.delete({ where: { id } });
|
|
1169
|
+
|
|
1170
|
+
return { success: true };
|
|
1171
|
+
}),
|
|
1172
|
+
|
|
959
1173
|
updateUploadProgress: protectedProcedure
|
|
960
1174
|
.input(updateUploadProgressSchema)
|
|
961
1175
|
.mutation(async ({ ctx, input }) => {
|
|
@@ -3,41 +3,43 @@ import { z } from "zod";
|
|
|
3
3
|
import {
|
|
4
4
|
listNotifications,
|
|
5
5
|
getNotification,
|
|
6
|
-
|
|
7
|
-
sendToMultiple,
|
|
6
|
+
getUnreadCount,
|
|
8
7
|
markAsRead,
|
|
8
|
+
markAllAsRead,
|
|
9
9
|
} from "../services/notification.js";
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Read-only notification API. Notifications are emitted exclusively by
|
|
13
|
+
* server-side domain services — there is intentionally no public "send"
|
|
14
|
+
* endpoint (the previous sendTo/sendToMultiple procedures allowed any logged-in
|
|
15
|
+
* user to notify arbitrary users and were removed).
|
|
16
|
+
*/
|
|
11
17
|
export const notificationRouter = createTRPCRouter({
|
|
12
|
-
list: protectedProcedure
|
|
13
|
-
|
|
14
|
-
|
|
18
|
+
list: protectedProcedure
|
|
19
|
+
.input(
|
|
20
|
+
z
|
|
21
|
+
.object({
|
|
22
|
+
limit: z.number().int().min(1).max(100).default(20),
|
|
23
|
+
cursor: z.string().nullish(),
|
|
24
|
+
})
|
|
25
|
+
.optional()
|
|
26
|
+
)
|
|
27
|
+
.query(({ ctx, input }) =>
|
|
28
|
+
listNotifications(ctx.user!.id, {
|
|
29
|
+
limit: input?.limit,
|
|
30
|
+
cursor: input?.cursor,
|
|
31
|
+
})
|
|
32
|
+
),
|
|
15
33
|
|
|
16
34
|
get: protectedProcedure
|
|
17
35
|
.input(z.object({ id: z.string() }))
|
|
18
36
|
.query(({ ctx, input }) => getNotification(ctx.user!.id, input.id)),
|
|
19
37
|
|
|
20
|
-
|
|
21
|
-
.input(
|
|
22
|
-
z.object({
|
|
23
|
-
receiverId: z.string(),
|
|
24
|
-
title: z.string(),
|
|
25
|
-
content: z.string(),
|
|
26
|
-
})
|
|
27
|
-
)
|
|
28
|
-
.mutation(({ input }) => sendNotification(input)),
|
|
29
|
-
|
|
30
|
-
sendToMultiple: protectedProcedure
|
|
31
|
-
.input(
|
|
32
|
-
z.object({
|
|
33
|
-
receiverIds: z.array(z.string()),
|
|
34
|
-
title: z.string(),
|
|
35
|
-
content: z.string(),
|
|
36
|
-
})
|
|
37
|
-
)
|
|
38
|
-
.mutation(({ input }) => sendToMultiple(input)),
|
|
38
|
+
unreadCount: protectedProcedure.query(({ ctx }) => getUnreadCount(ctx.user!.id)),
|
|
39
39
|
|
|
40
40
|
markAsRead: protectedProcedure
|
|
41
41
|
.input(z.object({ id: z.string() }))
|
|
42
42
|
.mutation(({ ctx, input }) => markAsRead(ctx.user!.id, input.id)),
|
|
43
|
+
|
|
44
|
+
markAllAsRead: protectedProcedure.mutation(({ ctx }) => markAllAsRead(ctx.user!.id)),
|
|
43
45
|
});
|
package/src/routers/school.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
updateMemberRole,
|
|
28
28
|
updateSchoolDetails,
|
|
29
29
|
} from "../services/school.js";
|
|
30
|
+
import { getSchoolAiUsage } from "../services/aiUsage.js";
|
|
30
31
|
|
|
31
32
|
const schoolRoleSchema = z.enum(["ADMIN", "TEACHER", "STUDENT"]);
|
|
32
33
|
|
|
@@ -142,6 +143,17 @@ export const schoolRouter = createTRPCRouter({
|
|
|
142
143
|
.input(schoolIdSchema)
|
|
143
144
|
.query(({ ctx, input }) => getAdminOverview(ctx.user!.id, input.schoolId)),
|
|
144
145
|
|
|
146
|
+
getAiUsage: protectedProcedure
|
|
147
|
+
.input(
|
|
148
|
+
z.object({
|
|
149
|
+
schoolId: z.string(),
|
|
150
|
+
startDate: z.date().optional(),
|
|
151
|
+
endDate: z.date().optional(),
|
|
152
|
+
topUsersLimit: z.number().min(1).max(50).optional(),
|
|
153
|
+
})
|
|
154
|
+
)
|
|
155
|
+
.query(({ ctx, input }) => getSchoolAiUsage(ctx.user!.id, input)),
|
|
156
|
+
|
|
145
157
|
listClasses: protectedProcedure
|
|
146
158
|
.input(schoolIdSchema)
|
|
147
159
|
.query(({ ctx, input }) => listClasses(ctx.user!.id, input.schoolId)),
|
package/src/routers/user.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
deleteUserAccount,
|
|
11
11
|
exportUserData,
|
|
12
12
|
} from "../services/userPrivacy.js";
|
|
13
|
+
import { getMyAiUsage } from "../services/aiUsage.js";
|
|
13
14
|
|
|
14
15
|
const fileUploadSchema = z.object({
|
|
15
16
|
filePath: z.string().min(1, "File path is required"),
|
|
@@ -89,6 +90,17 @@ export const userRouter = createTRPCRouter({
|
|
|
89
90
|
exportUserData(ctx.user!.id),
|
|
90
91
|
),
|
|
91
92
|
|
|
93
|
+
getMyAiUsage: protectedProcedure
|
|
94
|
+
.input(
|
|
95
|
+
z
|
|
96
|
+
.object({
|
|
97
|
+
startDate: z.date().optional(),
|
|
98
|
+
endDate: z.date().optional(),
|
|
99
|
+
})
|
|
100
|
+
.optional(),
|
|
101
|
+
)
|
|
102
|
+
.query(({ ctx, input }) => getMyAiUsage(ctx.user!.id, input ?? {})),
|
|
103
|
+
|
|
92
104
|
deleteAccount: protectedProcedure
|
|
93
105
|
.input(
|
|
94
106
|
z.object({
|
|
@@ -16,6 +16,7 @@ import { openai } from '@ai-sdk/openai';
|
|
|
16
16
|
import { getRedis } from '../lib/redis.js';
|
|
17
17
|
import { pusher, chatChannel } from '../lib/pusher.js';
|
|
18
18
|
import { logger } from '../utils/logger.js';
|
|
19
|
+
import { logInferenceUsage, type AiUsageAttribution } from '../utils/inference.js';
|
|
19
20
|
|
|
20
21
|
const STREAM_REDIS_TTL = 600; // 10 minutes
|
|
21
22
|
const STREAM_KEY_PREFIX = 'stream:';
|
|
@@ -179,6 +180,10 @@ export interface StreamChatOptions {
|
|
|
179
180
|
maxOutputTokens?: number;
|
|
180
181
|
/** Max agent steps (tool round-trips). Defaults to 6. */
|
|
181
182
|
maxSteps?: number;
|
|
183
|
+
/** Reason recorded on the AiUsageLog row. Defaults to 'chat_stream'. */
|
|
184
|
+
usageReason?: string;
|
|
185
|
+
/** Who/what this usage is attributed to (user / school / class). */
|
|
186
|
+
attribution?: AiUsageAttribution;
|
|
182
187
|
}
|
|
183
188
|
|
|
184
189
|
export interface StreamChatResult {
|
|
@@ -207,6 +212,8 @@ export async function streamChatResponse(
|
|
|
207
212
|
temperature = 0.7,
|
|
208
213
|
maxOutputTokens = 4096,
|
|
209
214
|
maxSteps = 6,
|
|
215
|
+
usageReason = 'chat_stream',
|
|
216
|
+
attribution,
|
|
210
217
|
} = options;
|
|
211
218
|
|
|
212
219
|
const redis = getRedis();
|
|
@@ -332,7 +339,35 @@ export async function streamChatResponse(
|
|
|
332
339
|
}
|
|
333
340
|
}
|
|
334
341
|
|
|
342
|
+
const usage = await Promise.resolve(result.totalUsage).catch(() => undefined);
|
|
343
|
+
await logInferenceUsage({
|
|
344
|
+
model,
|
|
345
|
+
operation: 'stream.text',
|
|
346
|
+
success: true,
|
|
347
|
+
estimatedInputTokens: 0,
|
|
348
|
+
reason: usageReason,
|
|
349
|
+
usage: usage
|
|
350
|
+
? {
|
|
351
|
+
promptTokens: usage.inputTokens,
|
|
352
|
+
completionTokens: usage.outputTokens,
|
|
353
|
+
totalTokens: usage.totalTokens,
|
|
354
|
+
}
|
|
355
|
+
: undefined,
|
|
356
|
+
attribution,
|
|
357
|
+
});
|
|
358
|
+
|
|
335
359
|
return { text: fullText, toolCalls, parts };
|
|
360
|
+
} catch (error) {
|
|
361
|
+
await logInferenceUsage({
|
|
362
|
+
model,
|
|
363
|
+
operation: 'stream.text',
|
|
364
|
+
success: false,
|
|
365
|
+
estimatedInputTokens: 0,
|
|
366
|
+
reason: usageReason,
|
|
367
|
+
error,
|
|
368
|
+
attribution,
|
|
369
|
+
});
|
|
370
|
+
throw error;
|
|
336
371
|
} finally {
|
|
337
372
|
if (redis) {
|
|
338
373
|
await redis.del(streamKey).catch(() => {});
|
package/src/services/aiStream.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { getRedis } from '../lib/redis.js';
|
|
|
8
8
|
import { prisma } from '../lib/prisma.js';
|
|
9
9
|
import { pusher, chatChannel } from '../lib/pusher.js';
|
|
10
10
|
import { getAIUserId, ensureAIUserExists } from '../utils/aiUser.js';
|
|
11
|
+
import { logInferenceUsage, type AiUsageAttribution } from '../utils/inference.js';
|
|
11
12
|
import { logger } from '../utils/logger.js';
|
|
12
13
|
import { JobStatus } from '@prisma/client';
|
|
13
14
|
import { aiTools, type ToolCallResult } from './aiTools.js';
|
|
@@ -25,6 +26,10 @@ export interface StreamOptions {
|
|
|
25
26
|
model?: string;
|
|
26
27
|
temperature?: number;
|
|
27
28
|
maxOutputTokens?: number;
|
|
29
|
+
/** Reason recorded on the AiUsageLog row. Defaults to 'ai_stream'. */
|
|
30
|
+
usageReason?: string;
|
|
31
|
+
/** Who/what this usage is attributed to (user / school / class). */
|
|
32
|
+
attribution?: AiUsageAttribution;
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
/**
|
|
@@ -42,6 +47,8 @@ export async function streamAIResponse(options: StreamOptions): Promise<void> {
|
|
|
42
47
|
model = 'gpt-4o-mini',
|
|
43
48
|
temperature = 0.7,
|
|
44
49
|
maxOutputTokens = 4096,
|
|
50
|
+
usageReason = 'ai_stream',
|
|
51
|
+
attribution,
|
|
45
52
|
} = options;
|
|
46
53
|
|
|
47
54
|
await ensureAIUserExists();
|
|
@@ -146,6 +153,23 @@ export async function streamAIResponse(options: StreamOptions): Promise<void> {
|
|
|
146
153
|
attachments: [],
|
|
147
154
|
});
|
|
148
155
|
|
|
156
|
+
const usage = await Promise.resolve(result.totalUsage).catch(() => undefined);
|
|
157
|
+
await logInferenceUsage({
|
|
158
|
+
model,
|
|
159
|
+
operation: 'stream.text',
|
|
160
|
+
success: true,
|
|
161
|
+
estimatedInputTokens: 0,
|
|
162
|
+
reason: usageReason,
|
|
163
|
+
usage: usage
|
|
164
|
+
? {
|
|
165
|
+
promptTokens: usage.inputTokens,
|
|
166
|
+
completionTokens: usage.outputTokens,
|
|
167
|
+
totalTokens: usage.totalTokens,
|
|
168
|
+
}
|
|
169
|
+
: undefined,
|
|
170
|
+
attribution: attribution ?? { classId },
|
|
171
|
+
});
|
|
172
|
+
|
|
149
173
|
logger.info('AI stream completed', {
|
|
150
174
|
aiMessageId,
|
|
151
175
|
conversationId,
|
|
@@ -155,6 +179,16 @@ export async function streamAIResponse(options: StreamOptions): Promise<void> {
|
|
|
155
179
|
} catch (error) {
|
|
156
180
|
logger.error('AI stream failed', { error, aiMessageId, conversationId });
|
|
157
181
|
|
|
182
|
+
await logInferenceUsage({
|
|
183
|
+
model,
|
|
184
|
+
operation: 'stream.text',
|
|
185
|
+
success: false,
|
|
186
|
+
estimatedInputTokens: 0,
|
|
187
|
+
reason: usageReason,
|
|
188
|
+
error,
|
|
189
|
+
attribution: attribution ?? { classId },
|
|
190
|
+
});
|
|
191
|
+
|
|
158
192
|
await prisma.message.update({
|
|
159
193
|
where: { id: aiMessageId },
|
|
160
194
|
data: { status: JobStatus.FAILED },
|