@studious-lms/server 1.4.0 → 1.4.2
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/.env.example +6 -0
- package/.env.test.example +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +36 -50
- package/dist/index.js.map +1 -1
- package/dist/lib/config/cors.d.ts +16 -0
- package/dist/lib/config/cors.d.ts.map +1 -0
- package/dist/lib/config/cors.js +75 -0
- package/dist/lib/config/cors.js.map +1 -0
- package/dist/lib/config/env.d.ts +14 -0
- package/dist/lib/config/env.d.ts.map +1 -1
- package/dist/lib/config/env.js +9 -2
- package/dist/lib/config/env.js.map +1 -1
- package/dist/lib/prisma.d.ts +14 -2
- package/dist/lib/prisma.d.ts.map +1 -1
- package/dist/lib/prisma.js +27 -8
- package/dist/lib/prisma.js.map +1 -1
- package/dist/middleware/security.d.ts.map +1 -1
- package/dist/middleware/security.js +3 -3
- package/dist/middleware/security.js.map +1 -1
- package/dist/models/agenda.d.ts +16 -16
- package/dist/models/announcement.d.ts +59 -23
- package/dist/models/announcement.d.ts.map +1 -1
- package/dist/models/assignment.d.ts +363 -276
- package/dist/models/assignment.d.ts.map +1 -1
- package/dist/models/attendance.d.ts +63 -21
- package/dist/models/attendance.d.ts.map +1 -1
- package/dist/models/auth.d.ts +102 -18
- package/dist/models/auth.d.ts.map +1 -1
- package/dist/models/class.d.ts +112 -64
- package/dist/models/class.d.ts.map +1 -1
- package/dist/models/comment.d.ts +52 -16
- package/dist/models/comment.d.ts.map +1 -1
- package/dist/models/conversation.d.ts +46 -16
- package/dist/models/conversation.d.ts.map +1 -1
- package/dist/models/event.d.ts +107 -53
- package/dist/models/event.d.ts.map +1 -1
- package/dist/models/file.d.ts +213 -165
- package/dist/models/file.d.ts.map +1 -1
- package/dist/models/folder.d.ts +161 -77
- package/dist/models/folder.d.ts.map +1 -1
- package/dist/models/labChat.d.ts +73 -31
- package/dist/models/labChat.d.ts.map +1 -1
- package/dist/models/marketing.d.ts +25 -7
- package/dist/models/marketing.d.ts.map +1 -1
- package/dist/models/message.d.ts +31 -13
- package/dist/models/message.d.ts.map +1 -1
- package/dist/models/newtonChat.d.ts +34 -10
- package/dist/models/newtonChat.d.ts.map +1 -1
- package/dist/models/notification.d.ts +25 -7
- package/dist/models/notification.d.ts.map +1 -1
- package/dist/models/section.d.ts +71 -23
- package/dist/models/section.d.ts.map +1 -1
- package/dist/models/user.d.ts +27 -9
- package/dist/models/user.d.ts.map +1 -1
- package/dist/models/worksheet.d.ts +237 -108
- package/dist/models/worksheet.d.ts.map +1 -1
- package/dist/pipelines/aiLabChat.d.ts +30 -6
- package/dist/pipelines/aiLabChat.d.ts.map +1 -1
- package/dist/pipelines/aiLabChat.js +157 -234
- 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 +8 -8
- package/dist/pipelines/gradeWorksheet.js +4 -4
- package/dist/pipelines/gradeWorksheet.js.map +1 -1
- package/dist/pipelines/labChatPrompt.d.ts +29 -0
- package/dist/pipelines/labChatPrompt.d.ts.map +1 -0
- package/dist/pipelines/labChatPrompt.js +146 -0
- package/dist/pipelines/labChatPrompt.js.map +1 -0
- package/dist/routers/_app.d.ts +1622 -1260
- 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/agenda.d.ts +16 -16
- package/dist/routers/announcement.d.ts +19 -19
- package/dist/routers/assignment.d.ts +307 -291
- package/dist/routers/assignment.d.ts.map +1 -1
- package/dist/routers/assignment.js +3 -2
- package/dist/routers/assignment.js.map +1 -1
- package/dist/routers/attendance.d.ts +7 -7
- package/dist/routers/auth.d.ts +1 -1
- package/dist/routers/class.d.ts +77 -71
- package/dist/routers/class.d.ts.map +1 -1
- package/dist/routers/comment.d.ts +6 -6
- package/dist/routers/conversation.d.ts +11 -11
- package/dist/routers/event.d.ts +35 -35
- package/dist/routers/file.d.ts +12 -12
- package/dist/routers/folder.d.ts +54 -54
- package/dist/routers/labChat.d.ts +12 -12
- package/dist/routers/marketing.d.ts +2 -2
- package/dist/routers/message.d.ts +2 -2
- package/dist/routers/newtonChat.d.ts +1 -1
- package/dist/routers/notifications.d.ts +4 -4
- package/dist/routers/section.d.ts +7 -7
- package/dist/routers/studentProgress.d.ts +161 -0
- package/dist/routers/studentProgress.d.ts.map +1 -0
- package/dist/routers/studentProgress.js +43 -0
- package/dist/routers/studentProgress.js.map +1 -0
- package/dist/routers/user.d.ts +1 -1
- package/dist/routers/worksheet.d.ts +58 -58
- package/dist/seedDatabase.d.ts +1 -1
- package/dist/services/agenda.d.ts +16 -16
- package/dist/services/announcement.d.ts +8 -8
- package/dist/services/assignment.d.ts +299 -283
- package/dist/services/assignment.d.ts.map +1 -1
- package/dist/services/assignment.js +24 -5
- package/dist/services/assignment.js.map +1 -1
- package/dist/services/attendance.d.ts +7 -7
- package/dist/services/auth.d.ts +1 -1
- package/dist/services/class.d.ts +73 -67
- package/dist/services/class.d.ts.map +1 -1
- package/dist/services/comment.d.ts +6 -6
- package/dist/services/conversation.d.ts +11 -11
- package/dist/services/event.d.ts +31 -31
- package/dist/services/file.d.ts +12 -12
- package/dist/services/folder.d.ts +52 -52
- package/dist/services/labChat.d.ts +12 -12
- 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/marketing.d.ts +2 -2
- 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/notification.d.ts +4 -4
- package/dist/services/section.d.ts +6 -6
- package/dist/services/studentProgress.d.ts +120 -0
- package/dist/services/studentProgress.d.ts.map +1 -0
- package/dist/services/studentProgress.js +481 -0
- package/dist/services/studentProgress.js.map +1 -0
- package/dist/services/worksheet.d.ts +49 -49
- package/dist/utils/inference.d.ts +0 -11
- package/dist/utils/inference.d.ts.map +1 -1
- package/dist/utils/inference.js +2 -50
- package/dist/utils/inference.js.map +1 -1
- package/package.json +2 -2
- package/prisma/migrations/20260410124000_add_submission_recommendation_state/migration.sql +14 -0
- package/prisma/schema.prisma +14 -0
- package/sentry.properties +3 -0
- package/src/index.ts +39 -51
- package/src/lib/config/cors.ts +96 -0
- package/src/lib/config/env.ts +12 -1
- package/src/lib/prisma.ts +25 -6
- package/src/middleware/security.ts +1 -1
- package/src/pipelines/aiLabChat.ts +206 -246
- package/src/pipelines/aiLabChatContract.ts +75 -0
- package/src/pipelines/gradeWorksheet.ts +2 -2
- package/src/pipelines/labChatPrompt.ts +196 -0
- package/src/routers/_app.ts +4 -2
- package/src/routers/assignment.ts +1 -0
- package/src/routers/studentProgress.ts +71 -0
- package/src/services/assignment.ts +30 -2
- package/src/services/labChat.ts +31 -22
- package/src/services/message.ts +97 -48
- package/src/services/studentProgress.ts +691 -0
- package/src/utils/inference.ts +0 -61
- package/tests/lib/aiLabChatContract.test.ts +32 -0
- package/tests/lib/cors.test.ts +103 -0
- package/tests/pipelines/aiLabChat.test.ts +75 -0
- package/tests/routers/studentProgress.test.ts +254 -0
- package/tests/utils/aiLabChatPrompt.test.ts +126 -0
- package/tests/utils/studentProgress.test.ts +361 -0
- package/vitest.unit.config.ts +8 -1
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Student progress service - assignment recommendations and progress chat.
|
|
3
|
+
*/
|
|
4
|
+
import { TRPCError } from "@trpc/server";
|
|
5
|
+
import type { AssignmentType } from "@prisma/client";
|
|
6
|
+
import { prisma } from "../lib/prisma.js";
|
|
7
|
+
import { isTeacherInClass } from "../models/class.js";
|
|
8
|
+
import { inference } from "../utils/inference.js";
|
|
9
|
+
import { logger } from "../utils/logger.js";
|
|
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
|
+
recommendationState: "NONE" | "OPEN" | "ASSIGNED" | "DISMISSED";
|
|
23
|
+
targetedAssignmentId: string | null;
|
|
24
|
+
assignment: {
|
|
25
|
+
id: string;
|
|
26
|
+
title: string;
|
|
27
|
+
dueDate: Date;
|
|
28
|
+
maxGrade: number | null;
|
|
29
|
+
weight: number;
|
|
30
|
+
type: AssignmentType;
|
|
31
|
+
section: { id: string; name: string } | null;
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type ProgressStudent = {
|
|
36
|
+
id: string;
|
|
37
|
+
username: string;
|
|
38
|
+
profile: { displayName: string | null } | null;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type RecommendationType =
|
|
42
|
+
| "missing"
|
|
43
|
+
| "low_score"
|
|
44
|
+
| "awaiting_feedback";
|
|
45
|
+
|
|
46
|
+
function calculatePercentage(submission: GradeSubmission) {
|
|
47
|
+
if (submission.gradeReceived == null || !submission.assignment.maxGrade) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
Math.round(
|
|
53
|
+
(submission.gradeReceived / submission.assignment.maxGrade) * 1000,
|
|
54
|
+
) / 10
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function calculateTrend(submissions: GradeSubmission[]) {
|
|
59
|
+
const graded = submissions
|
|
60
|
+
.filter(
|
|
61
|
+
(submission) =>
|
|
62
|
+
submission.gradeReceived != null && submission.assignment.maxGrade,
|
|
63
|
+
)
|
|
64
|
+
.sort((a, b) => {
|
|
65
|
+
const aTime = a.submittedAt?.getTime() ?? a.assignment.dueDate.getTime();
|
|
66
|
+
const bTime = b.submittedAt?.getTime() ?? b.assignment.dueDate.getTime();
|
|
67
|
+
return aTime - bTime;
|
|
68
|
+
})
|
|
69
|
+
.map((submission) => calculatePercentage(submission))
|
|
70
|
+
.filter((value): value is number => value != null);
|
|
71
|
+
|
|
72
|
+
if (graded.length < 2) return 0;
|
|
73
|
+
|
|
74
|
+
const midpoint = Math.floor(graded.length / 2);
|
|
75
|
+
const first = graded.slice(0, midpoint);
|
|
76
|
+
const second = graded.slice(midpoint);
|
|
77
|
+
const average = (values: number[]) =>
|
|
78
|
+
values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
79
|
+
|
|
80
|
+
return Math.round((average(second) - average(first)) * 10) / 10;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function getOverallGrade(submissions: GradeSubmission[]) {
|
|
84
|
+
let totalWeighted = 0;
|
|
85
|
+
let totalWeight = 0;
|
|
86
|
+
|
|
87
|
+
for (const submission of submissions) {
|
|
88
|
+
if (
|
|
89
|
+
submission.gradeReceived != null &&
|
|
90
|
+
submission.assignment.maxGrade &&
|
|
91
|
+
submission.assignment.weight
|
|
92
|
+
) {
|
|
93
|
+
totalWeighted +=
|
|
94
|
+
(submission.gradeReceived / submission.assignment.maxGrade) *
|
|
95
|
+
submission.assignment.weight;
|
|
96
|
+
totalWeight += submission.assignment.weight;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return totalWeight > 0
|
|
101
|
+
? Math.round((totalWeighted / totalWeight) * 1000) / 10
|
|
102
|
+
: null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function normalizeAssignmentType(
|
|
106
|
+
assignmentType: AssignmentType,
|
|
107
|
+
): AssignmentType {
|
|
108
|
+
if (assignmentType === "TEST" || assignmentType === "QUIZ") {
|
|
109
|
+
return "HOMEWORK";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return assignmentType;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function buildSuggestedAssignment(
|
|
116
|
+
recommendationType: RecommendationType,
|
|
117
|
+
submission: GradeSubmission,
|
|
118
|
+
displayName: string,
|
|
119
|
+
percentage: number | null,
|
|
120
|
+
) {
|
|
121
|
+
const sourceTitle = submission.assignment.title;
|
|
122
|
+
const section = submission.assignment.section?.name;
|
|
123
|
+
const titleByType: Record<RecommendationType, string> = {
|
|
124
|
+
missing: `Make-up: ${sourceTitle}`,
|
|
125
|
+
low_score: `Targeted practice: ${sourceTitle}`,
|
|
126
|
+
awaiting_feedback: `Revision follow-up: ${sourceTitle}`,
|
|
127
|
+
};
|
|
128
|
+
const leadByType: Record<RecommendationType, string> = {
|
|
129
|
+
missing: `${displayName} missed the original assignment and needs a focused make-up task on the same material.`,
|
|
130
|
+
low_score:
|
|
131
|
+
percentage != null
|
|
132
|
+
? `${displayName} scored ${percentage}% and needs targeted reinforcement on the underlying concepts.`
|
|
133
|
+
: `${displayName} needs targeted reinforcement on the underlying concepts.`,
|
|
134
|
+
awaiting_feedback:
|
|
135
|
+
`${displayName} has work in progress here, so a short follow-up can keep momentum once feedback lands.`,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
title: titleByType[recommendationType],
|
|
140
|
+
instructions: [
|
|
141
|
+
leadByType[recommendationType],
|
|
142
|
+
`Source assignment: ${sourceTitle}.`,
|
|
143
|
+
section ? `Section: ${section}.` : null,
|
|
144
|
+
"Keep the work concise and focused on the specific area of concern.",
|
|
145
|
+
]
|
|
146
|
+
.filter((line): line is string => Boolean(line))
|
|
147
|
+
.join("\n\n"),
|
|
148
|
+
type: normalizeAssignmentType(submission.assignment.type),
|
|
149
|
+
maxGrade: submission.assignment.maxGrade ?? 100,
|
|
150
|
+
weight: submission.assignment.weight || 1,
|
|
151
|
+
graded: true,
|
|
152
|
+
sectionId: submission.assignment.section?.id ?? null,
|
|
153
|
+
sourceAssignmentId: submission.assignment.id,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function buildRecommendationCandidate(
|
|
158
|
+
submission: GradeSubmission,
|
|
159
|
+
displayName: string,
|
|
160
|
+
now: Date,
|
|
161
|
+
) {
|
|
162
|
+
if (
|
|
163
|
+
submission.recommendationState === "ASSIGNED" ||
|
|
164
|
+
submission.recommendationState === "DISMISSED"
|
|
165
|
+
) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const percentage = calculatePercentage(submission);
|
|
170
|
+
const isMissing =
|
|
171
|
+
!submission.submitted &&
|
|
172
|
+
submission.assignment.dueDate.getTime() < now.getTime();
|
|
173
|
+
const isLowScore = percentage != null && percentage < 70;
|
|
174
|
+
const isUnreturned =
|
|
175
|
+
Boolean(submission.submitted) &&
|
|
176
|
+
submission.gradeReceived == null &&
|
|
177
|
+
!submission.returned;
|
|
178
|
+
const reasons: string[] = [];
|
|
179
|
+
let priorityScore = 0;
|
|
180
|
+
let recommendationType: RecommendationType | null = null;
|
|
181
|
+
|
|
182
|
+
if (isMissing) {
|
|
183
|
+
reasons.push("Missing past-due work");
|
|
184
|
+
priorityScore += 100;
|
|
185
|
+
recommendationType = "missing";
|
|
186
|
+
}
|
|
187
|
+
if (isLowScore) {
|
|
188
|
+
reasons.push(`Scored ${percentage}%`);
|
|
189
|
+
priorityScore += 85 - (percentage ?? 0);
|
|
190
|
+
recommendationType ??= "low_score";
|
|
191
|
+
}
|
|
192
|
+
if (isUnreturned) {
|
|
193
|
+
reasons.push("Awaiting grade or feedback");
|
|
194
|
+
priorityScore += 15;
|
|
195
|
+
recommendationType ??= "awaiting_feedback";
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (priorityScore <= 0 || !recommendationType) {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
assignmentId: submission.assignment.id,
|
|
204
|
+
submissionId: submission.id,
|
|
205
|
+
title: submission.assignment.title,
|
|
206
|
+
type: submission.assignment.type,
|
|
207
|
+
sectionId: submission.assignment.section?.id ?? null,
|
|
208
|
+
sectionName: submission.assignment.section?.name ?? null,
|
|
209
|
+
dueDate: submission.assignment.dueDate,
|
|
210
|
+
gradeReceived: submission.gradeReceived,
|
|
211
|
+
maxGrade: submission.assignment.maxGrade,
|
|
212
|
+
percentage,
|
|
213
|
+
submitted: Boolean(submission.submitted),
|
|
214
|
+
returned: Boolean(submission.returned),
|
|
215
|
+
recommendationType,
|
|
216
|
+
reasons,
|
|
217
|
+
priorityScore,
|
|
218
|
+
suggestedAssignment: buildSuggestedAssignment(
|
|
219
|
+
recommendationType,
|
|
220
|
+
submission,
|
|
221
|
+
displayName,
|
|
222
|
+
percentage,
|
|
223
|
+
),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function summarizeStudentRecommendations(
|
|
228
|
+
student: ProgressStudent,
|
|
229
|
+
submissions: GradeSubmission[],
|
|
230
|
+
) {
|
|
231
|
+
const now = new Date();
|
|
232
|
+
const displayName = student.profile?.displayName ?? student.username;
|
|
233
|
+
const recommendationCandidates = submissions
|
|
234
|
+
.map((submission) =>
|
|
235
|
+
buildRecommendationCandidate(submission, displayName, now),
|
|
236
|
+
)
|
|
237
|
+
.filter(
|
|
238
|
+
(
|
|
239
|
+
candidate,
|
|
240
|
+
): candidate is NonNullable<
|
|
241
|
+
ReturnType<typeof buildRecommendationCandidate>
|
|
242
|
+
> => candidate != null,
|
|
243
|
+
)
|
|
244
|
+
.sort((a, b) => b.priorityScore - a.priorityScore);
|
|
245
|
+
|
|
246
|
+
const recommendations = recommendationCandidates
|
|
247
|
+
.slice(0, 5)
|
|
248
|
+
.map(({ priorityScore, ...candidate }) => candidate);
|
|
249
|
+
|
|
250
|
+
const gradedPercentages = submissions
|
|
251
|
+
.map(calculatePercentage)
|
|
252
|
+
.filter((value): value is number => value != null);
|
|
253
|
+
const lowScoreCount = gradedPercentages.filter(
|
|
254
|
+
(percentage) => percentage < 70,
|
|
255
|
+
).length;
|
|
256
|
+
const missingCount = submissions.filter(
|
|
257
|
+
(submission) =>
|
|
258
|
+
!submission.submitted &&
|
|
259
|
+
submission.assignment.dueDate.getTime() < now.getTime(),
|
|
260
|
+
).length;
|
|
261
|
+
const trend = calculateTrend(submissions);
|
|
262
|
+
const overallGrade = getOverallGrade(submissions);
|
|
263
|
+
|
|
264
|
+
const nextSteps = [
|
|
265
|
+
missingCount > 0
|
|
266
|
+
? `Prioritize ${missingCount} missing assignment${missingCount === 1 ? "" : "s"}.`
|
|
267
|
+
: null,
|
|
268
|
+
lowScoreCount > 0
|
|
269
|
+
? `Review ${lowScoreCount} low-scoring assignment${lowScoreCount === 1 ? "" : "s"} before introducing new material.`
|
|
270
|
+
: null,
|
|
271
|
+
trend < -5
|
|
272
|
+
? "Schedule a check-in because recent performance is trending down."
|
|
273
|
+
: null,
|
|
274
|
+
recommendationCandidates.length === 0
|
|
275
|
+
? "No urgent assignment issues detected from the available grades."
|
|
276
|
+
: null,
|
|
277
|
+
].filter((step): step is string => Boolean(step));
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
student: {
|
|
281
|
+
id: student.id,
|
|
282
|
+
username: student.username,
|
|
283
|
+
displayName,
|
|
284
|
+
},
|
|
285
|
+
summary: {
|
|
286
|
+
overallGrade,
|
|
287
|
+
trend,
|
|
288
|
+
completedAssignments: submissions.filter((submission) =>
|
|
289
|
+
Boolean(submission.submitted),
|
|
290
|
+
).length,
|
|
291
|
+
totalAssignments: submissions.length,
|
|
292
|
+
missingCount,
|
|
293
|
+
lowScoreCount,
|
|
294
|
+
},
|
|
295
|
+
recommendations,
|
|
296
|
+
recommendationCandidates,
|
|
297
|
+
nextSteps,
|
|
298
|
+
priorityScore: recommendationCandidates[0]?.priorityScore ?? 0,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function syncRecommendationStates(
|
|
303
|
+
submissions: GradeSubmission[],
|
|
304
|
+
recommendationCandidates: Array<
|
|
305
|
+
NonNullable<ReturnType<typeof buildRecommendationCandidate>>
|
|
306
|
+
>,
|
|
307
|
+
) {
|
|
308
|
+
const candidateSubmissionIds = new Set(
|
|
309
|
+
recommendationCandidates.map((candidate) => candidate.submissionId),
|
|
310
|
+
);
|
|
311
|
+
const updates: Promise<unknown>[] = [];
|
|
312
|
+
|
|
313
|
+
for (const submission of submissions) {
|
|
314
|
+
const shouldBeOpen = candidateSubmissionIds.has(submission.id);
|
|
315
|
+
|
|
316
|
+
if (shouldBeOpen && submission.recommendationState === "NONE") {
|
|
317
|
+
updates.push(
|
|
318
|
+
prisma.submission.update({
|
|
319
|
+
where: { id: submission.id },
|
|
320
|
+
data: {
|
|
321
|
+
recommendationState: "OPEN",
|
|
322
|
+
recommendationUpdatedAt: new Date(),
|
|
323
|
+
},
|
|
324
|
+
}),
|
|
325
|
+
);
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (!shouldBeOpen && submission.recommendationState === "OPEN") {
|
|
330
|
+
updates.push(
|
|
331
|
+
prisma.submission.update({
|
|
332
|
+
where: { id: submission.id },
|
|
333
|
+
data: {
|
|
334
|
+
recommendationState: "NONE",
|
|
335
|
+
recommendationUpdatedAt: new Date(),
|
|
336
|
+
},
|
|
337
|
+
}),
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (updates.length) {
|
|
343
|
+
await Promise.all(updates);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function summarizeStudentRecommendationsWithCases(
|
|
348
|
+
student: ProgressStudent,
|
|
349
|
+
submissions: GradeSubmission[],
|
|
350
|
+
) {
|
|
351
|
+
const summary = summarizeStudentRecommendations(student, submissions);
|
|
352
|
+
await syncRecommendationStates(submissions, summary.recommendationCandidates);
|
|
353
|
+
return summary;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function loadStudentProgressContext(
|
|
357
|
+
viewerId: string,
|
|
358
|
+
classId: string,
|
|
359
|
+
studentId: string,
|
|
360
|
+
) {
|
|
361
|
+
const isTeacher = await isTeacherInClass(classId, viewerId);
|
|
362
|
+
if (viewerId !== studentId && !isTeacher) {
|
|
363
|
+
throw new TRPCError({
|
|
364
|
+
code: "UNAUTHORIZED",
|
|
365
|
+
message: "You can only view your own progress",
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const [classData, student, submissions] = await Promise.all([
|
|
370
|
+
prisma.class.findUnique({
|
|
371
|
+
where: { id: classId },
|
|
372
|
+
select: { id: true, name: true, subject: true },
|
|
373
|
+
}),
|
|
374
|
+
prisma.user.findFirst({
|
|
375
|
+
where: {
|
|
376
|
+
id: studentId,
|
|
377
|
+
studentIn: { some: { id: classId } },
|
|
378
|
+
},
|
|
379
|
+
select: {
|
|
380
|
+
id: true,
|
|
381
|
+
username: true,
|
|
382
|
+
profile: { select: { displayName: true } },
|
|
383
|
+
},
|
|
384
|
+
}),
|
|
385
|
+
prisma.submission.findMany({
|
|
386
|
+
where: {
|
|
387
|
+
studentId,
|
|
388
|
+
assignment: { classId, graded: true },
|
|
389
|
+
},
|
|
390
|
+
include: {
|
|
391
|
+
assignment: {
|
|
392
|
+
select: {
|
|
393
|
+
id: true,
|
|
394
|
+
title: true,
|
|
395
|
+
dueDate: true,
|
|
396
|
+
maxGrade: true,
|
|
397
|
+
weight: true,
|
|
398
|
+
type: true,
|
|
399
|
+
section: { select: { id: true, name: true } },
|
|
400
|
+
},
|
|
401
|
+
},
|
|
402
|
+
},
|
|
403
|
+
orderBy: { assignment: { dueDate: "asc" } },
|
|
404
|
+
}),
|
|
405
|
+
]);
|
|
406
|
+
|
|
407
|
+
if (!classData) {
|
|
408
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Class not found" });
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (!student) {
|
|
412
|
+
throw new TRPCError({
|
|
413
|
+
code: "NOT_FOUND",
|
|
414
|
+
message: "Student not found in this class",
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return { classData, student, submissions };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export async function getStudentProgressRecommendations(
|
|
422
|
+
viewerId: string,
|
|
423
|
+
classId: string,
|
|
424
|
+
studentId: string,
|
|
425
|
+
) {
|
|
426
|
+
const { student, submissions } = await loadStudentProgressContext(
|
|
427
|
+
viewerId,
|
|
428
|
+
classId,
|
|
429
|
+
studentId,
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
const summary = await summarizeStudentRecommendationsWithCases(student, submissions);
|
|
433
|
+
const { recommendationCandidates: _recommendationCandidates, ...summaryResponse } =
|
|
434
|
+
summary;
|
|
435
|
+
return {
|
|
436
|
+
student: summaryResponse.student,
|
|
437
|
+
summary: summaryResponse.summary,
|
|
438
|
+
recommendations: summaryResponse.recommendations,
|
|
439
|
+
nextSteps: summaryResponse.nextSteps,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export async function getClassProgressRecommendations(
|
|
444
|
+
viewerId: string,
|
|
445
|
+
classId: string,
|
|
446
|
+
) {
|
|
447
|
+
const teacherInClass = await isTeacherInClass(classId, viewerId);
|
|
448
|
+
if (!teacherInClass) {
|
|
449
|
+
throw new TRPCError({
|
|
450
|
+
code: "UNAUTHORIZED",
|
|
451
|
+
message: "Only teachers can view class-wide recommendations",
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const classData = await prisma.class.findUnique({
|
|
456
|
+
where: { id: classId },
|
|
457
|
+
select: {
|
|
458
|
+
id: true,
|
|
459
|
+
name: true,
|
|
460
|
+
subject: true,
|
|
461
|
+
students: {
|
|
462
|
+
select: {
|
|
463
|
+
id: true,
|
|
464
|
+
username: true,
|
|
465
|
+
profile: { select: { displayName: true } },
|
|
466
|
+
submissions: {
|
|
467
|
+
where: {
|
|
468
|
+
assignment: { classId, graded: true },
|
|
469
|
+
},
|
|
470
|
+
include: {
|
|
471
|
+
assignment: {
|
|
472
|
+
select: {
|
|
473
|
+
id: true,
|
|
474
|
+
title: true,
|
|
475
|
+
dueDate: true,
|
|
476
|
+
maxGrade: true,
|
|
477
|
+
weight: true,
|
|
478
|
+
type: true,
|
|
479
|
+
section: { select: { id: true, name: true } },
|
|
480
|
+
},
|
|
481
|
+
},
|
|
482
|
+
},
|
|
483
|
+
orderBy: { assignment: { dueDate: "asc" } },
|
|
484
|
+
},
|
|
485
|
+
},
|
|
486
|
+
},
|
|
487
|
+
},
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
if (!classData) {
|
|
491
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Class not found" });
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const students = (
|
|
495
|
+
await Promise.all(
|
|
496
|
+
classData.students.map((student) =>
|
|
497
|
+
summarizeStudentRecommendationsWithCases(student, student.submissions),
|
|
498
|
+
),
|
|
499
|
+
)
|
|
500
|
+
)
|
|
501
|
+
.map(({ recommendationCandidates: _recommendationCandidates, ...student }) => student)
|
|
502
|
+
.filter((student) => student.recommendations.length > 0)
|
|
503
|
+
.sort((a, b) => b.priorityScore - a.priorityScore);
|
|
504
|
+
|
|
505
|
+
const totalRecommendations = students.reduce(
|
|
506
|
+
(sum, student) => sum + student.recommendations.length,
|
|
507
|
+
0,
|
|
508
|
+
);
|
|
509
|
+
const studentsWithConcerns = students.length;
|
|
510
|
+
const totalMissingAssignments = students.reduce(
|
|
511
|
+
(sum, student) => sum + student.summary.missingCount,
|
|
512
|
+
0,
|
|
513
|
+
);
|
|
514
|
+
const totalLowScoreAssignments = students.reduce(
|
|
515
|
+
(sum, student) => sum + student.summary.lowScoreCount,
|
|
516
|
+
0,
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
return {
|
|
520
|
+
class: {
|
|
521
|
+
id: classData.id,
|
|
522
|
+
name: classData.name,
|
|
523
|
+
subject: classData.subject,
|
|
524
|
+
},
|
|
525
|
+
summary: {
|
|
526
|
+
studentsWithConcerns,
|
|
527
|
+
totalRecommendations,
|
|
528
|
+
totalMissingAssignments,
|
|
529
|
+
totalLowScoreAssignments,
|
|
530
|
+
},
|
|
531
|
+
students: students.map(({ priorityScore, ...student }) => student),
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
export async function dismissStudentRecommendation(
|
|
536
|
+
viewerId: string,
|
|
537
|
+
input: {
|
|
538
|
+
classId: string;
|
|
539
|
+
studentId: string;
|
|
540
|
+
submissionId: string;
|
|
541
|
+
},
|
|
542
|
+
) {
|
|
543
|
+
const teacherInClass = await isTeacherInClass(input.classId, viewerId);
|
|
544
|
+
if (!teacherInClass) {
|
|
545
|
+
throw new TRPCError({
|
|
546
|
+
code: "UNAUTHORIZED",
|
|
547
|
+
message: "Only teachers can dismiss recommendations",
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const submission = await prisma.submission.findFirst({
|
|
552
|
+
where: {
|
|
553
|
+
id: input.submissionId,
|
|
554
|
+
studentId: input.studentId,
|
|
555
|
+
assignment: {
|
|
556
|
+
classId: input.classId,
|
|
557
|
+
},
|
|
558
|
+
},
|
|
559
|
+
select: { id: true },
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
if (!submission) {
|
|
563
|
+
throw new TRPCError({
|
|
564
|
+
code: "NOT_FOUND",
|
|
565
|
+
message: "Submission not found for this student in this class",
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
await prisma.submission.update({
|
|
570
|
+
where: { id: input.submissionId },
|
|
571
|
+
data: {
|
|
572
|
+
recommendationState: "DISMISSED",
|
|
573
|
+
recommendationUpdatedAt: new Date(),
|
|
574
|
+
},
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
return { success: true };
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function buildProgressSummary(submissions: GradeSubmission[]) {
|
|
581
|
+
return submissions.map((submission) => ({
|
|
582
|
+
title: submission.assignment.title,
|
|
583
|
+
type: submission.assignment.type,
|
|
584
|
+
dueDate: submission.assignment.dueDate.toISOString(),
|
|
585
|
+
submitted: Boolean(submission.submitted),
|
|
586
|
+
returned: Boolean(submission.returned),
|
|
587
|
+
gradeReceived: submission.gradeReceived,
|
|
588
|
+
maxGrade: submission.assignment.maxGrade,
|
|
589
|
+
percentage: calculatePercentage(submission),
|
|
590
|
+
section: submission.assignment.section?.name ?? null,
|
|
591
|
+
}));
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
export async function chatAboutStudentProgress(
|
|
595
|
+
viewerId: string,
|
|
596
|
+
input: {
|
|
597
|
+
classId: string;
|
|
598
|
+
studentId: string;
|
|
599
|
+
message: string;
|
|
600
|
+
history?: ProgressChatMessage[];
|
|
601
|
+
},
|
|
602
|
+
) {
|
|
603
|
+
const { classData, student, submissions } = await loadStudentProgressContext(
|
|
604
|
+
viewerId,
|
|
605
|
+
input.classId,
|
|
606
|
+
input.studentId,
|
|
607
|
+
);
|
|
608
|
+
|
|
609
|
+
const displayName = student.profile?.displayName ?? student.username;
|
|
610
|
+
const summary = {
|
|
611
|
+
overallGrade: getOverallGrade(submissions),
|
|
612
|
+
trend: calculateTrend(submissions),
|
|
613
|
+
assignments: buildProgressSummary(submissions),
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
const messages = [
|
|
617
|
+
{
|
|
618
|
+
role: "system" as const,
|
|
619
|
+
content:
|
|
620
|
+
"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.",
|
|
621
|
+
},
|
|
622
|
+
{
|
|
623
|
+
role: "user" as const,
|
|
624
|
+
content: JSON.stringify({
|
|
625
|
+
class: classData,
|
|
626
|
+
student: { id: student.id, username: student.username, displayName },
|
|
627
|
+
progress: summary,
|
|
628
|
+
}),
|
|
629
|
+
},
|
|
630
|
+
...(input.history ?? []).slice(-8).map((message) => ({
|
|
631
|
+
role: message.role,
|
|
632
|
+
content: message.content,
|
|
633
|
+
})),
|
|
634
|
+
{ role: "user" as const, content: input.message },
|
|
635
|
+
];
|
|
636
|
+
|
|
637
|
+
try {
|
|
638
|
+
const response = await inference<string>(messages);
|
|
639
|
+
if (typeof response !== "string" || response.trim().length === 0) {
|
|
640
|
+
throw new Error("Student progress chat returned an empty response");
|
|
641
|
+
}
|
|
642
|
+
return { message: response, isFallback: false };
|
|
643
|
+
} catch (error) {
|
|
644
|
+
logger.error("Failed to generate student progress chat response", {
|
|
645
|
+
error,
|
|
646
|
+
classId: input.classId,
|
|
647
|
+
studentId: input.studentId,
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
const overall =
|
|
651
|
+
summary.overallGrade == null
|
|
652
|
+
? "not enough graded work"
|
|
653
|
+
: `${summary.overallGrade}%`;
|
|
654
|
+
const missingItems = summary.assignments.filter(
|
|
655
|
+
(assignment) =>
|
|
656
|
+
!assignment.submitted &&
|
|
657
|
+
new Date(assignment.dueDate).getTime() < Date.now(),
|
|
658
|
+
).length;
|
|
659
|
+
const lowScores = summary.assignments.filter(
|
|
660
|
+
(assignment) =>
|
|
661
|
+
assignment.percentage != null && assignment.percentage < 70,
|
|
662
|
+
).length;
|
|
663
|
+
const awaitingFeedback = summary.assignments.filter(
|
|
664
|
+
(assignment) =>
|
|
665
|
+
assignment.submitted &&
|
|
666
|
+
assignment.gradeReceived == null &&
|
|
667
|
+
!assignment.returned,
|
|
668
|
+
).length;
|
|
669
|
+
|
|
670
|
+
const fallbackParts = [
|
|
671
|
+
`${displayName}'s current overall grade is ${overall}.`,
|
|
672
|
+
missingItems > 0
|
|
673
|
+
? `${missingItems} assignment${missingItems === 1 ? "" : "s"} ${missingItems === 1 ? "appears" : "appear"} overdue.`
|
|
674
|
+
: null,
|
|
675
|
+
lowScores > 0
|
|
676
|
+
? `${lowScores} graded assignment${lowScores === 1 ? " is" : "s are"} below 70%.`
|
|
677
|
+
: null,
|
|
678
|
+
awaitingFeedback > 0
|
|
679
|
+
? `${awaitingFeedback} assignment${awaitingFeedback === 1 ? " is" : "s are"} still awaiting feedback.`
|
|
680
|
+
: null,
|
|
681
|
+
summary.trend < -5
|
|
682
|
+
? "Recent performance is trending down, so a check-in would be reasonable."
|
|
683
|
+
: "Performance looks relatively steady from the available data.",
|
|
684
|
+
].filter((part): part is string => Boolean(part));
|
|
685
|
+
|
|
686
|
+
return {
|
|
687
|
+
message: fallbackParts.join(" "),
|
|
688
|
+
isFallback: true,
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
}
|