@studious-lms/server 1.4.0 → 1.4.1
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/dist/pipelines/aiLabChat.d.ts +9 -5
- package/dist/pipelines/aiLabChat.d.ts.map +1 -1
- package/dist/pipelines/aiLabChat.js +37 -144
- package/dist/pipelines/aiLabChat.js.map +1 -1
- package/dist/pipelines/aiLabChatContract.d.ts +413 -0
- package/dist/pipelines/aiLabChatContract.d.ts.map +1 -0
- package/dist/pipelines/aiLabChatContract.js +74 -0
- package/dist/pipelines/aiLabChatContract.js.map +1 -0
- package/dist/pipelines/labChatPrompt.d.ts +2 -0
- package/dist/pipelines/labChatPrompt.d.ts.map +1 -0
- package/dist/pipelines/labChatPrompt.js +72 -0
- package/dist/pipelines/labChatPrompt.js.map +1 -0
- package/dist/routers/_app.d.ts +146 -0
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/_app.js +4 -2
- package/dist/routers/_app.js.map +1 -1
- package/dist/routers/studentProgress.d.ts +75 -0
- package/dist/routers/studentProgress.d.ts.map +1 -0
- package/dist/routers/studentProgress.js +33 -0
- package/dist/routers/studentProgress.js.map +1 -0
- package/dist/services/labChat.d.ts.map +1 -1
- package/dist/services/labChat.js +31 -15
- package/dist/services/labChat.js.map +1 -1
- package/dist/services/message.d.ts.map +1 -1
- package/dist/services/message.js +90 -48
- package/dist/services/message.js.map +1 -1
- package/dist/services/studentProgress.d.ts +45 -0
- package/dist/services/studentProgress.d.ts.map +1 -0
- package/dist/services/studentProgress.js +291 -0
- package/dist/services/studentProgress.js.map +1 -0
- package/package.json +2 -2
- package/sentry.properties +3 -0
- package/src/pipelines/aiLabChat.ts +37 -148
- package/src/pipelines/aiLabChatContract.ts +75 -0
- package/src/pipelines/labChatPrompt.ts +68 -0
- package/src/routers/_app.ts +4 -2
- package/src/routers/studentProgress.ts +47 -0
- package/src/services/labChat.ts +31 -22
- package/src/services/message.ts +97 -48
- package/src/services/studentProgress.ts +390 -0
- package/tests/lib/aiLabChatContract.test.ts +32 -0
- package/tests/pipelines/aiLabChat.test.ts +95 -0
- package/tests/routers/studentProgress.test.ts +283 -0
- package/tests/utils/aiLabChatPrompt.test.ts +18 -0
- package/vitest.unit.config.ts +7 -1
package/src/services/labChat.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
} from "../models/labChat.js";
|
|
20
20
|
import { generateAndSendLabIntroduction, generateAndSendLabResponse } from "../pipelines/aiLabChat.js";
|
|
21
21
|
import { isAIUser } from "../utils/aiUser.js";
|
|
22
|
+
import { logger } from "../utils/logger.js";
|
|
22
23
|
|
|
23
24
|
/** Create a lab chat with conversation and teacher members. Broadcasts via Pusher. */
|
|
24
25
|
export async function createLabChat(
|
|
@@ -243,6 +244,7 @@ export async function postToLabChat(
|
|
|
243
244
|
content,
|
|
244
245
|
senderId: userId,
|
|
245
246
|
conversationId: labChat.conversationId,
|
|
247
|
+
...(isAIUser(userId) ? {} : { status: GenerationStatus.PENDING }),
|
|
246
248
|
},
|
|
247
249
|
include: {
|
|
248
250
|
sender: {
|
|
@@ -296,10 +298,6 @@ export async function postToLabChat(
|
|
|
296
298
|
}
|
|
297
299
|
|
|
298
300
|
if (!isAIUser(userId)) {
|
|
299
|
-
await prisma.message.update({
|
|
300
|
-
where: { id: result.id },
|
|
301
|
-
data: { status: GenerationStatus.PENDING },
|
|
302
|
-
});
|
|
303
301
|
try {
|
|
304
302
|
await pusher.trigger(teacherChannel(labChat.classId), "lab-response-pending", {
|
|
305
303
|
labChatId,
|
|
@@ -309,11 +307,16 @@ export async function postToLabChat(
|
|
|
309
307
|
} catch (error) {
|
|
310
308
|
console.error("Failed to broadcast lab response pending:", error);
|
|
311
309
|
}
|
|
312
|
-
generateAndSendLabResponse(
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
310
|
+
generateAndSendLabResponse(labChatId, content, {
|
|
311
|
+
classId: labChat.classId,
|
|
312
|
+
messageId: result.id,
|
|
313
|
+
}).catch((error) =>
|
|
314
|
+
logger.error("Failed to generate lab response", {
|
|
315
|
+
error,
|
|
316
|
+
labChatId,
|
|
317
|
+
messageId: result.id,
|
|
318
|
+
conversationId: labChat.conversationId,
|
|
319
|
+
})
|
|
317
320
|
);
|
|
318
321
|
}
|
|
319
322
|
|
|
@@ -419,9 +422,15 @@ export async function rerunLastResponse(userId: string, labChatId: string) {
|
|
|
419
422
|
});
|
|
420
423
|
}
|
|
421
424
|
|
|
422
|
-
await prisma
|
|
423
|
-
|
|
424
|
-
|
|
425
|
+
await prisma.$transaction([
|
|
426
|
+
prisma.message.delete({
|
|
427
|
+
where: { id: lastMessage.id },
|
|
428
|
+
}),
|
|
429
|
+
prisma.message.update({
|
|
430
|
+
where: { id: teacherMessage.id },
|
|
431
|
+
data: { status: GenerationStatus.PENDING },
|
|
432
|
+
}),
|
|
433
|
+
]);
|
|
425
434
|
|
|
426
435
|
try {
|
|
427
436
|
await pusher.trigger(chatChannel(labChat.conversationId), "message-deleted", {
|
|
@@ -432,11 +441,6 @@ export async function rerunLastResponse(userId: string, labChatId: string) {
|
|
|
432
441
|
} catch (error) {
|
|
433
442
|
console.error("Failed to broadcast message deletion:", error);
|
|
434
443
|
}
|
|
435
|
-
|
|
436
|
-
await prisma.message.update({
|
|
437
|
-
where: { id: teacherMessage.id },
|
|
438
|
-
data: { status: GenerationStatus.PENDING },
|
|
439
|
-
});
|
|
440
444
|
try {
|
|
441
445
|
await pusher.trigger(teacherChannel(labChat.classId), "lab-response-pending", {
|
|
442
446
|
labChatId,
|
|
@@ -447,11 +451,16 @@ export async function rerunLastResponse(userId: string, labChatId: string) {
|
|
|
447
451
|
console.error("Failed to broadcast lab response pending:", error);
|
|
448
452
|
}
|
|
449
453
|
|
|
450
|
-
generateAndSendLabResponse(
|
|
451
|
-
|
|
452
|
-
teacherMessage.
|
|
453
|
-
|
|
454
|
-
|
|
454
|
+
generateAndSendLabResponse(labChatId, teacherMessage.content, {
|
|
455
|
+
classId: labChat.classId,
|
|
456
|
+
messageId: teacherMessage.id,
|
|
457
|
+
}).catch((error) =>
|
|
458
|
+
logger.error("Failed to generate lab response", {
|
|
459
|
+
error,
|
|
460
|
+
labChatId,
|
|
461
|
+
messageId: teacherMessage.id,
|
|
462
|
+
conversationId: labChat.conversationId,
|
|
463
|
+
})
|
|
455
464
|
);
|
|
456
465
|
|
|
457
466
|
return { success: true };
|
package/src/services/message.ts
CHANGED
|
@@ -363,6 +363,8 @@ const CREATED_INDICES_KEY: Record<"assignment" | "worksheet" | "section", string
|
|
|
363
363
|
section: "sections",
|
|
364
364
|
};
|
|
365
365
|
|
|
366
|
+
const MARK_SUGGESTION_MAX_RETRIES = 5;
|
|
367
|
+
|
|
366
368
|
/** Mark an AI-suggested item as created. Stores in message meta for fast reads. */
|
|
367
369
|
export async function markSuggestionCreated(
|
|
368
370
|
userId: string,
|
|
@@ -393,61 +395,108 @@ export async function markSuggestionCreated(
|
|
|
393
395
|
});
|
|
394
396
|
}
|
|
395
397
|
|
|
396
|
-
const
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
398
|
+
const conversationId = existingMessage.conversationId;
|
|
399
|
+
let lastError: Error | null = null;
|
|
400
|
+
|
|
401
|
+
for (let attempt = 0; attempt < MARK_SUGGESTION_MAX_RETRIES; attempt++) {
|
|
402
|
+
const msg = await prisma.message.findUnique({
|
|
403
|
+
where: { id: messageId },
|
|
404
|
+
select: { meta: true },
|
|
405
|
+
});
|
|
406
|
+
if (!msg) {
|
|
407
|
+
throw new TRPCError({
|
|
408
|
+
code: "NOT_FOUND",
|
|
409
|
+
message: "Message not found",
|
|
410
|
+
});
|
|
411
|
+
}
|
|
409
412
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
413
|
+
const currentMeta = (msg.meta as Record<string, unknown>) ?? {};
|
|
414
|
+
const createdIndices = (currentMeta.createdIndices as Record<string, number[]>) ?? {};
|
|
415
|
+
const typeIndices = createdIndices[CREATED_INDICES_KEY[type]] ?? [];
|
|
416
|
+
if (typeIndices.includes(index)) return { success: true };
|
|
417
|
+
|
|
418
|
+
const newTypeIndices = [...typeIndices, index].sort((a, b) => a - b);
|
|
419
|
+
const newMeta = {
|
|
420
|
+
...currentMeta,
|
|
421
|
+
createdIndices: {
|
|
422
|
+
...createdIndices,
|
|
423
|
+
[CREATED_INDICES_KEY[type]]: newTypeIndices,
|
|
424
|
+
},
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
const newMetaJson = JSON.stringify(newMeta);
|
|
428
|
+
const currentMetaJson = msg.meta === null ? null : JSON.stringify(msg.meta);
|
|
429
|
+
|
|
430
|
+
const updatedCount =
|
|
431
|
+
currentMetaJson === null
|
|
432
|
+
? await prisma.$executeRaw`
|
|
433
|
+
UPDATE "Message" SET meta = ${newMetaJson}::jsonb
|
|
434
|
+
WHERE id = ${messageId} AND meta IS NULL
|
|
435
|
+
`
|
|
436
|
+
: await prisma.$executeRaw`
|
|
437
|
+
UPDATE "Message" SET meta = ${newMetaJson}::jsonb
|
|
438
|
+
WHERE id = ${messageId} AND meta = ${currentMetaJson}::jsonb
|
|
439
|
+
`;
|
|
440
|
+
|
|
441
|
+
if (Number(updatedCount) > 0) {
|
|
442
|
+
const updated = await prisma.message.findUnique({
|
|
443
|
+
where: { id: messageId },
|
|
444
|
+
include: {
|
|
445
|
+
sender: {
|
|
446
|
+
select: {
|
|
447
|
+
id: true,
|
|
448
|
+
username: true,
|
|
449
|
+
profile: {
|
|
450
|
+
select: { displayName: true, profilePicture: true },
|
|
451
|
+
},
|
|
452
|
+
},
|
|
420
453
|
},
|
|
454
|
+
attachments: { select: { id: true, name: true, type: true } },
|
|
455
|
+
mentions: { select: { userId: true } },
|
|
421
456
|
},
|
|
422
|
-
}
|
|
423
|
-
attachments: { select: { id: true, name: true, type: true } },
|
|
424
|
-
},
|
|
425
|
-
});
|
|
457
|
+
});
|
|
426
458
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
459
|
+
if (updated) {
|
|
460
|
+
const mentionedUserIds = updated.mentions.map((m) => m.userId);
|
|
461
|
+
try {
|
|
462
|
+
await pusher.trigger(
|
|
463
|
+
chatChannel(conversationId),
|
|
464
|
+
"message-updated",
|
|
465
|
+
{
|
|
466
|
+
id: updated.id,
|
|
467
|
+
content: updated.content,
|
|
468
|
+
senderId: updated.senderId,
|
|
469
|
+
conversationId: updated.conversationId,
|
|
470
|
+
createdAt: updated.createdAt,
|
|
471
|
+
sender: updated.sender,
|
|
472
|
+
attachments: updated.attachments ?? [],
|
|
473
|
+
meta: newMeta,
|
|
474
|
+
mentionedUserIds,
|
|
475
|
+
}
|
|
476
|
+
);
|
|
477
|
+
} catch (error) {
|
|
478
|
+
logger.error("Failed to broadcast suggestion status via Pusher", {
|
|
479
|
+
error,
|
|
480
|
+
messageId,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
441
483
|
}
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
messageId,
|
|
447
|
-
});
|
|
484
|
+
return { success: true };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
lastError = new Error("Concurrent update conflict");
|
|
448
488
|
}
|
|
449
489
|
|
|
450
|
-
|
|
490
|
+
logger.error("markSuggestionCreated failed after retries", {
|
|
491
|
+
messageId,
|
|
492
|
+
type,
|
|
493
|
+
index,
|
|
494
|
+
error: lastError,
|
|
495
|
+
});
|
|
496
|
+
throw new TRPCError({
|
|
497
|
+
code: "CONFLICT",
|
|
498
|
+
message: "Failed to update suggestion status after retries due to concurrent updates",
|
|
499
|
+
});
|
|
451
500
|
}
|
|
452
501
|
|
|
453
502
|
export async function markAsRead(userId: string, conversationId: string) {
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Student progress service - assignment recommendations and progress chat.
|
|
3
|
+
*/
|
|
4
|
+
import { TRPCError } from "@trpc/server";
|
|
5
|
+
import { prisma } from "../lib/prisma.js";
|
|
6
|
+
import { inference } from "../utils/inference.js";
|
|
7
|
+
import { logger } from "../utils/logger.js";
|
|
8
|
+
import { isTeacherInClass } from "../models/class.js";
|
|
9
|
+
import type { AssignmentType } from "@prisma/client";
|
|
10
|
+
|
|
11
|
+
type ProgressChatMessage = {
|
|
12
|
+
role: "user" | "assistant";
|
|
13
|
+
content: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type GradeSubmission = {
|
|
17
|
+
id: string;
|
|
18
|
+
gradeReceived: number | null;
|
|
19
|
+
submitted: boolean | null;
|
|
20
|
+
returned: boolean | null;
|
|
21
|
+
submittedAt: Date | null;
|
|
22
|
+
assignment: {
|
|
23
|
+
id: string;
|
|
24
|
+
title: string;
|
|
25
|
+
dueDate: Date;
|
|
26
|
+
maxGrade: number | null;
|
|
27
|
+
weight: number;
|
|
28
|
+
type: AssignmentType;
|
|
29
|
+
section: { id: string; name: string } | null;
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function calculatePercentage(submission: GradeSubmission) {
|
|
34
|
+
if (submission.gradeReceived == null || !submission.assignment.maxGrade)
|
|
35
|
+
return null;
|
|
36
|
+
return (
|
|
37
|
+
Math.round(
|
|
38
|
+
(submission.gradeReceived / submission.assignment.maxGrade) * 1000,
|
|
39
|
+
) / 10
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function calculateTrend(submissions: GradeSubmission[]) {
|
|
44
|
+
const graded = submissions
|
|
45
|
+
.filter(
|
|
46
|
+
(submission) =>
|
|
47
|
+
submission.gradeReceived != null && submission.assignment.maxGrade,
|
|
48
|
+
)
|
|
49
|
+
.sort((a, b) => {
|
|
50
|
+
const aTime = a.submittedAt?.getTime() ?? a.assignment.dueDate.getTime();
|
|
51
|
+
const bTime = b.submittedAt?.getTime() ?? b.assignment.dueDate.getTime();
|
|
52
|
+
return aTime - bTime;
|
|
53
|
+
})
|
|
54
|
+
.map((submission) => calculatePercentage(submission))
|
|
55
|
+
.filter((value): value is number => value != null);
|
|
56
|
+
|
|
57
|
+
if (graded.length < 2) return 0;
|
|
58
|
+
const midpoint = Math.floor(graded.length / 2);
|
|
59
|
+
const first = graded.slice(0, midpoint);
|
|
60
|
+
const second = graded.slice(midpoint);
|
|
61
|
+
const average = (values: number[]) =>
|
|
62
|
+
values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
63
|
+
return Math.round((average(second) - average(first)) * 10) / 10;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function getOverallGrade(submissions: GradeSubmission[]) {
|
|
67
|
+
let totalWeighted = 0;
|
|
68
|
+
let totalWeight = 0;
|
|
69
|
+
|
|
70
|
+
for (const submission of submissions) {
|
|
71
|
+
if (
|
|
72
|
+
submission.gradeReceived != null &&
|
|
73
|
+
submission.assignment.maxGrade &&
|
|
74
|
+
submission.assignment.weight
|
|
75
|
+
) {
|
|
76
|
+
totalWeighted +=
|
|
77
|
+
(submission.gradeReceived / submission.assignment.maxGrade) *
|
|
78
|
+
submission.assignment.weight;
|
|
79
|
+
totalWeight += submission.assignment.weight;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return totalWeight > 0
|
|
84
|
+
? Math.round((totalWeighted / totalWeight) * 1000) / 10
|
|
85
|
+
: null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function loadStudentProgressContext(
|
|
89
|
+
viewerId: string,
|
|
90
|
+
classId: string,
|
|
91
|
+
studentId: string,
|
|
92
|
+
) {
|
|
93
|
+
const isTeacher = await isTeacherInClass(classId, viewerId);
|
|
94
|
+
if (viewerId !== studentId && !isTeacher) {
|
|
95
|
+
throw new TRPCError({
|
|
96
|
+
code: "UNAUTHORIZED",
|
|
97
|
+
message: "You can only view your own progress",
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const [classData, student, submissions] = await Promise.all([
|
|
102
|
+
prisma.class.findUnique({
|
|
103
|
+
where: { id: classId },
|
|
104
|
+
select: { id: true, name: true, subject: true },
|
|
105
|
+
}),
|
|
106
|
+
prisma.user.findFirst({
|
|
107
|
+
where: {
|
|
108
|
+
id: studentId,
|
|
109
|
+
studentIn: { some: { id: classId } },
|
|
110
|
+
},
|
|
111
|
+
select: {
|
|
112
|
+
id: true,
|
|
113
|
+
username: true,
|
|
114
|
+
profile: { select: { displayName: true } },
|
|
115
|
+
},
|
|
116
|
+
}),
|
|
117
|
+
prisma.submission.findMany({
|
|
118
|
+
where: {
|
|
119
|
+
studentId,
|
|
120
|
+
assignment: { classId, graded: true },
|
|
121
|
+
},
|
|
122
|
+
include: {
|
|
123
|
+
assignment: {
|
|
124
|
+
select: {
|
|
125
|
+
id: true,
|
|
126
|
+
title: true,
|
|
127
|
+
dueDate: true,
|
|
128
|
+
maxGrade: true,
|
|
129
|
+
weight: true,
|
|
130
|
+
type: true,
|
|
131
|
+
section: { select: { id: true, name: true } },
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
orderBy: { assignment: { dueDate: "asc" } },
|
|
136
|
+
}),
|
|
137
|
+
]);
|
|
138
|
+
|
|
139
|
+
if (!classData) {
|
|
140
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Class not found" });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!student) {
|
|
144
|
+
throw new TRPCError({
|
|
145
|
+
code: "NOT_FOUND",
|
|
146
|
+
message: "Student not found in this class",
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return { classData, student, submissions };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function getStudentProgressRecommendations(
|
|
154
|
+
viewerId: string,
|
|
155
|
+
classId: string,
|
|
156
|
+
studentId: string,
|
|
157
|
+
) {
|
|
158
|
+
const { student, submissions } = await loadStudentProgressContext(
|
|
159
|
+
viewerId,
|
|
160
|
+
classId,
|
|
161
|
+
studentId,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const now = new Date();
|
|
165
|
+
const recommendationCandidates = submissions
|
|
166
|
+
.map((submission) => {
|
|
167
|
+
const percentage = calculatePercentage(submission);
|
|
168
|
+
const isMissing =
|
|
169
|
+
!submission.submitted &&
|
|
170
|
+
submission.assignment.dueDate.getTime() < now.getTime();
|
|
171
|
+
const isLowScore = percentage != null && percentage < 70;
|
|
172
|
+
const isUnreturned =
|
|
173
|
+
Boolean(submission.submitted) &&
|
|
174
|
+
submission.gradeReceived == null &&
|
|
175
|
+
!submission.returned;
|
|
176
|
+
const isUpcoming =
|
|
177
|
+
!Boolean(submission.submitted) &&
|
|
178
|
+
!submission.submittedAt &&
|
|
179
|
+
!submission.returned &&
|
|
180
|
+
submission.gradeReceived == null &&
|
|
181
|
+
submission.assignment.dueDate.getTime() >= now.getTime();
|
|
182
|
+
const reasons: string[] = [];
|
|
183
|
+
let priorityScore = 0;
|
|
184
|
+
|
|
185
|
+
if (isMissing) {
|
|
186
|
+
reasons.push("Missing past-due work");
|
|
187
|
+
priorityScore += 100;
|
|
188
|
+
}
|
|
189
|
+
if (isLowScore) {
|
|
190
|
+
reasons.push(`Scored ${percentage}%`);
|
|
191
|
+
priorityScore += 85 - (percentage ?? 0);
|
|
192
|
+
}
|
|
193
|
+
if (isUnreturned) {
|
|
194
|
+
reasons.push("Awaiting grade or feedback");
|
|
195
|
+
priorityScore += 15;
|
|
196
|
+
}
|
|
197
|
+
if (isUpcoming) {
|
|
198
|
+
reasons.push("Upcoming graded assignment");
|
|
199
|
+
priorityScore += 5;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
assignmentId: submission.assignment.id,
|
|
204
|
+
submissionId: submission.id,
|
|
205
|
+
title: submission.assignment.title,
|
|
206
|
+
type: submission.assignment.type,
|
|
207
|
+
sectionName: submission.assignment.section?.name ?? null,
|
|
208
|
+
dueDate: submission.assignment.dueDate,
|
|
209
|
+
gradeReceived: submission.gradeReceived,
|
|
210
|
+
maxGrade: submission.assignment.maxGrade,
|
|
211
|
+
percentage,
|
|
212
|
+
submitted: Boolean(submission.submitted),
|
|
213
|
+
returned: Boolean(submission.returned),
|
|
214
|
+
reasons,
|
|
215
|
+
priorityScore,
|
|
216
|
+
};
|
|
217
|
+
})
|
|
218
|
+
.filter((candidate) => candidate.priorityScore > 0)
|
|
219
|
+
.sort((a, b) => b.priorityScore - a.priorityScore)
|
|
220
|
+
.slice(0, 5);
|
|
221
|
+
|
|
222
|
+
const gradedPercentages = submissions
|
|
223
|
+
.map(calculatePercentage)
|
|
224
|
+
.filter((value): value is number => value != null);
|
|
225
|
+
const lowScoreCount = gradedPercentages.filter(
|
|
226
|
+
(percentage) => percentage < 70,
|
|
227
|
+
).length;
|
|
228
|
+
const missingCount = submissions.filter(
|
|
229
|
+
(submission) =>
|
|
230
|
+
!submission.submitted &&
|
|
231
|
+
submission.assignment.dueDate.getTime() < now.getTime(),
|
|
232
|
+
).length;
|
|
233
|
+
const trend = calculateTrend(submissions);
|
|
234
|
+
const overallGrade = getOverallGrade(submissions);
|
|
235
|
+
|
|
236
|
+
const nextSteps = [
|
|
237
|
+
missingCount > 0
|
|
238
|
+
? `Prioritize ${missingCount} missing assignment${missingCount === 1 ? "" : "s"}.`
|
|
239
|
+
: null,
|
|
240
|
+
lowScoreCount > 0
|
|
241
|
+
? `Review ${lowScoreCount} low-scoring assignment${lowScoreCount === 1 ? "" : "s"} before introducing new material.`
|
|
242
|
+
: null,
|
|
243
|
+
trend < -5
|
|
244
|
+
? "Schedule a check-in because recent performance is trending down."
|
|
245
|
+
: null,
|
|
246
|
+
recommendationCandidates.length === 0
|
|
247
|
+
? "No urgent assignment issues detected from the available grades."
|
|
248
|
+
: null,
|
|
249
|
+
].filter((step): step is string => Boolean(step));
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
student: {
|
|
253
|
+
id: student.id,
|
|
254
|
+
username: student.username,
|
|
255
|
+
displayName: student.profile?.displayName ?? student.username,
|
|
256
|
+
},
|
|
257
|
+
summary: {
|
|
258
|
+
overallGrade,
|
|
259
|
+
trend,
|
|
260
|
+
completedAssignments: submissions.filter((submission) =>
|
|
261
|
+
Boolean(submission.submitted),
|
|
262
|
+
).length,
|
|
263
|
+
totalAssignments: submissions.length,
|
|
264
|
+
missingCount,
|
|
265
|
+
lowScoreCount,
|
|
266
|
+
},
|
|
267
|
+
recommendations: recommendationCandidates.map(
|
|
268
|
+
({ priorityScore, ...candidate }) => candidate,
|
|
269
|
+
),
|
|
270
|
+
nextSteps,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function buildProgressSummary(submissions: GradeSubmission[]) {
|
|
275
|
+
return submissions.map((submission) => ({
|
|
276
|
+
title: submission.assignment.title,
|
|
277
|
+
type: submission.assignment.type,
|
|
278
|
+
dueDate: submission.assignment.dueDate.toISOString(),
|
|
279
|
+
submitted: Boolean(submission.submitted),
|
|
280
|
+
returned: Boolean(submission.returned),
|
|
281
|
+
gradeReceived: submission.gradeReceived,
|
|
282
|
+
maxGrade: submission.assignment.maxGrade,
|
|
283
|
+
percentage: calculatePercentage(submission),
|
|
284
|
+
section: submission.assignment.section?.name ?? null,
|
|
285
|
+
}));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export async function chatAboutStudentProgress(
|
|
289
|
+
viewerId: string,
|
|
290
|
+
input: {
|
|
291
|
+
classId: string;
|
|
292
|
+
studentId: string;
|
|
293
|
+
message: string;
|
|
294
|
+
history?: ProgressChatMessage[];
|
|
295
|
+
},
|
|
296
|
+
) {
|
|
297
|
+
const { classData, student, submissions } = await loadStudentProgressContext(
|
|
298
|
+
viewerId,
|
|
299
|
+
input.classId,
|
|
300
|
+
input.studentId,
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
const displayName = student.profile?.displayName ?? student.username;
|
|
304
|
+
const summary = {
|
|
305
|
+
overallGrade: getOverallGrade(submissions),
|
|
306
|
+
trend: calculateTrend(submissions),
|
|
307
|
+
assignments: buildProgressSummary(submissions),
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const messages = [
|
|
311
|
+
{
|
|
312
|
+
role: "system" as const,
|
|
313
|
+
content:
|
|
314
|
+
"You are an educational progress assistant for teachers and students. Use only the provided class and grade context. Be concise, specific, supportive, and avoid fabricating grades or assignments.",
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
role: "user" as const,
|
|
318
|
+
content: JSON.stringify({
|
|
319
|
+
class: classData,
|
|
320
|
+
student: { id: student.id, username: student.username, displayName },
|
|
321
|
+
progress: summary,
|
|
322
|
+
}),
|
|
323
|
+
},
|
|
324
|
+
...(input.history ?? []).slice(-8).map((message) => ({
|
|
325
|
+
role: message.role,
|
|
326
|
+
content: message.content,
|
|
327
|
+
})),
|
|
328
|
+
{ role: "user" as const, content: input.message },
|
|
329
|
+
];
|
|
330
|
+
|
|
331
|
+
try {
|
|
332
|
+
const response = await inference<string>(messages);
|
|
333
|
+
if (typeof response !== "string" || response.trim().length === 0) {
|
|
334
|
+
throw new Error("Student progress chat returned an empty response");
|
|
335
|
+
}
|
|
336
|
+
return { message: response, isFallback: false };
|
|
337
|
+
} catch (error) {
|
|
338
|
+
logger.error("Failed to generate student progress chat response", {
|
|
339
|
+
error,
|
|
340
|
+
classId: input.classId,
|
|
341
|
+
studentId: input.studentId,
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
const overall =
|
|
345
|
+
summary.overallGrade == null
|
|
346
|
+
? "not enough graded work"
|
|
347
|
+
: `${summary.overallGrade}%`;
|
|
348
|
+
const missingItems = summary.assignments.filter(
|
|
349
|
+
(assignment) =>
|
|
350
|
+
!assignment.submitted &&
|
|
351
|
+
new Date(assignment.dueDate).getTime() < Date.now(),
|
|
352
|
+
).length;
|
|
353
|
+
const lowScores = summary.assignments.filter(
|
|
354
|
+
(assignment) =>
|
|
355
|
+
assignment.percentage != null && assignment.percentage < 70,
|
|
356
|
+
).length;
|
|
357
|
+
const awaitingFeedback = summary.assignments.filter(
|
|
358
|
+
(assignment) =>
|
|
359
|
+
assignment.submitted &&
|
|
360
|
+
assignment.gradeReceived == null &&
|
|
361
|
+
!assignment.returned,
|
|
362
|
+
).length;
|
|
363
|
+
const trendLabel =
|
|
364
|
+
summary.overallGrade == null
|
|
365
|
+
? "not enough graded work to determine a recent trend"
|
|
366
|
+
: summary.trend > 5
|
|
367
|
+
? "improving"
|
|
368
|
+
: summary.trend < -5
|
|
369
|
+
? "declining"
|
|
370
|
+
: "stable";
|
|
371
|
+
const advice = [
|
|
372
|
+
missingItems > 0
|
|
373
|
+
? `Review ${missingItems} missing assignment${missingItems === 1 ? "" : "s"}.`
|
|
374
|
+
: null,
|
|
375
|
+
lowScores > 0
|
|
376
|
+
? `Use targeted practice for ${lowScores} low-scoring assignment${lowScores === 1 ? "" : "s"}.`
|
|
377
|
+
: null,
|
|
378
|
+
awaitingFeedback > 0
|
|
379
|
+
? `Check back after feedback is returned for ${awaitingFeedback} submitted assignment${awaitingFeedback === 1 ? "" : "s"}.`
|
|
380
|
+
: null,
|
|
381
|
+
missingItems === 0 && lowScores === 0 && awaitingFeedback === 0
|
|
382
|
+
? "Check upcoming work and ask for feedback as new grades are returned."
|
|
383
|
+
: null,
|
|
384
|
+
].filter((item): item is string => Boolean(item));
|
|
385
|
+
return {
|
|
386
|
+
message: `${displayName}'s current overall progress is ${overall}, with ${trendLabel}. ${advice.join(" ")}`,
|
|
387
|
+
isFallback: true,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { labChatArrayFieldInstructions, labChatResponseFormat, labChatResponseSchema } from "../../src/pipelines/aiLabChatContract.js";
|
|
3
|
+
|
|
4
|
+
describe("aiLabChat contract", () => {
|
|
5
|
+
test("defaults worksheet and section collections to empty arrays", () => {
|
|
6
|
+
const parsed = labChatResponseSchema.parse({
|
|
7
|
+
text: "Summary",
|
|
8
|
+
docs: null,
|
|
9
|
+
assignmentsToCreate: null,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
expect(parsed.worksheetsToCreate).toEqual([]);
|
|
13
|
+
expect(parsed.sectionsToCreate).toEqual([]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("rejects null worksheet and section collections", () => {
|
|
17
|
+
expect(() => labChatResponseSchema.parse({
|
|
18
|
+
text: "Summary",
|
|
19
|
+
docs: null,
|
|
20
|
+
worksheetsToCreate: null,
|
|
21
|
+
sectionsToCreate: null,
|
|
22
|
+
assignmentsToCreate: null,
|
|
23
|
+
})).toThrow();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("prompt instructions require arrays for worksheet and section fields", () => {
|
|
27
|
+
expect(labChatResponseFormat).toContain(`"worksheetsToCreate": array`);
|
|
28
|
+
expect(labChatResponseFormat).toContain(`"sectionsToCreate": array`);
|
|
29
|
+
expect(labChatArrayFieldInstructions).toContain(`Use [] when there are no worksheets to create.`);
|
|
30
|
+
expect(labChatArrayFieldInstructions).toContain(`Use [] when there are no sections to create.`);
|
|
31
|
+
});
|
|
32
|
+
});
|