@studious-lms/server 1.3.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/models/class.d.ts +24 -2
- package/dist/models/class.d.ts.map +1 -1
- package/dist/models/class.js +180 -81
- package/dist/models/class.js.map +1 -1
- package/dist/models/worksheet.d.ts +34 -34
- package/dist/pipelines/aiLabChat.d.ts +61 -2
- package/dist/pipelines/aiLabChat.d.ts.map +1 -1
- package/dist/pipelines/aiLabChat.js +204 -172
- 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/gradeWorksheet.d.ts +4 -4
- 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 +284 -56
- 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/class.d.ts +24 -3
- 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 +10 -1
- package/dist/routers/labChat.d.ts.map +1 -1
- package/dist/routers/labChat.js +6 -3
- package/dist/routers/labChat.js.map +1 -1
- package/dist/routers/message.d.ts +11 -0
- package/dist/routers/message.d.ts.map +1 -1
- package/dist/routers/message.js +10 -3
- package/dist/routers/message.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/routers/worksheet.d.ts +24 -24
- package/dist/services/class.d.ts +24 -2
- package/dist/services/class.d.ts.map +1 -1
- package/dist/services/class.js +18 -6
- package/dist/services/class.js.map +1 -1
- package/dist/services/labChat.d.ts +5 -1
- package/dist/services/labChat.d.ts.map +1 -1
- package/dist/services/labChat.js +112 -4
- package/dist/services/labChat.js.map +1 -1
- package/dist/services/message.d.ts +8 -0
- package/dist/services/message.d.ts.map +1 -1
- package/dist/services/message.js +116 -2
- 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/dist/services/worksheet.d.ts +18 -18
- package/package.json +2 -2
- package/prisma/schema.prisma +1 -1
- package/sentry.properties +3 -0
- package/src/models/class.ts +189 -84
- package/src/pipelines/aiLabChat.ts +246 -184
- package/src/pipelines/aiLabChatContract.ts +75 -0
- package/src/pipelines/labChatPrompt.ts +68 -0
- package/src/routers/_app.ts +4 -2
- package/src/routers/class.ts +1 -1
- package/src/routers/labChat.ts +7 -0
- package/src/routers/message.ts +13 -0
- package/src/routers/studentProgress.ts +47 -0
- package/src/services/class.ts +14 -7
- package/src/services/labChat.ts +120 -5
- package/src/services/message.ts +142 -0
- 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
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const prismaMock = {
|
|
4
|
+
labChat: {
|
|
5
|
+
findUnique: vi.fn(),
|
|
6
|
+
},
|
|
7
|
+
message: {
|
|
8
|
+
update: vi.fn(),
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const pusherTriggerMock = vi.fn();
|
|
13
|
+
const teacherChannelMock = vi.fn((classId: string) => `teacher-${classId}`);
|
|
14
|
+
const loggerErrorMock = vi.fn();
|
|
15
|
+
const consoleErrorMock = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
16
|
+
|
|
17
|
+
vi.mock('../../src/lib/prisma.js', () => ({
|
|
18
|
+
prisma: prismaMock,
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
vi.mock('../../src/lib/pusher.js', () => ({
|
|
22
|
+
pusher: {
|
|
23
|
+
trigger: pusherTriggerMock,
|
|
24
|
+
},
|
|
25
|
+
teacherChannel: teacherChannelMock,
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
vi.mock('../../src/utils/logger.js', () => ({
|
|
29
|
+
logger: {
|
|
30
|
+
error: loggerErrorMock,
|
|
31
|
+
info: vi.fn(),
|
|
32
|
+
},
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
vi.mock('../../src/utils/aiUser.js', () => ({
|
|
36
|
+
getAIUserId: vi.fn(),
|
|
37
|
+
isAIUser: vi.fn(() => false),
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
vi.mock('../../src/utils/inference.js', () => ({
|
|
41
|
+
inference: vi.fn(),
|
|
42
|
+
inferenceClient: {},
|
|
43
|
+
sendAIMessage: vi.fn(),
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
vi.mock('../../src/lib/jsonConversion.js', () => ({
|
|
47
|
+
createPdf: vi.fn(),
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
vi.mock('../../src/lib/googleCloudStorage.js', () => ({
|
|
51
|
+
bucket: {
|
|
52
|
+
file: vi.fn(),
|
|
53
|
+
},
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
const { generateAndSendLabResponse } = await import('../../src/pipelines/aiLabChat.js');
|
|
57
|
+
|
|
58
|
+
describe('generateAndSendLabResponse', () => {
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
vi.clearAllMocks();
|
|
61
|
+
consoleErrorMock.mockClear();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('broadcasts lab-response-failed even if marking the message as FAILED throws', async () => {
|
|
65
|
+
const generationError = new Error('boom');
|
|
66
|
+
const statusError = new Error('message missing');
|
|
67
|
+
|
|
68
|
+
prismaMock.labChat.findUnique.mockRejectedValueOnce(generationError);
|
|
69
|
+
prismaMock.message.update.mockRejectedValueOnce(statusError);
|
|
70
|
+
pusherTriggerMock.mockResolvedValueOnce(undefined);
|
|
71
|
+
|
|
72
|
+
await expect(
|
|
73
|
+
generateAndSendLabResponse('lab-chat-1', 'teacher prompt', {
|
|
74
|
+
classId: 'class-1',
|
|
75
|
+
messageId: 'message-1',
|
|
76
|
+
}),
|
|
77
|
+
).rejects.toThrow(generationError);
|
|
78
|
+
|
|
79
|
+
expect(prismaMock.message.update).toHaveBeenCalledWith({
|
|
80
|
+
where: { id: 'message-1' },
|
|
81
|
+
data: { status: 'FAILED' },
|
|
82
|
+
});
|
|
83
|
+
expect(teacherChannelMock).toHaveBeenCalledWith('class-1');
|
|
84
|
+
expect(pusherTriggerMock).toHaveBeenCalledWith('teacher-class-1', 'lab-response-failed', {
|
|
85
|
+
labChatId: 'lab-chat-1',
|
|
86
|
+
messageId: 'message-1',
|
|
87
|
+
error: 'AI response generation failed',
|
|
88
|
+
});
|
|
89
|
+
expect(loggerErrorMock).toHaveBeenCalledWith('Failed to set message status FAILED:', {
|
|
90
|
+
error: statusError,
|
|
91
|
+
labChatId: 'lab-chat-1',
|
|
92
|
+
messageId: 'message-1',
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
});
|