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