@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,319 @@
1
+ /**
2
+ * Generate files via OpenAI Code Interpreter (Responses API).
3
+ * Supports every format the sandbox can produce (pdf, docx, xlsx, pptx, …).
4
+ */
5
+ import OpenAI from 'openai';
6
+ import type { Response } from 'openai/resources/responses/responses.js';
7
+ import { logger } from '../utils/logger.js';
8
+
9
+ const DOCUMENT_GEN_MODEL = 'gpt-4o-mini';
10
+
11
+ /**
12
+ * Extensions OpenAI Code Interpreter can read/write in its sandbox.
13
+ * @see https://developers.openai.com/api/docs/guides/tools-code-interpreter
14
+ */
15
+ export const SUPPORTED_DOCUMENT_FORMATS = [
16
+ 'pdf',
17
+ 'docx',
18
+ 'doc',
19
+ 'xlsx',
20
+ 'pptx',
21
+ 'csv',
22
+ 'md',
23
+ 'html',
24
+ 'txt',
25
+ 'json',
26
+ 'xml',
27
+ 'tex',
28
+ 'png',
29
+ 'jpeg',
30
+ 'jpg',
31
+ 'gif',
32
+ 'zip',
33
+ 'tar',
34
+ 'py',
35
+ 'js',
36
+ 'ts',
37
+ 'css',
38
+ 'sh',
39
+ ] as const;
40
+
41
+ export type DocumentFormat = (typeof SUPPORTED_DOCUMENT_FORMATS)[number];
42
+
43
+ const FORMAT_MIME: Record<DocumentFormat, string> = {
44
+ pdf: 'application/pdf',
45
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
46
+ doc: 'application/msword',
47
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
48
+ pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
49
+ csv: 'text/csv',
50
+ md: 'text/markdown',
51
+ html: 'text/html',
52
+ txt: 'text/plain',
53
+ json: 'application/json',
54
+ xml: 'application/xml',
55
+ tex: 'text/x-tex',
56
+ png: 'image/png',
57
+ jpeg: 'image/jpeg',
58
+ jpg: 'image/jpeg',
59
+ gif: 'image/gif',
60
+ zip: 'application/zip',
61
+ tar: 'application/x-tar',
62
+ py: 'text/x-python',
63
+ js: 'text/javascript',
64
+ ts: 'application/typescript',
65
+ css: 'text/css',
66
+ sh: 'application/x-sh',
67
+ };
68
+
69
+ const FORMAT_HINTS: Record<DocumentFormat, string> = {
70
+ pdf: 'fpdf2 or reportlab — structured document with headings and body text',
71
+ docx: 'python-docx — Word document with headings and paragraphs',
72
+ doc: 'python-docx saving as .doc if supported, otherwise .docx',
73
+ xlsx: 'openpyxl or pandas — spreadsheet with labelled columns/rows',
74
+ pptx: 'python-pptx — slides with title + bullet content per slide',
75
+ csv: 'pandas or csv module — tabular data with headers',
76
+ md: 'write the markdown file directly',
77
+ html: 'write a complete, styled HTML page',
78
+ txt: 'write plain text with clear section breaks',
79
+ json: 'json.dumps with readable indentation',
80
+ xml: 'well-formed XML document',
81
+ tex: 'LaTeX source that compiles to the requested content',
82
+ png: 'matplotlib or Pillow — chart, diagram, or visual',
83
+ jpeg: 'matplotlib or Pillow — save as JPEG',
84
+ jpg: 'matplotlib or Pillow — save as JPEG',
85
+ gif: 'matplotlib or Pillow — save as GIF',
86
+ zip: 'zipfile — bundle multiple related files if needed',
87
+ tar: 'tarfile — archive if multiple files are required',
88
+ py: 'valid Python source code file',
89
+ js: 'valid JavaScript source file',
90
+ ts: 'valid TypeScript source file',
91
+ css: 'stylesheet file',
92
+ sh: 'shell script file',
93
+ };
94
+
95
+ export interface GenerateDocumentInput {
96
+ title: string;
97
+ /** Markdown or plain-text source material for the output file. */
98
+ content: string;
99
+ /** Output format. Defaults to pdf. */
100
+ format?: DocumentFormat | string;
101
+ }
102
+
103
+ export interface GeneratedDocumentResult {
104
+ bytes: Buffer;
105
+ filename: string;
106
+ mimeType: string;
107
+ format: DocumentFormat;
108
+ }
109
+
110
+ export interface ContainerGeneratedFile {
111
+ containerId: string;
112
+ fileId: string;
113
+ filename: string;
114
+ }
115
+
116
+ export function normalizeDocumentFormat(
117
+ format?: string | null,
118
+ ): DocumentFormat {
119
+ const raw = (format ?? 'pdf').toLowerCase().replace(/^\./, '');
120
+ if ((SUPPORTED_DOCUMENT_FORMATS as readonly string[]).includes(raw)) {
121
+ return raw as DocumentFormat;
122
+ }
123
+ return 'pdf';
124
+ }
125
+
126
+ export function mimeTypeForFormat(format: DocumentFormat): string {
127
+ return FORMAT_MIME[format];
128
+ }
129
+
130
+ export function extensionForFormat(format: DocumentFormat): string {
131
+ return format === 'jpg' ? 'jpg' : format;
132
+ }
133
+
134
+ /** Pull generated-file citations for a given extension out of a Responses output. */
135
+ export function extractContainerGeneratedFiles(
136
+ response: Response,
137
+ extension: DocumentFormat,
138
+ ): ContainerGeneratedFile[] {
139
+ const seen = new Set<string>();
140
+ const files: ContainerGeneratedFile[] = [];
141
+ const suffix = `.${extension}`;
142
+
143
+ const add = (containerId: string, fileId: string, filename: string) => {
144
+ const key = `${containerId}:${fileId}`;
145
+ if (seen.has(key)) return;
146
+ seen.add(key);
147
+ files.push({ containerId, fileId, filename });
148
+ };
149
+
150
+ for (const item of response.output ?? []) {
151
+ if (item.type !== 'message') continue;
152
+ for (const block of item.content ?? []) {
153
+ if (block.type !== 'output_text') continue;
154
+ for (const ann of block.annotations ?? []) {
155
+ if (ann.type !== 'container_file_citation') continue;
156
+ const name = ann.filename?.toLowerCase() ?? '';
157
+ if (name.endsWith(suffix)) {
158
+ add(ann.container_id, ann.file_id, ann.filename);
159
+ }
160
+ }
161
+ }
162
+ }
163
+
164
+ return files;
165
+ }
166
+
167
+ function containerIdFromResponse(response: Response): string | undefined {
168
+ for (const item of response.output ?? []) {
169
+ if (item.type === 'code_interpreter_call' && item.container_id) {
170
+ return item.container_id;
171
+ }
172
+ }
173
+ return undefined;
174
+ }
175
+
176
+ async function listContainerGeneratedFiles(
177
+ client: OpenAI,
178
+ containerId: string,
179
+ extension: DocumentFormat,
180
+ ): Promise<ContainerGeneratedFile[]> {
181
+ const suffix = `.${extension}`;
182
+ const files: ContainerGeneratedFile[] = [];
183
+ for await (const file of client.containers.files.list(containerId)) {
184
+ const path = file.path?.toLowerCase() ?? '';
185
+ if (path.endsWith(suffix) && file.source === 'assistant') {
186
+ files.push({
187
+ containerId,
188
+ fileId: file.id,
189
+ filename: file.path.split('/').pop() ?? file.path,
190
+ });
191
+ }
192
+ }
193
+ return files;
194
+ }
195
+
196
+ async function downloadContainerFile(
197
+ client: OpenAI,
198
+ file: ContainerGeneratedFile,
199
+ ): Promise<Buffer | null> {
200
+ try {
201
+ const res = await client.containers.files.content.retrieve(file.fileId, {
202
+ container_id: file.containerId,
203
+ });
204
+ const bytes = await res.arrayBuffer();
205
+ return Buffer.from(bytes);
206
+ } catch (error) {
207
+ logger.error('Failed to download container file', { error, file });
208
+ return null;
209
+ }
210
+ }
211
+
212
+ function safeBasename(title: string): string {
213
+ const base = title
214
+ .replace(/[^a-zA-Z0-9\s\-_]/g, '')
215
+ .replace(/\s+/g, '_')
216
+ .substring(0, 60);
217
+ return base || 'document';
218
+ }
219
+
220
+ function buildDocumentGenInstructions(format: DocumentFormat): string {
221
+ return [
222
+ 'You are a document generator for K-12 and university teachers.',
223
+ 'When given a title, output format, and source content:',
224
+ '1. Use the python tool to create exactly ONE output file of the requested type.',
225
+ `2. For this format use: ${FORMAT_HINTS[format]}.`,
226
+ `3. You MUST output a ${format.toUpperCase()} file — not any other format.`,
227
+ `4. The saved filename MUST end with .${extensionForFormat(format)}.`,
228
+ '5. Structure content clearly — headings, sections, lists, tables/charts as appropriate.',
229
+ '6. Include the document title prominently where the format allows.',
230
+ '7. Put ONLY the educational content in the file — no meta commentary.',
231
+ '8. You MUST save the file to disk in the working directory before you finish.',
232
+ ...(format !== 'pdf'
233
+ ? [`9. Do NOT create a PDF when ${format.toUpperCase()} was requested.`]
234
+ : []),
235
+ ].join(' ');
236
+ }
237
+
238
+ /** Generate a file via OpenAI Code Interpreter. */
239
+ export async function generateDocumentWithOpenAI(
240
+ input: GenerateDocumentInput,
241
+ ): Promise<GeneratedDocumentResult | null> {
242
+ const client = new OpenAI();
243
+ const format = normalizeDocumentFormat(input.format);
244
+ const ext = extensionForFormat(format);
245
+ const filename = `${safeBasename(input.title)}.${ext}`;
246
+
247
+ try {
248
+ const response = await client.responses.create({
249
+ model: DOCUMENT_GEN_MODEL,
250
+ instructions: buildDocumentGenInstructions(format),
251
+ tools: [
252
+ {
253
+ type: 'code_interpreter',
254
+ container: { type: 'auto' },
255
+ },
256
+ ],
257
+ tool_choice: 'required',
258
+ input: [
259
+ `Create a ${ext.toUpperCase()} file using the python tool.`,
260
+ `Output filename (exact): ${filename}`,
261
+ `Output format: ${ext}`,
262
+ `Document title: ${input.title}`,
263
+ 'Source content (markdown or plain text — adapt to the output format):',
264
+ input.content,
265
+ ].join('\n\n'),
266
+ });
267
+
268
+ let matches = extractContainerGeneratedFiles(response, format);
269
+
270
+ if (matches.length === 0) {
271
+ const containerId = containerIdFromResponse(response);
272
+ if (containerId) {
273
+ matches = await listContainerGeneratedFiles(client, containerId, format);
274
+ }
275
+ }
276
+
277
+ if (matches.length === 0) {
278
+ logger.error('OpenAI document gen produced no matching files', {
279
+ title: input.title,
280
+ format,
281
+ outputTypes: (response.output ?? []).map((o) => o.type),
282
+ });
283
+ return null;
284
+ }
285
+
286
+ const preferred =
287
+ matches.find((f) => f.filename.toLowerCase() === filename.toLowerCase()) ??
288
+ matches[0];
289
+
290
+ const bytes = await downloadContainerFile(client, preferred);
291
+ if (!bytes) return null;
292
+
293
+ return {
294
+ bytes,
295
+ filename: preferred.filename,
296
+ mimeType: mimeTypeForFormat(format),
297
+ format,
298
+ };
299
+ } catch (error) {
300
+ logger.error('OpenAI document generation failed', {
301
+ error,
302
+ title: input.title,
303
+ format,
304
+ });
305
+ return null;
306
+ }
307
+ }
308
+
309
+ /** @deprecated Use {@link generateDocumentWithOpenAI} */
310
+ export async function generatePdfWithOpenAI(
311
+ input: Omit<GenerateDocumentInput, 'format'>,
312
+ ): Promise<Buffer | null> {
313
+ const result = await generateDocumentWithOpenAI({ ...input, format: 'pdf' });
314
+ return result?.bytes ?? null;
315
+ }
316
+
317
+ /** @deprecated Use {@link extractContainerGeneratedFiles} */
318
+ export const extractContainerPdfFiles = (response: Response) =>
319
+ extractContainerGeneratedFiles(response, 'pdf');
@@ -1,4 +1,7 @@
1
1
  import { prisma } from "../lib/prisma.js";
