@studious-lms/server 1.12.0 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (338) hide show
  1. package/.dockerignore +28 -0
  2. package/.env.example +5 -0
  3. package/.github/workflows/deploy.yml +123 -0
  4. package/Dockerfile +54 -0
  5. package/dist/index.js +5 -2
  6. package/dist/index.js.map +1 -1
  7. package/dist/lib/config/cors.d.ts.map +1 -1
  8. package/dist/lib/config/cors.js +31 -11
  9. package/dist/lib/config/cors.js.map +1 -1
  10. package/dist/lib/config/env.d.ts +17 -0
  11. package/dist/lib/config/env.d.ts.map +1 -1
  12. package/dist/lib/config/env.js +8 -2
  13. package/dist/lib/config/env.js.map +1 -1
  14. package/dist/lib/googleCloudStorage.d.ts +10 -0
  15. package/dist/lib/googleCloudStorage.d.ts.map +1 -1
  16. package/dist/lib/googleCloudStorage.js +22 -2
  17. package/dist/lib/googleCloudStorage.js.map +1 -1
  18. package/dist/lib/prisma.d.ts +104 -5
  19. package/dist/lib/prisma.d.ts.map +1 -1
  20. package/dist/lib/prisma.js +33 -2
  21. package/dist/lib/prisma.js.map +1 -1
  22. package/dist/models/agenda.d.ts +20 -20
  23. package/dist/models/announcement.d.ts +177 -69
  24. package/dist/models/announcement.d.ts.map +1 -1
  25. package/dist/models/assignment.d.ts +551 -353
  26. package/dist/models/assignment.d.ts.map +1 -1
  27. package/dist/models/attendance.d.ts +197 -71
  28. package/dist/models/attendance.d.ts.map +1 -1
  29. package/dist/models/auth.d.ts +348 -114
  30. package/dist/models/auth.d.ts.map +1 -1
  31. package/dist/models/class.d.ts +324 -188
  32. package/dist/models/class.d.ts.map +1 -1
  33. package/dist/models/class.js +9 -3
  34. package/dist/models/class.js.map +1 -1
  35. package/dist/models/cloudStorage.d.ts +146 -56
  36. package/dist/models/cloudStorage.d.ts.map +1 -1
  37. package/dist/models/comment.d.ts +269 -110
  38. package/dist/models/comment.d.ts.map +1 -1
  39. package/dist/models/comment.js +3 -2
  40. package/dist/models/comment.js.map +1 -1
  41. package/dist/models/conversation.d.ts +206 -63
  42. package/dist/models/conversation.d.ts.map +1 -1
  43. package/dist/models/conversation.js +13 -2
  44. package/dist/models/conversation.js.map +1 -1
  45. package/dist/models/department.d.ts +196 -70
  46. package/dist/models/department.d.ts.map +1 -1
  47. package/dist/models/event.d.ts +287 -125
  48. package/dist/models/event.d.ts.map +1 -1
  49. package/dist/models/file.d.ts +389 -263
  50. package/dist/models/file.d.ts.map +1 -1
  51. package/dist/models/folder.d.ts +433 -181
  52. package/dist/models/folder.d.ts.map +1 -1
  53. package/dist/models/labChat.d.ts +209 -83
  54. package/dist/models/labChat.d.ts.map +1 -1
  55. package/dist/models/marketing.d.ts +80 -26
  56. package/dist/models/marketing.d.ts.map +1 -1
  57. package/dist/models/message.d.ts +95 -41
  58. package/dist/models/message.d.ts.map +1 -1
  59. package/dist/models/newtonChat.d.ts +83 -29
  60. package/dist/models/newtonChat.d.ts.map +1 -1
  61. package/dist/models/notification.d.ts +61 -25
  62. package/dist/models/notification.d.ts.map +1 -1
  63. package/dist/models/school.d.ts +349 -133
  64. package/dist/models/school.d.ts.map +1 -1
  65. package/dist/models/section.d.ts +216 -72
  66. package/dist/models/section.d.ts.map +1 -1
  67. package/dist/models/tag.d.ts +156 -48
  68. package/dist/models/tag.d.ts.map +1 -1
  69. package/dist/models/user.d.ts +82 -28
  70. package/dist/models/user.d.ts.map +1 -1
  71. package/dist/models/worksheet.d.ts +1023 -410
  72. package/dist/models/worksheet.d.ts.map +1 -1
  73. package/dist/models/worksheet.js +19 -7
  74. package/dist/models/worksheet.js.map +1 -1
  75. package/dist/pipelines/aiLabChat.d.ts +0 -8
  76. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  77. package/dist/pipelines/aiLabChat.js +50 -67
  78. package/dist/pipelines/aiLabChat.js.map +1 -1
  79. package/dist/pipelines/aiLabChatContract.d.ts +393 -388
  80. package/dist/pipelines/aiLabChatContract.d.ts.map +1 -1
  81. package/dist/pipelines/aiLabChatContract.js +63 -69
  82. package/dist/pipelines/aiLabChatContract.js.map +1 -1
  83. package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
  84. package/dist/pipelines/aiNewtonChat.js +41 -41
  85. package/dist/pipelines/aiNewtonChat.js.map +1 -1
  86. package/dist/pipelines/gradeWorksheet.d.ts +8 -8
  87. package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
  88. package/dist/pipelines/gradeWorksheet.js +59 -14
  89. package/dist/pipelines/gradeWorksheet.js.map +1 -1
  90. package/dist/pipelines/labChatPrompt.d.ts +10 -8
  91. package/dist/pipelines/labChatPrompt.d.ts.map +1 -1
  92. package/dist/pipelines/labChatPrompt.js +47 -67
  93. package/dist/pipelines/labChatPrompt.js.map +1 -1
  94. package/dist/pipelines/pdfToWorksheet.d.ts +2 -2
  95. package/dist/routers/_app.d.ts +2268 -1898
  96. package/dist/routers/_app.d.ts.map +1 -1
  97. package/dist/routers/_app.js +4 -2
  98. package/dist/routers/_app.js.map +1 -1
  99. package/dist/routers/admin.d.ts +13 -13
  100. package/dist/routers/agenda.d.ts +18 -18
  101. package/dist/routers/announcement.d.ts +32 -28
  102. package/dist/routers/announcement.d.ts.map +1 -1
  103. package/dist/routers/announcement.js +6 -5
  104. package/dist/routers/announcement.js.map +1 -1
  105. package/dist/routers/assignment.d.ts +335 -327
  106. package/dist/routers/assignment.d.ts.map +1 -1
  107. package/dist/routers/assignment.js +4 -4
  108. package/dist/routers/assignment.js.map +1 -1
  109. package/dist/routers/attendance.d.ts +10 -10
  110. package/dist/routers/auth.d.ts +2 -2
  111. package/dist/routers/class.d.ts +170 -147
  112. package/dist/routers/class.d.ts.map +1 -1
  113. package/dist/routers/comment.d.ts +40 -16
  114. package/dist/routers/comment.d.ts.map +1 -1
  115. package/dist/routers/comment.js +6 -3
  116. package/dist/routers/comment.js.map +1 -1
  117. package/dist/routers/conversation.d.ts +17 -17
  118. package/dist/routers/department.d.ts +34 -34
  119. package/dist/routers/discussion.d.ts +65 -53
  120. package/dist/routers/discussion.d.ts.map +1 -1
  121. package/dist/routers/discussion.js +6 -2
  122. package/dist/routers/discussion.js.map +1 -1
  123. package/dist/routers/embedding.d.ts +69 -0
  124. package/dist/routers/embedding.d.ts.map +1 -0
  125. package/dist/routers/embedding.js +143 -0
  126. package/dist/routers/embedding.js.map +1 -0
  127. package/dist/routers/event.d.ts +39 -39
  128. package/dist/routers/file.d.ts +12 -12
  129. package/dist/routers/folder.d.ts +38 -38
  130. package/dist/routers/labChat.d.ts +19 -19
  131. package/dist/routers/marketing.d.ts +2 -2
  132. package/dist/routers/message.d.ts +13 -13
  133. package/dist/routers/newtonChat.d.ts +4 -4
  134. package/dist/routers/notifications.d.ts +9 -9
  135. package/dist/routers/permissions.d.ts +14 -14
  136. package/dist/routers/school.d.ts +55 -55
  137. package/dist/routers/search.d.ts +22 -1
  138. package/dist/routers/search.d.ts.map +1 -1
  139. package/dist/routers/search.js +9 -3
  140. package/dist/routers/search.js.map +1 -1
  141. package/dist/routers/section.d.ts +4 -4
  142. package/dist/routers/studentProgress.d.ts +1 -1
  143. package/dist/routers/tag.d.ts +7 -7
  144. package/dist/routers/user.d.ts +10 -10
  145. package/dist/routers/worksheet.d.ts +97 -56
  146. package/dist/routers/worksheet.d.ts.map +1 -1
  147. package/dist/routers/worksheet.js +28 -2
  148. package/dist/routers/worksheet.js.map +1 -1
  149. package/dist/routes/aiStream.d.ts +2 -0
  150. package/dist/routes/aiStream.d.ts.map +1 -0
  151. package/dist/routes/aiStream.js +696 -0
  152. package/dist/routes/aiStream.js.map +1 -0
  153. package/dist/seedDatabase.d.ts +2 -2
  154. package/dist/seedDatabase.js +6 -6
  155. package/dist/seedDatabase.js.map +1 -1
  156. package/dist/services/adminInstitution.d.ts +8 -8
  157. package/dist/services/adminRunJob.d.ts +4 -4
  158. package/dist/services/agenda.d.ts +18 -18
  159. package/dist/services/aiChatStream.d.ts +68 -0
  160. package/dist/services/aiChatStream.d.ts.map +1 -0
  161. package/dist/services/aiChatStream.js +157 -0
  162. package/dist/services/aiChatStream.js.map +1 -0
  163. package/dist/services/aiModifierTools.d.ts +32 -0
  164. package/dist/services/aiModifierTools.d.ts.map +1 -0
  165. package/dist/services/aiModifierTools.js +165 -0
  166. package/dist/services/aiModifierTools.js.map +1 -0
  167. package/dist/services/aiProposalTools.d.ts +32 -0
  168. package/dist/services/aiProposalTools.d.ts.map +1 -0
  169. package/dist/services/aiProposalTools.js +207 -0
  170. package/dist/services/aiProposalTools.js.map +1 -0
  171. package/dist/services/aiStream.d.ts +22 -0
  172. package/dist/services/aiStream.d.ts.map +1 -0
  173. package/dist/services/aiStream.js +142 -0
  174. package/dist/services/aiStream.js.map +1 -0
  175. package/dist/services/aiTools.d.ts +16 -0
  176. package/dist/services/aiTools.d.ts.map +1 -0
  177. package/dist/services/aiTools.js +271 -0
  178. package/dist/services/aiTools.js.map +1 -0
  179. package/dist/services/announcement.d.ts +12 -12
  180. package/dist/services/announcement.d.ts.map +1 -1
  181. package/dist/services/announcement.js +7 -2
  182. package/dist/services/announcement.js.map +1 -1
  183. package/dist/services/assignment.d.ts +293 -293
  184. package/dist/services/assignment.d.ts.map +1 -1
  185. package/dist/services/assignment.js +36 -15
  186. package/dist/services/assignment.js.map +1 -1
  187. package/dist/services/attendance.d.ts +10 -10
  188. package/dist/services/auth.d.ts +2 -2
  189. package/dist/services/class.d.ts +161 -138
  190. package/dist/services/class.d.ts.map +1 -1
  191. package/dist/services/class.js +25 -4
  192. package/dist/services/class.js.map +1 -1
  193. package/dist/services/comment.d.ts +34 -16
  194. package/dist/services/comment.d.ts.map +1 -1
  195. package/dist/services/comment.js +23 -2
  196. package/dist/services/comment.js.map +1 -1
  197. package/dist/services/conversation.d.ts +17 -17
  198. package/dist/services/conversation.d.ts.map +1 -1
  199. package/dist/services/conversation.js +9 -7
  200. package/dist/services/conversation.js.map +1 -1
  201. package/dist/services/department.d.ts +33 -33
  202. package/dist/services/discussion.d.ts +58 -58
  203. package/dist/services/discussion.d.ts.map +1 -1
  204. package/dist/services/discussion.js +8 -8
  205. package/dist/services/discussion.js.map +1 -1
  206. package/dist/services/discussionSummary.d.ts +21 -0
  207. package/dist/services/discussionSummary.d.ts.map +1 -0
  208. package/dist/services/discussionSummary.js +179 -0
  209. package/dist/services/discussionSummary.js.map +1 -0
  210. package/dist/services/embedding.d.ts +67 -0
  211. package/dist/services/embedding.d.ts.map +1 -0
  212. package/dist/services/embedding.js +226 -0
  213. package/dist/services/embedding.js.map +1 -0
  214. package/dist/services/event.d.ts +37 -37
  215. package/dist/services/file.d.ts +24 -24
  216. package/dist/services/folder.d.ts +36 -36
  217. package/dist/services/labChat.d.ts +19 -19
  218. package/dist/services/marketing.d.ts +2 -2
  219. package/dist/services/message.d.ts +11 -11
  220. package/dist/services/message.d.ts.map +1 -1
  221. package/dist/services/message.js +2 -2
  222. package/dist/services/message.js.map +1 -1
  223. package/dist/services/newtonChat.d.ts +4 -4
  224. package/dist/services/notification.d.ts +9 -9
  225. package/dist/services/notification.d.ts.map +1 -1
  226. package/dist/services/permissions.d.ts +14 -14
  227. package/dist/services/school.d.ts +50 -50
  228. package/dist/services/search.d.ts +12 -0
  229. package/dist/services/search.d.ts.map +1 -1
  230. package/dist/services/search.js +120 -2
  231. package/dist/services/search.js.map +1 -1
  232. package/dist/services/section.d.ts +3 -3
  233. package/dist/services/tag.d.ts +4 -4
  234. package/dist/services/user.d.ts +4 -4
  235. package/dist/services/user.d.ts.map +1 -1
  236. package/dist/services/user.js +10 -15
  237. package/dist/services/user.js.map +1 -1
  238. package/dist/services/worksheet.d.ts +62 -47
  239. package/dist/services/worksheet.d.ts.map +1 -1
  240. package/dist/services/worksheet.js +11 -4
  241. package/dist/services/worksheet.js.map +1 -1
  242. package/dist/utils/streamBroadcast.d.ts +11 -0
  243. package/dist/utils/streamBroadcast.d.ts.map +1 -0
  244. package/dist/utils/streamBroadcast.js +32 -0
  245. package/dist/utils/streamBroadcast.js.map +1 -0
  246. package/dist/workers/connection.d.ts +15 -0
  247. package/dist/workers/connection.d.ts.map +1 -1
  248. package/dist/workers/connection.js +20 -2
  249. package/dist/workers/connection.js.map +1 -1
  250. package/dist/workers/index.js +4 -2
  251. package/dist/workers/index.js.map +1 -1
  252. package/dist/workers/processors/createRunJobWorker.d.ts.map +1 -1
  253. package/dist/workers/processors/createRunJobWorker.js +31 -8
  254. package/dist/workers/processors/createRunJobWorker.js.map +1 -1
  255. package/dist/workers/processors/embedContent.d.ts +8 -0
  256. package/dist/workers/processors/embedContent.d.ts.map +1 -0
  257. package/dist/workers/processors/embedContent.js +76 -0
  258. package/dist/workers/processors/embedContent.js.map +1 -0
  259. package/dist/workers/queues.d.ts +6 -0
  260. package/dist/workers/queues.d.ts.map +1 -1
  261. package/dist/workers/queues.js +5 -3
  262. package/dist/workers/queues.js.map +1 -1
  263. package/package.json +5 -1
  264. package/prisma/migrations/20260609215135_init/migration.sql +1412 -0
  265. package/prisma/migrations/20260610130000_add_worksheet_tags/migration.sql +13 -0
  266. package/prisma/schema.prisma +39 -7
  267. package/src/index.ts +4 -0
  268. package/src/lib/config/cors.ts +32 -9
  269. package/src/lib/config/env.ts +6 -0
  270. package/src/lib/googleCloudStorage.ts +21 -0
  271. package/src/lib/prisma.ts +40 -1
  272. package/src/models/class.ts +10 -3
  273. package/src/models/comment.ts +1 -0
  274. package/src/models/conversation.ts +12 -0
  275. package/src/models/worksheet.ts +16 -7
  276. package/src/pipelines/aiLabChat.ts +54 -79
  277. package/src/pipelines/aiLabChatContract.ts +69 -69
  278. package/src/pipelines/aiNewtonChat.ts +46 -43
  279. package/src/pipelines/gradeWorksheet.ts +66 -15
  280. package/src/pipelines/labChatPrompt.ts +63 -88
  281. package/src/routers/_app.ts +2 -0
  282. package/src/routers/announcement.ts +4 -3
  283. package/src/routers/assignment.ts +2 -2
  284. package/src/routers/comment.ts +5 -0
  285. package/src/routers/discussion.ts +7 -0
  286. package/src/routers/embedding.ts +159 -0
  287. package/src/routers/search.ts +10 -1
  288. package/src/routers/worksheet.ts +29 -0
  289. package/src/routes/aiStream.ts +789 -0
  290. package/src/seedDatabase.ts +4 -4
  291. package/src/services/aiChatStream.ts +219 -0
  292. package/src/services/aiModifierTools.ts +188 -0
  293. package/src/services/aiProposalTools.ts +272 -0
  294. package/src/services/aiStream.ts +175 -0
  295. package/src/services/aiTools.ts +290 -0
  296. package/src/services/announcement.ts +7 -0
  297. package/src/services/assignment.ts +36 -14
  298. package/src/services/class.ts +30 -2
  299. package/src/services/comment.ts +22 -0
  300. package/src/services/conversation.ts +9 -4
  301. package/src/services/discussion.ts +9 -8
  302. package/src/services/discussionSummary.ts +226 -0
  303. package/src/services/embedding.ts +285 -0
  304. package/src/services/message.ts +2 -2
  305. package/src/services/search.ts +138 -0
  306. package/src/services/user.ts +9 -17
  307. package/src/services/worksheet.ts +10 -2
  308. package/src/utils/streamBroadcast.ts +48 -0
  309. package/src/workers/connection.ts +19 -0
  310. package/src/workers/index.ts +2 -0
  311. package/src/workers/processors/createRunJobWorker.ts +32 -6
  312. package/src/workers/processors/embedContent.ts +90 -0
  313. package/src/workers/queues.ts +5 -1
  314. package/tests/integration/routers/conversation.test.ts +9 -7
  315. package/tests/unit/pipelines/aiLabChatContract.test.ts +38 -21
  316. package/tests/unit/pipelines/labChatPrompt.test.ts +23 -36
  317. package/prisma/migrations/20250807062924_init/migration.sql +0 -436
  318. package/prisma/migrations/20250919063704_init2/migration.sql +0 -24
  319. package/prisma/migrations/20250920143543_add_profile_fields/migration.sql +0 -15
  320. package/prisma/migrations/20250922165147_add_chat_models/migration.sql +0 -68
  321. package/prisma/migrations/20250922165314_add_chat_roles/migration.sql +0 -5
  322. package/prisma/migrations/20250922171242_refactor_notification_system/migration.sql +0 -20
  323. package/prisma/migrations/20250922172208_add_mentions_system/migration.sql +0 -21
  324. package/prisma/migrations/20250925072732_add_lab_chat_system/migration.sql +0 -25
  325. package/prisma/migrations/20251020151505_add_upload_tracking_fields/migration.sql +0 -57
  326. package/prisma/migrations/20251109122857_annuoncements_comments/migration.sql +0 -30
  327. package/prisma/migrations/20251109135555_reactions_announcements_comments/migration.sql +0 -35
  328. package/prisma/migrations/20260410124000_add_submission_recommendation_state/migration.sql +0 -14
  329. package/prisma/migrations/20260415103500_add_session_type/migration.sql +0 -33
  330. package/prisma/migrations/20260415133000_add_ai_usage_logs/migration.sql +0 -19
  331. package/prisma/migrations/20260417083000_add_conversation_member_hidden_at/migration.sql +0 -2
  332. package/prisma/migrations/20260502230500_assignment_submission_comments/migration.sql +0 -60
  333. package/prisma/migrations/20260504071500_school_memberships/migration.sql +0 -81
  334. package/prisma/migrations/20260506162500_consolidate_run_job_and_unify_job_status/migration.sql +0 -166
  335. package/prisma/migrations/20260509220000_institution_overhaul/migration.sql +0 -184
  336. package/prisma/migrations/20260510170000_add_folder_permissions/migration.sql +0 -93
  337. package/prisma/migrations/20260510183000_add_discussions/migration.sql +0 -151
  338. /package/.github/workflows/{npm.yml → npm-publish.yml} +0 -0
