@studious-lms/server 1.4.1 → 1.4.3

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 (248) hide show
  1. package/.coderabbit.yaml +4 -1
  2. package/.env.example +6 -0
  3. package/.env.test.example +2 -0
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +101 -76
  6. package/dist/index.js.map +1 -1
  7. package/dist/lib/config/cors.d.ts +16 -0
  8. package/dist/lib/config/cors.d.ts.map +1 -0
  9. package/dist/lib/config/cors.js +75 -0
  10. package/dist/lib/config/cors.js.map +1 -0
  11. package/dist/lib/config/env.d.ts +14 -0
  12. package/dist/lib/config/env.d.ts.map +1 -1
  13. package/dist/lib/config/env.js +9 -2
  14. package/dist/lib/config/env.js.map +1 -1
  15. package/dist/lib/fileUpload.d.ts +10 -2
  16. package/dist/lib/fileUpload.d.ts.map +1 -1
  17. package/dist/lib/fileUpload.js +38 -6
  18. package/dist/lib/fileUpload.js.map +1 -1
  19. package/dist/lib/prisma.d.ts +14 -2
  20. package/dist/lib/prisma.d.ts.map +1 -1
  21. package/dist/lib/prisma.js +27 -8
  22. package/dist/lib/prisma.js.map +1 -1
  23. package/dist/middleware/auth.d.ts.map +1 -1
  24. package/dist/middleware/auth.js +18 -5
  25. package/dist/middleware/auth.js.map +1 -1
  26. package/dist/middleware/security.d.ts.map +1 -1
  27. package/dist/middleware/security.js +3 -3
  28. package/dist/middleware/security.js.map +1 -1
  29. package/dist/models/agenda.d.ts +16 -16
  30. package/dist/models/announcement.d.ts +59 -23
  31. package/dist/models/announcement.d.ts.map +1 -1
  32. package/dist/models/assignment.d.ts +363 -276
  33. package/dist/models/assignment.d.ts.map +1 -1
  34. package/dist/models/attendance.d.ts +63 -21
  35. package/dist/models/attendance.d.ts.map +1 -1
  36. package/dist/models/auth.d.ts +115 -26
  37. package/dist/models/auth.d.ts.map +1 -1
  38. package/dist/models/auth.js +53 -30
  39. package/dist/models/auth.js.map +1 -1
  40. package/dist/models/class.d.ts +115 -65
  41. package/dist/models/class.d.ts.map +1 -1
  42. package/dist/models/class.js +16 -4
  43. package/dist/models/class.js.map +1 -1
  44. package/dist/models/comment.d.ts +52 -16
  45. package/dist/models/comment.d.ts.map +1 -1
  46. package/dist/models/conversation.d.ts +46 -16
  47. package/dist/models/conversation.d.ts.map +1 -1
  48. package/dist/models/event.d.ts +107 -53
  49. package/dist/models/event.d.ts.map +1 -1
  50. package/dist/models/file.d.ts +213 -165
  51. package/dist/models/file.d.ts.map +1 -1
  52. package/dist/models/folder.d.ts +161 -77
  53. package/dist/models/folder.d.ts.map +1 -1
  54. package/dist/models/labChat.d.ts +73 -31
  55. package/dist/models/labChat.d.ts.map +1 -1
  56. package/dist/models/marketing.d.ts +25 -7
  57. package/dist/models/marketing.d.ts.map +1 -1
  58. package/dist/models/message.d.ts +31 -13
  59. package/dist/models/message.d.ts.map +1 -1
  60. package/dist/models/newtonChat.d.ts +34 -10
  61. package/dist/models/newtonChat.d.ts.map +1 -1
  62. package/dist/models/notification.d.ts +29 -10
  63. package/dist/models/notification.d.ts.map +1 -1
  64. package/dist/models/notification.js +13 -9
  65. package/dist/models/notification.js.map +1 -1
  66. package/dist/models/section.d.ts +71 -23
  67. package/dist/models/section.d.ts.map +1 -1
  68. package/dist/models/user.d.ts +27 -9
  69. package/dist/models/user.d.ts.map +1 -1
  70. package/dist/models/worksheet.d.ts +606 -106
  71. package/dist/models/worksheet.d.ts.map +1 -1
  72. package/dist/models/worksheet.js +168 -2
  73. package/dist/models/worksheet.js.map +1 -1
  74. package/dist/pipelines/aiLabChat.d.ts +22 -2
  75. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  76. package/dist/pipelines/aiLabChat.js +133 -95
  77. package/dist/pipelines/aiLabChat.js.map +1 -1
  78. package/dist/pipelines/aiLabChatContract.d.ts +25 -25
  79. package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
  80. package/dist/pipelines/aiNewtonChat.js +25 -14
  81. package/dist/pipelines/aiNewtonChat.js.map +1 -1
  82. package/dist/pipelines/gradeWorksheet.d.ts +8 -8
  83. package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
  84. package/dist/pipelines/gradeWorksheet.js +12 -5
  85. package/dist/pipelines/gradeWorksheet.js.map +1 -1
  86. package/dist/pipelines/labChatPrompt.d.ts +27 -0
  87. package/dist/pipelines/labChatPrompt.d.ts.map +1 -1
  88. package/dist/pipelines/labChatPrompt.js +143 -69
  89. package/dist/pipelines/labChatPrompt.js.map +1 -1
  90. package/dist/routers/_app.d.ts +1579 -1257
  91. package/dist/routers/_app.d.ts.map +1 -1
  92. package/dist/routers/agenda.d.ts +16 -16
  93. package/dist/routers/announcement.d.ts +19 -19
  94. package/dist/routers/assignment.d.ts +307 -291
  95. package/dist/routers/assignment.d.ts.map +1 -1
  96. package/dist/routers/assignment.js +3 -2
  97. package/dist/routers/assignment.js.map +1 -1
  98. package/dist/routers/attendance.d.ts +7 -7
  99. package/dist/routers/auth.d.ts +1 -1
  100. package/dist/routers/class.d.ts +79 -72
  101. package/dist/routers/class.d.ts.map +1 -1
  102. package/dist/routers/class.js +3 -3
  103. package/dist/routers/class.js.map +1 -1
  104. package/dist/routers/comment.d.ts +6 -6
  105. package/dist/routers/conversation.d.ts +11 -11
  106. package/dist/routers/event.d.ts +35 -35
  107. package/dist/routers/file.d.ts +12 -12
  108. package/dist/routers/folder.d.ts +54 -54
  109. package/dist/routers/labChat.d.ts +18 -12
  110. package/dist/routers/labChat.d.ts.map +1 -1
  111. package/dist/routers/labChat.js +5 -3
  112. package/dist/routers/labChat.js.map +1 -1
  113. package/dist/routers/marketing.d.ts +2 -2
  114. package/dist/routers/message.d.ts +33 -2
  115. package/dist/routers/message.d.ts.map +1 -1
  116. package/dist/routers/message.js +27 -4
  117. package/dist/routers/message.js.map +1 -1
  118. package/dist/routers/newtonChat.d.ts +7 -1
  119. package/dist/routers/newtonChat.d.ts.map +1 -1
  120. package/dist/routers/newtonChat.js +5 -4
  121. package/dist/routers/newtonChat.js.map +1 -1
  122. package/dist/routers/notifications.d.ts +13 -6
  123. package/dist/routers/notifications.d.ts.map +1 -1
  124. package/dist/routers/notifications.js +4 -4
  125. package/dist/routers/notifications.js.map +1 -1
  126. package/dist/routers/section.d.ts +7 -7
  127. package/dist/routers/studentProgress.d.ts +86 -0
  128. package/dist/routers/studentProgress.d.ts.map +1 -1
  129. package/dist/routers/studentProgress.js +14 -4
  130. package/dist/routers/studentProgress.js.map +1 -1
  131. package/dist/routers/user.d.ts +1 -1
  132. package/dist/routers/worksheet.d.ts +64 -62
  133. package/dist/routers/worksheet.d.ts.map +1 -1
  134. package/dist/routers/worksheet.js +18 -16
  135. package/dist/routers/worksheet.js.map +1 -1
  136. package/dist/seedDatabase.d.ts +1 -1
  137. package/dist/services/agenda.d.ts +16 -16
  138. package/dist/services/announcement.d.ts +8 -8
  139. package/dist/services/assignment.d.ts +299 -283
  140. package/dist/services/assignment.d.ts.map +1 -1
  141. package/dist/services/assignment.js +24 -5
  142. package/dist/services/assignment.js.map +1 -1
  143. package/dist/services/attendance.d.ts +7 -7
  144. package/dist/services/auth.d.ts +2 -2
  145. package/dist/services/auth.d.ts.map +1 -1
  146. package/dist/services/auth.js +19 -12
  147. package/dist/services/auth.js.map +1 -1
  148. package/dist/services/class.d.ts +74 -68
  149. package/dist/services/class.d.ts.map +1 -1
  150. package/dist/services/class.js +6 -3
  151. package/dist/services/class.js.map +1 -1
  152. package/dist/services/comment.d.ts +6 -6
  153. package/dist/services/conversation.d.ts +11 -11
  154. package/dist/services/event.d.ts +31 -31
  155. package/dist/services/file.d.ts +12 -12
  156. package/dist/services/file.d.ts.map +1 -1
  157. package/dist/services/file.js +7 -2
  158. package/dist/services/file.js.map +1 -1
  159. package/dist/services/folder.d.ts +52 -52
  160. package/dist/services/labChat.d.ts +18 -12
  161. package/dist/services/labChat.d.ts.map +1 -1
  162. package/dist/services/labChat.js +30 -18
  163. package/dist/services/labChat.js.map +1 -1
  164. package/dist/services/marketing.d.ts +2 -2
  165. package/dist/services/message.d.ts +30 -0
  166. package/dist/services/message.d.ts.map +1 -1
  167. package/dist/services/message.js +97 -21
  168. package/dist/services/message.js.map +1 -1
  169. package/dist/services/newtonChat.d.ts +6 -1
  170. package/dist/services/newtonChat.d.ts.map +1 -1
  171. package/dist/services/newtonChat.js +36 -18
  172. package/dist/services/newtonChat.js.map +1 -1
  173. package/dist/services/notification.d.ts +16 -9
  174. package/dist/services/notification.d.ts.map +1 -1
  175. package/dist/services/notification.js +24 -9
  176. package/dist/services/notification.js.map +1 -1
  177. package/dist/services/section.d.ts +6 -6
  178. package/dist/services/studentProgress.d.ts +75 -0
  179. package/dist/services/studentProgress.d.ts.map +1 -1
  180. package/dist/services/studentProgress.js +300 -107
  181. package/dist/services/studentProgress.js.map +1 -1
  182. package/dist/services/worksheet.d.ts +64 -64
  183. package/dist/services/worksheet.d.ts.map +1 -1
  184. package/dist/services/worksheet.js +138 -54
  185. package/dist/services/worksheet.js.map +1 -1
  186. package/dist/trpc.d.ts.map +1 -1
  187. package/dist/trpc.js +11 -17
  188. package/dist/trpc.js.map +1 -1
  189. package/dist/utils/inference.d.ts +9 -12
  190. package/dist/utils/inference.d.ts.map +1 -1
  191. package/dist/utils/inference.js +135 -95
  192. package/dist/utils/inference.js.map +1 -1
  193. package/package.json +1 -1
  194. package/prisma/migrations/20260410124000_add_submission_recommendation_state/migration.sql +14 -0
  195. package/prisma/migrations/20260415103500_add_session_type/migration.sql +33 -0
  196. package/prisma/migrations/20260415133000_add_ai_usage_logs/migration.sql +19 -0
  197. package/prisma/schema.prisma +41 -1
  198. package/scripts/test-pre-push.ts +6 -2
  199. package/src/index.ts +119 -79
  200. package/src/lib/config/cors.ts +96 -0
  201. package/src/lib/config/env.ts +12 -1
  202. package/src/lib/fileUpload.ts +55 -5
  203. package/src/lib/prisma.ts +25 -6
  204. package/src/middleware/auth.ts +19 -4
  205. package/src/middleware/security.ts +1 -1
  206. package/src/models/auth.ts +59 -29
  207. package/src/models/class.ts +15 -4
  208. package/src/models/notification.ts +13 -7
  209. package/src/models/worksheet.ts +202 -0
  210. package/src/pipelines/aiLabChat.ts +183 -104
  211. package/src/pipelines/aiNewtonChat.ts +24 -14
  212. package/src/pipelines/gradeWorksheet.ts +10 -2
  213. package/src/pipelines/labChatPrompt.ts +196 -68
  214. package/src/routers/assignment.ts +1 -0
  215. package/src/routers/class.ts +2 -2
  216. package/src/routers/labChat.ts +3 -1
  217. package/src/routers/message.ts +38 -1
  218. package/src/routers/newtonChat.ts +4 -2
  219. package/src/routers/notifications.ts +2 -2
  220. package/src/routers/studentProgress.ts +25 -1
  221. package/src/routers/worksheet.ts +31 -19
  222. package/src/services/assignment.ts +30 -2
  223. package/src/services/auth.ts +26 -12
  224. package/src/services/class.ts +4 -1
  225. package/src/services/file.ts +6 -0
  226. package/src/services/labChat.ts +42 -18
  227. package/src/services/message.ts +163 -18
  228. package/src/services/newtonChat.ts +49 -18
  229. package/src/services/notification.ts +27 -8
  230. package/src/services/studentProgress.ts +425 -121
  231. package/src/services/worksheet.ts +191 -51
  232. package/src/trpc.ts +12 -18
  233. package/src/utils/inference.ts +205 -112
  234. package/tests/globalSetup.ts +13 -4
  235. package/tests/lib/cors.test.ts +103 -0
  236. package/tests/pipelines/aiLabChat.test.ts +64 -84
  237. package/tests/routers/auth.test.ts +120 -17
  238. package/tests/routers/class.test.ts +7 -1
  239. package/tests/routers/message.test.ts +236 -9
  240. package/tests/routers/newtonChat.test.ts +81 -0
  241. package/tests/routers/notification.test.ts +12 -0
  242. package/tests/routers/studentProgress.test.ts +2 -31
  243. package/tests/routers/worksheet.test.ts +194 -0
  244. package/tests/setup.ts +22 -5
  245. package/tests/utils/aiLabChatPrompt.test.ts +114 -6
  246. package/tests/utils/inference.test.ts +218 -0
  247. package/tests/utils/studentProgress.test.ts +361 -0
  248. package/vitest.unit.config.ts +1 -0
