@studious-lms/server 1.13.0 → 1.15.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 (375) 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/instrument.js +3 -3
  8. package/dist/instrument.js.map +1 -1
  9. package/dist/lib/config/cors.d.ts.map +1 -1
  10. package/dist/lib/config/cors.js +31 -11
  11. package/dist/lib/config/cors.js.map +1 -1
  12. package/dist/lib/config/env.d.ts +17 -0
  13. package/dist/lib/config/env.d.ts.map +1 -1
  14. package/dist/lib/config/env.js +8 -2
  15. package/dist/lib/config/env.js.map +1 -1
  16. package/dist/lib/googleCloudStorage.d.ts +10 -0
  17. package/dist/lib/googleCloudStorage.d.ts.map +1 -1
  18. package/dist/lib/googleCloudStorage.js +22 -2
  19. package/dist/lib/googleCloudStorage.js.map +1 -1
  20. package/dist/lib/inferDocumentFormat.d.ts +10 -0
  21. package/dist/lib/inferDocumentFormat.d.ts.map +1 -0
  22. package/dist/lib/inferDocumentFormat.js +48 -0
  23. package/dist/lib/inferDocumentFormat.js.map +1 -0
  24. package/dist/lib/legal.d.ts +3 -0
  25. package/dist/lib/legal.d.ts.map +1 -0
  26. package/dist/lib/legal.js +6 -0
  27. package/dist/lib/legal.js.map +1 -0
  28. package/dist/lib/prisma.d.ts +104 -5
  29. package/dist/lib/prisma.d.ts.map +1 -1
  30. package/dist/lib/prisma.js +33 -2
  31. package/dist/lib/prisma.js.map +1 -1
  32. package/dist/models/agenda.d.ts +20 -20
  33. package/dist/models/announcement.d.ts +211 -101
  34. package/dist/models/announcement.d.ts.map +1 -1
  35. package/dist/models/assignment.d.ts +560 -362
  36. package/dist/models/assignment.d.ts.map +1 -1
  37. package/dist/models/attendance.d.ts +197 -71
  38. package/dist/models/attendance.d.ts.map +1 -1
  39. package/dist/models/auth.d.ts +356 -114
  40. package/dist/models/auth.d.ts.map +1 -1
  41. package/dist/models/auth.js +4 -2
  42. package/dist/models/auth.js.map +1 -1
  43. package/dist/models/class.d.ts +327 -201
  44. package/dist/models/class.d.ts.map +1 -1
  45. package/dist/models/class.js +2 -2
  46. package/dist/models/class.js.map +1 -1
  47. package/dist/models/cloudStorage.d.ts +146 -56
  48. package/dist/models/cloudStorage.d.ts.map +1 -1
  49. package/dist/models/comment.d.ts +262 -110
  50. package/dist/models/comment.d.ts.map +1 -1
  51. package/dist/models/conversation.d.ts +206 -80
  52. package/dist/models/conversation.d.ts.map +1 -1
  53. package/dist/models/department.d.ts +196 -70
  54. package/dist/models/department.d.ts.map +1 -1
  55. package/dist/models/event.d.ts +305 -141
  56. package/dist/models/event.d.ts.map +1 -1
  57. package/dist/models/file.d.ts +422 -272
  58. package/dist/models/file.d.ts.map +1 -1
  59. package/dist/models/folder.d.ts +433 -181
  60. package/dist/models/folder.d.ts.map +1 -1
  61. package/dist/models/labChat.d.ts +205 -79
  62. package/dist/models/labChat.d.ts.map +1 -1
  63. package/dist/models/marketing.d.ts +79 -38
  64. package/dist/models/marketing.d.ts.map +1 -1
  65. package/dist/models/marketing.js +12 -4
  66. package/dist/models/marketing.js.map +1 -1
  67. package/dist/models/message.d.ts +97 -43
  68. package/dist/models/message.d.ts.map +1 -1
  69. package/dist/models/newtonChat.d.ts +84 -30
  70. package/dist/models/newtonChat.d.ts.map +1 -1
  71. package/dist/models/notification.d.ts +57 -21
  72. package/dist/models/notification.d.ts.map +1 -1
  73. package/dist/models/school.d.ts +349 -133
  74. package/dist/models/school.d.ts.map +1 -1
  75. package/dist/models/section.d.ts +216 -72
  76. package/dist/models/section.d.ts.map +1 -1
  77. package/dist/models/tag.d.ts +156 -48
  78. package/dist/models/tag.d.ts.map +1 -1
  79. package/dist/models/user.d.ts +82 -28
  80. package/dist/models/user.d.ts.map +1 -1
  81. package/dist/models/worksheet.d.ts +1032 -423
  82. package/dist/models/worksheet.d.ts.map +1 -1
  83. package/dist/models/worksheet.js +8 -2
  84. package/dist/models/worksheet.js.map +1 -1
  85. package/dist/pipelines/aiLabChat.d.ts +7 -9
  86. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  87. package/dist/pipelines/aiLabChat.js +194 -125
  88. package/dist/pipelines/aiLabChat.js.map +1 -1
  89. package/dist/pipelines/aiLabChatContract.d.ts +233 -389
  90. package/dist/pipelines/aiLabChatContract.d.ts.map +1 -1
  91. package/dist/pipelines/aiLabChatContract.js +54 -70
  92. package/dist/pipelines/aiLabChatContract.js.map +1 -1
  93. package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
  94. package/dist/pipelines/aiNewtonChat.js +41 -41
  95. package/dist/pipelines/aiNewtonChat.js.map +1 -1
  96. package/dist/pipelines/gradeWorksheet.d.ts +8 -8
  97. package/dist/pipelines/labChatPrompt.d.ts +18 -11
  98. package/dist/pipelines/labChatPrompt.d.ts.map +1 -1
  99. package/dist/pipelines/labChatPrompt.js +93 -75
  100. package/dist/pipelines/labChatPrompt.js.map +1 -1
  101. package/dist/pipelines/pdfToWorksheet.d.ts +1 -1
  102. package/dist/routers/_app.d.ts +2601 -2151
  103. package/dist/routers/_app.d.ts.map +1 -1
  104. package/dist/routers/_app.js +4 -2
  105. package/dist/routers/_app.js.map +1 -1
  106. package/dist/routers/admin.d.ts +13 -13
  107. package/dist/routers/agenda.d.ts +18 -18
  108. package/dist/routers/announcement.d.ts +60 -60
  109. package/dist/routers/assignment.d.ts +393 -383
  110. package/dist/routers/assignment.d.ts.map +1 -1
  111. package/dist/routers/attendance.d.ts +10 -10
  112. package/dist/routers/auth.d.ts +4 -2
  113. package/dist/routers/auth.d.ts.map +1 -1
  114. package/dist/routers/auth.js +15 -2
  115. package/dist/routers/auth.js.map +1 -1
  116. package/dist/routers/class.d.ts +157 -153
  117. package/dist/routers/class.d.ts.map +1 -1
  118. package/dist/routers/comment.d.ts +18 -18
  119. package/dist/routers/conversation.d.ts +11 -11
  120. package/dist/routers/department.d.ts +34 -34
  121. package/dist/routers/discussion.d.ts +68 -56
  122. package/dist/routers/discussion.d.ts.map +1 -1
  123. package/dist/routers/discussion.js +6 -2
  124. package/dist/routers/discussion.js.map +1 -1
  125. package/dist/routers/embedding.d.ts +73 -0
  126. package/dist/routers/embedding.d.ts.map +1 -0
  127. package/dist/routers/embedding.js +151 -0
  128. package/dist/routers/embedding.js.map +1 -0
  129. package/dist/routers/event.d.ts +57 -55
  130. package/dist/routers/event.d.ts.map +1 -1
  131. package/dist/routers/file.d.ts +14 -14
  132. package/dist/routers/folder.d.ts +38 -38
  133. package/dist/routers/labChat.d.ts +16 -16
  134. package/dist/routers/marketing.d.ts +1 -14
  135. package/dist/routers/marketing.d.ts.map +1 -1
  136. package/dist/routers/message.d.ts +13 -13
  137. package/dist/routers/newtonChat.d.ts +4 -4
  138. package/dist/routers/notifications.d.ts +5 -5
  139. package/dist/routers/permissions.d.ts +14 -14
  140. package/dist/routers/school.d.ts +59 -55
  141. package/dist/routers/school.d.ts.map +1 -1
  142. package/dist/routers/search.d.ts +22 -1
  143. package/dist/routers/search.d.ts.map +1 -1
  144. package/dist/routers/search.js +9 -3
  145. package/dist/routers/search.js.map +1 -1
  146. package/dist/routers/section.d.ts +4 -4
  147. package/dist/routers/studentProgress.d.ts +1 -1
  148. package/dist/routers/tag.d.ts +7 -7
  149. package/dist/routers/user.d.ts +99 -10
  150. package/dist/routers/user.d.ts.map +1 -1
  151. package/dist/routers/user.js +19 -2
  152. package/dist/routers/user.js.map +1 -1
  153. package/dist/routers/worksheet.d.ts +93 -55
  154. package/dist/routers/worksheet.d.ts.map +1 -1
  155. package/dist/routers/worksheet.js +26 -2
  156. package/dist/routers/worksheet.js.map +1 -1
  157. package/dist/routes/aiStream.d.ts +2 -0
  158. package/dist/routes/aiStream.d.ts.map +1 -0
  159. package/dist/routes/aiStream.js +726 -0
  160. package/dist/routes/aiStream.js.map +1 -0
  161. package/dist/seedDatabase.d.ts +3 -1
  162. package/dist/seedDatabase.d.ts.map +1 -1
  163. package/dist/seedDatabase.js +6 -6
  164. package/dist/seedDatabase.js.map +1 -1
  165. package/dist/services/adminInstitution.d.ts +8 -8
  166. package/dist/services/adminRunJob.d.ts +4 -4
  167. package/dist/services/agenda.d.ts +18 -18
  168. package/dist/services/aiChatStream.d.ts +88 -0
  169. package/dist/services/aiChatStream.d.ts.map +1 -0
  170. package/dist/services/aiChatStream.js +254 -0
  171. package/dist/services/aiChatStream.js.map +1 -0
  172. package/dist/services/aiModifierTools.d.ts +16 -0
  173. package/dist/services/aiModifierTools.d.ts.map +1 -0
  174. package/dist/services/aiModifierTools.js +274 -0
  175. package/dist/services/aiModifierTools.js.map +1 -0
  176. package/dist/services/aiProposalTools.d.ts +32 -0
  177. package/dist/services/aiProposalTools.d.ts.map +1 -0
  178. package/dist/services/aiProposalTools.js +207 -0
  179. package/dist/services/aiProposalTools.js.map +1 -0
  180. package/dist/services/aiStream.d.ts +22 -0
  181. package/dist/services/aiStream.d.ts.map +1 -0
  182. package/dist/services/aiStream.js +142 -0
  183. package/dist/services/aiStream.js.map +1 -0
  184. package/dist/services/aiTools.d.ts +16 -0
  185. package/dist/services/aiTools.d.ts.map +1 -0
  186. package/dist/services/aiTools.js +271 -0
  187. package/dist/services/aiTools.js.map +1 -0
  188. package/dist/services/announcement.d.ts +42 -42
  189. package/dist/services/announcement.d.ts.map +1 -1
  190. package/dist/services/announcement.js +7 -2
  191. package/dist/services/announcement.js.map +1 -1
  192. package/dist/services/assignment.d.ts +355 -345
  193. package/dist/services/assignment.d.ts.map +1 -1
  194. package/dist/services/assignment.js +30 -9
  195. package/dist/services/assignment.js.map +1 -1
  196. package/dist/services/attendance.d.ts +10 -10
  197. package/dist/services/auth.d.ts +4 -2
  198. package/dist/services/auth.d.ts.map +1 -1
  199. package/dist/services/auth.js +13 -4
  200. package/dist/services/auth.js.map +1 -1
  201. package/dist/services/class.d.ts +146 -142
  202. package/dist/services/class.d.ts.map +1 -1
  203. package/dist/services/class.js +24 -4
  204. package/dist/services/class.js.map +1 -1
  205. package/dist/services/comment.d.ts +18 -18
  206. package/dist/services/conversation.d.ts +11 -11
  207. package/dist/services/department.d.ts +33 -33
  208. package/dist/services/discussion.d.ts +58 -58
  209. package/dist/services/discussion.d.ts.map +1 -1
  210. package/dist/services/discussion.js +15 -8
  211. package/dist/services/discussion.js.map +1 -1
  212. package/dist/services/discussionSummary.d.ts +21 -0
  213. package/dist/services/discussionSummary.d.ts.map +1 -0
  214. package/dist/services/discussionSummary.js +179 -0
  215. package/dist/services/discussionSummary.js.map +1 -0
  216. package/dist/services/embedding.d.ts +85 -0
  217. package/dist/services/embedding.d.ts.map +1 -0
  218. package/dist/services/embedding.js +372 -0
  219. package/dist/services/embedding.js.map +1 -0
  220. package/dist/services/event.d.ts +55 -53
  221. package/dist/services/event.d.ts.map +1 -1
  222. package/dist/services/file.d.ts +28 -28
  223. package/dist/services/folder.d.ts +36 -36
  224. package/dist/services/folder.d.ts.map +1 -1
  225. package/dist/services/folder.js +32 -2
  226. package/dist/services/folder.js.map +1 -1
  227. package/dist/services/labChat.d.ts +16 -16
  228. package/dist/services/marketing.d.ts +2 -15
  229. package/dist/services/marketing.d.ts.map +1 -1
  230. package/dist/services/marketing.js +5 -5
  231. package/dist/services/marketing.js.map +1 -1
  232. package/dist/services/message.d.ts +11 -11
  233. package/dist/services/message.d.ts.map +1 -1
  234. package/dist/services/message.js +2 -2
  235. package/dist/services/message.js.map +1 -1
  236. package/dist/services/newtonChat.d.ts +4 -4
  237. package/dist/services/notification.d.ts +5 -5
  238. package/dist/services/notification.d.ts.map +1 -1
  239. package/dist/services/openaiDocumentGen.d.ts +37 -0
  240. package/dist/services/openaiDocumentGen.d.ts.map +1 -0
  241. package/dist/services/openaiDocumentGen.js +262 -0
  242. package/dist/services/openaiDocumentGen.js.map +1 -0
  243. package/dist/services/permissions.d.ts +14 -14
  244. package/dist/services/school.d.ts +54 -50
  245. package/dist/services/school.d.ts.map +1 -1
  246. package/dist/services/search.d.ts +13 -1
  247. package/dist/services/search.d.ts.map +1 -1
  248. package/dist/services/search.js +145 -2
  249. package/dist/services/search.js.map +1 -1
  250. package/dist/services/section.d.ts +3 -3
  251. package/dist/services/tag.d.ts +4 -4
  252. package/dist/services/user.d.ts +4 -4
  253. package/dist/services/user.d.ts.map +1 -1
  254. package/dist/services/user.js +10 -15
  255. package/dist/services/user.js.map +1 -1
  256. package/dist/services/userPrivacy.d.ts +86 -0
  257. package/dist/services/userPrivacy.d.ts.map +1 -0
  258. package/dist/services/userPrivacy.js +184 -0
  259. package/dist/services/userPrivacy.js.map +1 -0
  260. package/dist/services/worksheet.d.ts +86 -57
  261. package/dist/services/worksheet.d.ts.map +1 -1
  262. package/dist/services/worksheet.js +57 -5
  263. package/dist/services/worksheet.js.map +1 -1
  264. package/dist/utils/email.d.ts.map +1 -1
  265. package/dist/utils/email.js +7 -3
  266. package/dist/utils/email.js.map +1 -1
  267. package/dist/utils/streamBroadcast.d.ts +11 -0
  268. package/dist/utils/streamBroadcast.d.ts.map +1 -0
  269. package/dist/utils/streamBroadcast.js +32 -0
  270. package/dist/utils/streamBroadcast.js.map +1 -0
  271. package/dist/workers/connection.d.ts +15 -0
  272. package/dist/workers/connection.d.ts.map +1 -1
  273. package/dist/workers/connection.js +20 -2
  274. package/dist/workers/connection.js.map +1 -1
  275. package/dist/workers/index.js +4 -2
  276. package/dist/workers/index.js.map +1 -1
  277. package/dist/workers/processors/createRunJobWorker.d.ts.map +1 -1
  278. package/dist/workers/processors/createRunJobWorker.js +31 -8
  279. package/dist/workers/processors/createRunJobWorker.js.map +1 -1
  280. package/dist/workers/processors/embedContent.d.ts +8 -0
  281. package/dist/workers/processors/embedContent.d.ts.map +1 -0
  282. package/dist/workers/processors/embedContent.js +67 -0
  283. package/dist/workers/processors/embedContent.js.map +1 -0
  284. package/dist/workers/queues.d.ts +6 -0
  285. package/dist/workers/queues.d.ts.map +1 -1
  286. package/dist/workers/queues.js +5 -3
  287. package/dist/workers/queues.js.map +1 -1
  288. package/package.json +5 -1
  289. package/prisma/migrations/20260609215135_init/migration.sql +1412 -0
  290. package/prisma/migrations/20260610130000_add_worksheet_tags/migration.sql +13 -0
  291. package/prisma/migrations/20260610140000_expand_embedding_source_types/migration.sql +4 -0
  292. package/prisma/migrations/20260611120000_add_user_terms_acceptance/migration.sql +3 -0
  293. package/prisma/schema.prisma +44 -7
  294. package/src/index.ts +4 -0
  295. package/src/instrument.ts +1 -1
  296. package/src/lib/config/cors.ts +32 -9
  297. package/src/lib/config/env.ts +6 -0
  298. package/src/lib/googleCloudStorage.ts +21 -0
  299. package/src/lib/inferDocumentFormat.ts +58 -0
  300. package/src/lib/legal.ts +2 -0
  301. package/src/lib/prisma.ts +40 -1
  302. package/src/models/auth.ts +4 -0
  303. package/src/models/class.ts +3 -2
  304. package/src/models/marketing.ts +10 -2
  305. package/src/models/worksheet.ts +5 -2
  306. package/src/pipelines/aiLabChat.ts +245 -146
  307. package/src/pipelines/aiLabChatContract.ts +59 -69
  308. package/src/pipelines/aiNewtonChat.ts +46 -43
  309. package/src/pipelines/labChatPrompt.ts +115 -90
  310. package/src/routers/_app.ts +2 -0
  311. package/src/routers/auth.ts +13 -0
  312. package/src/routers/discussion.ts +7 -0
  313. package/src/routers/embedding.ts +164 -0
  314. package/src/routers/search.ts +10 -1
  315. package/src/routers/user.ts +26 -0
  316. package/src/routers/worksheet.ts +27 -0
  317. package/src/routes/aiStream.ts +827 -0
  318. package/src/seedDatabase.ts +4 -4
  319. package/src/services/aiChatStream.ts +341 -0
  320. package/src/services/aiModifierTools.ts +341 -0
  321. package/src/services/aiProposalTools.ts +274 -0
  322. package/src/services/aiStream.ts +175 -0
  323. package/src/services/aiTools.ts +290 -0
  324. package/src/services/announcement.ts +7 -0
  325. package/src/services/assignment.ts +31 -7
  326. package/src/services/auth.ts +14 -2
  327. package/src/services/class.ts +27 -2
  328. package/src/services/discussion.ts +22 -8
  329. package/src/services/discussionSummary.ts +226 -0
  330. package/src/services/embedding.ts +446 -0
  331. package/src/services/folder.ts +31 -0
  332. package/src/services/marketing.ts +3 -3
  333. package/src/services/message.ts +2 -2
  334. package/src/services/openaiDocumentGen.ts +319 -0
  335. package/src/services/search.ts +163 -1
  336. package/src/services/user.ts +9 -17
  337. package/src/services/userPrivacy.ts +196 -0
  338. package/src/services/worksheet.ts +77 -3
  339. package/src/utils/email.ts +5 -1
  340. package/src/utils/streamBroadcast.ts +48 -0
  341. package/src/workers/connection.ts +19 -0
  342. package/src/workers/index.ts +2 -0
  343. package/src/workers/processors/createRunJobWorker.ts +32 -6
  344. package/src/workers/processors/embedContent.ts +86 -0
  345. package/src/workers/queues.ts +5 -1
  346. package/tests/helpers/registerInput.ts +20 -0
  347. package/tests/integration/globalSetup.ts +10 -18
  348. package/tests/integration/routers/auth.test.ts +13 -18
  349. package/tests/unit/lib/inferDocumentFormat.test.ts +33 -0
  350. package/tests/unit/pipelines/aiLabChatContract.test.ts +38 -21
  351. package/tests/unit/pipelines/labChatPrompt.test.ts +23 -36
  352. package/tests/unit/services/aiChatStream.test.ts +92 -0
  353. package/tests/unit/services/openaiDocumentGen.test.ts +65 -0
  354. package/prisma/migrations/20250807062924_init/migration.sql +0 -436
  355. package/prisma/migrations/20250919063704_init2/migration.sql +0 -24
  356. package/prisma/migrations/20250920143543_add_profile_fields/migration.sql +0 -15
  357. package/prisma/migrations/20250922165147_add_chat_models/migration.sql +0 -68
  358. package/prisma/migrations/20250922165314_add_chat_roles/migration.sql +0 -5
  359. package/prisma/migrations/20250922171242_refactor_notification_system/migration.sql +0 -20
  360. package/prisma/migrations/20250922172208_add_mentions_system/migration.sql +0 -21
  361. package/prisma/migrations/20250925072732_add_lab_chat_system/migration.sql +0 -25
  362. package/prisma/migrations/20251020151505_add_upload_tracking_fields/migration.sql +0 -57
  363. package/prisma/migrations/20251109122857_annuoncements_comments/migration.sql +0 -30
  364. package/prisma/migrations/20251109135555_reactions_announcements_comments/migration.sql +0 -35
  365. package/prisma/migrations/20260410124000_add_submission_recommendation_state/migration.sql +0 -14
  366. package/prisma/migrations/20260415103500_add_session_type/migration.sql +0 -33
  367. package/prisma/migrations/20260415133000_add_ai_usage_logs/migration.sql +0 -19
  368. package/prisma/migrations/20260417083000_add_conversation_member_hidden_at/migration.sql +0 -2
  369. package/prisma/migrations/20260502230500_assignment_submission_comments/migration.sql +0 -60
  370. package/prisma/migrations/20260504071500_school_memberships/migration.sql +0 -81
  371. package/prisma/migrations/20260506162500_consolidate_run_job_and_unify_job_status/migration.sql +0 -166
  372. package/prisma/migrations/20260509220000_institution_overhaul/migration.sql +0 -184
  373. package/prisma/migrations/20260510170000_add_folder_permissions/migration.sql +0 -93
  374. package/prisma/migrations/20260510183000_add_discussions/migration.sql +0 -151
  375. /package/.github/workflows/{npm.yml → npm-publish.yml} +0 -0