@@ -0,0 +1,226 @@
1
+ /**
2
+ * AI-powered discussion summary service with in-memory + Redis caching.
3
+ */
4
+ import { prisma } from "../lib/prisma.js";
5
+ import { getRedis } from "../lib/redis.js";
6
+ import { inference } from "../utils/inference.js";
7
+ import { logger } from "../utils/logger.js";
8
+ import { TRPCError } from "@trpc/server";
9
+ import { z } from "zod";
10
+ import { classMemberOrSchoolAdminWhere } from "../models/access.js";
11
+
12
+ const discussionSummarySchema = z.object({
13
+ summary: z
14
+ .string()
15
+ .describe("A concise 2-4 sentence TL;DR of the entire discussion thread."),
16
+ keyQuestions: z
17
+ .array(z.string())
18
+ .describe("Up to 5 important student questions raised in the thread."),
19
+ unresolvedIssues: z
20
+ .array(z.string())
21
+ .describe(
22
+ "Up to 3 questions or topics that remain unanswered or unresolved.",
23
+ ),
24
+ participantCount: z
25
+ .number()
26
+ .describe("Number of unique participants in the thread."),
27
+ });
28
+
29
+ export type DiscussionSummary = z.infer<typeof discussionSummarySchema>;
30
+
31
+ const REDIS_PREFIX = "discussionSummary:";
32
+ const REDIS_TTL_SECONDS = 60 * 30; // 30 min
33
+
34
+ const memoryCache = new Map<
35
+ string,
36
+ { value: DiscussionSummary; replyCount: number; cachedAt: number }
37
+ >();
38
+ const MEMORY_TTL_MS = 10 * 60 * 1000; // 10 min
39
+
40
+ function memoryCacheKey(discussionId: string) {
41
+ return discussionId;
42
+ }
43
+
44
+ function getMemoryCacheHit(
45
+ discussionId: string,
46
+ currentReplyCount: number,
47
+ ): DiscussionSummary | undefined {
48
+ const entry = memoryCache.get(memoryCacheKey(discussionId));
49
+ if (!entry) return undefined;
50
+ if (Date.now() - entry.cachedAt > MEMORY_TTL_MS) {
51
+ memoryCache.delete(memoryCacheKey(discussionId));
52
+ return undefined;
53
+ }
54
+ if (entry.replyCount !== currentReplyCount) {
55
+ memoryCache.delete(memoryCacheKey(discussionId));
56
+ return undefined;
57
+ }
58
+ return entry.value;
59
+ }
60
+
61
+ function setMemoryCache(
62
+ discussionId: string,
63
+ value: DiscussionSummary,
64
+ replyCount: number,
65
+ ) {
66
+ memoryCache.set(memoryCacheKey(discussionId), {
67
+ value,
68
+ replyCount,
69
+ cachedAt: Date.now(),
70
+ });
71
+ }
72
+
73
+ async function getRedisCacheHit(
74
+ discussionId: string,
75
+ currentReplyCount: number,
76
+ ): Promise<DiscussionSummary | undefined> {
77
+ const redis = getRedis();
78
+ if (!redis) return undefined;
79
+ try {
80
+ const raw = await redis.get(`${REDIS_PREFIX}${discussionId}`);
81
+ if (!raw) return undefined;
82
+ const parsed = JSON.parse(raw) as {
83
+ value: DiscussionSummary;
84
+ replyCount: number;
85
+ };
86
+ if (parsed.replyCount !== currentReplyCount) return undefined;
87
+ return parsed.value;
88
+ } catch {
89
+ return undefined;
90
+ }
91
+ }
92
+
93
+ async function setRedisCache(
94
+ discussionId: string,
95
+ value: DiscussionSummary,
96
+ replyCount: number,
97
+ ) {
98
+ const redis = getRedis();
99
+ if (!redis) return;
100
+ try {
101
+ await redis.set(
102
+ `${REDIS_PREFIX}${discussionId}`,
103
+ JSON.stringify({ value, replyCount }),
104
+ "EX",
105
+ REDIS_TTL_SECONDS,
106
+ );
107
+ } catch (err) {
108
+ logger.error("Failed to cache discussion summary in Redis", { err });
109
+ }
110
+ }
111
+
112
+ export async function getDiscussionSummary(
113
+ userId: string,
114
+ discussionId: string,
115
+ ): Promise<DiscussionSummary> {
116
+ const discussion = await prisma.discussion.findUnique({
117
+ where: { id: discussionId },
118
+ select: {
119
+ id: true,
120
+ classId: true,
121
+ title: true,
122
+ body: true,
123
+ type: true,
124
+ replies: {
125
+ where: { deletedAt: null },
126
+ select: {
127
+ body: true,
128
+ author: {
129
+ select: {
130
+ username: true,
131
+ profile: { select: { displayName: true } },
132
+ },
133
+ },
134
+ createdAt: true,
135
+ },
136
+ orderBy: { createdAt: "asc" },
137
+ },
138
+ author: {
139
+ select: {
140
+ username: true,
141
+ profile: { select: { displayName: true } },
142
+ },
143
+ },
144
+ },
145
+ });
146
+
147
+ if (!discussion) {
148
+ throw new TRPCError({
149
+ code: "NOT_FOUND",
150
+ message: "Discussion not found",
151
+ });
152
+ }
153
+
154
+ // Verify user is a member of the class
155
+ const classMembership = await prisma.class.findFirst({
156
+ where: { id: discussion.classId, ...classMemberOrSchoolAdminWhere(userId) },
157
+ select: { id: true },
158
+ });
159
+ if (!classMembership) {
160
+ throw new TRPCError({
161
+ code: "UNAUTHORIZED",
162
+ message: "You are not a member of this class",
163
+ });
164
+ }
165
+
166
+ const replyCount = discussion.replies.length;
167
+
168
+ // Check memory cache
169
+ const memoryCached = getMemoryCacheHit(discussionId, replyCount);
170
+ if (memoryCached) return memoryCached;
171
+
172
+ // Check Redis cache
173
+ const redisCached = await getRedisCacheHit(discussionId, replyCount);
174
+ if (redisCached) {
175
+ setMemoryCache(discussionId, redisCached, replyCount);
176
+ return redisCached;
177
+ }
178
+
179
+ // Build context for inference
180
+ const authorName =
181
+ discussion.author.profile?.displayName ?? discussion.author.username;
182
+ const threadText = [
183
+ `Title: ${discussion.title}`,
184
+ `Type: ${discussion.type}`,
185
+ `Author: ${authorName}`,
186
+ `Original post:\n${discussion.body}`,
187
+ "",
188
+ ...discussion.replies.map((reply, i) => {
189
+ const name =
190
+ reply.author.profile?.displayName ?? reply.author.username;
191
+ return `Reply ${i + 1} by ${name}:\n${reply.body}`;
192
+ }),
193
+ ].join("\n\n");
194
+
195
+ const messages = [
196
+ {
197
+ role: "system" as const,
198
+ content:
199
+ "You are a helpful assistant summarizing a class discussion thread for a teacher. Be concise, specific, and grounded in the actual content. Do not fabricate information.",
200
+ },
201
+ {
202
+ role: "user" as const,
203
+ content: threadText,
204
+ },
205
+ {
206
+ role: "user" as const,
207
+ content:
208
+ "Generate a structured summary of this discussion thread. Include a TL;DR, key student questions, and any unresolved issues.",
209
+ },
210
+ ];
211
+
212
+ const result = await inference<DiscussionSummary>(
213
+ messages,
214
+ discussionSummarySchema,
215
+ {
216
+ reason: "discussion_summary",
217
+ metadata: { discussionId, classId: discussion.classId },
218
+ },
219
+ );
220
+
221
+ // Cache result
222
+ setMemoryCache(discussionId, result, replyCount);
223
+ await setRedisCache(discussionId, result, replyCount);
224
+
225
+ return result;
226
+ }
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Embedding service – pgvector-backed RAG for class materials.
3
+ * Handles text chunking, embedding generation, storage, and semantic search.
4
+ */
5
+ import { prisma } from '../lib/prisma.js';
6
+ import { openAIClient } from '../utils/inference.js';
7
+ import { logger } from '../utils/logger.js';
8
+ import { enqueueRunJob } from '../workers/queues.js';
9
+ import type { EmbeddingSourceType } from '@prisma/client';
10
+
11
+ const EMBEDDING_MODEL = 'text-embedding-3-small';
12
+ const EMBEDDING_DIMENSIONS = 1536;
13
+ const CHUNK_SIZE = 512; // tokens (~2048 chars)
14
+ const CHUNK_OVERLAP = 100; // tokens (~400 chars)
15
+ const CHARS_PER_TOKEN = 4;
16
+
17
+ export interface EmbeddingSearchResult {
18
+ id: string;
19
+ sourceType: EmbeddingSourceType;
20
+ sourceId: string;
21
+ content: string;
22
+ metadata: Record<string, unknown>;
23
+ similarity: number;
24
+ }
25
+
26
+ /**
27
+ * Split text into overlapping chunks for embedding.
28
+ */
29
+ export function chunkText(text: string, chunkSize = CHUNK_SIZE, overlap = CHUNK_OVERLAP): string[] {
30
+ const maxChars = chunkSize * CHARS_PER_TOKEN;
31
+ const overlapChars = overlap * CHARS_PER_TOKEN;
32
+
33
+ if (text.length <= maxChars) {
34
+ return [text];
35
+ }
36
+
37
+ const chunks: string[] = [];
38
+ let start = 0;
39
+
40
+ while (start < text.length) {
41
+ const end = Math.min(start + maxChars, text.length);
42
+ chunks.push(text.slice(start, end));
43
+
44
+ if (end >= text.length) break;
45
+ start = end - overlapChars;
46
+ }
47
+
48
+ return chunks;
49
+ }
50
+
51
+ /**
52
+ * Generate embedding vector for a text string.
53
+ */
54
+ export async function generateEmbedding(text: string): Promise<number[]> {
55
+ const response = await openAIClient.embeddings.create({
56
+ model: EMBEDDING_MODEL,
57
+ input: text,
58
+ dimensions: EMBEDDING_DIMENSIONS,
59
+ });
60
+
61
+ return response.data[0].embedding;
62
+ }
63
+
64
+ /**
65
+ * Generate embeddings for multiple texts in a single batch.
66
+ */
67
+ export async function generateEmbeddings(texts: string[]): Promise<number[][]> {
68
+ if (texts.length === 0) return [];
69
+
70
+ const response = await openAIClient.embeddings.create({
71
+ model: EMBEDDING_MODEL,
72
+ input: texts,
73
+ dimensions: EMBEDDING_DIMENSIONS,
74
+ });
75
+
76
+ return response.data
77
+ .sort((a, b) => a.index - b.index)
78
+ .map((item) => item.embedding);
79
+ }
80
+
81
+ /**
82
+ * Store embeddings for a source document. Replaces any existing embeddings for
83
+ * the same sourceType + sourceId combination.
84
+ */
85
+ export async function indexContent(params: {
86
+ classId: string;
87
+ sourceType: EmbeddingSourceType;
88
+ sourceId: string;
89
+ text: string;
90
+ metadata?: Record<string, unknown>;
91
+ }): Promise<number> {
92
+ const { classId, sourceType, sourceId, text, metadata = {} } = params;
93
+
94
+ // Remove existing embeddings for this source
95
+ await prisma.embedding.deleteMany({
96
+ where: { classId, sourceType, sourceId },
97
+ });
98
+
99
+ const chunks = chunkText(text);
100
+ if (chunks.length === 0) return 0;
101
+
102
+ const embeddings = await generateEmbeddings(chunks);
103
+
104
+ // Insert using raw SQL for pgvector support
105
+ for (let i = 0; i < chunks.length; i++) {
106
+ const vectorStr = `[${embeddings[i].join(',')}]`;
107
+ const chunkMetadata = { ...metadata, chunkIndex: i, totalChunks: chunks.length };
108
+
109
+ await prisma.$executeRaw`
110
+ INSERT INTO "Embedding" (id, "classId", "sourceType", "sourceId", content, vector, metadata, "createdAt")
111
+ VALUES (
112
+ gen_random_uuid(),
113
+ ${classId},
114
+ ${sourceType}::"EmbeddingSourceType",
115
+ ${sourceId},
116
+ ${chunks[i]},
117
+ ${vectorStr}::vector,
118
+ ${JSON.stringify(chunkMetadata)}::jsonb,
119
+ NOW()
120
+ )
121
+ `;
122
+ }
123
+
124
+ logger.info('Content indexed', { classId, sourceType, sourceId, chunks: chunks.length });
125
+ return chunks.length;
126
+ }
127
+
128
+ /**
129
+ * Semantic search over class embeddings using cosine similarity.
130
+ */
131
+ export async function searchEmbeddings(params: {
132
+ classId: string;
133
+ query: string;
134
+ limit?: number;
135
+ sourceTypes?: EmbeddingSourceType[];
136
+ }): Promise<EmbeddingSearchResult[]> {
137
+ const { classId, query, limit = 5, sourceTypes } = params;
138
+
139
+ const queryEmbedding = await generateEmbedding(query);
140
+ const vectorStr = `[${queryEmbedding.join(',')}]`;
141
+
142
+ let results: EmbeddingSearchResult[];
143
+
144
+ if (sourceTypes && sourceTypes.length > 0) {
145
+ results = await prisma.$queryRaw<EmbeddingSearchResult[]>`
146
+ SELECT
147
+ id,
148
+ "sourceType",
149
+ "sourceId",
150
+ content,
151
+ metadata,
152
+ 1 - (vector <=> ${vectorStr}::vector) as similarity
153
+ FROM "Embedding"
154
+ WHERE "classId" = ${classId}
155
+ AND "sourceType" = ANY(${sourceTypes}::"EmbeddingSourceType"[])
156
+ ORDER BY vector <=> ${vectorStr}::vector
157
+ LIMIT ${limit}
158
+ `;
159
+ } else {
160
+ results = await prisma.$queryRaw<EmbeddingSearchResult[]>`
161
+ SELECT
162
+ id,
163
+ "sourceType",
164
+ "sourceId",
165
+ content,
166
+ metadata,
167
+ 1 - (vector <=> ${vectorStr}::vector) as similarity
168
+ FROM "Embedding"
169
+ WHERE "classId" = ${classId}
170
+ ORDER BY vector <=> ${vectorStr}::vector
171
+ LIMIT ${limit}
172
+ `;
173
+ }
174
+
175
+ return results;
176
+ }
177
+
178
+ /**
179
+ * Remove all embeddings for a given source.
180
+ */
181
+ export async function removeEmbeddings(params: {
182
+ classId: string;
183
+ sourceType: EmbeddingSourceType;
184
+ sourceId: string;
185
+ }): Promise<void> {
186
+ await prisma.embedding.deleteMany({
187
+ where: {
188
+ classId: params.classId,
189
+ sourceType: params.sourceType,
190
+ sourceId: params.sourceId,
191
+ },
192
+ });
193
+ }
194
+
195
+ /**
196
+ * Index an assignment (title + instructions) into the vector store.
197
+ */
198
+ export async function indexAssignment(classId: string, assignmentId: string): Promise<void> {
199
+ const assignment = await prisma.assignment.findUnique({
200
+ where: { id: assignmentId },
201
+ select: { title: true, instructions: true, type: true },
202
+ });
203
+
204
+ if (!assignment) return;
205
+
206
+ const text = [
207
+ `Title: ${assignment.title}`,
208
+ `Type: ${assignment.type}`,
209
+ assignment.instructions ? `Instructions: ${assignment.instructions}` : '',
210
+ ].filter(Boolean).join('\n');
211
+
212
+ await indexContent({
213
+ classId,
214
+ sourceType: 'ASSIGNMENT',
215
+ sourceId: assignmentId,
216
+ text,
217
+ metadata: { title: assignment.title, type: assignment.type },
218
+ });
219
+ }
220
+
221
+ /**
222
+ * Index a worksheet (questions + answers) into the vector store.
223
+ */
224
+ export async function indexWorksheet(classId: string, worksheetId: string): Promise<void> {
225
+ const worksheet = await prisma.worksheet.findUnique({
226
+ where: { id: worksheetId },
227
+ include: { questions: true },
228
+ });
229
+
230
+ if (!worksheet) return;
231
+
232
+ const questionTexts = worksheet.questions.map((q, i) =>
233
+ `Question ${i + 1} (${q.type}): ${q.question}\nAnswer: ${q.answer}`
234
+ );
235
+
236
+ const text = [
237
+ `Worksheet: ${worksheet.name}`,
238
+ ...questionTexts,
239
+ ].join('\n\n');
240
+
241
+ await indexContent({
242
+ classId,
243
+ sourceType: 'WORKSHEET',
244
+ sourceId: worksheetId,
245
+ text,
246
+ metadata: { name: worksheet.name, questionCount: worksheet.questions.length },
247
+ });
248
+ }
249
+
250
+ /**
251
+ * Index an announcement into the vector store.
252
+ */
253
+ export async function indexAnnouncement(classId: string, announcementId: string): Promise<void> {
254
+ const announcement = await prisma.announcement.findUnique({
255
+ where: { id: announcementId },
256
+ select: { remarks: true, teacherId: true },
257
+ });
258
+
259
+ if (!announcement || !announcement.remarks) return;
260
+
261
+ const text = `Announcement: ${announcement.remarks}`;
262
+
263
+ await indexContent({
264
+ classId,
265
+ sourceType: 'ANNOUNCEMENT',
266
+ sourceId: announcementId,
267
+ text,
268
+ metadata: { teacherId: announcement.teacherId },
269
+ });
270
+ }
271
+
272
+ /**
273
+ * Fire-and-forget: enqueue a background job to embed content.
274
+ * Safe to call from service functions after a create/update.
275
+ */
276
+ export function enqueueEmbedding(
277
+ classId: string,
278
+ sourceType: EmbeddingSourceType,
279
+ sourceId: string,
280
+ ): void {
281
+ enqueueRunJob('EMBED_CONTENT', { classId, sourceType: sourceType as string, sourceId }, { classId })
282
+ .catch((err) => {
283
+ logger.error('Failed to enqueue embedding job', { err, classId, sourceType, sourceId });
284
+ });
285
+ }
@@ -2,7 +2,7 @@
2
2
  * Message service – send, update, delete messages; mark as read; track mentions.