@@ -2,11 +2,11 @@
2
2
  * Student progress service - assignment recommendations and progress chat.
3
3
  */
4
4
  import { TRPCError } from "@trpc/server";
5
+ import type { AssignmentType } from "@prisma/client";
5
6
  import { prisma } from "../lib/prisma.js";
7
+ import { isTeacherInClass } from "../models/class.js";
6
8
  import { inference } from "../utils/inference.js";
7
9
  import { logger } from "../utils/logger.js";
8
- import { isTeacherInClass } from "../models/class.js";
9
- import type { AssignmentType } from "@prisma/client";
10
10
 
11
11
  type ProgressChatMessage = {
12
12
  role: "user" | "assistant";
@@ -19,6 +19,8 @@ type GradeSubmission = {
19
19
  submitted: boolean | null;
20
20
  returned: boolean | null;
21
21
  submittedAt: Date | null;
22
+ recommendationState: "NONE" | "OPEN" | "ASSIGNED" | "DISMISSED";
23
+ targetedAssignmentId: string | null;
22
24
  assignment: {
23
25
  id: string;
24
26
  title: string;
@@ -30,9 +32,22 @@ type GradeSubmission = {
30
32
  };
31
33
  };
32
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
+
33
46
  function calculatePercentage(submission: GradeSubmission) {
34
- if (submission.gradeReceived == null || !submission.assignment.maxGrade)
47
+ if (submission.gradeReceived == null || !submission.assignment.maxGrade) {
35
48
  return null;
49
+ }
50
+
36
51
  return (
37
52
  Math.round(
38
53
  (submission.gradeReceived / submission.assignment.maxGrade) * 1000,
@@ -55,11 +70,13 @@ function calculateTrend(submissions: GradeSubmission[]) {
55
70
  .filter((value): value is number => value != null);
56
71
 
57
72
  if (graded.length < 2) return 0;
73
+
58
74
  const midpoint = Math.floor(graded.length / 2);
59
75
  const first = graded.slice(0, midpoint);
60
76
  const second = graded.slice(midpoint);
61
77
  const average = (values: number[]) =>
62
78
  values.reduce((sum, value) => sum + value, 0) / values.length;
79
+
63
80
  return Math.round((average(second) - average(first)) * 10) / 10;
64
81
  }
65
82
 
@@ -85,6 +102,257 @@ function getOverallGrade(submissions: GradeSubmission[]) {
85
102
  : null;
86
103
  }
87
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
+
88
356
  async function loadStudentProgressContext(
89
357
  viewerId: string,
90
358
  classId: string,
@@ -161,116 +429,154 @@ export async function getStudentProgressRecommendations(
161
429
  studentId,
162
430
  );
163
431
 
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);
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
+ }
221
442
 
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);
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
+ }
235
454
 
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));
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
+ );
250
518
 