@@ -0,0 +1,175 @@
1
+ /**
2
+ * AI Streaming service – handles streamed AI responses with tool calls.
3
+ * Uses Vercel AI SDK for streaming and Redis for temporary chunk caching.
4
+ */
5
+ import { streamText, stepCountIs, type ModelMessage } from 'ai';
6
+ import { openai } from '@ai-sdk/openai';
7
+ import { getRedis } from '../lib/redis.js';
8
+ import { prisma } from '../lib/prisma.js';
9
+ import { pusher, chatChannel } from '../lib/pusher.js';
10
+ import { getAIUserId, ensureAIUserExists } from '../utils/aiUser.js';
11
+ import { logger } from '../utils/logger.js';
12
+ import { JobStatus } from '@prisma/client';
13
+ import { aiTools, type ToolCallResult } from './aiTools.js';
14
+
15
+ const STREAM_REDIS_TTL = 600; // 10 minutes TTL for stream keys
16
+ const STREAM_KEY_PREFIX = 'stream:';
17
+
18
+ export interface StreamOptions {
19
+ conversationId: string;
20
+ aiMessageId: string;
21
+ messages: ModelMessage[];
22
+ systemPrompt: string;
23
+ classId?: string;
24
+ enableTools?: boolean;
25
+ model?: string;
26
+ temperature?: number;
27
+ maxOutputTokens?: number;
28
+ }
29
+
30
+ /**
31
+ * Stream an AI response, broadcasting chunks via Pusher and caching in Redis.
32
+ * On completion, persists the full response to the database.
33
+ */
34
+ export async function streamAIResponse(options: StreamOptions): Promise<void> {
35
+ const {
36
+ conversationId,
37
+ aiMessageId,
38
+ messages,
39
+ systemPrompt,
40
+ classId,
41
+ enableTools = true,
42
+ model = 'gpt-4o-mini',
43
+ temperature = 0.7,
44
+ maxOutputTokens = 4096,
45
+ } = options;
46
+
47
+ await ensureAIUserExists();
48
+
49
+ const redis = getRedis();
50
+ const streamKey = `${STREAM_KEY_PREFIX}${aiMessageId}`;
51
+
52
+ // Initialize Redis stream cache
53
+ if (redis) {
54
+ await redis.set(streamKey, '', 'EX', STREAM_REDIS_TTL);
55
+ }
56
+
57
+ let fullContent = '';
58
+ const toolCallResults: ToolCallResult[] = [];
59
+
60
+ try {
61
+ const toolSet = enableTools && classId ? aiTools(classId) : undefined;
62
+
63
+ const result = streamText({
64
+ model: openai(model),
65
+ system: systemPrompt,
66
+ messages,
67
+ tools: toolSet,
68
+ temperature,
69
+ maxOutputTokens,
70
+ stopWhen: stepCountIs(5),
71
+ onStepFinish: async ({ toolCalls, toolResults }) => {
72
+ if (toolCalls && toolCalls.length > 0) {
73
+ for (const tc of toolCalls) {
74
+ try {
75
+ await pusher.trigger(chatChannel(conversationId), 'message-tool-call', {
76
+ messageId: aiMessageId,
77
+ toolName: tc.toolName,
78
+ status: 'executing',
79
+ });
80
+ } catch (err) {
81
+ logger.error('Failed to broadcast tool call event', { err, aiMessageId });
82
+ }
83
+ }
84
+ }
85
+
86
+ if (toolResults) {
87
+ for (const tr of toolResults) {
88
+ toolCallResults.push({
89
+ toolName: tr.toolName,
90
+ result: tr.output,
91
+ });
92
+ }
93
+ }
94
+ },
95
+ });
96
+
97
+ // Stream text chunks to client
98
+ for await (const chunk of result.textStream) {
99
+ fullContent += chunk;
100
+
101
+ // Cache in Redis
102
+ if (redis) {
103
+ await redis.append(streamKey, chunk);
104
+ }
105
+
106
+ // Broadcast chunk to client via Pusher
107
+ try {
108
+ await pusher.trigger(chatChannel(conversationId), 'message-stream-chunk', {
109
+ messageId: aiMessageId,
110
+ chunk,
111
+ accumulated: fullContent.length,
112
+ });
113
+ } catch (err) {
114
+ logger.warn('Failed to broadcast stream chunk', { err, aiMessageId });
115
+ }
116
+ }
117
+
118
+ // Stream complete — persist to DB
119
+ const meta = toolCallResults.length > 0
120
+ ? JSON.stringify({ toolCalls: toolCallResults })
121
+ : undefined;
122
+
123
+ await prisma.message.update({
124
+ where: { id: aiMessageId },
125
+ data: {
126
+ content: fullContent,
127
+ status: JobStatus.COMPLETED,
128
+ ...(meta ? { meta: JSON.parse(meta) } : {}),
129
+ },
130
+ });
131
+
132
+ // Broadcast final message-updated event
133
+ await pusher.trigger(chatChannel(conversationId), 'message-updated', {
134
+ id: aiMessageId,
135
+ content: fullContent,
136
+ senderId: getAIUserId(),
137
+ conversationId,
138
+ status: JobStatus.COMPLETED,
139
+ sender: {
140
+ id: getAIUserId(),
141
+ username: 'Newton_AI',
142
+ profile: { displayName: 'Newton AI', profilePicture: null },
143
+ },
144
+ mentionedUserIds: [],
145
+ meta: meta ? JSON.parse(meta) : {},
146
+ attachments: [],
147
+ });
148
+
149
+ logger.info('AI stream completed', {
150
+ aiMessageId,
151
+ conversationId,
152
+ contentLength: fullContent.length,
153
+ toolCalls: toolCallResults.length,
154
+ });
155
+ } catch (error) {
156
+ logger.error('AI stream failed', { error, aiMessageId, conversationId });
157
+
158
+ await prisma.message.update({
159
+ where: { id: aiMessageId },
160
+ data: { status: JobStatus.FAILED },
161
+ });
162
+
163
+ await pusher.trigger(chatChannel(conversationId), 'message-updated', {
164
+ id: aiMessageId,
165
+ conversationId,
166
+ status: JobStatus.FAILED,
167
+ });
168
+
169
+ throw error;
170
+ } finally {
171
+ if (redis) {
172
+ await redis.del(streamKey);
173
+ }
174
+ }
175
+ }
@@ -0,0 +1,290 @@
1
+ /**
2
+ * AI Tool definitions and execution for agent tool calls.
3
+ * Provides RAG search, assignment lookup, worksheet creation, student progress,
4
+ * and web search tools.
5
+ */
6
+ import { jsonSchema, type ToolSet } from 'ai';
7
+ import OpenAI from 'openai';
8
+ import { searchEmbeddings } from './embedding.js';
9
+ import { prisma } from '../lib/prisma.js';
10
+ import { logger } from '../utils/logger.js';
11
+
12
+ export interface ToolCallResult {
13
+ toolName: string;
14
+ result: unknown;
15
+ }
16
+
17
+ /**
18
+ * Build the tool set for a given class context.
19
+ * Tools are scoped to a specific class for security.
20
+ */
21
+ export function aiTools(classId: string): ToolSet {
22
+ return {
23
+ search_materials: {
24
+ description: 'Search class materials, files, assignments, worksheets, and announcements using semantic similarity. Use this to find relevant context for answering questions.',
25
+ inputSchema: jsonSchema({
26
+ type: 'object',
27
+ properties: {
28
+ query: { type: 'string', description: 'The search query to find relevant materials' },
29
+ limit: { type: 'number', description: 'Maximum results (1-10)', default: 5 },
30
+ },
31
+ required: ['query'],
32
+ }),
33
+ execute: async ({ query, limit = 5 }: { query: string; limit?: number }) => {
34
+ try {
35
+ const results = await searchEmbeddings({ classId, query, limit });
36
+ return results.map((r) => ({
37
+ sourceType: r.sourceType,
38
+ sourceId: r.sourceId,
39
+ content: r.content.slice(0, 500),
40
+ similarity: Math.round(r.similarity * 100) / 100,
41
+ metadata: r.metadata,
42
+ }));
43
+ } catch (error) {
44
+ logger.error('search_materials tool failed', { error, classId, query });
45
+ return [];
46
+ }
47
+ },
48
+ },
49
+
50
+ search_assignments: {
51
+ description: 'Search existing assignments by title, instructions, or type. Returns assignment details including due dates and submission types.',
52
+ inputSchema: jsonSchema({
53
+ type: 'object',
54
+ properties: {
55
+ query: { type: 'string', description: 'Search query for assignments' },
56
+ limit: { type: 'number', description: 'Maximum results (1-10)', default: 5 },
57
+ },
58
+ required: ['query'],
59
+ }),
60
+ execute: async ({ query, limit = 5 }: { query: string; limit?: number }) => {
61
+ try {
62
+ const results = await searchEmbeddings({
63
+ classId,
64
+ query,
65
+ limit,
66
+ sourceTypes: ['ASSIGNMENT'],
67
+ });
68
+
69
+ const assignmentIds = results.map((r) => r.sourceId);
70
+ const assignments = await prisma.assignment.findMany({
71
+ where: { id: { in: assignmentIds } },
72
+ select: {
73
+ id: true,
74
+ title: true,
75
+ instructions: true,
76
+ type: true,
77
+ dueDate: true,
78
+ maxGrade: true,
79
+ section: { select: { name: true } },
80
+ },
81
+ });
82
+
83
+ return assignments.map((a) => ({
84
+ id: a.id,
85
+ title: a.title,
86
+ instructions: a.instructions?.slice(0, 200) ?? null,
87
+ type: a.type,
88
+ dueDate: a.dueDate.toISOString(),
89
+ maxGrade: a.maxGrade,
90
+ section: a.section?.name ?? null,
91
+ }));
92
+ } catch (error) {
93
+ logger.error('search_assignments tool failed', { error, classId, query });
94
+ return [];
95
+ }
96
+ },
97
+ },
98
+
99
+ get_student_progress: {
100
+ description: 'Get progress summary for a specific student in this class, including grades and submission status.',
101
+ inputSchema: jsonSchema({
102
+ type: 'object',
103
+ properties: {
104
+ studentId: { type: 'string', description: 'The student user ID' },
105
+ },
106
+ required: ['studentId'],
107
+ }),
108
+ execute: async ({ studentId }: { studentId: string }) => {
109
+ try {
110
+ const submissions = await prisma.submission.findMany({
111
+ where: {
112
+ studentId,
113
+ assignment: { classId },
114
+ },
115
+ select: {
116
+ id: true,
117
+ gradeReceived: true,
118
+ submittedAt: true,
119
+ assignment: {
120
+ select: {
121
+ title: true,
122
+ maxGrade: true,
123
+ type: true,
124
+ },
125
+ },
126
+ },
127
+ orderBy: { submittedAt: 'desc' },
128
+ take: 20,
129
+ });
130
+
131
+ const totalAssignments = await prisma.assignment.count({
132
+ where: { classId },
133
+ });
134
+
135
+ const submitted = submissions.filter((s) => s.submittedAt);
136
+ const graded = submissions.filter((s) => s.gradeReceived !== null);
137
+ const avgGrade = graded.length > 0
138
+ ? graded.reduce((sum, s) => sum + (s.gradeReceived || 0), 0) / graded.length
139
+ : null;
140
+
141
+ return {
142
+ studentId,
143
+ totalAssignments,
144
+ submittedCount: submitted.length,
145
+ gradedCount: graded.length,
146
+ averageGrade: avgGrade ? Math.round(avgGrade * 10) / 10 : null,
147
+ recentSubmissions: submissions.slice(0, 5).map((s) => ({
148
+ assignment: s.assignment.title,
149
+ gradeReceived: s.gradeReceived,
150
+ maxGrade: s.assignment.maxGrade,
151
+ submittedAt: s.submittedAt?.toISOString() ?? null,
152
+ })),
153
+ };
154
+ } catch (error) {
155
+ logger.error('get_student_progress tool failed', { error, classId, studentId });
156
+ return { error: 'Failed to fetch student progress' };
157
+ }
158
+ },
159
+ },
160
+
161
+ get_worksheet_details: {
162
+ description: 'Get full details of a worksheet including all questions, answers, and mark schemes.',
163
+ inputSchema: jsonSchema({
164
+ type: 'object',
165
+ properties: {
166
+ worksheetId: { type: 'string', description: 'The worksheet ID to look up' },
167
+ },
168
+ required: ['worksheetId'],
169
+ }),
170
+ execute: async ({ worksheetId }: { worksheetId: string }) => {
171
+ try {
172
+ const worksheet = await prisma.worksheet.findUnique({
173
+ where: { id: worksheetId, classId },
174
+ include: {
175
+ questions: {
176
+ orderBy: { order: 'asc' },
177
+ },
178
+ },
179
+ });
180
+
181
+ if (!worksheet) {
182
+ return { error: 'Worksheet not found' };
183
+ }
184
+
185
+ return {
186
+ id: worksheet.id,
187
+ name: worksheet.name,
188
+ questions: worksheet.questions.map((q) => ({
189
+ id: q.id,
190
+ type: q.type,
191
+ question: q.question,
192
+ answer: q.answer,
193
+ options: q.options,
194
+ points: q.points,
195
+ order: q.order,
196
+ })),
197
+ };
198
+ } catch (error) {
199
+ logger.error('get_worksheet_details tool failed', { error, classId, worksheetId });
200
+ return { error: 'Failed to fetch worksheet' };
201
+ }
202
+ },
203
+ },
204
+
205
+ get_mark_scheme: {
206
+ description: 'Get a mark scheme (rubric) by ID, including criteria and point allocations.',
207
+ inputSchema: jsonSchema({
208
+ type: 'object',
209
+ properties: {
210
+ markSchemeId: { type: 'string', description: 'The mark scheme ID' },
211
+ },
212
+ required: ['markSchemeId'],
213
+ }),
214
+ execute: async ({ markSchemeId }: { markSchemeId: string }) => {
215
+ try {
216
+ const markScheme = await prisma.markScheme.findUnique({
217
+ where: { id: markSchemeId },
218
+ select: { id: true, name: true, structured: true },
219
+ });
220
+
221
+ if (!markScheme) {
222
+ return { error: 'Mark scheme not found' };
223
+ }
224
+
225
+ let parsed: unknown = {};
226
+ try { parsed = JSON.parse(markScheme.structured || '{}'); } catch { /* noop */ }
227
+
228
+ return {
229
+ id: markScheme.id,
230
+ name: markScheme.name,
231
+ structured: parsed,
232
+ };
233
+ } catch (error) {
234
+ logger.error('get_mark_scheme tool failed', { error, classId, markSchemeId });
235
+ return { error: 'Failed to fetch mark scheme' };
236
+ }
237
+ },
238
+ },
239
+
240
+ web_search: {
241
+ description: 'Search the web for current information on a topic. Use this when you need up-to-date facts, definitions, explanations, or context beyond the class materials.',
242
+ inputSchema: jsonSchema({
243
+ type: 'object',
244
+ properties: {
245
+ query: { type: 'string', description: 'The search query to look up on the web' },
246
+ },
247
+ required: ['query'],
248
+ }),
249
+ execute: async ({ query }: { query: string }) => {
250
+ try {
251
+ const client = new OpenAI();
252
+ const response = await client.responses.create({
253
+ model: 'gpt-4o-mini',
254
+ tools: [{ type: 'web_search' }],
255
+ input: query,
256
+ });
257
+
258
+ const messageOutput = response.output.find(
259
+ (item) => item.type === 'message',
260
+ );
261
+ if (!messageOutput || messageOutput.type !== 'message') {
262
+ return { summary: 'No results found.', sources: [] };
263
+ }
264
+
265
+ const textContent = messageOutput.content.find(
266
+ (c) => c.type === 'output_text',
267
+ );
268
+ if (!textContent || textContent.type !== 'output_text') {
269
+ return { summary: 'No results found.', sources: [] };
270
+ }
271
+
272
+ const sources = textContent.annotations
273
+ .filter(
274
+ (a): a is Extract<typeof a, { type: 'url_citation' }> =>
275
+ a.type === 'url_citation',
276
+ )
277
+ .map((a) => ({ url: a.url, title: a.title }));
278
+
279
+ return {
280
+ summary: textContent.text.slice(0, 2000),
281
+ sources: sources.slice(0, 5),
282
+ };
283
+ } catch (error) {
284
+ logger.error('web_search tool failed', { error, query });
285
+ return { summary: 'Web search failed. Please try again.', sources: [] };
286
+ }
287
+ },
288
+ },
289
+ };
290
+ }
@@ -16,6 +16,7 @@ import { createDirectUploadFiles, type DirectUploadFile } from "../lib/fileUploa
16
16
  import { deleteFile } from "../lib/googleCloudStorage.js";