2
+ import { generateEmbedding } from "./embedding.js";
3
+ import { logger } from "../utils/logger.js";
4
+ import type { EmbeddingSourceType } from "@prisma/client";
2
5
 
3
6
  type SearchScope =
4
7
  | "class"
@@ -8,7 +11,9 @@ type SearchScope =
8
11
  | "worksheet"
9
12
  | "file"
10
13
  | "announcement"
11
- | "section";
14
+ | "section"
15
+ | "discussion"
16
+ | "submission";
12
17
 
13
18
  type SearchResult = {
14
19
  id: string;
@@ -58,6 +63,8 @@ const trimResults = (results: SearchResult[], limit: number) =>
58
63
  file: 5,
59
64
  announcement: 6,
60
65
  section: 7,
66
+ discussion: 8,
67
+ submission: 9,
61
68
  };
62
69
  const priorityDiff = priority[a.type] - priority[b.type];
63
70
  if (priorityDiff !== 0) return priorityDiff;
@@ -395,3 +402,158 @@ export async function searchGlobalClasses(
395
402
 
396
403
  return { query, results: trimResults(results, limit) };
397
404
  }
405
+
406
+ const SOURCE_TYPE_TO_SCOPE: Record<EmbeddingSourceType, SearchScope> = {
407
+ ASSIGNMENT: "assignment",
408
+ WORKSHEET: "worksheet",
409
+ ANNOUNCEMENT: "announcement",
410
+ FILE: "file",
411
+ SECTION: "section",
412
+ DISCUSSION: "discussion",
413
+ SUBMISSION: "submission",
414
+ SYLLABUS: "syllabus",
415
+ };
416
+
417
+ const MIN_SIMILARITY = 0.25;
418
+
419
+ /**
420
+ * Cross-class semantic search via pgvector.
421
+ * Generates an embedding for `query`, searches across all classes the user
422
+ * belongs to, and maps hits back to SearchResult shape.
423
+ */
424
+ export async function searchSemanticGlobal(
425
+ userId: string,
426
+ input: { query: string; limit?: number },
427
+ ) {
428
+ const query = input.query.trim().slice(0, 200);
429
+ if (!query) {
430
+ return { query, results: [] as SearchResult[] };
431
+ }
432
+ const limit = Math.min(Math.max(input.limit ?? SEARCH_LIMIT, 1), 30);
433
+
434
+ const memberships = await prisma.class.findMany({
435
+ where: {
436
+ OR: [
437
+ { teachers: { some: { id: userId } } },
438
+ { students: { some: { id: userId } } },
439
+ ],
440
+ },
441
+ select: { id: true, name: true },
442
+ });
443
+ const allClassIds = memberships.map((cls) => cls.id);
444
+ if (allClassIds.length === 0) {
445
+ return { query, results: [] as SearchResult[] };
446
+ }
447
+ const classById = new Map(memberships.map((cls) => [cls.id, cls]));
448
+
449
+ let queryVector: number[];
450
+ try {
451
+ queryVector = await generateEmbedding(query);
452
+ } catch (err) {
453
+ logger.error("Semantic search embedding failed", { err });
454
+ return { query, results: [] as SearchResult[] };
455
+ }
456
+
457
+ const vectorStr = `[${queryVector.join(",")}]`;
458
+
459
+ type SemanticRow = {
460
+ sourceType: EmbeddingSourceType;
461
+ sourceId: string;
462
+ classId: string;
463
+ content: string;
464
+ similarity: number;
465
+ };
466
+
467
+ const hits = await prisma.$queryRaw<SemanticRow[]>`
468
+ SELECT DISTINCT ON ("sourceType", "sourceId")
469
+ "sourceType",
470
+ "sourceId",
471
+ "classId",
472
+ content,
473
+ 1 - (vector <=> ${vectorStr}::vector) as similarity
474
+ FROM "Embedding"
475
+ WHERE "classId" = ANY(${allClassIds}::text[])
476
+ ORDER BY "sourceType", "sourceId", vector <=> ${vectorStr}::vector
477
+ LIMIT ${limit * 3}
478
+ `;
479
+
480
+ const filtered = hits
481
+ .filter((h) => h.similarity >= MIN_SIMILARITY)
482
+ .sort((a, b) => b.similarity - a.similarity)
483
+ .slice(0, limit);
484
+
485
+ const results: SearchResult[] = [];
486
+ for (const hit of filtered) {
487
+ const cls = classById.get(hit.classId);
488
+ const className = cls?.name ?? "Class";
489
+ const scope = SOURCE_TYPE_TO_SCOPE[hit.sourceType];
490
+ if (!scope) continue;
491
+
492
+ const snippet = hit.content.replace(/\s+/g, " ").trim().slice(0, 140);
493
+
494
+ let title = snippet.slice(0, 60);
495
+ let href = `/class/${hit.classId}`;
496
+
497
+ if (scope === "assignment") {
498
+ const assignment = await prisma.assignment.findUnique({
499
+ where: { id: hit.sourceId },
500
+ select: { title: true },
501
+ });
502
+ title = assignment?.title ?? title;
503
+ href = `/class/${hit.classId}/assignment/${hit.sourceId}`;
504
+ } else if (scope === "worksheet") {
505
+ const worksheet = await prisma.worksheet.findUnique({
506
+ where: { id: hit.sourceId },
507
+ select: { name: true },
508
+ });
509
+ title = worksheet?.name ?? title;
510
+ href = `/class/${hit.classId}/worksheets`;
511
+ } else if (scope === "announcement") {
512
+ href = `/class/${hit.classId}`;
513
+ } else if (scope === "file") {
514
+ const file = await prisma.file.findUnique({
515
+ where: { id: hit.sourceId },
516
+ select: { name: true },
517
+ });
518
+ title = file?.name ?? title;
519
+ href = `/class/${hit.classId}/files`;
520
+ } else if (scope === "section") {
521
+ const section = await prisma.section.findUnique({
522
+ where: { id: hit.sourceId },
523
+ select: { name: true },
524
+ });
525
+ title = section?.name ?? title;
526
+ href = `/class/${hit.classId}/assignments`;
527
+ } else if (scope === "discussion") {
528
+ const discussion = await prisma.discussion.findUnique({
529
+ where: { id: hit.sourceId },
530
+ select: { title: true },
531
+ });
532
+ title = discussion?.title ?? title;
533
+ href = `/class/${hit.classId}/discussions/${hit.sourceId}`;
534
+ } else if (scope === "submission") {
535
+ const submission = await prisma.submission.findUnique({
536
+ where: { id: hit.sourceId },
537
+ select: { assignment: { select: { title: true, id: true } } },
538
+ });
539
+ title = submission?.assignment.title ? `Submission: ${submission.assignment.title}` : title;
540
+ href = submission?.assignment.id ? `/class/${hit.classId}/assignment/${submission.assignment.id}` : href;
541
+ } else if (scope === "syllabus") {
542
+ title = `${className} Syllabus`;
543
+ href = `/class/${hit.classId}/syllabus`;
544
+ }
545
+
546
+ results.push({
547
+ id: `semantic:${hit.sourceId}`,
548
+ type: scope,
549
+ title,
550
+ subtitle: `${className} · ${scope.charAt(0).toUpperCase() + scope.slice(1)}`,
551
+ classId: hit.classId,
552
+ className,
553
+ href,
554
+ matchedText: snippet,
555
+ });
556
+ }
557
+
558
+ return { query, results };
559
+ }
@@ -5,15 +5,7 @@ import { TRPCError } from "@trpc/server";
5
5
  import { logger } from "../utils/logger.js";
