@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.
Files changed (306) hide show
  1. package/.dockerignore +28 -0
  2. package/.env.example +5 -0
  3. package/.github/workflows/deploy.yml +123 -0
  4. package/Dockerfile +54 -0
  5. package/dist/index.js +5 -2
  6. package/dist/index.js.map +1 -1
  7. package/dist/lib/config/cors.d.ts.map +1 -1
  8. package/dist/lib/config/cors.js +31 -11
  9. package/dist/lib/config/cors.js.map +1 -1
  10. package/dist/lib/config/env.d.ts +17 -0
  11. package/dist/lib/config/env.d.ts.map +1 -1
  12. package/dist/lib/config/env.js +8 -2
  13. package/dist/lib/config/env.js.map +1 -1
  14. package/dist/lib/googleCloudStorage.d.ts +10 -0
  15. package/dist/lib/googleCloudStorage.d.ts.map +1 -1
  16. package/dist/lib/googleCloudStorage.js +22 -2
  17. package/dist/lib/googleCloudStorage.js.map +1 -1
  18. package/dist/lib/prisma.d.ts +104 -5
  19. package/dist/lib/prisma.d.ts.map +1 -1
  20. package/dist/lib/prisma.js +33 -2
  21. package/dist/lib/prisma.js.map +1 -1
  22. package/dist/models/agenda.d.ts +20 -20
  23. package/dist/models/announcement.d.ts +177 -69
  24. package/dist/models/announcement.d.ts.map +1 -1
  25. package/dist/models/assignment.d.ts +551 -353
  26. package/dist/models/assignment.d.ts.map +1 -1
  27. package/dist/models/attendance.d.ts +197 -71
  28. package/dist/models/attendance.d.ts.map +1 -1
  29. package/dist/models/auth.d.ts +348 -114
  30. package/dist/models/auth.d.ts.map +1 -1
  31. package/dist/models/class.d.ts +324 -198
  32. package/dist/models/class.d.ts.map +1 -1
  33. package/dist/models/class.js +2 -2
  34. package/dist/models/class.js.map +1 -1
  35. package/dist/models/cloudStorage.d.ts +146 -56
  36. package/dist/models/cloudStorage.d.ts.map +1 -1
  37. package/dist/models/comment.d.ts +267 -115
  38. package/dist/models/comment.d.ts.map +1 -1
  39. package/dist/models/conversation.d.ts +199 -73
  40. package/dist/models/conversation.d.ts.map +1 -1
  41. package/dist/models/department.d.ts +196 -70
  42. package/dist/models/department.d.ts.map +1 -1
  43. package/dist/models/event.d.ts +287 -125
  44. package/dist/models/event.d.ts.map +1 -1
  45. package/dist/models/file.d.ts +389 -263
  46. package/dist/models/file.d.ts.map +1 -1
  47. package/dist/models/folder.d.ts +433 -181
  48. package/dist/models/folder.d.ts.map +1 -1
  49. package/dist/models/labChat.d.ts +209 -83
  50. package/dist/models/labChat.d.ts.map +1 -1
  51. package/dist/models/marketing.d.ts +80 -26
  52. package/dist/models/marketing.d.ts.map +1 -1
  53. package/dist/models/message.d.ts +95 -41
  54. package/dist/models/message.d.ts.map +1 -1
  55. package/dist/models/newtonChat.d.ts +83 -29
  56. package/dist/models/newtonChat.d.ts.map +1 -1
  57. package/dist/models/notification.d.ts +61 -25
  58. package/dist/models/notification.d.ts.map +1 -1
  59. package/dist/models/school.d.ts +349 -133
  60. package/dist/models/school.d.ts.map +1 -1
  61. package/dist/models/section.d.ts +216 -72
  62. package/dist/models/section.d.ts.map +1 -1
  63. package/dist/models/tag.d.ts +156 -48
  64. package/dist/models/tag.d.ts.map +1 -1
  65. package/dist/models/user.d.ts +82 -28
  66. package/dist/models/user.d.ts.map +1 -1
  67. package/dist/models/worksheet.d.ts +1020 -411
  68. package/dist/models/worksheet.d.ts.map +1 -1
  69. package/dist/models/worksheet.js +8 -2
  70. package/dist/models/worksheet.js.map +1 -1
  71. package/dist/pipelines/aiLabChat.d.ts +0 -8
  72. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  73. package/dist/pipelines/aiLabChat.js +50 -67
  74. package/dist/pipelines/aiLabChat.js.map +1 -1
  75. package/dist/pipelines/aiLabChatContract.d.ts +393 -388
  76. package/dist/pipelines/aiLabChatContract.d.ts.map +1 -1
  77. package/dist/pipelines/aiLabChatContract.js +63 -69
  78. package/dist/pipelines/aiLabChatContract.js.map +1 -1
  79. package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
  80. package/dist/pipelines/aiNewtonChat.js +41 -41
  81. package/dist/pipelines/aiNewtonChat.js.map +1 -1
  82. package/dist/pipelines/gradeWorksheet.d.ts +8 -8
  83. package/dist/pipelines/labChatPrompt.d.ts +10 -8
  84. package/dist/pipelines/labChatPrompt.d.ts.map +1 -1
  85. package/dist/pipelines/labChatPrompt.js +47 -67
  86. package/dist/pipelines/labChatPrompt.js.map +1 -1
  87. package/dist/pipelines/pdfToWorksheet.d.ts +2 -2
  88. package/dist/routers/_app.d.ts +2203 -1957
  89. package/dist/routers/_app.d.ts.map +1 -1
  90. package/dist/routers/_app.js +4 -2
  91. package/dist/routers/_app.js.map +1 -1
  92. package/dist/routers/admin.d.ts +13 -13
  93. package/dist/routers/agenda.d.ts +18 -18
  94. package/dist/routers/announcement.d.ts +32 -32
  95. package/dist/routers/assignment.d.ts +335 -335
  96. package/dist/routers/attendance.d.ts +10 -10
  97. package/dist/routers/auth.d.ts +2 -2
  98. package/dist/routers/class.d.ts +147 -147
  99. package/dist/routers/comment.d.ts +22 -22
  100. package/dist/routers/conversation.d.ts +17 -17
  101. package/dist/routers/department.d.ts +34 -34
  102. package/dist/routers/discussion.d.ts +65 -53
  103. package/dist/routers/discussion.d.ts.map +1 -1
  104. package/dist/routers/discussion.js +6 -2
  105. package/dist/routers/discussion.js.map +1 -1
  106. package/dist/routers/embedding.d.ts +69 -0
  107. package/dist/routers/embedding.d.ts.map +1 -0
  108. package/dist/routers/embedding.js +143 -0
  109. package/dist/routers/embedding.js.map +1 -0
  110. package/dist/routers/event.d.ts +39 -39
  111. package/dist/routers/file.d.ts +12 -12
  112. package/dist/routers/folder.d.ts +38 -38
  113. package/dist/routers/labChat.d.ts +19 -19
  114. package/dist/routers/marketing.d.ts +2 -2
  115. package/dist/routers/message.d.ts +13 -13
  116. package/dist/routers/newtonChat.d.ts +4 -4
  117. package/dist/routers/notifications.d.ts +9 -9
  118. package/dist/routers/permissions.d.ts +14 -14
  119. package/dist/routers/school.d.ts +55 -55
  120. package/dist/routers/search.d.ts +22 -1
  121. package/dist/routers/search.d.ts.map +1 -1
  122. package/dist/routers/search.js +9 -3
  123. package/dist/routers/search.js.map +1 -1
  124. package/dist/routers/section.d.ts +4 -4
  125. package/dist/routers/studentProgress.d.ts +1 -1
  126. package/dist/routers/tag.d.ts +7 -7
  127. package/dist/routers/user.d.ts +10 -10
  128. package/dist/routers/worksheet.d.ts +95 -57
  129. package/dist/routers/worksheet.d.ts.map +1 -1
  130. package/dist/routers/worksheet.js +26 -2
  131. package/dist/routers/worksheet.js.map +1 -1
  132. package/dist/routes/aiStream.d.ts +2 -0
  133. package/dist/routes/aiStream.d.ts.map +1 -0
  134. package/dist/routes/aiStream.js +696 -0
  135. package/dist/routes/aiStream.js.map +1 -0
  136. package/dist/seedDatabase.d.ts +2 -2
  137. package/dist/seedDatabase.js +6 -6
  138. package/dist/seedDatabase.js.map +1 -1
  139. package/dist/services/adminInstitution.d.ts +8 -8
  140. package/dist/services/adminRunJob.d.ts +4 -4
  141. package/dist/services/agenda.d.ts +18 -18
  142. package/dist/services/aiChatStream.d.ts +68 -0
  143. package/dist/services/aiChatStream.d.ts.map +1 -0
  144. package/dist/services/aiChatStream.js +157 -0
  145. package/dist/services/aiChatStream.js.map +1 -0
  146. package/dist/services/aiModifierTools.d.ts +32 -0
  147. package/dist/services/aiModifierTools.d.ts.map +1 -0
  148. package/dist/services/aiModifierTools.js +165 -0
  149. package/dist/services/aiModifierTools.js.map +1 -0
  150. package/dist/services/aiProposalTools.d.ts +32 -0
  151. package/dist/services/aiProposalTools.d.ts.map +1 -0
  152. package/dist/services/aiProposalTools.js +207 -0
  153. package/dist/services/aiProposalTools.js.map +1 -0
  154. package/dist/services/aiStream.d.ts +22 -0
  155. package/dist/services/aiStream.d.ts.map +1 -0
  156. package/dist/services/aiStream.js +142 -0
  157. package/dist/services/aiStream.js.map +1 -0
  158. package/dist/services/aiTools.d.ts +16 -0
  159. package/dist/services/aiTools.d.ts.map +1 -0
  160. package/dist/services/aiTools.js +271 -0
  161. package/dist/services/aiTools.js.map +1 -0
  162. package/dist/services/announcement.d.ts +12 -12
  163. package/dist/services/announcement.d.ts.map +1 -1
  164. package/dist/services/announcement.js +7 -2
  165. package/dist/services/announcement.js.map +1 -1
  166. package/dist/services/assignment.d.ts +293 -293
  167. package/dist/services/assignment.d.ts.map +1 -1
  168. package/dist/services/assignment.js +28 -9
  169. package/dist/services/assignment.js.map +1 -1
  170. package/dist/services/attendance.d.ts +10 -10
  171. package/dist/services/auth.d.ts +2 -2
  172. package/dist/services/class.d.ts +138 -138
  173. package/dist/services/class.d.ts.map +1 -1
  174. package/dist/services/class.js +19 -3
  175. package/dist/services/class.js.map +1 -1
  176. package/dist/services/comment.d.ts +22 -22
  177. package/dist/services/conversation.d.ts +17 -17
  178. package/dist/services/department.d.ts +33 -33
  179. package/dist/services/discussion.d.ts +58 -58
  180. package/dist/services/discussion.d.ts.map +1 -1
  181. package/dist/services/discussion.js +8 -8
  182. package/dist/services/discussion.js.map +1 -1
  183. package/dist/services/discussionSummary.d.ts +21 -0
  184. package/dist/services/discussionSummary.d.ts.map +1 -0
  185. package/dist/services/discussionSummary.js +179 -0
  186. package/dist/services/discussionSummary.js.map +1 -0
  187. package/dist/services/embedding.d.ts +67 -0
  188. package/dist/services/embedding.d.ts.map +1 -0
  189. package/dist/services/embedding.js +226 -0
  190. package/dist/services/embedding.js.map +1 -0
  191. package/dist/services/event.d.ts +37 -37
  192. package/dist/services/file.d.ts +24 -24
  193. package/dist/services/folder.d.ts +36 -36
  194. package/dist/services/labChat.d.ts +19 -19
  195. package/dist/services/marketing.d.ts +2 -2
  196. package/dist/services/message.d.ts +11 -11
  197. package/dist/services/message.d.ts.map +1 -1
  198. package/dist/services/message.js +2 -2
  199. package/dist/services/message.js.map +1 -1
  200. package/dist/services/newtonChat.d.ts +4 -4
  201. package/dist/services/notification.d.ts +9 -9
  202. package/dist/services/notification.d.ts.map +1 -1
  203. package/dist/services/permissions.d.ts +14 -14
  204. package/dist/services/school.d.ts +50 -50
  205. package/dist/services/search.d.ts +12 -0
  206. package/dist/services/search.d.ts.map +1 -1
  207. package/dist/services/search.js +120 -2
  208. package/dist/services/search.js.map +1 -1
  209. package/dist/services/section.d.ts +3 -3
  210. package/dist/services/tag.d.ts +4 -4
  211. package/dist/services/user.d.ts +4 -4
  212. package/dist/services/user.d.ts.map +1 -1
  213. package/dist/services/user.js +10 -15
  214. package/dist/services/user.js.map +1 -1
  215. package/dist/services/worksheet.d.ts +60 -48
  216. package/dist/services/worksheet.d.ts.map +1 -1
  217. package/dist/services/worksheet.js +11 -4
  218. package/dist/services/worksheet.js.map +1 -1
  219. package/dist/utils/streamBroadcast.d.ts +11 -0
  220. package/dist/utils/streamBroadcast.d.ts.map +1 -0
  221. package/dist/utils/streamBroadcast.js +32 -0
  222. package/dist/utils/streamBroadcast.js.map +1 -0
  223. package/dist/workers/connection.d.ts +15 -0
  224. package/dist/workers/connection.d.ts.map +1 -1
  225. package/dist/workers/connection.js +20 -2
  226. package/dist/workers/connection.js.map +1 -1
  227. package/dist/workers/index.js +4 -2
  228. package/dist/workers/index.js.map +1 -1
  229. package/dist/workers/processors/createRunJobWorker.d.ts.map +1 -1
  230. package/dist/workers/processors/createRunJobWorker.js +31 -8
  231. package/dist/workers/processors/createRunJobWorker.js.map +1 -1
  232. package/dist/workers/processors/embedContent.d.ts +8 -0
  233. package/dist/workers/processors/embedContent.d.ts.map +1 -0
  234. package/dist/workers/processors/embedContent.js +76 -0
  235. package/dist/workers/processors/embedContent.js.map +1 -0
  236. package/dist/workers/queues.d.ts +6 -0
  237. package/dist/workers/queues.d.ts.map +1 -1
  238. package/dist/workers/queues.js +5 -3
  239. package/dist/workers/queues.js.map +1 -1
  240. package/package.json +5 -1
  241. package/prisma/migrations/20260609215135_init/migration.sql +1412 -0
  242. package/prisma/migrations/20260610130000_add_worksheet_tags/migration.sql +13 -0
  243. package/prisma/schema.prisma +38 -7
  244. package/src/index.ts +4 -0
  245. package/src/lib/config/cors.ts +32 -9
  246. package/src/lib/config/env.ts +6 -0
  247. package/src/lib/googleCloudStorage.ts +21 -0
  248. package/src/lib/prisma.ts +40 -1
  249. package/src/models/class.ts +3 -2
  250. package/src/models/worksheet.ts +5 -2
  251. package/src/pipelines/aiLabChat.ts +54 -79
  252. package/src/pipelines/aiLabChatContract.ts +69 -69
  253. package/src/pipelines/aiNewtonChat.ts +46 -43
  254. package/src/pipelines/labChatPrompt.ts +63 -88
  255. package/src/routers/_app.ts +2 -0
  256. package/src/routers/discussion.ts +7 -0
  257. package/src/routers/embedding.ts +159 -0
  258. package/src/routers/search.ts +10 -1
  259. package/src/routers/worksheet.ts +27 -0
  260. package/src/routes/aiStream.ts +789 -0
  261. package/src/seedDatabase.ts +4 -4
  262. package/src/services/aiChatStream.ts +219 -0
  263. package/src/services/aiModifierTools.ts +188 -0
  264. package/src/services/aiProposalTools.ts +272 -0
  265. package/src/services/aiStream.ts +175 -0
  266. package/src/services/aiTools.ts +290 -0
  267. package/src/services/announcement.ts +7 -0
  268. package/src/services/assignment.ts +28 -7
  269. package/src/services/class.ts +20 -1
  270. package/src/services/discussion.ts +9 -8
  271. package/src/services/discussionSummary.ts +226 -0
  272. package/src/services/embedding.ts +285 -0
  273. package/src/services/message.ts +2 -2
  274. package/src/services/search.ts +138 -0
  275. package/src/services/user.ts +9 -17
  276. package/src/services/worksheet.ts +9 -2
  277. package/src/utils/streamBroadcast.ts +48 -0
  278. package/src/workers/connection.ts +19 -0
  279. package/src/workers/index.ts +2 -0
  280. package/src/workers/processors/createRunJobWorker.ts +32 -6
  281. package/src/workers/processors/embedContent.ts +90 -0
  282. package/src/workers/queues.ts +5 -1
  283. package/tests/unit/pipelines/aiLabChatContract.test.ts +38 -21
  284. package/tests/unit/pipelines/labChatPrompt.test.ts +23 -36
  285. package/prisma/migrations/20250807062924_init/migration.sql +0 -436
  286. package/prisma/migrations/20250919063704_init2/migration.sql +0 -24
  287. package/prisma/migrations/20250920143543_add_profile_fields/migration.sql +0 -15
  288. package/prisma/migrations/20250922165147_add_chat_models/migration.sql +0 -68
  289. package/prisma/migrations/20250922165314_add_chat_roles/migration.sql +0 -5
  290. package/prisma/migrations/20250922171242_refactor_notification_system/migration.sql +0 -20
  291. package/prisma/migrations/20250922172208_add_mentions_system/migration.sql +0 -21
  292. package/prisma/migrations/20250925072732_add_lab_chat_system/migration.sql +0 -25
  293. package/prisma/migrations/20251020151505_add_upload_tracking_fields/migration.sql +0 -57
  294. package/prisma/migrations/20251109122857_annuoncements_comments/migration.sql +0 -30
  295. package/prisma/migrations/20251109135555_reactions_announcements_comments/migration.sql +0 -35
  296. package/prisma/migrations/20260410124000_add_submission_recommendation_state/migration.sql +0 -14
  297. package/prisma/migrations/20260415103500_add_session_type/migration.sql +0 -33
  298. package/prisma/migrations/20260415133000_add_ai_usage_logs/migration.sql +0 -19
  299. package/prisma/migrations/20260417083000_add_conversation_member_hidden_at/migration.sql +0 -2
  300. package/prisma/migrations/20260502230500_assignment_submission_comments/migration.sql +0 -60
  301. package/prisma/migrations/20260504071500_school_memberships/migration.sql +0 -81
  302. package/prisma/migrations/20260506162500_consolidate_run_job_and_unify_job_status/migration.sql +0 -166
  303. package/prisma/migrations/20260509220000_institution_overhaul/migration.sql +0 -184
  304. package/prisma/migrations/20260510170000_add_folder_permissions/migration.sql +0 -93
  305. package/prisma/migrations/20260510183000_add_discussions/migration.sql +0 -151
  306. /package/.github/workflows/{npm.yml → npm-publish.yml} +0 -0