3
3
  * Broadcasts real-time updates via Pusher.
4
4
  */
5
- import type { Prisma } from "@prisma/client";
5
+ import type { AppTransactionClient } from "../lib/prisma.js";
6
6
  import { TRPCError } from "@trpc/server";
7
7
  import { prisma } from "../lib/prisma.js";
8
8
  import { chatChannel, pusher } from "../lib/pusher.js";
@@ -35,7 +35,7 @@ export function ensureMessageHasContentOrAttachments(
35
35
  }
36
36
 
37
37
  export async function claimMessageAttachments(
38
- tx: Prisma.TransactionClient,
38
+ tx: AppTransactionClient,
39
39
  userId: string,
40
40
  conversationId: string,
41
41
  messageId: string,
@@ -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"
@@ -395,3 +398,138 @@ export async function searchGlobalClasses(
395
398
 
396
399
  return { query, results: trimResults(results, limit) };
397
400
  }
401
+
402
+ const SOURCE_TYPE_TO_SCOPE: Record<EmbeddingSourceType, SearchScope> = {
403
+ ASSIGNMENT: "assignment",
404
+ WORKSHEET: "worksheet",
405
+ ANNOUNCEMENT: "announcement",
406
+ FILE: "file",
407
+ SECTION: "section",
408
+ };
409
+
410
+ const MIN_SIMILARITY = 0.25;
411
+
412
+ /**
413
+ * Cross-class semantic search via pgvector.
414
+ * Generates an embedding for `query`, searches across all classes the user
415
+ * belongs to, and maps hits back to SearchResult shape.
416
+ */
417
+ export async function searchSemanticGlobal(
418
+ userId: string,
419
+ input: { query: string; limit?: number },
420
+ ) {
421
+ const query = input.query.trim().slice(0, 200);
422
+ if (!query) {
423
+ return { query, results: [] as SearchResult[] };
424
+ }
425
+ const limit = Math.min(Math.max(input.limit ?? SEARCH_LIMIT, 1), 30);
426
+
427
+ const memberships = await prisma.class.findMany({
428
+ where: {
429
+ OR: [
430
+ { teachers: { some: { id: userId } } },
431
+ { students: { some: { id: userId } } },
432
+ ],
433
+ },
434
+ select: { id: true, name: true },
435
+ });
436
+ const allClassIds = memberships.map((cls) => cls.id);
437
+ if (allClassIds.length === 0) {
438
+ return { query, results: [] as SearchResult[] };
439
+ }
440
+ const classById = new Map(memberships.map((cls) => [cls.id, cls]));
441
+
442
+ let queryVector: number[];
443
+ try {
444
+ queryVector = await generateEmbedding(query);
445
+ } catch (err) {
446
+ logger.error("Semantic search embedding failed", { err });
447
+ return { query, results: [] as SearchResult[] };
448
+ }
449
+
450
+ const vectorStr = `[${queryVector.join(",")}]`;
451
+
452
+ type SemanticRow = {
453
+ sourceType: EmbeddingSourceType;
454
+ sourceId: string;
455
+ classId: string;
456
+ content: string;
457
+ similarity: number;
458
+ };
459
+
460
+ const hits = await prisma.$queryRaw<SemanticRow[]>`
461
+ SELECT DISTINCT ON ("sourceType", "sourceId")
462
+ "sourceType",
463
+ "sourceId",
464
+ "classId",
465
+ content,
466
+ 1 - (vector <=> ${vectorStr}::vector) as similarity
467
+ FROM "Embedding"
468
+ WHERE "classId" = ANY(${allClassIds}::text[])
469
+ ORDER BY "sourceType", "sourceId", vector <=> ${vectorStr}::vector
470
+ LIMIT ${limit * 3}
471
+ `;
472
+
473
+ const filtered = hits
474
+ .filter((h) => h.similarity >= MIN_SIMILARITY)
475
+ .sort((a, b) => b.similarity - a.similarity)
476
+ .slice(0, limit);
477
+
478
+ const results: SearchResult[] = [];
479
+ for (const hit of filtered) {
480
+ const cls = classById.get(hit.classId);
481
+ const className = cls?.name ?? "Class";
482
+ const scope = SOURCE_TYPE_TO_SCOPE[hit.sourceType];
483
+ if (!scope) continue;
484
+
485
+ const snippet = hit.content.replace(/\s+/g, " ").trim().slice(0, 140);
486
+
487
+ let title = snippet.slice(0, 60);
488
+ let href = `/class/${hit.classId}`;
489
+
490
+ if (scope === "assignment") {
491
+ const assignment = await prisma.assignment.findUnique({
492
+ where: { id: hit.sourceId },
493
+ select: { title: true },
494
+ });
495
+ title = assignment?.title ?? title;
496
+ href = `/class/${hit.classId}/assignment/${hit.sourceId}`;
497
+ } else if (scope === "worksheet") {
498
+ const worksheet = await prisma.worksheet.findUnique({
499
+ where: { id: hit.sourceId },
500
+ select: { name: true },
501
+ });
502
+ title = worksheet?.name ?? title;
503
+ href = `/class/${hit.classId}/worksheets`;
504
+ } else if (scope === "announcement") {
505
+ href = `/class/${hit.classId}`;
506
+ } else if (scope === "file") {
507
+ const file = await prisma.file.findUnique({
508
+ where: { id: hit.sourceId },
509
+ select: { name: true },
510
+ });
511
+ title = file?.name ?? title;
512
+ href = `/class/${hit.classId}/files`;
513
+ } else if (scope === "section") {
514
+ const section = await prisma.section.findUnique({
515
+ where: { id: hit.sourceId },
516
+ select: { name: true },
517
+ });
518
+ title = section?.name ?? title;
519
+ href = `/class/${hit.classId}/assignments`;
520
+ }
521
+
522
+ results.push({
523
+ id: `semantic:${hit.sourceId}`,
524
+ type: scope,
525
+ title,
526
+ subtitle: `${className} · ${scope.charAt(0).toUpperCase() + scope.slice(1)}`,
527
+ classId: hit.classId,
528
+ className,
529
+ href,
530
+ matchedText: snippet,
531
+ });
532
+ }
533
+
534
+ return { query, results };
535
+ }