@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.
Files changed (45) hide show
  1. package/dist/pipelines/aiLabChat.d.ts +9 -5
  2. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  3. package/dist/pipelines/aiLabChat.js +37 -144
  4. package/dist/pipelines/aiLabChat.js.map +1 -1
  5. package/dist/pipelines/aiLabChatContract.d.ts +413 -0
  6. package/dist/pipelines/aiLabChatContract.d.ts.map +1 -0
  7. package/dist/pipelines/aiLabChatContract.js +74 -0
  8. package/dist/pipelines/aiLabChatContract.js.map +1 -0
  9. package/dist/pipelines/labChatPrompt.d.ts +2 -0
  10. package/dist/pipelines/labChatPrompt.d.ts.map +1 -0
  11. package/dist/pipelines/labChatPrompt.js +72 -0
  12. package/dist/pipelines/labChatPrompt.js.map +1 -0
  13. package/dist/routers/_app.d.ts +146 -0
  14. package/dist/routers/_app.d.ts.map +1 -1
  15. package/dist/routers/_app.js +4 -2
  16. package/dist/routers/_app.js.map +1 -1
  17. package/dist/routers/studentProgress.d.ts +75 -0
  18. package/dist/routers/studentProgress.d.ts.map +1 -0
  19. package/dist/routers/studentProgress.js +33 -0
  20. package/dist/routers/studentProgress.js.map +1 -0
  21. package/dist/services/labChat.d.ts.map +1 -1
  22. package/dist/services/labChat.js +31 -15
  23. package/dist/services/labChat.js.map +1 -1
  24. package/dist/services/message.d.ts.map +1 -1
  25. package/dist/services/message.js +90 -48
  26. package/dist/services/message.js.map +1 -1
  27. package/dist/services/studentProgress.d.ts +45 -0
  28. package/dist/services/studentProgress.d.ts.map +1 -0
  29. package/dist/services/studentProgress.js +291 -0
  30. package/dist/services/studentProgress.js.map +1 -0
  31. package/package.json +2 -2
  32. package/sentry.properties +3 -0
  33. package/src/pipelines/aiLabChat.ts +37 -148
  34. package/src/pipelines/aiLabChatContract.ts +75 -0
  35. package/src/pipelines/labChatPrompt.ts +68 -0
  36. package/src/routers/_app.ts +4 -2
  37. package/src/routers/studentProgress.ts +47 -0
  38. package/src/services/labChat.ts +31 -22
  39. package/src/services/message.ts +97 -48
  40. package/src/services/studentProgress.ts +390 -0
  41. package/tests/lib/aiLabChatContract.test.ts +32 -0
  42. package/tests/pipelines/aiLabChat.test.ts +95 -0
  43. package/tests/routers/studentProgress.test.ts +283 -0
  44. package/tests/utils/aiLabChatPrompt.test.ts +18 -0
  45. package/vitest.unit.config.ts +7 -1
@@ -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
- labChatId,
314
- content,
315
- labChat.conversationId,
316
- { classId: labChat.classId, messageId: result.id }
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.message.delete({
423
- where: { id: lastMessage.id },
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
- labChatId,
452
- teacherMessage.content,
453
- labChat.conversationId,
454
- { classId: labChat.classId, messageId: teacherMessage.id }
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 };
@@ -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 currentMeta = (existingMessage.meta as Record<string, unknown>) ?? {};
397
- const createdIndices = (currentMeta.createdIndices as Record<string, number[]>) ?? {};
398
- const typeIndices = createdIndices[CREATED_INDICES_KEY[type]] ?? [];
399
- if (typeIndices.includes(index)) return { success: true };
400
-
401
- const newTypeIndices = [...typeIndices, index].sort((a, b) => a - b);
402
- const newMeta = {
403
- ...currentMeta,
404
- createdIndices: {
405
- ...createdIndices,
406
- [CREATED_INDICES_KEY[type]]: newTypeIndices,
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
- const updated = await prisma.message.update({
411
- where: { id: messageId },
412
- data: { meta: newMeta as object },
413
- include: {
414
- sender: {
415
- select: {
416
- id: true,
417
- username: true,
418
- profile: {
419
- select: { displayName: true, profilePicture: true },
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
- try {
428
- await pusher.trigger(
429
- chatChannel(existingMessage.conversationId),
430
- "message-updated",
431
- {
432
- id: updated.id,
433
- content: updated.content,
434
- senderId: updated.senderId,
435
- conversationId: updated.conversationId,
436
- createdAt: updated.createdAt,
437
- sender: updated.sender,
438
- attachments: updated.attachments ?? [],
439
- meta: newMeta,
440
- mentionedUserIds: [] as string[],
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
- } catch (error) {
444
- logger.error("Failed to broadcast suggestion status via Pusher", {
445
- error,
446
- messageId,
447
- });
484
+ return { success: true };
485
+ }
486
+
487
+ lastError = new Error("Concurrent update conflict");
448
488
  }
449
489
 
450
- return { success: true };
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
+ });