@@ -0,0 +1,696 @@
1
+ /**
2
+ * Express routes for AI streaming endpoints.
3
+ * These routes use the Vercel AI SDK's data stream protocol for streaming AI responses.
4
+ */
5
+
6
+ !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]="de32fb67-392b-5976-8b7c-1098752de11a")}catch(e){}}();
7
+ import { Router } from 'express';
8
+ import { streamText, stepCountIs } from 'ai';
9
+ import { openai } from '@ai-sdk/openai';
10
+ import { findUserBySessionToken } from '../models/auth.js';
11
+ import { prisma } from '../lib/prisma.js';
12
+ import { logger } from '../utils/logger.js';
13
+ import { aiTools } from '../services/aiTools.js';
14
+ import { assignmentModifierTools, worksheetModifierTools, } from '../services/aiModifierTools.js';
15
+ import { getRedis } from '../lib/redis.js';
16
+ import { pusher, chatChannel, teacherChannel } from '../lib/pusher.js';
17
+ import { getAIUserId, ensureAIUserExists, isAIUser } from '../utils/aiUser.js';
18
+ import { JobStatus } from '@prisma/client';
19
+ import { updateAssignmentRecord } from '../services/assignment.js';
20
+ import { updateWorksheetRecord } from '../services/worksheet.js';
21
+ export const aiStreamRouter = Router();
22
+ /**
23
+ * Authenticate request from session token (header or cookie).
24
+ */
25
+ async function authenticateRequest(req) {
26
+ const userHeader = req.headers['x-user'];
27
+ const tokenFromUserHeader = typeof userHeader === 'string' ? userHeader : Array.isArray(userHeader) ? userHeader[0] : undefined;
28
+ const authorizationHeader = req.headers.authorization;
29
+ const tokenFromAuthorization = typeof authorizationHeader === 'string' && authorizationHeader.startsWith('Bearer ')
30
+ ? authorizationHeader.slice(7)
31
+ : null;
32
+ const token = tokenFromUserHeader ?? tokenFromAuthorization ?? undefined;
33
+ if (!token)
34
+ return null;
35
+ return findUserBySessionToken(token);
36
+ }
37
+ /**
38
+ * POST /api/ai/chat/stream
39
+ *
40
+ * Streams an AI response for a conversation (Lab Chat or Newton Tutor).
41
+ * Uses the Vercel AI SDK data stream protocol.
42
+ */
43
+ aiStreamRouter.post('/chat/stream', async (req, res) => {
44
+ const user = await authenticateRequest(req);
45
+ if (!user) {
46
+ return res.status(401).json({ error: 'Authentication required' });
47
+ }
48
+ const { conversationId } = req.body;
49
+ if (!conversationId) {
50
+ return res.status(400).json({ error: 'conversationId is required' });
51
+ }
52
+ // Verify user has access to this conversation
53
+ const membership = await prisma.conversationMember.findFirst({
54
+ where: { conversationId, userId: user.id },
55
+ });
56
+ if (!membership) {
57
+ return res.status(403).json({ error: 'Access denied' });
58
+ }
59
+ // Determine context: Lab Chat or Newton Chat
60
+ const labChat = await prisma.labChat.findFirst({
61
+ where: { conversationId },
62
+ select: {
63
+ id: true,
64
+ classId: true,
65
+ context: true,
66
+ class: { select: { subject: true, name: true } },
67
+ },
68
+ });
69
+ const newtonChat = !labChat ? await prisma.newtonChat.findFirst({
70
+ where: { conversationId },
71
+ select: {
72
+ id: true,
73
+ submission: {
74
+ select: {
75
+ id: true,
76
+ assignment: {
77
+ select: {
78
+ title: true,
79
+ instructions: true,
80
+ aiPolicyLevel: true,
81
+ class: { select: { id: true, subject: true, name: true } },
82
+ },
83
+ },
84
+ },
85
+ },
86
+ },
87
+ }) : null;
88
+ if (!labChat && !newtonChat) {
89
+ return res.status(404).json({ error: 'No AI chat context found for this conversation' });
90
+ }
91
+ const classId = labChat?.classId || newtonChat?.submission.assignment.class.id;
92
+ if (!classId) {
93
+ return res.status(400).json({ error: 'Unable to determine class context' });
94
+ }
95
+ // Build message history from DB
96
+ const recentMessages = await prisma.message.findMany({
97
+ where: { conversationId, status: { not: JobStatus.PENDING } },
98
+ orderBy: { createdAt: 'asc' },
99
+ take: 20,
100
+ select: {
101
+ content: true,
102
+ senderId: true,
103
+ status: true,
104
+ },
105
+ });
106
+ const messages = recentMessages
107
+ .filter((m) => m.content && m.content.length > 0)
108
+ .map((m) => ({
109
+ role: isAIUser(m.senderId) ? 'assistant' : 'user',
110
+ content: m.content,
111
+ }));
112
+ // Build system prompt based on context type
113
+ let systemPrompt;
114
+ if (labChat) {
115
+ systemPrompt = buildLabChatSystemPrompt(labChat);
116
+ }
117
+ else {
118
+ systemPrompt = buildNewtonSystemPrompt(newtonChat);
119
+ }
120
+ // Create pending AI message
121
+ await ensureAIUserExists();
122
+ const aiMessage = await prisma.message.create({
123
+ data: {
124
+ content: '',
125
+ senderId: getAIUserId(),
126
+ conversationId,
127
+ status: JobStatus.PENDING,
128
+ },
129
+ });
130
+ // Broadcast pending state
131
+ await pusher.trigger(chatChannel(conversationId), 'new-message', {
132
+ id: aiMessage.id,
133
+ content: '',
134
+ senderId: getAIUserId(),
135
+ conversationId,
136
+ status: JobStatus.PENDING,
137
+ createdAt: aiMessage.createdAt,
138
+ sender: {
139
+ id: getAIUserId(),
140
+ username: 'Newton_AI',
141
+ profile: { displayName: 'Newton AI', profilePicture: null },
142
+ },
143
+ mentionedUserIds: [],
144
+ meta: {},
145
+ attachments: [],
146
+ });
147
+ // Cache stream content in Redis
148
+ const redis = getRedis();
149
+ const streamKey = `stream:${aiMessage.id}`;
150
+ if (redis) {
151
+ await redis.set(streamKey, '', 'EX', 600);
152
+ }
153
+ try {
154
+ const tools = aiTools(classId);
155
+ const result = streamText({
156
+ model: openai('gpt-4o-mini'),
157
+ system: systemPrompt,
158
+ messages,
159
+ tools,
160
+ temperature: 0.7,
161
+ maxOutputTokens: 4096,
162
+ stopWhen: stepCountIs(5),
163
+ onStepFinish: async ({ toolCalls }) => {
164
+ // Broadcast tool call events so all clients can show inline indicators
165
+ if (toolCalls && toolCalls.length > 0) {
166
+ for (const tc of toolCalls) {
167
+ await pusher.trigger(chatChannel(conversationId), 'message-tool-call', {
168
+ messageId: aiMessage.id,
169
+ toolName: tc.toolName,
170
+ status: 'executing',
171
+ }).catch((err) => {
172
+ logger.error('Failed to broadcast tool call event', { err, messageId: aiMessage.id });
173
+ });
174
+ }
175
+ }
176
+ },
177
+ onFinish: async ({ text }) => {
178
+ // Persist completed message
179
+ await prisma.message.update({
180
+ where: { id: aiMessage.id },
181
+ data: { content: text, status: JobStatus.COMPLETED },
182
+ });
183
+ // Broadcast final state
184
+ await pusher.trigger(chatChannel(conversationId), 'message-updated', {
185
+ id: aiMessage.id,
186
+ content: text,
187
+ senderId: getAIUserId(),
188
+ conversationId,
189
+ status: JobStatus.COMPLETED,
190
+ sender: {
191
+ id: getAIUserId(),
192
+ username: 'Newton_AI',
193
+ profile: { displayName: 'Newton AI', profilePicture: null },
194
+ },
195
+ mentionedUserIds: [],
196
+ meta: {},
197
+ attachments: [],
198
+ });
199
+ // Clean up Redis
200
+ if (redis) {
201
+ await redis.del(streamKey);
202
+ }
203
+ logger.info('AI stream completed', {
204
+ messageId: aiMessage.id,
205
+ conversationId,
206
+ contentLength: text.length,
207
+ });
208
+ },
209
+ });
210
+ // Stream to the requesting client via SSE and broadcast chunks via Pusher
211
+ // so other participants also see progressive updates.
212
+ res.writeHead(200, {
213
+ 'Content-Type': 'text/plain; charset=utf-8',
214
+ 'Transfer-Encoding': 'chunked',
215
+ 'Cache-Control': 'no-cache',
216
+ Connection: 'keep-alive',
217
+ });
218
+ let chunkBuffer = '';
219
+ for await (const chunk of result.textStream) {
220
+ // Write to HTTP response for the requesting client
221
+ res.write(chunk);
222
+ // Buffer chunks to avoid excessive Pusher calls (batch per ~100 chars)
223
+ chunkBuffer += chunk;
224
+ if (chunkBuffer.length >= 100) {
225
+ await pusher.trigger(chatChannel(conversationId), 'message-stream-chunk', {
226
+ messageId: aiMessage.id,
227
+ chunk: chunkBuffer,
228
+ }).catch(() => { });
229
+ // Update Redis cache
230
+ if (redis) {
231
+ await redis.append(streamKey, chunkBuffer);
232
+ }
233
+ chunkBuffer = '';
234
+ }
235
+ }
236
+ // Flush remaining buffer
237
+ if (chunkBuffer.length > 0) {
238
+ await pusher.trigger(chatChannel(conversationId), 'message-stream-chunk', {
239
+ messageId: aiMessage.id,
240
+ chunk: chunkBuffer,
241
+ }).catch(() => { });
242
+ if (redis) {
243
+ await redis.append(streamKey, chunkBuffer);
244
+ }
245
+ }
246
+ res.end();
247
+ }
248
+ catch (error) {
249
+ logger.error('AI stream error', { error, conversationId, messageId: aiMessage.id });
250
+ await prisma.message.update({
251
+ where: { id: aiMessage.id },
252
+ data: { status: JobStatus.FAILED },
253
+ });
254
+ if (redis) {
255
+ await redis.del(streamKey);
256
+ }
257
+ if (!res.headersSent) {
258
+ return res.status(500).json({ error: 'Stream failed' });
259
+ }
260
+ }
261
+ });
262
+ /**
263
+ * POST /api/ai/modify/stream
264
+ *
265
+ * Streams AI modifications to an assignment or worksheet.
266
+ * Uses ordered-key output format for progressive rendering.
267
+ */
268
+ aiStreamRouter.post('/modify/stream', async (req, res) => {
269
+ const user = await authenticateRequest(req);
270
+ if (!user) {
271
+ return res.status(401).json({ error: 'Authentication required' });
272
+ }
273
+ const { assignmentId, worksheetId, prompt, classId } = req.body;
274
+ if (!prompt || !classId) {
275
+ return res.status(400).json({ error: 'prompt and classId are required' });
276
+ }
277
+ if (!assignmentId && !worksheetId) {
278
+ return res.status(400).json({ error: 'Either assignmentId or worksheetId is required' });
279
+ }
280
+ // Verify teacher access to class
281
+ const teacherInClass = await prisma.class.findFirst({
282
+ where: {
283
+ id: classId,
284
+ teachers: { some: { id: user.id } },
285
+ },
286
+ });
287
+ if (!teacherInClass) {
288
+ return res.status(403).json({ error: 'Teacher access required' });
289
+ }
290
+ // Build context from existing resource and snapshot pre-edit state for undo
291
+ let resourceContext;
292
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
293
+ let preEditSnapshot = null;
294
+ if (assignmentId) {
295
+ const assignment = await prisma.assignment.findUnique({
296
+ where: { id: assignmentId },
297
+ include: {
298
+ markScheme: { select: { structured: true } },
299
+ gradingBoundary: { select: { structured: true } },
300
+ section: { select: { name: true } },
301
+ worksheets: { include: { questions: { orderBy: { order: 'asc' } } } },
302
+ },
303
+ });
304
+ if (!assignment) {
305
+ return res.status(404).json({ error: 'Assignment not found' });
306
+ }
307
+ resourceContext = buildAssignmentContext(assignment);
308
+ preEditSnapshot = {
309
+ title: assignment.title,
310
+ instructions: assignment.instructions,
311
+ dueDate: assignment.dueDate?.toISOString() ?? null,
312
+ type: assignment.type,
313
+ maxGrade: assignment.maxGrade,
314
+ graded: assignment.graded,
315
+ weight: assignment.weight,
316
+ acceptFiles: assignment.acceptFiles,
317
+ acceptExtendedResponse: assignment.acceptExtendedResponse,
318
+ acceptWorksheet: assignment.acceptWorksheet,
319
+ aiPolicyLevel: assignment.aiPolicyLevel,
320
+ modifiedAt: assignment.modifiedAt?.toISOString() ?? null,
321
+ };
322
+ }
323
+ else {
324
+ const worksheet = await prisma.worksheet.findUnique({
325
+ where: { id: worksheetId },
326
+ include: { questions: { orderBy: { order: 'asc' } } },
327
+ });
328
+ if (!worksheet) {
329
+ return res.status(404).json({ error: 'Worksheet not found' });
330
+ }
331
+ resourceContext = buildWorksheetContext(worksheet);
332
+ preEditSnapshot = {
333
+ name: worksheet.name,
334
+ };
335
+ }
336
+ const systemPrompt = buildModifierSystemPrompt(assignmentId ? 'assignment' : 'worksheet');
337
+ const messages = [
338
+ {
339
+ role: 'user',
340
+ content: `Here is the current ${assignmentId ? 'assignment' : 'worksheet'}:\n\n${resourceContext}\n\nTeacher's request: ${prompt}`,
341
+ },
342
+ ];
343
+ const resourceId = assignmentId || worksheetId;
344
+ const resourceType = assignmentId ? 'assignment' : 'worksheet';
345
+ const redis = getRedis();
346
+ const modifyingKey = `aiModifying:${resourceId}`;
347
+ const channel = teacherChannel(classId);
348
+ // Set Redis flag so other users know a modification is in progress
349
+ if (redis) {
350
+ await redis.set(modifyingKey, JSON.stringify({
351
+ userId: user.id,
352
+ username: user.username,
353
+ resourceType,
354
+ resourceId,
355
+ startedAt: Date.now(),
356
+ }), 'EX', 300); // 5 min TTL safety net
357
+ // Store pre-edit snapshot for undo (30 min TTL)
358
+ if (preEditSnapshot) {
359
+ const snapshotKey = `aiModifierSnapshot:${resourceId}`;
360
+ await redis.set(snapshotKey, JSON.stringify({
361
+ resourceType,
362
+ resourceId,
363
+ classId,
364
+ userId: user.id,
365
+ snapshot: preEditSnapshot,
366
+ createdAt: Date.now(),
367
+ }), 'EX', 1800);
368
+ }
369
+ }
370
+ // Broadcast modifier-started to all teachers in the class
371
+ await pusher.trigger(channel, 'modifier-started', {
372
+ resourceId,
373
+ resourceType,
374
+ userId: user.id,
375
+ username: user.username,
376
+ }).catch((err) => {
377
+ logger.error('Failed to broadcast modifier-started', { err, resourceId });
378
+ });
379
+ // Stream to requesting client via SSE; field changes are persisted by the
380
+ // tools and broadcast to all open editors so they update live + stay locked.
381
+ res.writeHead(200, {
382
+ 'Content-Type': 'text/plain; charset=utf-8',
383
+ 'Transfer-Encoding': 'chunked',
384
+ 'Cache-Control': 'no-cache',
385
+ Connection: 'keep-alive',
386
+ });
387
+ // Called after a tool persists a single field — apply it live everywhere.
388
+ const onFieldUpdate = async (update) => {
389
+ await pusher.trigger(channel, 'modifier-field-updated', {
390
+ resourceId,
391
+ resourceType,
392
+ field: update.field,
393
+ value: update.value,
394
+ label: update.label,
395
+ }).catch((err) => {
396
+ logger.warn('Failed to broadcast modifier-field-updated', { err, resourceId });
397
+ });
398
+ // Human-readable progress for the requesting client's bar.
399
+ res.write(`Updated ${update.label}.\n`);
400
+ };
401
+ try {
402
+ const tools = {
403
+ ...aiTools(classId),
404
+ ...(assignmentId
405
+ ? assignmentModifierTools(user.id, assignmentId, classId, onFieldUpdate)
406
+ : worksheetModifierTools(worksheetId, user.id, onFieldUpdate)),
407
+ };
408
+ const result = streamText({
409
+ model: openai('gpt-4o-mini'),
410
+ system: systemPrompt,
411
+ messages,
412
+ tools,
413
+ temperature: 0.3,
414
+ maxOutputTokens: 8192,
415
+ stopWhen: stepCountIs(8),
416
+ });
417
+ // Drain the stream. Field writes happen inside the tools' execute (which
418
+ // call onFieldUpdate); any narration text is forwarded to the SSE client.
419
+ for await (const chunk of result.textStream) {
420
+ res.write(chunk);
421
+ }
422
+ // Clean up Redis flag and broadcast completion
423
+ if (redis) {
424
+ await redis.del(modifyingKey);
425
+ }
426
+ await pusher.trigger(channel, 'modifier-complete', {
427
+ resourceId,
428
+ resourceType,
429
+ canUndo: Boolean(redis && preEditSnapshot),
430
+ }).catch(() => { });
431
+ res.end();
432
+ }
433
+ catch (error) {
434
+ logger.error('AI modify stream error', { error, assignmentId, worksheetId });
435
+ // Clean up Redis flag and broadcast failure
436
+ if (redis) {
437
+ await redis.del(modifyingKey);
438
+ }
439
+ await pusher.trigger(channel, 'modifier-failed', {
440
+ resourceId,
441
+ resourceType,
442
+ }).catch(() => { });
443
+ if (!res.headersSent) {
444
+ return res.status(500).json({ error: 'Stream failed' });
445
+ }
446
+ }
447
+ });
448
+ /**
449
+ * GET /api/ai/modify/status?resourceId=xxx
450
+ *
451
+ * Returns whether an AI modification is currently in progress for a resource.
452
+ */
453
+ aiStreamRouter.get('/modify/status', async (req, res) => {
454
+ const user = await authenticateRequest(req);
455
+ if (!user) {
456
+ return res.status(401).json({ error: 'Authentication required' });
457
+ }
458
+ const { resourceId } = req.query;
459
+ if (!resourceId) {
460
+ return res.status(400).json({ error: 'resourceId is required' });
461
+ }
462
+ const redis = getRedis();
463
+ if (!redis) {
464
+ return res.json({ isModifying: false });
465
+ }
466
+ const data = await redis.get(`aiModifying:${resourceId}`);
467
+ if (!data) {
468
+ return res.json({ isModifying: false });
469
+ }
470
+ try {
471
+ const parsed = JSON.parse(data);
472
+ return res.json({
473
+ isModifying: true,
474
+ userId: parsed.userId,
475
+ username: parsed.username,
476
+ resourceType: parsed.resourceType,
477
+ startedAt: parsed.startedAt,
478
+ });
479
+ }
480
+ catch {
481
+ return res.json({ isModifying: false });
482
+ }
483
+ });
484
+ /**
485
+ * POST /api/ai/modify/undo
486
+ *
487
+ * Reverts the most recent AI modifier run for a resource by restoring the
488
+ * pre-edit snapshot stored in Redis.
489
+ */
490
+ aiStreamRouter.post('/modify/undo', async (req, res) => {
491
+ const user = await authenticateRequest(req);
492
+ if (!user) {
493
+ return res.status(401).json({ error: 'Authentication required' });
494
+ }
495
+ const { resourceId, classId } = req.body;
496
+ if (!resourceId || !classId) {
497
+ return res.status(400).json({ error: 'resourceId and classId are required' });
498
+ }
499
+ // Verify teacher access to class
500
+ const teacherInClass = await prisma.class.findFirst({
501
+ where: {
502
+ id: classId,
503
+ teachers: { some: { id: user.id } },
504
+ },
505
+ });
506
+ if (!teacherInClass) {
507
+ return res.status(403).json({ error: 'Teacher access required' });
508
+ }
509
+ const redis = getRedis();
510
+ if (!redis) {
511
+ return res.status(400).json({ error: 'Undo not available' });
512
+ }
513
+ const snapshotKey = `aiModifierSnapshot:${resourceId}`;
514
+ const raw = await redis.get(snapshotKey);
515
+ if (!raw) {
516
+ return res.status(404).json({ error: 'No undo snapshot available (may have expired)' });
517
+ }
518
+ let parsed;
519
+ try {
520
+ parsed = JSON.parse(raw);
521
+ }
522
+ catch {
523
+ return res.status(500).json({ error: 'Corrupt snapshot data' });
524
+ }
525
+ try {
526
+ if (parsed.resourceType === 'assignment') {
527
+ const { modifiedAt: _modifiedAt, ...fields } = parsed.snapshot;
528
+ await updateAssignmentRecord(user.id, {
529
+ id: resourceId,
530
+ classId,
531
+ ...fields,
532
+ });
533
+ }
534
+ else {
535
+ await updateWorksheetRecord(resourceId, user.id, parsed.snapshot);
536
+ }
537
+ // Remove the snapshot so undo can only be used once
538
+ await redis.del(snapshotKey);
539
+ // Broadcast undo to all teachers so they refresh
540
+ const channel = teacherChannel(classId);
541
+ await pusher.trigger(channel, 'modifier-undone', {
542
+ resourceId,
543
+ resourceType: parsed.resourceType,
544
+ }).catch((err) => {
545
+ logger.warn('Failed to broadcast modifier-undone', { err, resourceId });
546
+ });
547
+ return res.json({ success: true });
548
+ }
549
+ catch (error) {
550
+ logger.error('AI modifier undo failed', { error, resourceId });
551
+ return res.status(500).json({ error: 'Failed to undo changes' });
552
+ }
553
+ });
554
+ // --- Helper functions ---
555
+ function buildLabChatSystemPrompt(labChat) {
556
+ return `You are Newton AI, an intelligent teaching assistant for the class "${labChat.class?.name}" (subject: ${labChat.class?.subject}).
557
+
558
+ You help teachers create course materials, plan lessons, and manage their class. You have access to tools that let you search through class materials, assignments, and student data.
559
+
560
+ ${labChat.context ? `Context: ${labChat.context}` : ''}
561
+
562
+ IMPORTANT RULES:
563
+ - Use plain text responses (no markdown formatting)
564
+ - Be conversational and helpful
565
+ - When you need information about class materials or assignments, use the search_materials or search_assignments tools
566
+ - Only ask clarifying questions about content/pedagogy, never about technical details
567
+ - Make reasonable defaults for colors, formats, and technical choices
568
+ - Keep responses concise (2-4 sentences for chat, longer for content creation)`;
569
+ }
570
+ function buildNewtonSystemPrompt(newtonChat) {
571
+ const { assignment } = newtonChat.submission;
572
+ const policyLevel = assignment.aiPolicyLevel || 2;
573
+ return `You are Newton, an AI tutor helping a student with their assignment.
574
+
575
+ Assignment: ${assignment.title}
576
+ Subject: ${assignment.class.subject}
577
+ Class: ${assignment.class.name}
578
+ Instructions: ${assignment.instructions || 'No specific instructions provided'}
579
+ AI Policy Level: ${policyLevel}/5
580
+
581
+ Your role:
582
+ - Help the student understand concepts related to their assignment
583
+ - Guide them through problems without giving direct answers
584
+ - Use the search_materials tool to find relevant class materials when helpful
585
+ - Encourage critical thinking and self-discovery
586
+ - Be supportive, clear, and educational
587
+ - Use plain text only (no markdown)
588
+
589
+ AI Policy Level ${policyLevel} means:
590
+ ${policyLevel <= 2 ? '- Very limited AI assistance. Do NOT provide direct answers or solutions. Only clarify concepts and ask guiding questions.' : ''}
591
+ ${policyLevel === 3 ? '- Moderate AI assistance. You can explain concepts and give examples, but avoid solving the exact assignment problems.' : ''}
592
+ ${policyLevel >= 4 ? '- Extended AI assistance allowed. You can provide detailed explanations, examples, and guided solutions.' : ''}
593
+
594
+ NEVER provide complete answers regardless of policy level. Always guide learning.`;
595
+ }
596
+ function buildModifierSystemPrompt(resourceType) {
597
+ if (resourceType === 'assignment') {
598
+ return `You are an AI assistant that edits an existing assignment based on teacher instructions.
599
+
600
+ HOW TO EDIT: You change the assignment by CALLING TOOLS — never by printing the new value as text.
601
+ - Call \`update_assignment_field\` once for every individual field the teacher wants changed.
602
+ Editable fields: title, instructions, dueDate, type, maxGrade, graded, weight,
603
+ acceptFiles, acceptExtendedResponse, acceptWorksheet, aiPolicyLevel.
604
+ - Pass the value as a string: booleans as "true"/"false", numbers as digits,
605
+ dueDate as an ISO 8601 datetime, and type as one of
606
+ HOMEWORK|QUIZ|TEST|PROJECT|ESSAY|DISCUSSION|PRESENTATION|LAB|OTHER.
607
+ - Use \`search_materials\` first only if you need class context.
608
+
609
+ RULES:
610
+ - Only change the fields the teacher actually asked about. Do not touch anything else.
611
+ - Each tool call saves immediately and is shown to the teacher live.
612
+ - After making all the changes, write a brief conversational summary of what you did.
613
+ Be human and specific — mention the actual values. For example:
614
+ "Alright, I've changed the title to 'Chapter 5 Review' and pushed the due date to next Friday."
615
+ Do NOT dump the full assignment contents — just summarize the changes naturally.`;
616
+ }
617
+ return `You are an AI assistant that edits an existing worksheet based on teacher instructions.
618
+
619
+ HOW TO EDIT: You change the worksheet by CALLING TOOLS — never by printing the new value as text.
620
+ - Call \`update_worksheet_field\` for the field the teacher wants changed (currently: name).
621
+ - Use \`search_materials\` first only if you need class context.
622
+
623
+ RULES:
624
+ - Only change what the teacher asked about.
625
+ - Each tool call saves immediately and is shown to the teacher live.
626
+ - After making all the changes, write a brief conversational summary of what you did.
627
+ Be human and specific — mention the actual values. For example:
628
+ "Done — I've renamed the worksheet to 'Unit 3 Practice Problems'."
629
+ Do NOT dump the full worksheet contents — just summarize naturally.`;
630
+ }
631
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
632
+ function buildAssignmentContext(assignment) {
633
+ let context = `TITLE: ${assignment.title}
634
+ INSTRUCTIONS: ${assignment.instructions || '(none)'}
635
+ DUE_DATE: ${assignment.dueDate.toISOString()}
636
+ TYPE: ${assignment.type}
637
+ MAX_GRADE: ${assignment.maxGrade ?? 100}
638
+ SECTION: ${assignment.section?.name || 'NONE'}
639
+ ACCEPT_FILES: ${assignment.acceptFiles}
640
+ ACCEPT_EXTENDED_RESPONSE: ${assignment.acceptExtendedResponse}
641
+ ACCEPT_WORKSHEET: ${assignment.acceptWorksheet}`;
642
+ if (assignment.markScheme) {
643
+ try {
644
+ const ms = JSON.parse(assignment.markScheme.structured);
645
+ context += '\nMARK_SCHEME_START';
646
+ if (Array.isArray(ms.criteria)) {
647
+ for (const c of ms.criteria) {
648
+ context += `\nMS_CRITERION: ${c.description} | POINTS: ${c.points}`;
649
+ }
650
+ }
651
+ context += '\nMARK_SCHEME_END';
652
+ }
653
+ catch { /* ignore malformed JSON */ }
654
+ }
655
+ if (assignment.worksheets && assignment.worksheets.length > 0) {
656
+ context += '\nQUESTIONS_START';
657
+ for (const ws of assignment.worksheets) {
658
+ for (const q of ws.questions) {
659
+ context += `\nQ_TYPE: ${q.type}`;
660
+ context += `\nQ_TEXT: ${q.question}`;
661
+ context += `\nQ_ANSWER: ${q.answer}`;
662
+ if (q.options && typeof q.options === 'object') {
663
+ const opts = Array.isArray(q.options) ? q.options : q.options.options;
664
+ if (Array.isArray(opts)) {
665
+ context += `\nQ_OPTIONS: ${opts.map((o) => typeof o === 'string' ? o : o.text || '').join('|')}`;
666
+ }
667
+ }
668
+ context += `\nQ_POINTS: ${q.points}`;
669
+ context += '\n---';
670
+ }
671
+ }
672
+ context += '\nQUESTIONS_END';
673
+ }
674
+ return context;
675
+ }
676
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
677
+ function buildWorksheetContext(worksheet) {
678
+ let context = `WORKSHEET_NAME: ${worksheet.name}\nQUESTIONS_START`;
679
+ for (const q of worksheet.questions) {
680
+ context += `\nQ_TYPE: ${q.type}`;
681
+ context += `\nQ_TEXT: ${q.question}`;
682
+ context += `\nQ_ANSWER: ${q.answer}`;
683
+ if (q.options && typeof q.options === 'object') {
684
+ const opts = Array.isArray(q.options) ? q.options : q.options.options;
685
+ if (Array.isArray(opts)) {
686
+ context += `\nQ_OPTIONS: ${opts.map((o) => typeof o === 'string' ? o : o.text || '').join('|')}`;
687
+ }
688
+ }
689
+ context += `\nQ_POINTS: ${q.points}`;
690
+ context += '\n---';
691
+ }
692
+ context += '\nQUESTIONS_END';
693
+ return context;
694
+ }
695
+ //# sourceMappingURL=aiStream.js.map
696
+ //# debugId=de32fb67-392b-5976-8b7c-1098752de11a