251
519
  return {
252
- student: {
253
- id: student.id,
254
- username: student.username,
255
- displayName: student.profile?.displayName ?? student.username,
520
+ class: {
521
+ id: classData.id,
522
+ name: classData.name,
523
+ subject: classData.subject,
256
524
  },
257
525
  summary: {
258
- overallGrade,
259
- trend,
260
- completedAssignments: submissions.filter((submission) =>
261
- Boolean(submission.submitted),
262
- ).length,
263
- totalAssignments: submissions.length,
264
- missingCount,
265
- lowScoreCount,
526
+ studentsWithConcerns,
527
+ totalRecommendations,
528
+ totalMissingAssignments,
529
+ totalLowScoreAssignments,
266
530
  },
267
- recommendations: recommendationCandidates.map(
268
- ({ priorityScore, ...candidate }) => candidate,
269
- ),
270
- nextSteps,
531
+ students: students.map(({ priorityScore, ...student }) => student),
271
532
  };
272
533
  }
273
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
+
274
580
  function buildProgressSummary(submissions: GradeSubmission[]) {
275
581
  return submissions.map((submission) => ({
276
582
  title: submission.assignment.title,
@@ -329,7 +635,10 @@ export async function chatAboutStudentProgress(
329
635
  ];
330
636
 
331
637
  try {
332
- const response = await inference<string>(messages);
638
+ const response = await inference<string>(messages, undefined, {
639
+ reason: "student_progress_chat",
640
+ metadata: { viewerId, classId: input.classId, studentId: input.studentId },
641
+ });
333
642
  if (typeof response !== "string" || response.trim().length === 0) {
334
643
  throw new Error("Student progress chat returned an empty response");
335
644
  }
@@ -360,30 +669,25 @@ export async function chatAboutStudentProgress(
360
669
  assignment.gradeReceived == null &&
361
670
  !assignment.returned,
362
671
  ).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 = [
672
+
673
+ const fallbackParts = [
674
+ `${displayName}'s current overall grade is ${overall}.`,
372
675
  missingItems > 0
373
- ? `Review ${missingItems} missing assignment${missingItems === 1 ? "" : "s"}.`
676
+ ? `${missingItems} assignment${missingItems === 1 ? "" : "s"} ${missingItems === 1 ? "appears" : "appear"} overdue.`
374
677
  : null,
375
678
  lowScores > 0
376
- ? `Use targeted practice for ${lowScores} low-scoring assignment${lowScores === 1 ? "" : "s"}.`
679
+ ? `${lowScores} graded assignment${lowScores === 1 ? " is" : "s are"} below 70%.`
377
680
  : null,
378
681
  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."
682
+ ? `${awaitingFeedback} assignment${awaitingFeedback === 1 ? " is" : "s are"} still awaiting feedback.`
383
683
  : null,
384
- ].filter((item): item is string => Boolean(item));
684
+ summary.trend < -5
685
+ ? "Recent performance is trending down, so a check-in would be reasonable."
686
+ : "Performance looks relatively steady from the available data.",
687
+ ].filter((part): part is string => Boolean(part));
688
+
385
689
  return {
386
- message: `${displayName}'s current overall progress is ${overall}, with ${trendLabel}. ${advice.join(" ")}`,
690
+ message: fallbackParts.join(" "),
387
691
  isFallback: true,
388
692
  };
389
693
  }