17
17
  import { sendToMultiple } from "./notification.js";
18
18
  import { logger } from "../utils/logger.js";
19
+ import { enqueueEmbedding } from "./embedding.js";
19
20
 
20
21
  export async function getAllAnnouncements(classId: string) {
21
22
  const announcements = await findAnnouncementsByClassId(classId);
@@ -127,6 +128,9 @@ export async function createAnnouncementRecord(
127
128
  logger.error("Failed to send announcement notifications:", error);
128
129
  });
129
130
 
131
+ // Auto-index announcement for RAG search
132
+ enqueueEmbedding(classId, 'ANNOUNCEMENT', announcement.id);
133
+
130
134
  return {
131
135
  announcement: announcementWithAttachments || announcement,
132
136
  uploadFiles: directUploadFiles.length > 0 ? directUploadFiles : undefined,
@@ -263,6 +267,9 @@ export async function updateAnnouncementRecord(
263
267
  },
264
268
  });
265
269
 
270
+ // Re-index announcement for RAG search after update
271
+ enqueueEmbedding(input.classId, 'ANNOUNCEMENT', input.id);
272
+
266
273
  return {
267
274
  announcement: updatedAnnouncement,
268
275
  uploadFiles: directUploadFiles.length > 0 ? directUploadFiles : undefined,
@@ -27,6 +27,7 @@ import { sendToMultiple } from "./notification.js";
27
27
  import { enqueueRunJob } from "../workers/queues.js";
28
28
  import { logger } from "../utils/logger.js";
29
29
  import { invalidateStudentProgressInferenceCache } from "./studentProgressInferenceCache.js";
30
+ import { enqueueEmbedding } from "./embedding.js";
30
31
 
31
32
  export async function assignmentExists(id: string) {
32
33
  const assignment = await findAssignmentById(id);
@@ -311,13 +312,18 @@ export async function createAssignmentRecord(
311
312
  }
312
313
  await Promise.all(fileOperations);
313
314
 
314
- sendToMultiple({
315
- receiverIds: (studentIds && studentIds.length > 0) ? studentIds : classData.students.map((s) => s.id),
316
- title: `New assignment in ${classData.name}`,
317
- content: `"${title}" is due ${new Date(dueDate).toLocaleDateString()}.\n\n[View assignment](/class/${classId}/assignments/${assignment.id})`,
318
- }).catch((error) => {
319
- logger.error("Failed to send assignment notifications:", error);
320
- });
315
+ if (!inProgress) {
316
+ sendToMultiple({
317
+ receiverIds: (studentIds && studentIds.length > 0) ? studentIds : classData.students.map((s) => s.id),
318
+ title: `New assignment in ${classData.name}`,
319
+ content: `"${title}" is due ${new Date(dueDate).toLocaleDateString()}.\n\n[View assignment](/class/${classId}/assignments/${assignment.id})`,
320
+ }).catch((error) => {
321
+ logger.error("Failed to send assignment notifications:", error);
322
+ });
323
+ }
324
+
325
+ // Auto-index assignment for RAG search
326
+ enqueueEmbedding(classId, 'ASSIGNMENT', assignment.id);
321
327
 
322
328
  return assignment;
323
329
  }
@@ -591,6 +597,21 @@ export async function updateAssignmentRecord(
591
597
  .catch((error) => logger.error("Failed to update max grade from rubric:", error));
592
598
  }
593
599
 
600
+ // Re-index assignment for RAG search after update
601
+ enqueueEmbedding(input.classId, 'ASSIGNMENT', input.id);
602
+ if (assignment.inProgress && input.inProgress === false && classData) {
603
+ const receiverIds = input.studentIds?.length
604
+ ? input.studentIds
605
+ : classData.students.map((s) => s.id);
606
+ sendToMultiple({
607
+ receiverIds,
608
+ title: `New assignment in ${classData.name}`,
609
+ content: `"${updatedAssignment.title}" is due ${new Date(updatedAssignment.dueDate).toLocaleDateString()}.\n\n[View assignment](/class/${classData.id}/assignments/${updatedAssignment.id})`,
610
+ }).catch((error) => {
611
+ logger.error("Failed to send assignment publish notifications:", error);
612
+ });
613
+ }
614
+
594
615
  return updatedAssignment;
595
616
  }
