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