6
6
  import { env } from "../lib/config/env.js";
7
7
  import { findUserById, findUserProfileByUserId, upsertUserProfile } from "../models/user.js";
8
-
9
- function getFileUrl(filePath: string | null): string | null {
10
- if (!filePath) return null;
11
- if (filePath.startsWith("http")) {
12
- return filePath;
13
- }
14
- const backendUrl = env.BACKEND_URL || "http://localhost:3001";
15
- return `${backendUrl}/api/files/${encodeURIComponent(filePath)}`;
16
- }
8
+ import { makeFilePublic } from "../lib/googleCloudStorage.js";
17
9
 
18
10
  /** Get user profile with display info and profile picture URLs. */
19
11
  export async function getProfile(userId: string) {
@@ -36,10 +28,8 @@ export async function getProfile(userId: string) {
36
28
  bio: (userProfile as any).bio || null,
37
29
  location: (userProfile as any).location || null,
38
30
  website: (userProfile as any).website || null,
39
- profilePicture: getFileUrl((userProfile as any).profilePicture),
40
- profilePictureThumbnail: getFileUrl(
41
- (userProfile as any).profilePictureThumbnail
42
- ),
31
+ profilePicture: (userProfile as any).profilePicture ?? null,
32
+ profilePictureThumbnail: (userProfile as any).profilePictureThumbnail ?? null,
43
33
  }
44
34
  : {
45
35
  displayName: null,
@@ -78,6 +68,10 @@ export async function updateProfile(
78
68
  try {
79
69
  profilePictureUrl = input.profilePicture.filePath;
80
70
  profilePictureThumbnail = null;
71
+
72
+ // Best-effort: make the uploaded file publicly readable so the public
73
+ // GCS URL resolved by the Prisma extension actually works.
74
+ await makeFilePublic(profilePictureUrl);
81
75
  } catch (error) {
82
76
  logger.error("Profile picture processing failed", {
83
77
  userId,
@@ -125,10 +119,8 @@ export async function updateProfile(
125
119
  bio: (updatedProfile as any).bio || null,
126
120
  location: (updatedProfile as any).location || null,
127
121
  website: (updatedProfile as any).website || null,
128
- profilePicture: getFileUrl((updatedProfile as any).profilePicture),
129
- profilePictureThumbnail: getFileUrl(
130
- (updatedProfile as any).profilePictureThumbnail
131
- ),
122
+ profilePicture: (updatedProfile as any).profilePicture ?? null,
123
+ profilePictureThumbnail: (updatedProfile as any).profilePictureThumbnail ?? null,
132
124
  },
133
125
  };
134
126
  }