@studious-lms/server 1.13.0 → 1.14.0
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/.dockerignore +28 -0
- package/.env.example +5 -0
- package/.github/workflows/deploy.yml +123 -0
- package/Dockerfile +54 -0
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/config/cors.d.ts.map +1 -1
- package/dist/lib/config/cors.js +31 -11
- package/dist/lib/config/cors.js.map +1 -1
- package/dist/lib/config/env.d.ts +17 -0
- package/dist/lib/config/env.d.ts.map +1 -1
- package/dist/lib/config/env.js +8 -2
- package/dist/lib/config/env.js.map +1 -1
- package/dist/lib/googleCloudStorage.d.ts +10 -0
- package/dist/lib/googleCloudStorage.d.ts.map +1 -1
- package/dist/lib/googleCloudStorage.js +22 -2
- package/dist/lib/googleCloudStorage.js.map +1 -1
- package/dist/lib/prisma.d.ts +104 -5
- package/dist/lib/prisma.d.ts.map +1 -1
- package/dist/lib/prisma.js +33 -2
- package/dist/lib/prisma.js.map +1 -1
- package/dist/models/agenda.d.ts +20 -20
- package/dist/models/announcement.d.ts +177 -69
- package/dist/models/announcement.d.ts.map +1 -1
- package/dist/models/assignment.d.ts +551 -353
- package/dist/models/assignment.d.ts.map +1 -1
- package/dist/models/attendance.d.ts +197 -71
- package/dist/models/attendance.d.ts.map +1 -1
- package/dist/models/auth.d.ts +348 -114
- package/dist/models/auth.d.ts.map +1 -1
- package/dist/models/class.d.ts +324 -198
- package/dist/models/class.d.ts.map +1 -1
- package/dist/models/class.js +2 -2
- package/dist/models/class.js.map +1 -1
- package/dist/models/cloudStorage.d.ts +146 -56
- package/dist/models/cloudStorage.d.ts.map +1 -1
- package/dist/models/comment.d.ts +267 -115
- package/dist/models/comment.d.ts.map +1 -1
- package/dist/models/conversation.d.ts +199 -73
- package/dist/models/conversation.d.ts.map +1 -1
- package/dist/models/department.d.ts +196 -70
- package/dist/models/department.d.ts.map +1 -1
- package/dist/models/event.d.ts +287 -125
- package/dist/models/event.d.ts.map +1 -1
- package/dist/models/file.d.ts +389 -263
- package/dist/models/file.d.ts.map +1 -1
- package/dist/models/folder.d.ts +433 -181
- package/dist/models/folder.d.ts.map +1 -1
- package/dist/models/labChat.d.ts +209 -83
- package/dist/models/labChat.d.ts.map +1 -1
- package/dist/models/marketing.d.ts +80 -26
- package/dist/models/marketing.d.ts.map +1 -1
- package/dist/models/message.d.ts +95 -41
- package/dist/models/message.d.ts.map +1 -1
- package/dist/models/newtonChat.d.ts +83 -29
- package/dist/models/newtonChat.d.ts.map +1 -1
- package/dist/models/notification.d.ts +61 -25
- package/dist/models/notification.d.ts.map +1 -1
- package/dist/models/school.d.ts +349 -133
- package/dist/models/school.d.ts.map +1 -1
- package/dist/models/section.d.ts +216 -72
- package/dist/models/section.d.ts.map +1 -1
- package/dist/models/tag.d.ts +156 -48
- package/dist/models/tag.d.ts.map +1 -1
- package/dist/models/user.d.ts +82 -28
- package/dist/models/user.d.ts.map +1 -1
- package/dist/models/worksheet.d.ts +1020 -411
- package/dist/models/worksheet.d.ts.map +1 -1
- package/dist/models/worksheet.js +8 -2
- package/dist/models/worksheet.js.map +1 -1
- package/dist/pipelines/aiLabChat.d.ts +0 -8
- package/dist/pipelines/aiLabChat.d.ts.map +1 -1
- package/dist/pipelines/aiLabChat.js +50 -67
- package/dist/pipelines/aiLabChat.js.map +1 -1
- package/dist/pipelines/aiLabChatContract.d.ts +393 -388
- package/dist/pipelines/aiLabChatContract.d.ts.map +1 -1
- package/dist/pipelines/aiLabChatContract.js +63 -69
- package/dist/pipelines/aiLabChatContract.js.map +1 -1
- package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
- package/dist/pipelines/aiNewtonChat.js +41 -41
- package/dist/pipelines/aiNewtonChat.js.map +1 -1
- package/dist/pipelines/gradeWorksheet.d.ts +8 -8
- package/dist/pipelines/labChatPrompt.d.ts +10 -8
- package/dist/pipelines/labChatPrompt.d.ts.map +1 -1
- package/dist/pipelines/labChatPrompt.js +47 -67
- package/dist/pipelines/labChatPrompt.js.map +1 -1
- package/dist/pipelines/pdfToWorksheet.d.ts +2 -2
- package/dist/routers/_app.d.ts +2203 -1957
- 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/admin.d.ts +13 -13
- package/dist/routers/agenda.d.ts +18 -18
- package/dist/routers/announcement.d.ts +32 -32
- package/dist/routers/assignment.d.ts +335 -335
- package/dist/routers/attendance.d.ts +10 -10
- package/dist/routers/auth.d.ts +2 -2
- package/dist/routers/class.d.ts +147 -147
- package/dist/routers/comment.d.ts +22 -22
- package/dist/routers/conversation.d.ts +17 -17
- package/dist/routers/department.d.ts +34 -34
- package/dist/routers/discussion.d.ts +65 -53
- package/dist/routers/discussion.d.ts.map +1 -1
- package/dist/routers/discussion.js +6 -2
- package/dist/routers/discussion.js.map +1 -1
- package/dist/routers/embedding.d.ts +69 -0
- package/dist/routers/embedding.d.ts.map +1 -0
- package/dist/routers/embedding.js +143 -0
- package/dist/routers/embedding.js.map +1 -0
- package/dist/routers/event.d.ts +39 -39
- package/dist/routers/file.d.ts +12 -12
- package/dist/routers/folder.d.ts +38 -38
- package/dist/routers/labChat.d.ts +19 -19
- package/dist/routers/marketing.d.ts +2 -2
- package/dist/routers/message.d.ts +13 -13
- package/dist/routers/newtonChat.d.ts +4 -4
- package/dist/routers/notifications.d.ts +9 -9
- package/dist/routers/permissions.d.ts +14 -14
- package/dist/routers/school.d.ts +55 -55
- package/dist/routers/search.d.ts +22 -1
- package/dist/routers/search.d.ts.map +1 -1
- package/dist/routers/search.js +9 -3
- package/dist/routers/search.js.map +1 -1
- package/dist/routers/section.d.ts +4 -4
- package/dist/routers/studentProgress.d.ts +1 -1
- package/dist/routers/tag.d.ts +7 -7
- package/dist/routers/user.d.ts +10 -10
- package/dist/routers/worksheet.d.ts +95 -57
- package/dist/routers/worksheet.d.ts.map +1 -1
- package/dist/routers/worksheet.js +26 -2
- package/dist/routers/worksheet.js.map +1 -1
- package/dist/routes/aiStream.d.ts +2 -0
- package/dist/routes/aiStream.d.ts.map +1 -0
- package/dist/routes/aiStream.js +696 -0
- package/dist/routes/aiStream.js.map +1 -0
- package/dist/seedDatabase.d.ts +2 -2
- package/dist/seedDatabase.js +6 -6
- package/dist/seedDatabase.js.map +1 -1
- package/dist/services/adminInstitution.d.ts +8 -8
- package/dist/services/adminRunJob.d.ts +4 -4
- package/dist/services/agenda.d.ts +18 -18
- package/dist/services/aiChatStream.d.ts +68 -0
- package/dist/services/aiChatStream.d.ts.map +1 -0
- package/dist/services/aiChatStream.js +157 -0
- package/dist/services/aiChatStream.js.map +1 -0
- package/dist/services/aiModifierTools.d.ts +32 -0
- package/dist/services/aiModifierTools.d.ts.map +1 -0
- package/dist/services/aiModifierTools.js +165 -0
- package/dist/services/aiModifierTools.js.map +1 -0
- package/dist/services/aiProposalTools.d.ts +32 -0
- package/dist/services/aiProposalTools.d.ts.map +1 -0
- package/dist/services/aiProposalTools.js +207 -0
- package/dist/services/aiProposalTools.js.map +1 -0
- package/dist/services/aiStream.d.ts +22 -0
- package/dist/services/aiStream.d.ts.map +1 -0
- package/dist/services/aiStream.js +142 -0
- package/dist/services/aiStream.js.map +1 -0
- package/dist/services/aiTools.d.ts +16 -0
- package/dist/services/aiTools.d.ts.map +1 -0
- package/dist/services/aiTools.js +271 -0
- package/dist/services/aiTools.js.map +1 -0
- package/dist/services/announcement.d.ts +12 -12
- package/dist/services/announcement.d.ts.map +1 -1
- package/dist/services/announcement.js +7 -2
- package/dist/services/announcement.js.map +1 -1
- package/dist/services/assignment.d.ts +293 -293
- package/dist/services/assignment.d.ts.map +1 -1
- package/dist/services/assignment.js +28 -9
- package/dist/services/assignment.js.map +1 -1
- package/dist/services/attendance.d.ts +10 -10
- package/dist/services/auth.d.ts +2 -2
- package/dist/services/class.d.ts +138 -138
- package/dist/services/class.d.ts.map +1 -1
- package/dist/services/class.js +19 -3
- package/dist/services/class.js.map +1 -1
- package/dist/services/comment.d.ts +22 -22
- package/dist/services/conversation.d.ts +17 -17
- package/dist/services/department.d.ts +33 -33
- package/dist/services/discussion.d.ts +58 -58
- package/dist/services/discussion.d.ts.map +1 -1
- package/dist/services/discussion.js +8 -8
- package/dist/services/discussion.js.map +1 -1
- package/dist/services/discussionSummary.d.ts +21 -0
- package/dist/services/discussionSummary.d.ts.map +1 -0
- package/dist/services/discussionSummary.js +179 -0
- package/dist/services/discussionSummary.js.map +1 -0
- package/dist/services/embedding.d.ts +67 -0
- package/dist/services/embedding.d.ts.map +1 -0
- package/dist/services/embedding.js +226 -0
- package/dist/services/embedding.js.map +1 -0
- package/dist/services/event.d.ts +37 -37
- package/dist/services/file.d.ts +24 -24
- package/dist/services/folder.d.ts +36 -36
- package/dist/services/labChat.d.ts +19 -19
- package/dist/services/marketing.d.ts +2 -2
- package/dist/services/message.d.ts +11 -11
- package/dist/services/message.d.ts.map +1 -1
- package/dist/services/message.js +2 -2
- package/dist/services/message.js.map +1 -1
- package/dist/services/newtonChat.d.ts +4 -4
- package/dist/services/notification.d.ts +9 -9
- package/dist/services/notification.d.ts.map +1 -1
- package/dist/services/permissions.d.ts +14 -14
- package/dist/services/school.d.ts +50 -50
- package/dist/services/search.d.ts +12 -0
- package/dist/services/search.d.ts.map +1 -1
- package/dist/services/search.js +120 -2
- package/dist/services/search.js.map +1 -1
- package/dist/services/section.d.ts +3 -3
- package/dist/services/tag.d.ts +4 -4
- package/dist/services/user.d.ts +4 -4
- package/dist/services/user.d.ts.map +1 -1
- package/dist/services/user.js +10 -15
- package/dist/services/user.js.map +1 -1
- package/dist/services/worksheet.d.ts +60 -48
- package/dist/services/worksheet.d.ts.map +1 -1
- package/dist/services/worksheet.js +11 -4
- package/dist/services/worksheet.js.map +1 -1
- package/dist/utils/streamBroadcast.d.ts +11 -0
- package/dist/utils/streamBroadcast.d.ts.map +1 -0
- package/dist/utils/streamBroadcast.js +32 -0
- package/dist/utils/streamBroadcast.js.map +1 -0
- package/dist/workers/connection.d.ts +15 -0
- package/dist/workers/connection.d.ts.map +1 -1
- package/dist/workers/connection.js +20 -2
- package/dist/workers/connection.js.map +1 -1
- package/dist/workers/index.js +4 -2
- package/dist/workers/index.js.map +1 -1
- package/dist/workers/processors/createRunJobWorker.d.ts.map +1 -1
- package/dist/workers/processors/createRunJobWorker.js +31 -8
- package/dist/workers/processors/createRunJobWorker.js.map +1 -1
- package/dist/workers/processors/embedContent.d.ts +8 -0
- package/dist/workers/processors/embedContent.d.ts.map +1 -0
- package/dist/workers/processors/embedContent.js +76 -0
- package/dist/workers/processors/embedContent.js.map +1 -0
- package/dist/workers/queues.d.ts +6 -0
- package/dist/workers/queues.d.ts.map +1 -1
- package/dist/workers/queues.js +5 -3
- package/dist/workers/queues.js.map +1 -1
- package/package.json +5 -1
- package/prisma/migrations/20260609215135_init/migration.sql +1412 -0
- package/prisma/migrations/20260610130000_add_worksheet_tags/migration.sql +13 -0
- package/prisma/schema.prisma +38 -7
- package/src/index.ts +4 -0
- package/src/lib/config/cors.ts +32 -9
- package/src/lib/config/env.ts +6 -0
- package/src/lib/googleCloudStorage.ts +21 -0
- package/src/lib/prisma.ts +40 -1
- package/src/models/class.ts +3 -2
- package/src/models/worksheet.ts +5 -2
- package/src/pipelines/aiLabChat.ts +54 -79
- package/src/pipelines/aiLabChatContract.ts +69 -69
- package/src/pipelines/aiNewtonChat.ts +46 -43
- package/src/pipelines/labChatPrompt.ts +63 -88
- package/src/routers/_app.ts +2 -0
- package/src/routers/discussion.ts +7 -0
- package/src/routers/embedding.ts +159 -0
- package/src/routers/search.ts +10 -1
- package/src/routers/worksheet.ts +27 -0
- package/src/routes/aiStream.ts +789 -0
- package/src/seedDatabase.ts +4 -4
- package/src/services/aiChatStream.ts +219 -0
- package/src/services/aiModifierTools.ts +188 -0
- package/src/services/aiProposalTools.ts +272 -0
- package/src/services/aiStream.ts +175 -0
- package/src/services/aiTools.ts +290 -0
- package/src/services/announcement.ts +7 -0
- package/src/services/assignment.ts +28 -7
- package/src/services/class.ts +20 -1
- package/src/services/discussion.ts +9 -8
- package/src/services/discussionSummary.ts +226 -0
- package/src/services/embedding.ts +285 -0
- package/src/services/message.ts +2 -2
- package/src/services/search.ts +138 -0
- package/src/services/user.ts +9 -17
- package/src/services/worksheet.ts +9 -2
- package/src/utils/streamBroadcast.ts +48 -0
- package/src/workers/connection.ts +19 -0
- package/src/workers/index.ts +2 -0
- package/src/workers/processors/createRunJobWorker.ts +32 -6
- package/src/workers/processors/embedContent.ts +90 -0
- package/src/workers/queues.ts +5 -1
- package/tests/unit/pipelines/aiLabChatContract.test.ts +38 -21
- package/tests/unit/pipelines/labChatPrompt.test.ts +23 -36
- package/prisma/migrations/20250807062924_init/migration.sql +0 -436
- package/prisma/migrations/20250919063704_init2/migration.sql +0 -24
- package/prisma/migrations/20250920143543_add_profile_fields/migration.sql +0 -15
- package/prisma/migrations/20250922165147_add_chat_models/migration.sql +0 -68
- package/prisma/migrations/20250922165314_add_chat_roles/migration.sql +0 -5
- package/prisma/migrations/20250922171242_refactor_notification_system/migration.sql +0 -20
- package/prisma/migrations/20250922172208_add_mentions_system/migration.sql +0 -21
- package/prisma/migrations/20250925072732_add_lab_chat_system/migration.sql +0 -25
- package/prisma/migrations/20251020151505_add_upload_tracking_fields/migration.sql +0 -57
- package/prisma/migrations/20251109122857_annuoncements_comments/migration.sql +0 -30
- package/prisma/migrations/20251109135555_reactions_announcements_comments/migration.sql +0 -35
- package/prisma/migrations/20260410124000_add_submission_recommendation_state/migration.sql +0 -14
- package/prisma/migrations/20260415103500_add_session_type/migration.sql +0 -33
- package/prisma/migrations/20260415133000_add_ai_usage_logs/migration.sql +0 -19
- package/prisma/migrations/20260417083000_add_conversation_member_hidden_at/migration.sql +0 -2
- package/prisma/migrations/20260502230500_assignment_submission_comments/migration.sql +0 -60
- package/prisma/migrations/20260504071500_school_memberships/migration.sql +0 -81
- package/prisma/migrations/20260506162500_consolidate_run_job_and_unify_job_status/migration.sql +0 -166
- package/prisma/migrations/20260509220000_institution_overhaul/migration.sql +0 -184
- package/prisma/migrations/20260510170000_add_folder_permissions/migration.sql +0 -93
- package/prisma/migrations/20260510183000_add_discussions/migration.sql +0 -151
- /package/.github/workflows/{npm.yml → npm-publish.yml} +0 -0
package/src/services/user.ts
CHANGED
|
@@ -5,15 +5,7 @@ import { TRPCError } from "@trpc/server";
|
|
|
5
5
|
import { logger } from "../utils/logger.js";
|
|
6
6
|
import { env } from "../lib/config/env.js";
|
|
7
7
|
import { findUserById, findUserProfileByUserId, upsertUserProfile } from "../models/user.js";
|
|
8
|
-
|
|
9
|
-
function getFileUrl(filePath: string | null): string | null {
|
|
10
|
-
if (!filePath) return null;
|
|
11
|
-
if (filePath.startsWith("http")) {
|
|
12
|
-
return filePath;
|
|
13
|
-
}
|
|
14
|
-
const backendUrl = env.BACKEND_URL || "http://localhost:3001";
|
|
15
|
-
return `${backendUrl}/api/files/${encodeURIComponent(filePath)}`;
|
|
16
|
-
}
|
|
8
|
+
import { makeFilePublic } from "../lib/googleCloudStorage.js";
|
|
17
9
|
|
|
18
10
|
/** Get user profile with display info and profile picture URLs. */
|
|
19
11
|
export async function getProfile(userId: string) {
|
|
@@ -36,10 +28,8 @@ export async function getProfile(userId: string) {
|
|
|
36
28
|
bio: (userProfile as any).bio || null,
|
|
37
29
|
location: (userProfile as any).location || null,
|
|
38
30
|
website: (userProfile as any).website || null,
|
|
39
|
-
profilePicture:
|
|
40
|
-
profilePictureThumbnail:
|
|
41
|
-
(userProfile as any).profilePictureThumbnail
|
|
42
|
-
),
|
|
31
|
+
profilePicture: (userProfile as any).profilePicture ?? null,
|
|
32
|
+
profilePictureThumbnail: (userProfile as any).profilePictureThumbnail ?? null,
|
|
43
33
|
}
|
|
44
34
|
: {
|
|
45
35
|
displayName: null,
|
|
@@ -78,6 +68,10 @@ export async function updateProfile(
|
|
|
78
68
|
try {
|
|
79
69
|
profilePictureUrl = input.profilePicture.filePath;
|
|
80
70
|
profilePictureThumbnail = null;
|
|
71
|
+
|
|
72
|
+
// Best-effort: make the uploaded file publicly readable so the public
|
|
73
|
+
// GCS URL resolved by the Prisma extension actually works.
|
|
74
|
+
await makeFilePublic(profilePictureUrl);
|
|
81
75
|
} catch (error) {
|
|
82
76
|
logger.error("Profile picture processing failed", {
|
|
83
77
|
userId,
|
|
@@ -125,10 +119,8 @@ export async function updateProfile(
|
|
|
125
119
|
bio: (updatedProfile as any).bio || null,
|
|
126
120
|
location: (updatedProfile as any).location || null,
|
|
127
121
|
website: (updatedProfile as any).website || null,
|
|
128
|
-
profilePicture:
|
|
129
|
-
profilePictureThumbnail:
|
|
130
|
-
(updatedProfile as any).profilePictureThumbnail
|
|
131
|
-
),
|
|
122
|
+
profilePicture: (updatedProfile as any).profilePicture ?? null,
|
|
123
|
+
profilePictureThumbnail: (updatedProfile as any).profilePictureThumbnail ?? null,
|
|
132
124
|
},
|
|
133
125
|
};
|
|
134
126
|
}
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
findStudentQuestionProgressByIdForStudentOrTeacher,
|
|
39
39
|
findStudentQuestionProgressByIdForTeacherAndResponse,
|
|
40
40
|
} from "../models/worksheet.js";
|
|
41
|
+
import { enqueueEmbedding } from "./embedding.js";
|
|
41
42
|
|
|
42
43
|
type MCQOptions = { id: string; text: string; isCorrect: boolean }[];
|
|
43
44
|
|
|
@@ -108,7 +109,10 @@ export async function updateWorksheetRecord(
|
|
|
108
109
|
message: "Only class teachers can update worksheets",
|
|
109
110
|
});
|
|
110
111
|
}
|
|
111
|
-
|
|
112
|
+
const updated = await updateWorksheet(worksheetId, data);
|
|
113
|
+
// Re-index worksheet for RAG search
|
|
114
|
+
enqueueEmbedding(worksheet.classId, 'WORKSHEET', worksheetId);
|
|
115
|
+
return updated;
|
|
112
116
|
}
|
|
113
117
|
|
|
114
118
|
export async function deleteWorksheetRecord(worksheetId: string, userId: string) {
|
|
@@ -123,7 +127,10 @@ export async function deleteWorksheetRecord(worksheetId: string, userId: string)
|
|
|
123
127
|
}
|
|
124
128
|
|
|
125
129
|
export async function createWorksheetRecord(classId: string, name: string) {
|
|
126
|
-
|
|
130
|
+
const worksheet = await createWorksheet({ classId, name });
|
|
131
|
+
// Auto-index worksheet for RAG search
|
|
132
|
+
enqueueEmbedding(classId, 'WORKSHEET', worksheet.id);
|
|
133
|
+
return worksheet;
|
|
127
134
|
}
|
|
128
135
|
|
|
129
136
|
/**
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Broadcast text content as sequential Pusher chunks so connected clients
|
|
3
|
+
* can render progressive streaming even when the underlying inference call
|
|
4
|
+
* returns the full text at once (structured-output pipelines).
|
|
5
|
+
*/
|
|
6
|
+
import { pusher, chatChannel } from '../lib/pusher.js';
|
|
7
|
+
|
|
8
|
+
const CHUNK_SIZE = 80;
|
|
9
|
+
|
|
10
|
+
interface BroadcastTextChunksOptions {
|
|
11
|
+
/** When true, skip the `message-stream-complete` event at the end.
|
|
12
|
+
* Callers that need to broadcast additional events (e.g. tool-call
|
|
13
|
+
* indicators) before the stream ends should set this to `true` and
|
|
14
|
+
* call {@link broadcastStreamComplete} explicitly when done. */
|
|
15
|
+
skipComplete?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function broadcastTextChunks(
|
|
19
|
+
conversationId: string,
|
|
20
|
+
messageId: string,
|
|
21
|
+
text: string,
|
|
22
|
+
options?: BroadcastTextChunksOptions,
|
|
23
|
+
): Promise<void> {
|
|
24
|
+
for (let i = 0; i < text.length; i += CHUNK_SIZE) {
|
|
25
|
+
const chunk = text.slice(i, i + CHUNK_SIZE);
|
|
26
|
+
await pusher
|
|
27
|
+
.trigger(chatChannel(conversationId), 'message-stream-chunk', {
|
|
28
|
+
messageId,
|
|
29
|
+
chunk,
|
|
30
|
+
})
|
|
31
|
+
.catch(() => {});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!options?.skipComplete) {
|
|
35
|
+
await broadcastStreamComplete(conversationId, messageId);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function broadcastStreamComplete(
|
|
40
|
+
conversationId: string,
|
|
41
|
+
messageId: string,
|
|
42
|
+
): Promise<void> {
|
|
43
|
+
await pusher
|
|
44
|
+
.trigger(chatChannel(conversationId), 'message-stream-complete', {
|
|
45
|
+
messageId,
|
|
46
|
+
})
|
|
47
|
+
.catch(() => {});
|
|
48
|
+
}
|
|
@@ -35,3 +35,22 @@ function buildBullConnection(): IORedis {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
export const bullConnection = buildBullConnection();
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Key prefix applied to every BullMQ Queue and Worker.
|
|
41
|
+
*
|
|
42
|
+
* Without this, BullMQ defaults to the `bull` namespace, so ANY process
|
|
43
|
+
* pointed at the same Redis instance shares the same queues. That is a
|
|
44
|
+
* problem when a single Redis (e.g. a shared Upstash instance) is used across
|
|
45
|
+
* environments while each environment has its own database: a worker can pick
|
|
46
|
+
* up a job enqueued elsewhere, fail to find its `RunJob` row, and crash-loop
|
|
47
|
+
* with "No record was found for an update".
|
|
48
|
+
*
|
|
49
|
+
* Namespacing by environment keeps each environment's jobs isolated. Override
|
|
50
|
+
* with `BULL_PREFIX` when you need finer-grained separation (e.g. per
|
|
51
|
+
* developer sharing one Redis).
|
|
52
|
+
*/
|
|
53
|
+
export function getBullPrefix(): string {
|
|
54
|
+
const explicit = env.BULL_PREFIX?.trim();
|
|
55
|
+
return explicit && explicit.length > 0 ? explicit : `bull:${env.NODE_ENV}`;
|
|
56
|
+
}
|
package/src/workers/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { aiNewtonChatWorker } from './processors/aiNewtonChat.js';
|
|
|
17
17
|
import { gradeWorksheetWorker } from './processors/gradeWorksheet.js';
|
|
18
18
|
import { pdfToWorksheetWorker } from './processors/pdfToWorksheet.js';
|
|
19
19
|
import { studentProgressChatWorker } from './processors/studentProgressChat.js';
|
|
20
|
+
import { embedContentWorker } from './processors/embedContent.js';
|
|
20
21
|
|
|
21
22
|
const workers: Worker<any>[] = [
|
|
22
23
|
pdfToWorksheetWorker,
|
|
@@ -24,6 +25,7 @@ const workers: Worker<any>[] = [
|
|
|
24
25
|
aiLabChatWorker,
|
|
25
26
|
aiNewtonChatWorker,
|
|
26
27
|
studentProgressChatWorker,
|
|
28
|
+
embedContentWorker,
|
|
27
29
|
];
|
|
28
30
|
|
|
29
31
|
logger.info('Workers ready', {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { Worker, type Job, type WorkerOptions } from 'bullmq';
|
|
19
19
|
import { Prisma, type RunJobType } from '@prisma/client';
|
|
20
|
-
import { bullConnection } from '../connection.js';
|
|
20
|
+
import { bullConnection, getBullPrefix } from '../connection.js';
|
|
21
21
|
import { prisma } from '../../lib/prisma.js';
|
|
22
22
|
import { logger } from '../../utils/logger.js';
|
|
23
23
|
import { QUEUE_NAMES, type JobInputs } from '../queues.js';
|
|
@@ -69,7 +69,21 @@ export function createRunJobWorker<T extends RunJobType>({
|
|
|
69
69
|
select: { status: true, result: true, userId: true, classId: true },
|
|
70
70
|
});
|
|
71
71
|
|
|
72
|
-
if (existing
|
|
72
|
+
if (!existing) {
|
|
73
|
+
// Orphaned job: the RunJob row this job points at does not exist in
|
|
74
|
+
// THIS database. Typically caused by a Redis instance shared across
|
|
75
|
+
// environments/databases (the `prefix` namespacing in this file's
|
|
76
|
+
// Worker options is the primary guard against that), or by a DB reset
|
|
77
|
+
// that left jobs behind in Redis. Discard instead of throwing on the
|
|
78
|
+
// `update` below and burning all retry attempts.
|
|
79
|
+
logger.warn(`${name} RunJob not found; discarding orphaned job`, {
|
|
80
|
+
runJobId,
|
|
81
|
+
bullJobId: job.id,
|
|
82
|
+
});
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (existing.status === 'SUCCEEDED') {
|
|
73
87
|
logger.info(`${name} RunJob already succeeded; skipping re-run`, {
|
|
74
88
|
runJobId,
|
|
75
89
|
bullJobId: job.id,
|
|
@@ -81,7 +95,7 @@ export function createRunJobWorker<T extends RunJobType>({
|
|
|
81
95
|
where: { id: runJobId },
|
|
82
96
|
data: {
|
|
83
97
|
status: 'RUNNING',
|
|
84
|
-
startedAt: existing
|
|
98
|
+
startedAt: existing.status === 'RUNNING' ? undefined : new Date(),
|
|
85
99
|
attempts: { increment: 1 },
|
|
86
100
|
},
|
|
87
101
|
});
|
|
@@ -90,8 +104,8 @@ export function createRunJobWorker<T extends RunJobType>({
|
|
|
90
104
|
runJobId,
|
|
91
105
|
bullJobId: job.id,
|
|
92
106
|
attempt: job.attemptsMade + 1,
|
|
93
|
-
userId: existing
|
|
94
|
-
classId: existing
|
|
107
|
+
userId: existing.userId ?? null,
|
|
108
|
+
classId: existing.classId ?? null,
|
|
95
109
|
});
|
|
96
110
|
|
|
97
111
|
await prisma.runJob.update({
|
|
@@ -106,7 +120,7 @@ export function createRunJobWorker<T extends RunJobType>({
|
|
|
106
120
|
|
|
107
121
|
return result;
|
|
108
122
|
},
|
|
109
|
-
{ connection: bullConnection, ...workerOptions },
|
|
123
|
+
{ connection: bullConnection, prefix: getBullPrefix(), ...workerOptions },
|
|
110
124
|
);
|
|
111
125
|
|
|
112
126
|
worker.on('error', (err) => {
|
|
@@ -143,6 +157,18 @@ export function createRunJobWorker<T extends RunJobType>({
|
|
|
143
157
|
},
|
|
144
158
|
})
|
|
145
159
|
.catch((updateError) => {
|
|
160
|
+
// P2025 = record not found. The RunJob row is gone (orphaned job from a
|
|
161
|
+
// shared/reset Redis), so there's nothing to flag as FAILED — log
|
|
162
|
+
// quietly instead of as an error to avoid noise.
|
|
163
|
+
if (
|
|
164
|
+
updateError instanceof Prisma.PrismaClientKnownRequestError &&
|
|
165
|
+
updateError.code === 'P2025'
|
|
166
|
+
) {
|
|
167
|
+
logger.warn(`${name} failure not recorded; RunJob no longer exists`, {
|
|
168
|
+
runJobId,
|
|
169
|
+
});
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
146
172
|
logger.error(`Failed to record ${name} failure on RunJob`, {
|
|
147
173
|
runJobId,
|
|
148
174
|
error:
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background worker for embedding content into pgvector.
|
|
3
|
+
* Handles indexing of files, assignments, worksheets, and announcements.
|
|
4
|
+
*/
|
|
5
|
+
import { createRunJobWorker } from './createRunJobWorker.js';
|
|
6
|
+
import {
|
|
7
|
+
indexAssignment,
|
|
8
|
+
indexWorksheet,
|
|
9
|
+
indexAnnouncement,
|
|
10
|
+
indexContent,
|
|
11
|
+
} from '../../services/embedding.js';
|
|
12
|
+
import { prisma } from '../../lib/prisma.js';
|
|
13
|
+
import { logger } from '../../utils/logger.js';
|
|
14
|
+
import type { EmbeddingSourceType } from '@prisma/client';
|
|
15
|
+
|
|
16
|
+
export const embedContentWorker = createRunJobWorker({
|
|
17
|
+
type: 'EMBED_CONTENT',
|
|
18
|
+
workerOptions: {
|
|
19
|
+
concurrency: 3,
|
|
20
|
+
limiter: { max: 10, duration: 60_000 },
|
|
21
|
+
},
|
|
22
|
+
run: async ({ classId, sourceType, sourceId }) => {
|
|
23
|
+
const typedSourceType = sourceType as EmbeddingSourceType;
|
|
24
|
+
|
|
25
|
+
switch (typedSourceType) {
|
|
26
|
+
case 'ASSIGNMENT':
|
|
27
|
+
await indexAssignment(classId, sourceId);
|
|
28
|
+
break;
|
|
29
|
+
|
|
30
|
+
case 'WORKSHEET':
|
|
31
|
+
await indexWorksheet(classId, sourceId);
|
|
32
|
+
break;
|
|
33
|
+
|
|
34
|
+
case 'ANNOUNCEMENT':
|
|
35
|
+
await indexAnnouncement(classId, sourceId);
|
|
36
|
+
break;
|
|
37
|
+
|
|
38
|
+
case 'FILE': {
|
|
39
|
+
// For files, fetch the file metadata and content
|
|
40
|
+
const file = await prisma.file.findUnique({
|
|
41
|
+
where: { id: sourceId },
|
|
42
|
+
select: { name: true, path: true, type: true },
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
if (!file) {
|
|
46
|
+
logger.warn('File not found for embedding', { sourceId });
|
|
47
|
+
return { skipped: true, reason: 'file_not_found' };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// For now, index the filename and any text-based content.
|
|
51
|
+
// PDF extraction would be added here with pdf-parse.
|
|
52
|
+
const text = `File: ${file.name}\nType: ${file.type}\nPath: ${file.path}`;
|
|
53
|
+
await indexContent({
|
|
54
|
+
classId,
|
|
55
|
+
sourceType: 'FILE',
|
|
56
|
+
sourceId,
|
|
57
|
+
text,
|
|
58
|
+
metadata: { name: file.name, type: file.type },
|
|
59
|
+
});
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
case 'SECTION': {
|
|
64
|
+
const section = await prisma.section.findUnique({
|
|
65
|
+
where: { id: sourceId },
|
|
66
|
+
select: { name: true },
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
if (!section) {
|
|
70
|
+
return { skipped: true, reason: 'section_not_found' };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
await indexContent({
|
|
74
|
+
classId,
|
|
75
|
+
sourceType: 'SECTION',
|
|
76
|
+
sourceId,
|
|
77
|
+
text: `Section: ${section.name}`,
|
|
78
|
+
metadata: { name: section.name },
|
|
79
|
+
});
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
default:
|
|
84
|
+
logger.warn('Unknown embedding source type', { sourceType, sourceId });
|
|
85
|
+
return { skipped: true, reason: 'unknown_source_type' };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { indexed: true, sourceType, sourceId };
|
|
89
|
+
},
|
|
90
|
+
});
|
package/src/workers/queues.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { Queue, type JobsOptions } from 'bullmq';
|
|
10
10
|
import { Prisma, type RunJobType } from '@prisma/client';
|
|
11
|
-
import { bullConnection } from './connection.js';
|
|
11
|
+
import { bullConnection, getBullPrefix } from './connection.js';
|
|
12
12
|
import { prisma } from '../lib/prisma.js';
|
|
13
13
|
import { logger } from '../utils/logger.js';
|
|
14
14
|
|
|
@@ -47,6 +47,8 @@ export type JobInputs = {
|
|
|
47
47
|
*/
|
|
48
48
|
AI_NEWTON_CHAT: { conversationId: string; aiMessageId: string };
|
|
49
49
|
STUDENT_PROGRESS_CHAT: { classId: string; studentId: string; message: string };
|
|
50
|
+
/** Embed content into pgvector for RAG search. */
|
|
51
|
+
EMBED_CONTENT: { classId: string; sourceType: string; sourceId: string };
|
|
50
52
|
};
|
|
51
53
|
|
|
52
54
|
export const QUEUE_NAMES: Record<RunJobType, string> = {
|
|
@@ -55,6 +57,7 @@ export const QUEUE_NAMES: Record<RunJobType, string> = {
|
|
|
55
57
|
AI_LAB_CHAT: 'aiLabChat',
|
|
56
58
|
AI_NEWTON_CHAT: 'aiNewtonChat',
|
|
57
59
|
STUDENT_PROGRESS_CHAT: 'studentProgressChat',
|
|
60
|
+
EMBED_CONTENT: 'embedContent',
|
|
58
61
|
};
|
|
59
62
|
|
|
60
63
|
/**
|
|
@@ -68,6 +71,7 @@ function getQueue(type: RunJobType): Queue {
|
|
|
68
71
|
if (!queue) {
|
|
69
72
|
queue = new Queue(QUEUE_NAMES[type], {
|
|
70
73
|
connection: bullConnection,
|
|
74
|
+
prefix: getBullPrefix(),
|
|
71
75
|
defaultJobOptions: DEFAULT_JOB_OPTIONS,
|
|
72
76
|
});
|
|
73
77
|
queueCache.set(type, queue);
|
|
@@ -1,32 +1,49 @@
|
|
|
1
1
|
import { describe, expect, test } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
assignmentToCreateSchema,
|
|
4
|
+
sectionToCreateSchema,
|
|
5
|
+
worksheetQuestionSchema,
|
|
6
|
+
} from "../../../src/pipelines/aiLabChatContract.js";
|
|
3
7
|
|
|
4
8
|
describe("aiLabChat contract", () => {
|
|
5
|
-
test("
|
|
6
|
-
const parsed =
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
test("worksheet questions default options, mark scheme, and points", () => {
|
|
10
|
+
const parsed = worksheetQuestionSchema.parse({
|
|
11
|
+
type: "SHORT_ANSWER",
|
|
12
|
+
question: "What is osmosis?",
|
|
13
|
+
answer: "Diffusion of water across a membrane",
|
|
14
|
+
order: 0,
|
|
10
15
|
});
|
|
11
16
|
|
|
12
|
-
expect(parsed.
|
|
13
|
-
expect(parsed.
|
|
17
|
+
expect(parsed.options).toEqual([]);
|
|
18
|
+
expect(parsed.markScheme).toEqual([]);
|
|
19
|
+
expect(parsed.points).toBe(0);
|
|
14
20
|
});
|
|
15
21
|
|
|
16
|
-
test("
|
|
17
|
-
expect(() =>
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
assignmentsToCreate: null,
|
|
23
|
-
})).toThrow();
|
|
22
|
+
test("section color must be a hex value when provided", () => {
|
|
23
|
+
expect(() => sectionToCreateSchema.parse({ name: "Unit 1", color: "blue" })).toThrow();
|
|
24
|
+
expect(sectionToCreateSchema.parse({ name: "Unit 1", color: "#3B82F6" }).color).toBe(
|
|
25
|
+
"#3B82F6",
|
|
26
|
+
);
|
|
27
|
+
expect(sectionToCreateSchema.parse({ name: "Unit 1" }).color).toBeUndefined();
|
|
24
28
|
});
|
|
25
29
|
|
|
26
|
-
test("
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
test("assignment requires an ISO datetime due date", () => {
|
|
31
|
+
const base = {
|
|
32
|
+
title: "Osmosis Quiz",
|
|
33
|
+
instructions: "Answer all questions.",
|
|
34
|
+
acceptFiles: false,
|
|
35
|
+
acceptExtendedResponse: false,
|
|
36
|
+
acceptWorksheet: true,
|
|
37
|
+
maxGrade: 10,
|
|
38
|
+
worksheetIds: [],
|
|
39
|
+
studentIds: [],
|
|
40
|
+
type: "QUIZ" as const,
|
|
41
|
+
attachments: [],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
expect(() => assignmentToCreateSchema.parse({ ...base, dueDate: "not-a-date" })).toThrow();
|
|
45
|
+
expect(
|
|
46
|
+
assignmentToCreateSchema.parse({ ...base, dueDate: "2026-04-08T12:00:00Z" }).dueDate,
|
|
47
|
+
).toBe("2026-04-08T12:00:00Z");
|
|
31
48
|
});
|
|
32
49
|
});
|
|
@@ -1,26 +1,24 @@
|
|
|
1
1
|
import { describe, expect, test } from "vitest";
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
buildLabChatAgentMessages,
|
|
5
|
+
buildLabChatAgentSystemPrompt,
|
|
6
6
|
} from "../../../src/pipelines/labChatPrompt";
|
|
7
|
-
import { labChatArrayFieldInstructions, labChatResponseFormat } from "../../../src/pipelines/aiLabChatContract";
|
|
8
7
|
|
|
9
|
-
describe("
|
|
10
|
-
test("
|
|
11
|
-
const prompt =
|
|
8
|
+
describe("buildLabChatAgentSystemPrompt", () => {
|
|
9
|
+
test("instructs the model to create content via proposal tools", () => {
|
|
10
|
+
const prompt = buildLabChatAgentSystemPrompt("Context");
|
|
12
11
|
|
|
13
|
-
expect(prompt).toContain(
|
|
14
|
-
expect(prompt).toContain(
|
|
15
|
-
expect(prompt).toContain(
|
|
16
|
-
expect(prompt).
|
|
17
|
-
expect(prompt).not.toContain('"sectionsToCreate": null | array');
|
|
12
|
+
expect(prompt).toContain("Context");
|
|
13
|
+
expect(prompt).toContain("HOW TO CREATE CONTENT (TOOLS):");
|
|
14
|
+
expect(prompt).toContain("propose_assignment");
|
|
15
|
+
expect(prompt).toContain("propose_worksheet");
|
|
18
16
|
});
|
|
19
17
|
});
|
|
20
18
|
|
|
21
|
-
describe("
|
|
22
|
-
test("
|
|
23
|
-
const messages =
|
|
19
|
+
describe("buildLabChatAgentMessages", () => {
|
|
20
|
+
test("folds the class context into the system prompt, not the message list", () => {
|
|
21
|
+
const { system, messages } = buildLabChatAgentMessages({
|
|
24
22
|
context: "Topic context",
|
|
25
23
|
classContext: "CLASS: Biology",
|
|
26
24
|
recentMessages: [
|
|
@@ -32,30 +30,19 @@ describe("buildLabChatResponseMessages", () => {
|
|
|
32
30
|
profile: { displayName: "Ms Patel" },
|
|
33
31
|
},
|
|
34
32
|
},
|
|
35
|
-
{
|
|
36
|
-
senderId: "ai-user",
|
|
37
|
-
content: "I can do that.",
|
|
38
|
-
},
|
|
39
33
|
],
|
|
40
34
|
isAIUser: (senderId) => senderId === "ai-user",
|
|
41
35
|
});
|
|
42
36
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
);
|
|
49
|
-
|
|
50
|
-
expect(teacherTurns).toHaveLength(1);
|
|
51
|
-
expect(messages.at(-2)).toMatchObject({
|
|
52
|
-
role: "developer",
|
|
53
|
-
content: expect.stringContaining("CLASS CONTEXT"),
|
|
54
|
-
});
|
|
37
|
+
expect(system).toContain("CLASS CONTEXT");
|
|
38
|
+
expect(system).toContain("CLASS: Biology");
|
|
39
|
+
expect(messages).toEqual([
|
|
40
|
+
{ role: "user", content: "Ms Patel: Create a quiz on osmosis" },
|
|
41
|
+
]);
|
|
55
42
|
});
|
|
56
43
|
|
|
57
44
|
test("sorts timestamped history into chronological order before prompt assembly", () => {
|
|
58
|
-
const messages =
|
|
45
|
+
const { messages } = buildLabChatAgentMessages({
|
|
59
46
|
context: "Topic context",
|
|
60
47
|
classContext: "CLASS: Biology",
|
|
61
48
|
recentMessages: [
|
|
@@ -79,18 +66,18 @@ describe("buildLabChatResponseMessages", () => {
|
|
|
79
66
|
isAIUser: (senderId) => senderId === "ai-user",
|
|
80
67
|
});
|
|
81
68
|
|
|
82
|
-
expect(messages[
|
|
69
|
+
expect(messages[0]).toMatchObject({
|
|
83
70
|
role: "user",
|
|
84
71
|
content: "Ms Patel: First request",
|
|
85
72
|
});
|
|
86
|
-
expect(messages[
|
|
73
|
+
expect(messages[1]).toMatchObject({
|
|
87
74
|
role: "assistant",
|
|
88
75
|
content: "Second response",
|
|
89
76
|
});
|
|
90
77
|
});
|
|
91
78
|
|
|
92
79
|
test("keeps original order among messages without timestamps", () => {
|
|
93
|
-
const messages =
|
|
80
|
+
const { messages } = buildLabChatAgentMessages({
|
|
94
81
|
context: "Topic context",
|
|
95
82
|
classContext: "CLASS: Biology",
|
|
96
83
|
recentMessages: [
|
|
@@ -114,11 +101,11 @@ describe("buildLabChatResponseMessages", () => {
|
|
|
114
101
|
isAIUser: () => false,
|
|
115
102
|
});
|
|
116
103
|
|
|
117
|
-
expect(messages[
|
|
104
|
+
expect(messages[0]).toMatchObject({
|
|
118
105
|
role: "user",
|
|
119
106
|
content: "Ms Patel: First untimed request",
|
|
120
107
|
});
|
|
121
|
-
expect(messages[
|
|
108
|
+
expect(messages[1]).toMatchObject({
|
|
122
109
|
role: "user",
|
|
123
110
|
content: "Ms Patel: Second untimed request",
|
|
124
111
|
});
|