@studious-lms/server 1.4.1 → 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 +22 -2
- package/dist/pipelines/aiLabChat.d.ts.map +1 -1
- package/dist/pipelines/aiLabChat.js +125 -95
- package/dist/pipelines/aiLabChat.js.map +1 -1
- package/dist/pipelines/aiLabChatContract.d.ts +22 -22
- 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 +27 -0
- package/dist/pipelines/labChatPrompt.d.ts.map +1 -1
- package/dist/pipelines/labChatPrompt.js +143 -69
- package/dist/pipelines/labChatPrompt.js.map +1 -1
- package/dist/routers/_app.d.ts +1439 -1223
- package/dist/routers/_app.d.ts.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 +86 -0
- package/dist/routers/studentProgress.d.ts.map +1 -1
- package/dist/routers/studentProgress.js +14 -4
- package/dist/routers/studentProgress.js.map +1 -1
- 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/marketing.d.ts +2 -2
- package/dist/services/notification.d.ts +4 -4
- package/dist/services/section.d.ts +6 -6
- package/dist/services/studentProgress.d.ts +75 -0
- package/dist/services/studentProgress.d.ts.map +1 -1
- package/dist/services/studentProgress.js +296 -106
- package/dist/services/studentProgress.js.map +1 -1
- 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 +1 -1
- package/prisma/migrations/20260410124000_add_submission_recommendation_state/migration.sql +14 -0
- package/prisma/schema.prisma +14 -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 +175 -104
- package/src/pipelines/gradeWorksheet.ts +2 -2
- package/src/pipelines/labChatPrompt.ts +196 -68
- package/src/routers/assignment.ts +1 -0
- package/src/routers/studentProgress.ts +25 -1
- package/src/services/assignment.ts +30 -2
- package/src/services/studentProgress.ts +421 -120
- package/src/utils/inference.ts +0 -61
- package/tests/lib/cors.test.ts +103 -0
- package/tests/pipelines/aiLabChat.test.ts +64 -84
- package/tests/routers/studentProgress.test.ts +2 -31
- package/tests/utils/aiLabChatPrompt.test.ts +114 -6
- package/tests/utils/studentProgress.test.ts +361 -0
- package/vitest.unit.config.ts +1 -0
|
@@ -7,10 +7,10 @@ export declare function createSectionRecord(userId: string, input: {
|
|
|
7
7
|
name: string;
|
|
8
8
|
color?: string;
|
|
9
9
|
}): Promise<{
|
|
10
|
-
id: string;
|
|
11
|
-
classId: string;
|
|
12
10
|
name: string;
|
|
11
|
+
id: string;
|
|
13
12
|
color: string | null;
|
|
13
|
+
classId: string;
|
|
14
14
|
order: number | null;
|
|
15
15
|
}>;
|
|
16
16
|
export declare function reorderSection(userId: string, input: {
|
|
@@ -19,10 +19,10 @@ export declare function reorderSection(userId: string, input: {
|
|
|
19
19
|
position: "start" | "end" | "before" | "after";
|
|
20
20
|
targetId?: string;
|
|
21
21
|
}): Promise<{
|
|
22
|
-
id: string;
|
|
23
|
-
classId: string;
|
|
24
22
|
name: string;
|
|
23
|
+
id: string;
|
|
25
24
|
color: string | null;
|
|
25
|
+
classId: string;
|
|
26
26
|
order: number | null;
|
|
27
27
|
} | null>;
|
|
28
28
|
export declare function updateSectionRecord(userId: string, input: {
|
|
@@ -31,10 +31,10 @@ export declare function updateSectionRecord(userId: string, input: {
|
|
|
31
31
|
name: string;
|
|
32
32
|
color?: string;
|
|
33
33
|
}): Promise<{
|
|
34
|
-
id: string;
|
|
35
|
-
classId: string;
|
|
36
34
|
name: string;
|
|
35
|
+
id: string;
|
|
37
36
|
color: string | null;
|
|
37
|
+
classId: string;
|
|
38
38
|
order: number | null;
|
|
39
39
|
}>;
|
|
40
40
|
export declare function reOrderSection(userId: string, input: {
|
|
@@ -2,6 +2,7 @@ type ProgressChatMessage = {
|
|
|
2
2
|
role: "user" | "assistant";
|
|
3
3
|
content: string;
|
|
4
4
|
};
|
|
5
|
+
type RecommendationType = "missing" | "low_score" | "awaiting_feedback";
|
|
5
6
|
export declare function getStudentProgressRecommendations(viewerId: string, classId: string, studentId: string): Promise<{
|
|
6
7
|
student: {
|
|
7
8
|
id: string;
|
|
@@ -21,6 +22,7 @@ export declare function getStudentProgressRecommendations(viewerId: string, clas
|
|
|
21
22
|
submissionId: string;
|
|
22
23
|
title: string;
|
|
23
24
|
type: import(".prisma/client").$Enums.AssignmentType;
|
|
25
|
+
sectionId: string | null;
|
|
24
26
|
sectionName: string | null;
|
|
25
27
|
dueDate: Date;
|
|
26
28
|
gradeReceived: number | null;
|
|
@@ -28,10 +30,83 @@ export declare function getStudentProgressRecommendations(viewerId: string, clas
|
|
|
28
30
|
percentage: number | null;
|
|
29
31
|
submitted: boolean;
|
|
30
32
|
returned: boolean;
|
|
33
|
+
recommendationType: RecommendationType;
|
|
31
34
|
reasons: string[];
|
|
35
|
+
suggestedAssignment: {
|
|
36
|
+
title: string;
|
|
37
|
+
instructions: string;
|
|
38
|
+
type: import(".prisma/client").$Enums.AssignmentType;
|
|
39
|
+
maxGrade: number;
|
|
40
|
+
weight: number;
|
|
41
|
+
graded: boolean;
|
|
42
|
+
sectionId: string | null;
|
|
43
|
+
sourceAssignmentId: string;
|
|
44
|
+
};
|
|
32
45
|
}[];
|
|
33
46
|
nextSteps: string[];
|
|
34
47
|
}>;
|
|
48
|
+
export declare function getClassProgressRecommendations(viewerId: string, classId: string): Promise<{
|
|
49
|
+
class: {
|
|
50
|
+
id: string;
|
|
51
|
+
name: string;
|
|
52
|
+
subject: string;
|
|
53
|
+
};
|
|
54
|
+
summary: {
|
|
55
|
+
studentsWithConcerns: number;
|
|
56
|
+
totalRecommendations: number;
|
|
57
|
+
totalMissingAssignments: number;
|
|
58
|
+
totalLowScoreAssignments: number;
|
|
59
|
+
};
|
|
60
|
+
students: {
|
|
61
|
+
student: {
|
|
62
|
+
id: string;
|
|
63
|
+
username: string;
|
|
64
|
+
displayName: string;
|
|
65
|
+
};
|
|
66
|
+
summary: {
|
|
67
|
+
overallGrade: number | null;
|
|
68
|
+
trend: number;
|
|
69
|
+
completedAssignments: number;
|
|
70
|
+
totalAssignments: number;
|
|
71
|
+
missingCount: number;
|
|
72
|
+
lowScoreCount: number;
|
|
73
|
+
};
|
|
74
|
+
recommendations: {
|
|
75
|
+
assignmentId: string;
|
|
76
|
+
submissionId: string;
|
|
77
|
+
title: string;
|
|
78
|
+
type: import(".prisma/client").$Enums.AssignmentType;
|
|
79
|
+
sectionId: string | null;
|
|
80
|
+
sectionName: string | null;
|
|
81
|
+
dueDate: Date;
|
|
82
|
+
gradeReceived: number | null;
|
|
83
|
+
maxGrade: number | null;
|
|
84
|
+
percentage: number | null;
|
|
85
|
+
submitted: boolean;
|
|
86
|
+
returned: boolean;
|
|
87
|
+
recommendationType: RecommendationType;
|
|
88
|
+
reasons: string[];
|
|
89
|
+
suggestedAssignment: {
|
|
90
|
+
title: string;
|
|
91
|
+
instructions: string;
|
|
92
|
+
type: import(".prisma/client").$Enums.AssignmentType;
|
|
93
|
+
maxGrade: number;
|
|
94
|
+
weight: number;
|
|
95
|
+
graded: boolean;
|
|
96
|
+
sectionId: string | null;
|
|
97
|
+
sourceAssignmentId: string;
|
|
98
|
+
};
|
|
99
|
+
}[];
|
|
100
|
+
nextSteps: string[];
|
|
101
|
+
}[];
|
|
102
|
+
}>;
|
|
103
|
+
export declare function dismissStudentRecommendation(viewerId: string, input: {
|
|
104
|
+
classId: string;
|
|
105
|
+
studentId: string;
|
|
106
|
+
submissionId: string;
|
|
107
|
+
}): Promise<{
|
|
108
|
+
success: boolean;
|
|
109
|
+
}>;
|
|
35
110
|
export declare function chatAboutStudentProgress(viewerId: string, input: {
|
|
36
111
|
classId: string;
|
|
37
112
|
studentId: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"studentProgress.d.ts","sourceRoot":"/","sources":["services/studentProgress.ts"],"names":[],"mappings":"AAUA,KAAK,mBAAmB,GAAG;IACzB,IAAI,EAAE,MAAM,GAAG,WAAW,CAAC;IAC3B,OAAO,EAAE,MAAM,CAAC;CACjB,CAAC;
|
|
1
|
+
{"version":3,"file":"studentProgress.d.ts","sourceRoot":"/","sources":["services/studentProgress.ts"],"names":[],"mappings":"AAUA,KAAK,mBAAmB,GAAG;IACzB,IAAI,EAAE,MAAM,GAAG,WAAW,CAAC;IAC3B,OAAO,EAAE,MAAM,CAAC;CACjB,CAAC;AA2BF,KAAK,kBAAkB,GACnB,SAAS,GACT,WAAW,GACX,mBAAmB,CAAC;AAyXxB,wBAAsB,iCAAiC,CACrD,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiBlB;AAED,wBAAsB,+BAA+B,CACnD,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwFhB;AAED,wBAAsB,4BAA4B,CAChD,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE;IACL,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,MAAM,CAAC;CACtB;;GAqCF;AAgBD,wBAAsB,wBAAwB,CAC5C,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE;IACL,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,mBAAmB,EAAE,CAAC;CACjC;;;GA0FF"}
|
|
@@ -2,15 +2,16 @@
|
|
|
2
2
|
* Student progress service - assignment recommendations and progress chat.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
5
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="a4f18375-dd27-5f8f-befa-f7b494a8bb88")}catch(e){}}();
|
|
6
6
|
import { TRPCError } from "@trpc/server";
|
|
7
7
|
import { prisma } from "../lib/prisma.js";
|
|
8
|
+
import { isTeacherInClass } from "../models/class.js";
|
|
8
9
|
import { inference } from "../utils/inference.js";
|
|
9
10
|
import { logger } from "../utils/logger.js";
|
|
10
|
-
import { isTeacherInClass } from "../models/class.js";
|
|
11
11
|
function calculatePercentage(submission) {
|
|
12
|
-
if (submission.gradeReceived == null || !submission.assignment.maxGrade)
|
|
12
|
+
if (submission.gradeReceived == null || !submission.assignment.maxGrade) {
|
|
13
13
|
return null;
|
|
14
|
+
}
|
|
14
15
|
return (Math.round((submission.gradeReceived / submission.assignment.maxGrade) * 1000) / 10);
|
|
15
16
|
}
|
|
16
17
|
function calculateTrend(submissions) {
|
|
@@ -48,6 +49,183 @@ function getOverallGrade(submissions) {
|
|
|
48
49
|
? Math.round((totalWeighted / totalWeight) * 1000) / 10
|
|
49
50
|
: null;
|
|
50
51
|
}
|
|
52
|
+
function normalizeAssignmentType(assignmentType) {
|
|
53
|
+
if (assignmentType === "TEST" || assignmentType === "QUIZ") {
|
|
54
|
+
return "HOMEWORK";
|
|
55
|
+
}
|
|
56
|
+
return assignmentType;
|
|
57
|
+
}
|
|
58
|
+
function buildSuggestedAssignment(recommendationType, submission, displayName, percentage) {
|
|
59
|
+
const sourceTitle = submission.assignment.title;
|
|
60
|
+
const section = submission.assignment.section?.name;
|
|
61
|
+
const titleByType = {
|
|
62
|
+
missing: `Make-up: ${sourceTitle}`,
|
|
63
|
+
low_score: `Targeted practice: ${sourceTitle}`,
|
|
64
|
+
awaiting_feedback: `Revision follow-up: ${sourceTitle}`,
|
|
65
|
+
};
|
|
66
|
+
const leadByType = {
|
|
67
|
+
missing: `${displayName} missed the original assignment and needs a focused make-up task on the same material.`,
|
|
68
|
+
low_score: percentage != null
|
|
69
|
+
? `${displayName} scored ${percentage}% and needs targeted reinforcement on the underlying concepts.`
|
|
70
|
+
: `${displayName} needs targeted reinforcement on the underlying concepts.`,
|
|
71
|
+
awaiting_feedback: `${displayName} has work in progress here, so a short follow-up can keep momentum once feedback lands.`,
|
|
72
|
+
};
|
|
73
|
+
return {
|
|
74
|
+
title: titleByType[recommendationType],
|
|
75
|
+
instructions: [
|
|
76
|
+
leadByType[recommendationType],
|
|
77
|
+
`Source assignment: ${sourceTitle}.`,
|
|
78
|
+
section ? `Section: ${section}.` : null,
|
|
79
|
+
"Keep the work concise and focused on the specific area of concern.",
|
|
80
|
+
]
|
|
81
|
+
.filter((line) => Boolean(line))
|
|
82
|
+
.join("\n\n"),
|
|
83
|
+
type: normalizeAssignmentType(submission.assignment.type),
|
|
84
|
+
maxGrade: submission.assignment.maxGrade ?? 100,
|
|
85
|
+
weight: submission.assignment.weight || 1,
|
|
86
|
+
graded: true,
|
|
87
|
+
sectionId: submission.assignment.section?.id ?? null,
|
|
88
|
+
sourceAssignmentId: submission.assignment.id,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function buildRecommendationCandidate(submission, displayName, now) {
|
|
92
|
+
if (submission.recommendationState === "ASSIGNED" ||
|
|
93
|
+
submission.recommendationState === "DISMISSED") {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
const percentage = calculatePercentage(submission);
|
|
97
|
+
const isMissing = !submission.submitted &&
|
|
98
|
+
submission.assignment.dueDate.getTime() < now.getTime();
|
|
99
|
+
const isLowScore = percentage != null && percentage < 70;
|
|
100
|
+
const isUnreturned = Boolean(submission.submitted) &&
|
|
101
|
+
submission.gradeReceived == null &&
|
|
102
|
+
!submission.returned;
|
|
103
|
+
const reasons = [];
|
|
104
|
+
let priorityScore = 0;
|
|
105
|
+
let recommendationType = null;
|
|
106
|
+
if (isMissing) {
|
|
107
|
+
reasons.push("Missing past-due work");
|
|
108
|
+
priorityScore += 100;
|
|
109
|
+
recommendationType = "missing";
|
|
110
|
+
}
|
|
111
|
+
if (isLowScore) {
|
|
112
|
+
reasons.push(`Scored ${percentage}%`);
|
|
113
|
+
priorityScore += 85 - (percentage ?? 0);
|
|
114
|
+
recommendationType ?? (recommendationType = "low_score");
|
|
115
|
+
}
|
|
116
|
+
if (isUnreturned) {
|
|
117
|
+
reasons.push("Awaiting grade or feedback");
|
|
118
|
+
priorityScore += 15;
|
|
119
|
+
recommendationType ?? (recommendationType = "awaiting_feedback");
|
|
120
|
+
}
|
|
121
|
+
if (priorityScore <= 0 || !recommendationType) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
assignmentId: submission.assignment.id,
|
|
126
|
+
submissionId: submission.id,
|
|
127
|
+
title: submission.assignment.title,
|
|
128
|
+
type: submission.assignment.type,
|
|
129
|
+
sectionId: submission.assignment.section?.id ?? null,
|
|
130
|
+
sectionName: submission.assignment.section?.name ?? null,
|
|
131
|
+
dueDate: submission.assignment.dueDate,
|
|
132
|
+
gradeReceived: submission.gradeReceived,
|
|
133
|
+
maxGrade: submission.assignment.maxGrade,
|
|
134
|
+
percentage,
|
|
135
|
+
submitted: Boolean(submission.submitted),
|
|
136
|
+
returned: Boolean(submission.returned),
|
|
137
|
+
recommendationType,
|
|
138
|
+
reasons,
|
|
139
|
+
priorityScore,
|
|
140
|
+
suggestedAssignment: buildSuggestedAssignment(recommendationType, submission, displayName, percentage),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
function summarizeStudentRecommendations(student, submissions) {
|
|
144
|
+
const now = new Date();
|
|
145
|
+
const displayName = student.profile?.displayName ?? student.username;
|
|
146
|
+
const recommendationCandidates = submissions
|
|
147
|
+
.map((submission) => buildRecommendationCandidate(submission, displayName, now))
|
|
148
|
+
.filter((candidate) => candidate != null)
|
|
149
|
+
.sort((a, b) => b.priorityScore - a.priorityScore);
|
|
150
|
+
const recommendations = recommendationCandidates
|
|
151
|
+
.slice(0, 5)
|
|
152
|
+
.map(({ priorityScore, ...candidate }) => candidate);
|
|
153
|
+
const gradedPercentages = submissions
|
|
154
|
+
.map(calculatePercentage)
|
|
155
|
+
.filter((value) => value != null);
|
|
156
|
+
const lowScoreCount = gradedPercentages.filter((percentage) => percentage < 70).length;
|
|
157
|
+
const missingCount = submissions.filter((submission) => !submission.submitted &&
|
|
158
|
+
submission.assignment.dueDate.getTime() < now.getTime()).length;
|
|
159
|
+
const trend = calculateTrend(submissions);
|
|
160
|
+
const overallGrade = getOverallGrade(submissions);
|
|
161
|
+
const nextSteps = [
|
|
162
|
+
missingCount > 0
|
|
163
|
+
? `Prioritize ${missingCount} missing assignment${missingCount === 1 ? "" : "s"}.`
|
|
164
|
+
: null,
|
|
165
|
+
lowScoreCount > 0
|
|
166
|
+
? `Review ${lowScoreCount} low-scoring assignment${lowScoreCount === 1 ? "" : "s"} before introducing new material.`
|
|
167
|
+
: null,
|
|
168
|
+
trend < -5
|
|
169
|
+
? "Schedule a check-in because recent performance is trending down."
|
|
170
|
+
: null,
|
|
171
|
+
recommendationCandidates.length === 0
|
|
172
|
+
? "No urgent assignment issues detected from the available grades."
|
|
173
|
+
: null,
|
|
174
|
+
].filter((step) => Boolean(step));
|
|
175
|
+
return {
|
|
176
|
+
student: {
|
|
177
|
+
id: student.id,
|
|
178
|
+
username: student.username,
|
|
179
|
+
displayName,
|
|
180
|
+
},
|
|
181
|
+
summary: {
|
|
182
|
+
overallGrade,
|
|
183
|
+
trend,
|
|
184
|
+
completedAssignments: submissions.filter((submission) => Boolean(submission.submitted)).length,
|
|
185
|
+
totalAssignments: submissions.length,
|
|
186
|
+
missingCount,
|
|
187
|
+
lowScoreCount,
|
|
188
|
+
},
|
|
189
|
+
recommendations,
|
|
190
|
+
recommendationCandidates,
|
|
191
|
+
nextSteps,
|
|
192
|
+
priorityScore: recommendationCandidates[0]?.priorityScore ?? 0,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
async function syncRecommendationStates(submissions, recommendationCandidates) {
|
|
196
|
+
const candidateSubmissionIds = new Set(recommendationCandidates.map((candidate) => candidate.submissionId));
|
|
197
|
+
const updates = [];
|
|
198
|
+
for (const submission of submissions) {
|
|
199
|
+
const shouldBeOpen = candidateSubmissionIds.has(submission.id);
|
|
200
|
+
if (shouldBeOpen && submission.recommendationState === "NONE") {
|
|
201
|
+
updates.push(prisma.submission.update({
|
|
202
|
+
where: { id: submission.id },
|
|
203
|
+
data: {
|
|
204
|
+
recommendationState: "OPEN",
|
|
205
|
+
recommendationUpdatedAt: new Date(),
|
|
206
|
+
},
|
|
207
|
+
}));
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (!shouldBeOpen && submission.recommendationState === "OPEN") {
|
|
211
|
+
updates.push(prisma.submission.update({
|
|
212
|
+
where: { id: submission.id },
|
|
213
|
+
data: {
|
|
214
|
+
recommendationState: "NONE",
|
|
215
|
+
recommendationUpdatedAt: new Date(),
|
|
216
|
+
},
|
|
217
|
+
}));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (updates.length) {
|
|
221
|
+
await Promise.all(updates);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
async function summarizeStudentRecommendationsWithCases(student, submissions) {
|
|
225
|
+
const summary = summarizeStudentRecommendations(student, submissions);
|
|
226
|
+
await syncRecommendationStates(submissions, summary.recommendationCandidates);
|
|
227
|
+
return summary;
|
|
228
|
+
}
|
|
51
229
|
async function loadStudentProgressContext(viewerId, classId, studentId) {
|
|
52
230
|
const isTeacher = await isTeacherInClass(classId, viewerId);
|
|
53
231
|
if (viewerId !== studentId && !isTeacher) {
|
|
@@ -106,98 +284,116 @@ async function loadStudentProgressContext(viewerId, classId, studentId) {
|
|
|
106
284
|
}
|
|
107
285
|
export async function getStudentProgressRecommendations(viewerId, classId, studentId) {
|
|
108
286
|
const { student, submissions } = await loadStudentProgressContext(viewerId, classId, studentId);
|
|
109
|
-
const
|
|
110
|
-
const recommendationCandidates =
|
|
111
|
-
.map((submission) => {
|
|
112
|
-
const percentage = calculatePercentage(submission);
|
|
113
|
-
const isMissing = !submission.submitted &&
|
|
114
|
-
submission.assignment.dueDate.getTime() < now.getTime();
|
|
115
|
-
const isLowScore = percentage != null && percentage < 70;
|
|
116
|
-
const isUnreturned = Boolean(submission.submitted) &&
|
|
117
|
-
submission.gradeReceived == null &&
|
|
118
|
-
!submission.returned;
|
|
119
|
-
const isUpcoming = !Boolean(submission.submitted) &&
|
|
120
|
-
!submission.submittedAt &&
|
|
121
|
-
!submission.returned &&
|
|
122
|
-
submission.gradeReceived == null &&
|
|
123
|
-
submission.assignment.dueDate.getTime() >= now.getTime();
|
|
124
|
-
const reasons = [];
|
|
125
|
-
let priorityScore = 0;
|
|
126
|
-
if (isMissing) {
|
|
127
|
-
reasons.push("Missing past-due work");
|
|
128
|
-
priorityScore += 100;
|
|
129
|
-
}
|
|
130
|
-
if (isLowScore) {
|
|
131
|
-
reasons.push(`Scored ${percentage}%`);
|
|
132
|
-
priorityScore += 85 - (percentage ?? 0);
|
|
133
|
-
}
|
|
134
|
-
if (isUnreturned) {
|
|
135
|
-
reasons.push("Awaiting grade or feedback");
|
|
136
|
-
priorityScore += 15;
|
|
137
|
-
}
|
|
138
|
-
if (isUpcoming) {
|
|
139
|
-
reasons.push("Upcoming graded assignment");
|
|
140
|
-
priorityScore += 5;
|
|
141
|
-
}
|
|
142
|
-
return {
|
|
143
|
-
assignmentId: submission.assignment.id,
|
|
144
|
-
submissionId: submission.id,
|
|
145
|
-
title: submission.assignment.title,
|
|
146
|
-
type: submission.assignment.type,
|
|
147
|
-
sectionName: submission.assignment.section?.name ?? null,
|
|
148
|
-
dueDate: submission.assignment.dueDate,
|
|
149
|
-
gradeReceived: submission.gradeReceived,
|
|
150
|
-
maxGrade: submission.assignment.maxGrade,
|
|
151
|
-
percentage,
|
|
152
|
-
submitted: Boolean(submission.submitted),
|
|
153
|
-
returned: Boolean(submission.returned),
|
|
154
|
-
reasons,
|
|
155
|
-
priorityScore,
|
|
156
|
-
};
|
|
157
|
-
})
|
|
158
|
-
.filter((candidate) => candidate.priorityScore > 0)
|
|
159
|
-
.sort((a, b) => b.priorityScore - a.priorityScore)
|
|
160
|
-
.slice(0, 5);
|
|
161
|
-
const gradedPercentages = submissions
|
|
162
|
-
.map(calculatePercentage)
|
|
163
|
-
.filter((value) => value != null);
|
|
164
|
-
const lowScoreCount = gradedPercentages.filter((percentage) => percentage < 70).length;
|
|
165
|
-
const missingCount = submissions.filter((submission) => !submission.submitted &&
|
|
166
|
-
submission.assignment.dueDate.getTime() < now.getTime()).length;
|
|
167
|
-
const trend = calculateTrend(submissions);
|
|
168
|
-
const overallGrade = getOverallGrade(submissions);
|
|
169
|
-
const nextSteps = [
|
|
170
|
-
missingCount > 0
|
|
171
|
-
? `Prioritize ${missingCount} missing assignment${missingCount === 1 ? "" : "s"}.`
|
|
172
|
-
: null,
|
|
173
|
-
lowScoreCount > 0
|
|
174
|
-
? `Review ${lowScoreCount} low-scoring assignment${lowScoreCount === 1 ? "" : "s"} before introducing new material.`
|
|
175
|
-
: null,
|
|
176
|
-
trend < -5
|
|
177
|
-
? "Schedule a check-in because recent performance is trending down."
|
|
178
|
-
: null,
|
|
179
|
-
recommendationCandidates.length === 0
|
|
180
|
-
? "No urgent assignment issues detected from the available grades."
|
|
181
|
-
: null,
|
|
182
|
-
].filter((step) => Boolean(step));
|
|
287
|
+
const summary = await summarizeStudentRecommendationsWithCases(student, submissions);
|
|
288
|
+
const { recommendationCandidates: _recommendationCandidates, ...summaryResponse } = summary;
|
|
183
289
|
return {
|
|
184
|
-
student:
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
290
|
+
student: summaryResponse.student,
|
|
291
|
+
summary: summaryResponse.summary,
|
|
292
|
+
recommendations: summaryResponse.recommendations,
|
|
293
|
+
nextSteps: summaryResponse.nextSteps,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
export async function getClassProgressRecommendations(viewerId, classId) {
|
|
297
|
+
const teacherInClass = await isTeacherInClass(classId, viewerId);
|
|
298
|
+
if (!teacherInClass) {
|
|
299
|
+
throw new TRPCError({
|
|
300
|
+
code: "UNAUTHORIZED",
|
|
301
|
+
message: "Only teachers can view class-wide recommendations",
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
const classData = await prisma.class.findUnique({
|
|
305
|
+
where: { id: classId },
|
|
306
|
+
select: {
|
|
307
|
+
id: true,
|
|
308
|
+
name: true,
|
|
309
|
+
subject: true,
|
|
310
|
+
students: {
|
|
311
|
+
select: {
|
|
312
|
+
id: true,
|
|
313
|
+
username: true,
|
|
314
|
+
profile: { select: { displayName: true } },
|
|
315
|
+
submissions: {
|
|
316
|
+
where: {
|
|
317
|
+
assignment: { classId, graded: true },
|
|
318
|
+
},
|
|
319
|
+
include: {
|
|
320
|
+
assignment: {
|
|
321
|
+
select: {
|
|
322
|
+
id: true,
|
|
323
|
+
title: true,
|
|
324
|
+
dueDate: true,
|
|
325
|
+
maxGrade: true,
|
|
326
|
+
weight: true,
|
|
327
|
+
type: true,
|
|
328
|
+
section: { select: { id: true, name: true } },
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
orderBy: { assignment: { dueDate: "asc" } },
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
if (!classData) {
|
|
339
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Class not found" });
|
|
340
|
+
}
|
|
341
|
+
const students = (await Promise.all(classData.students.map((student) => summarizeStudentRecommendationsWithCases(student, student.submissions))))
|
|
342
|
+
.map(({ recommendationCandidates: _recommendationCandidates, ...student }) => student)
|
|
343
|
+
.filter((student) => student.recommendations.length > 0)
|
|
344
|
+
.sort((a, b) => b.priorityScore - a.priorityScore);
|
|
345
|
+
const totalRecommendations = students.reduce((sum, student) => sum + student.recommendations.length, 0);
|
|
346
|
+
const studentsWithConcerns = students.length;
|
|
347
|
+
const totalMissingAssignments = students.reduce((sum, student) => sum + student.summary.missingCount, 0);
|
|
348
|
+
const totalLowScoreAssignments = students.reduce((sum, student) => sum + student.summary.lowScoreCount, 0);
|
|
349
|
+
return {
|
|
350
|
+
class: {
|
|
351
|
+
id: classData.id,
|
|
352
|
+
name: classData.name,
|
|
353
|
+
subject: classData.subject,
|
|
188
354
|
},
|
|
189
355
|
summary: {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
missingCount,
|
|
195
|
-
lowScoreCount,
|
|
356
|
+
studentsWithConcerns,
|
|
357
|
+
totalRecommendations,
|
|
358
|
+
totalMissingAssignments,
|
|
359
|
+
totalLowScoreAssignments,
|
|
196
360
|
},
|
|
197
|
-
|
|
198
|
-
nextSteps,
|
|
361
|
+
students: students.map(({ priorityScore, ...student }) => student),
|
|
199
362
|
};
|
|
200
363
|
}
|
|
364
|
+
export async function dismissStudentRecommendation(viewerId, input) {
|
|
365
|
+
const teacherInClass = await isTeacherInClass(input.classId, viewerId);
|
|
366
|
+
if (!teacherInClass) {
|
|
367
|
+
throw new TRPCError({
|
|
368
|
+
code: "UNAUTHORIZED",
|
|
369
|
+
message: "Only teachers can dismiss recommendations",
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
const submission = await prisma.submission.findFirst({
|
|
373
|
+
where: {
|
|
374
|
+
id: input.submissionId,
|
|
375
|
+
studentId: input.studentId,
|
|
376
|
+
assignment: {
|
|
377
|
+
classId: input.classId,
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
select: { id: true },
|
|
381
|
+
});
|
|
382
|
+
if (!submission) {
|
|
383
|
+
throw new TRPCError({
|
|
384
|
+
code: "NOT_FOUND",
|
|
385
|
+
message: "Submission not found for this student in this class",
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
await prisma.submission.update({
|
|
389
|
+
where: { id: input.submissionId },
|
|
390
|
+
data: {
|
|
391
|
+
recommendationState: "DISMISSED",
|
|
392
|
+
recommendationUpdatedAt: new Date(),
|
|
393
|
+
},
|
|
394
|
+
});
|
|
395
|
+
return { success: true };
|
|
396
|
+
}
|
|
201
397
|
function buildProgressSummary(submissions) {
|
|
202
398
|
return submissions.map((submission) => ({
|
|
203
399
|
title: submission.assignment.title,
|
|
@@ -260,32 +456,26 @@ export async function chatAboutStudentProgress(viewerId, input) {
|
|
|
260
456
|
const awaitingFeedback = summary.assignments.filter((assignment) => assignment.submitted &&
|
|
261
457
|
assignment.gradeReceived == null &&
|
|
262
458
|
!assignment.returned).length;
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
: summary.trend > 5
|
|
266
|
-
? "improving"
|
|
267
|
-
: summary.trend < -5
|
|
268
|
-
? "declining"
|
|
269
|
-
: "stable";
|
|
270
|
-
const advice = [
|
|
459
|
+
const fallbackParts = [
|
|
460
|
+
`${displayName}'s current overall grade is ${overall}.`,
|
|
271
461
|
missingItems > 0
|
|
272
|
-
?
|
|
462
|
+
? `${missingItems} assignment${missingItems === 1 ? "" : "s"} ${missingItems === 1 ? "appears" : "appear"} overdue.`
|
|
273
463
|
: null,
|
|
274
464
|
lowScores > 0
|
|
275
|
-
?
|
|
465
|
+
? `${lowScores} graded assignment${lowScores === 1 ? " is" : "s are"} below 70%.`
|
|
276
466
|
: null,
|
|
277
467
|
awaitingFeedback > 0
|
|
278
|
-
?
|
|
279
|
-
: null,
|
|
280
|
-
missingItems === 0 && lowScores === 0 && awaitingFeedback === 0
|
|
281
|
-
? "Check upcoming work and ask for feedback as new grades are returned."
|
|
468
|
+
? `${awaitingFeedback} assignment${awaitingFeedback === 1 ? " is" : "s are"} still awaiting feedback.`
|
|
282
469
|
: null,
|
|
283
|
-
|
|
470
|
+
summary.trend < -5
|
|
471
|
+
? "Recent performance is trending down, so a check-in would be reasonable."
|
|
472
|
+
: "Performance looks relatively steady from the available data.",
|
|
473
|
+
].filter((part) => Boolean(part));
|
|
284
474
|
return {
|
|
285
|
-
message:
|
|
475
|
+
message: fallbackParts.join(" "),
|
|
286
476
|
isFallback: true,
|
|
287
477
|
};
|
|
288
478
|
}
|
|
289
479
|
}
|
|
290
480
|
//# sourceMappingURL=studentProgress.js.map
|
|
291
|
-
//# debugId=
|
|
481
|
+
//# debugId=a4f18375-dd27-5f8f-befa-f7b494a8bb88
|