596
617
 
@@ -916,6 +937,9 @@ export async function updateSubmissionAsTeacherRecord(
916
937
  );
917
938
  }
918
939
 
940
+ // Index submission for RAG search after teacher grades/feedback
941
+ enqueueEmbedding(input.classId, 'SUBMISSION', submissionId);
942
+
919
943
  return updated;
920
944
  }
921
945
 
@@ -10,6 +10,7 @@ import { sendMail } from "../utils/email.js";
10
10
  import { prismaWrapper } from "../utils/prismaWrapper.js";
11
11
  import { env } from "../lib/config/env.js";
12
12
  import { logger } from "../utils/logger.js";
13
+ import { CURRENT_TERMS_VERSION } from "../lib/legal.js";
13
14
  import {
14
15
  findUserByUsername,
15
16
  findUserByUsernameOrEmail,
@@ -32,8 +33,17 @@ export async function register(input: {
32
33
  username: string;
33
34
  email: string;
34
35
  password: string;
36
+ agreeToTerms: true;
37
+ termsVersion: string;
35
38
  }) {
36
- const { username, email, password } = input;
39
+ const { username, email, password, termsVersion } = input;
40
+
41
+ if (termsVersion !== CURRENT_TERMS_VERSION) {
42
+ throw new TRPCError({
43
+ code: "BAD_REQUEST",
44
+ message: "Please accept the current Terms of Service and Privacy Policy.",
45
+ });
46
+ }
37
47
 
38
48
  const existingUser = await prismaWrapper.findFirst(
39
49
  () => findUserByUsernameOrEmail(username, email),
@@ -70,7 +80,9 @@ export async function register(input: {
70
80
  username,
71
81
  email,
72
82
  password: await hash(password, 10),
73
- verified: true,
83
+ verified: false,
84
+ termsAcceptedAt: new Date(),
85
+ termsVersion,
74
86
  }),
75
87
  "creating new user during registration"
76
88
  );
@@ -6,6 +6,7 @@ import { SessionType } from "@prisma/client";
6
6
  import { TRPCError } from "@trpc/server";
7
7
  import { prisma } from "../lib/prisma.js";
8
8
  import { generateInviteCode } from "../utils/generateInviteCode.js";
9
+ import { enqueueEmbedding } from "./embedding.js";
9
10
  import {
10
11
  findTeacherClasses,
11
12
  findStudentClasses,
@@ -27,6 +28,8 @@ import {
27
28
  } from "../models/class.js";
28
29
  import { Class, Folder } from "@prisma/client";
29
30
  import { invalidateStudentProgressInferenceCache } from "./studentProgressInferenceCache.js";
31
+ import { sendToMultiple } from "./notification.js";
32
+ import { logger } from "../utils/logger.js";
30
33
 
31
34
  async function requireActiveSchoolMembers(schoolId: string, userIds: string[]) {
32
35
  const uniqueUserIds = [...new Set(userIds)];
@@ -624,10 +627,15 @@ export async function updateSyllabus(classId: string, contents: string) {
624
627
  message: "Missing key contents",
625
628
  });
626
629
  }
627
- return prisma.class.update({
630
+ const updated = await prisma.class.update({
628
631
  where: { id: classId },
629
632
  data: { syllabus: contents },
630
633
  });
634
+
635
+ // Index syllabus for RAG search
636
+ enqueueEmbedding(classId, 'SYLLABUS', classId);
637
+
638
+ return updated;
631
639
  }
632
640
 
633
641
  export async function listLabDrafts(classId: string, teacherId: string) {
@@ -748,7 +756,7 @@ export async function publishLabDraft(
748
756
  });
749
757
  }
750
758
 
751
- return prisma.assignment.update({
759
+ const published = await prisma.assignment.update({
752
760
  where: { id: draftId },
753
761
  data: {
754
762
  inProgress: false,
@@ -759,6 +767,23 @@ export async function publishLabDraft(
759
767
  modifiedAt: new Date(),
760
768
  },
761
769
  });
770
+
771
+ const classData = await prisma.class.findUnique({
772
+ where: { id: classId },
773
+ select: { name: true, students: { select: { id: true } } },
774
+ });
775
+
776
+ if (classData) {
777
+ sendToMultiple({
778
+ receiverIds: classData.students.map((s) => s.id),
779
+ title: `New assignment in ${classData.name}`,
780
+ content: `"${published.title}" is due ${new Date(published.dueDate).toLocaleDateString()}.\n\n[View assignment](/class/${classId}/assignments/${published.id})`,
781
+ }).catch((error) => {
782
+ logger.error("Failed to send lab draft publish notifications:", error);
783
+ });
784
+ }
785
+
786
+ return published;
762
787
  }
763
788
 
764
789
  export async function getFiles(classId: string) {