@studious-lms/server 1.13.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 (306) 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 -198
  32. package/dist/models/class.d.ts.map +1 -1
  33. package/dist/models/class.js +2 -2
  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 +267 -115
  38. package/dist/models/comment.d.ts.map +1 -1
  39. package/dist/models/conversation.d.ts +199 -73
  40. package/dist/models/conversation.d.ts.map +1 -1
  41. package/dist/models/department.d.ts +196 -70
  42. package/dist/models/department.d.ts.map +1 -1
  43. package/dist/models/event.d.ts +287 -125
  44. package/dist/models/event.d.ts.map +1 -1
  45. package/dist/models/file.d.ts +389 -263
  46. package/dist/models/file.d.ts.map +1 -1
  47. package/dist/models/folder.d.ts +433 -181
  48. package/dist/models/folder.d.ts.map +1 -1
  49. package/dist/models/labChat.d.ts +209 -83
  50. package/dist/models/labChat.d.ts.map +1 -1
  51. package/dist/models/marketing.d.ts +80 -26
  52. package/dist/models/marketing.d.ts.map +1 -1
  53. package/dist/models/message.d.ts +95 -41
  54. package/dist/models/message.d.ts.map +1 -1
  55. package/dist/models/newtonChat.d.ts +83 -29
  56. package/dist/models/newtonChat.d.ts.map +1 -1
  57. package/dist/models/notification.d.ts +61 -25
  58. package/dist/models/notification.d.ts.map +1 -1
  59. package/dist/models/school.d.ts +349 -133
  60. package/dist/models/school.d.ts.map +1 -1
  61. package/dist/models/section.d.ts +216 -72
  62. package/dist/models/section.d.ts.map +1 -1
  63. package/dist/models/tag.d.ts +156 -48
  64. package/dist/models/tag.d.ts.map +1 -1
  65. package/dist/models/user.d.ts +82 -28
  66. package/dist/models/user.d.ts.map +1 -1
  67. package/dist/models/worksheet.d.ts +1020 -411
  68. package/dist/models/worksheet.d.ts.map +1 -1
  69. package/dist/models/worksheet.js +8 -2
  70. package/dist/models/worksheet.js.map +1 -1
  71. package/dist/pipelines/aiLabChat.d.ts +0 -8
  72. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  73. package/dist/pipelines/aiLabChat.js +50 -67
  74. package/dist/pipelines/aiLabChat.js.map +1 -1
  75. package/dist/pipelines/aiLabChatContract.d.ts +393 -388
  76. package/dist/pipelines/aiLabChatContract.d.ts.map +1 -1
  77. package/dist/pipelines/aiLabChatContract.js +63 -69
  78. package/dist/pipelines/aiLabChatContract.js.map +1 -1
  79. package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
  80. package/dist/pipelines/aiNewtonChat.js +41 -41
  81. package/dist/pipelines/aiNewtonChat.js.map +1 -1
  82. package/dist/pipelines/gradeWorksheet.d.ts +8 -8
  83. package/dist/pipelines/labChatPrompt.d.ts +10 -8
  84. package/dist/pipelines/labChatPrompt.d.ts.map +1 -1
  85. package/dist/pipelines/labChatPrompt.js +47 -67
  86. package/dist/pipelines/labChatPrompt.js.map +1 -1
  87. package/dist/pipelines/pdfToWorksheet.d.ts +2 -2
  88. package/dist/routers/_app.d.ts +2203 -1957
  89. package/dist/routers/_app.d.ts.map +1 -1
  90. package/dist/routers/_app.js +4 -2
  91. package/dist/routers/_app.js.map +1 -1
  92. package/dist/routers/admin.d.ts +13 -13
  93. package/dist/routers/agenda.d.ts +18 -18
  94. package/dist/routers/announcement.d.ts +32 -32
  95. package/dist/routers/assignment.d.ts +335 -335
  96. package/dist/routers/attendance.d.ts +10 -10
  97. package/dist/routers/auth.d.ts +2 -2
  98. package/dist/routers/class.d.ts +147 -147
  99. package/dist/routers/comment.d.ts +22 -22
  100. package/dist/routers/conversation.d.ts +17 -17
  101. package/dist/routers/department.d.ts +34 -34
  102. package/dist/routers/discussion.d.ts +65 -53
  103. package/dist/routers/discussion.d.ts.map +1 -1
  104. package/dist/routers/discussion.js +6 -2
  105. package/dist/routers/discussion.js.map +1 -1
  106. package/dist/routers/embedding.d.ts +69 -0
  107. package/dist/routers/embedding.d.ts.map +1 -0
  108. package/dist/routers/embedding.js +143 -0
  109. package/dist/routers/embedding.js.map +1 -0
  110. package/dist/routers/event.d.ts +39 -39
  111. package/dist/routers/file.d.ts +12 -12
  112. package/dist/routers/folder.d.ts +38 -38
  113. package/dist/routers/labChat.d.ts +19 -19
  114. package/dist/routers/marketing.d.ts +2 -2
  115. package/dist/routers/message.d.ts +13 -13
  116. package/dist/routers/newtonChat.d.ts +4 -4
  117. package/dist/routers/notifications.d.ts +9 -9
  118. package/dist/routers/permissions.d.ts +14 -14
  119. package/dist/routers/school.d.ts +55 -55
  120. package/dist/routers/search.d.ts +22 -1
  121. package/dist/routers/search.d.ts.map +1 -1
  122. package/dist/routers/search.js +9 -3
  123. package/dist/routers/search.js.map +1 -1
  124. package/dist/routers/section.d.ts +4 -4
  125. package/dist/routers/studentProgress.d.ts +1 -1
  126. package/dist/routers/tag.d.ts +7 -7
  127. package/dist/routers/user.d.ts +10 -10
  128. package/dist/routers/worksheet.d.ts +95 -57
  129. package/dist/routers/worksheet.d.ts.map +1 -1
  130. package/dist/routers/worksheet.js +26 -2
  131. package/dist/routers/worksheet.js.map +1 -1
  132. package/dist/routes/aiStream.d.ts +2 -0
  133. package/dist/routes/aiStream.d.ts.map +1 -0
  134. package/dist/routes/aiStream.js +696 -0
  135. package/dist/routes/aiStream.js.map +1 -0
  136. package/dist/seedDatabase.d.ts +2 -2
  137. package/dist/seedDatabase.js +6 -6
  138. package/dist/seedDatabase.js.map +1 -1
  139. package/dist/services/adminInstitution.d.ts +8 -8
  140. package/dist/services/adminRunJob.d.ts +4 -4
  141. package/dist/services/agenda.d.ts +18 -18
  142. package/dist/services/aiChatStream.d.ts +68 -0
  143. package/dist/services/aiChatStream.d.ts.map +1 -0
  144. package/dist/services/aiChatStream.js +157 -0
  145. package/dist/services/aiChatStream.js.map +1 -0
  146. package/dist/services/aiModifierTools.d.ts +32 -0
  147. package/dist/services/aiModifierTools.d.ts.map +1 -0
  148. package/dist/services/aiModifierTools.js +165 -0
  149. package/dist/services/aiModifierTools.js.map +1 -0
  150. package/dist/services/aiProposalTools.d.ts +32 -0
  151. package/dist/services/aiProposalTools.d.ts.map +1 -0
  152. package/dist/services/aiProposalTools.js +207 -0
  153. package/dist/services/aiProposalTools.js.map +1 -0
  154. package/dist/services/aiStream.d.ts +22 -0
  155. package/dist/services/aiStream.d.ts.map +1 -0
  156. package/dist/services/aiStream.js +142 -0
  157. package/dist/services/aiStream.js.map +1 -0
  158. package/dist/services/aiTools.d.ts +16 -0
  159. package/dist/services/aiTools.d.ts.map +1 -0
  160. package/dist/services/aiTools.js +271 -0
  161. package/dist/services/aiTools.js.map +1 -0
  162. package/dist/services/announcement.d.ts +12 -12
  163. package/dist/services/announcement.d.ts.map +1 -1
  164. package/dist/services/announcement.js +7 -2
  165. package/dist/services/announcement.js.map +1 -1
  166. package/dist/services/assignment.d.ts +293 -293
  167. package/dist/services/assignment.d.ts.map +1 -1
  168. package/dist/services/assignment.js +28 -9
  169. package/dist/services/assignment.js.map +1 -1
  170. package/dist/services/attendance.d.ts +10 -10
  171. package/dist/services/auth.d.ts +2 -2
  172. package/dist/services/class.d.ts +138 -138
  173. package/dist/services/class.d.ts.map +1 -1
  174. package/dist/services/class.js +19 -3
  175. package/dist/services/class.js.map +1 -1
  176. package/dist/services/comment.d.ts +22 -22
  177. package/dist/services/conversation.d.ts +17 -17
  178. package/dist/services/department.d.ts +33 -33
  179. package/dist/services/discussion.d.ts +58 -58
  180. package/dist/services/discussion.d.ts.map +1 -1
  181. package/dist/services/discussion.js +8 -8
  182. package/dist/services/discussion.js.map +1 -1
  183. package/dist/services/discussionSummary.d.ts +21 -0
  184. package/dist/services/discussionSummary.d.ts.map +1 -0
  185. package/dist/services/discussionSummary.js +179 -0
  186. package/dist/services/discussionSummary.js.map +1 -0
  187. package/dist/services/embedding.d.ts +67 -0
  188. package/dist/services/embedding.d.ts.map +1 -0
  189. package/dist/services/embedding.js +226 -0
  190. package/dist/services/embedding.js.map +1 -0
  191. package/dist/services/event.d.ts +37 -37
  192. package/dist/services/file.d.ts +24 -24
  193. package/dist/services/folder.d.ts +36 -36
  194. package/dist/services/labChat.d.ts +19 -19
  195. package/dist/services/marketing.d.ts +2 -2
  196. package/dist/services/message.d.ts +11 -11
  197. package/dist/services/message.d.ts.map +1 -1
  198. package/dist/services/message.js +2 -2
  199. package/dist/services/message.js.map +1 -1
  200. package/dist/services/newtonChat.d.ts +4 -4
  201. package/dist/services/notification.d.ts +9 -9
  202. package/dist/services/notification.d.ts.map +1 -1
  203. package/dist/services/permissions.d.ts +14 -14
  204. package/dist/services/school.d.ts +50 -50
  205. package/dist/services/search.d.ts +12 -0
  206. package/dist/services/search.d.ts.map +1 -1
  207. package/dist/services/search.js +120 -2
  208. package/dist/services/search.js.map +1 -1
  209. package/dist/services/section.d.ts +3 -3
  210. package/dist/services/tag.d.ts +4 -4
  211. package/dist/services/user.d.ts +4 -4
  212. package/dist/services/user.d.ts.map +1 -1
  213. package/dist/services/user.js +10 -15
  214. package/dist/services/user.js.map +1 -1
  215. package/dist/services/worksheet.d.ts +60 -48
  216. package/dist/services/worksheet.d.ts.map +1 -1
  217. package/dist/services/worksheet.js +11 -4
  218. package/dist/services/worksheet.js.map +1 -1
  219. package/dist/utils/streamBroadcast.d.ts +11 -0
  220. package/dist/utils/streamBroadcast.d.ts.map +1 -0
  221. package/dist/utils/streamBroadcast.js +32 -0
  222. package/dist/utils/streamBroadcast.js.map +1 -0
  223. package/dist/workers/connection.d.ts +15 -0
  224. package/dist/workers/connection.d.ts.map +1 -1
  225. package/dist/workers/connection.js +20 -2
  226. package/dist/workers/connection.js.map +1 -1
  227. package/dist/workers/index.js +4 -2
  228. package/dist/workers/index.js.map +1 -1
  229. package/dist/workers/processors/createRunJobWorker.d.ts.map +1 -1
  230. package/dist/workers/processors/createRunJobWorker.js +31 -8
  231. package/dist/workers/processors/createRunJobWorker.js.map +1 -1
  232. package/dist/workers/processors/embedContent.d.ts +8 -0
  233. package/dist/workers/processors/embedContent.d.ts.map +1 -0
  234. package/dist/workers/processors/embedContent.js +76 -0
  235. package/dist/workers/processors/embedContent.js.map +1 -0
  236. package/dist/workers/queues.d.ts +6 -0
  237. package/dist/workers/queues.d.ts.map +1 -1
  238. package/dist/workers/queues.js +5 -3
  239. package/dist/workers/queues.js.map +1 -1
  240. package/package.json +5 -1
  241. package/prisma/migrations/20260609215135_init/migration.sql +1412 -0
  242. package/prisma/migrations/20260610130000_add_worksheet_tags/migration.sql +13 -0
  243. package/prisma/schema.prisma +38 -7
  244. package/src/index.ts +4 -0
  245. package/src/lib/config/cors.ts +32 -9
  246. package/src/lib/config/env.ts +6 -0
  247. package/src/lib/googleCloudStorage.ts +21 -0
  248. package/src/lib/prisma.ts +40 -1
  249. package/src/models/class.ts +3 -2
  250. package/src/models/worksheet.ts +5 -2
  251. package/src/pipelines/aiLabChat.ts +54 -79
  252. package/src/pipelines/aiLabChatContract.ts +69 -69
  253. package/src/pipelines/aiNewtonChat.ts +46 -43
  254. package/src/pipelines/labChatPrompt.ts +63 -88
  255. package/src/routers/_app.ts +2 -0
  256. package/src/routers/discussion.ts +7 -0
  257. package/src/routers/embedding.ts +159 -0
  258. package/src/routers/search.ts +10 -1
  259. package/src/routers/worksheet.ts +27 -0
  260. package/src/routes/aiStream.ts +789 -0
  261. package/src/seedDatabase.ts +4 -4
  262. package/src/services/aiChatStream.ts +219 -0
  263. package/src/services/aiModifierTools.ts +188 -0
  264. package/src/services/aiProposalTools.ts +272 -0
  265. package/src/services/aiStream.ts +175 -0
  266. package/src/services/aiTools.ts +290 -0
  267. package/src/services/announcement.ts +7 -0
  268. package/src/services/assignment.ts +28 -7
  269. package/src/services/class.ts +20 -1
  270. package/src/services/discussion.ts +9 -8
  271. package/src/services/discussionSummary.ts +226 -0
  272. package/src/services/embedding.ts +285 -0
  273. package/src/services/message.ts +2 -2
  274. package/src/services/search.ts +138 -0
  275. package/src/services/user.ts +9 -17
  276. package/src/services/worksheet.ts +9 -2
  277. package/src/utils/streamBroadcast.ts +48 -0
  278. package/src/workers/connection.ts +19 -0
  279. package/src/workers/index.ts +2 -0
  280. package/src/workers/processors/createRunJobWorker.ts +32 -6
  281. package/src/workers/processors/embedContent.ts +90 -0
  282. package/src/workers/queues.ts +5 -1
  283. package/tests/unit/pipelines/aiLabChatContract.test.ts +38 -21
  284. package/tests/unit/pipelines/labChatPrompt.test.ts +23 -36
  285. package/prisma/migrations/20250807062924_init/migration.sql +0 -436
  286. package/prisma/migrations/20250919063704_init2/migration.sql +0 -24
  287. package/prisma/migrations/20250920143543_add_profile_fields/migration.sql +0 -15
  288. package/prisma/migrations/20250922165147_add_chat_models/migration.sql +0 -68
  289. package/prisma/migrations/20250922165314_add_chat_roles/migration.sql +0 -5
  290. package/prisma/migrations/20250922171242_refactor_notification_system/migration.sql +0 -20
  291. package/prisma/migrations/20250922172208_add_mentions_system/migration.sql +0 -21
  292. package/prisma/migrations/20250925072732_add_lab_chat_system/migration.sql +0 -25
  293. package/prisma/migrations/20251020151505_add_upload_tracking_fields/migration.sql +0 -57
  294. package/prisma/migrations/20251109122857_annuoncements_comments/migration.sql +0 -30
  295. package/prisma/migrations/20251109135555_reactions_announcements_comments/migration.sql +0 -35
  296. package/prisma/migrations/20260410124000_add_submission_recommendation_state/migration.sql +0 -14
  297. package/prisma/migrations/20260415103500_add_session_type/migration.sql +0 -33
  298. package/prisma/migrations/20260415133000_add_ai_usage_logs/migration.sql +0 -19
  299. package/prisma/migrations/20260417083000_add_conversation_member_hidden_at/migration.sql +0 -2
  300. package/prisma/migrations/20260502230500_assignment_submission_comments/migration.sql +0 -60
  301. package/prisma/migrations/20260504071500_school_memberships/migration.sql +0 -81
  302. package/prisma/migrations/20260506162500_consolidate_run_job_and_unify_job_status/migration.sql +0 -166
  303. package/prisma/migrations/20260509220000_institution_overhaul/migration.sql +0 -184
  304. package/prisma/migrations/20260510170000_add_folder_permissions/migration.sql +0 -93
  305. package/prisma/migrations/20260510183000_add_discussions/migration.sql +0 -151
  306. /package/.github/workflows/{npm.yml → npm-publish.yml} +0 -0
@@ -0,0 +1,290 @@
1
+ /**
2
+ * AI Tool definitions and execution for agent tool calls.
3
+ * Provides RAG search, assignment lookup, worksheet creation, student progress,
4
+ * and web search tools.
5
+ */
6
+ import { jsonSchema, type ToolSet } from 'ai';
7
+ import OpenAI from 'openai';
8
+ import { searchEmbeddings } from './embedding.js';
9
+ import { prisma } from '../lib/prisma.js';
10
+ import { logger } from '../utils/logger.js';
11
+
12
+ export interface ToolCallResult {
13
+ toolName: string;
14
+ result: unknown;
15
+ }
16
+
17
+ /**
18
+ * Build the tool set for a given class context.
19
+ * Tools are scoped to a specific class for security.
20
+ */
21
+ export function aiTools(classId: string): ToolSet {
22
+ return {
23
+ search_materials: {
24
+ description: 'Search class materials, files, assignments, worksheets, and announcements using semantic similarity. Use this to find relevant context for answering questions.',
25
+ inputSchema: jsonSchema({
26
+ type: 'object',
27
+ properties: {
28
+ query: { type: 'string', description: 'The search query to find relevant materials' },
29
+ limit: { type: 'number', description: 'Maximum results (1-10)', default: 5 },
30
+ },
31
+ required: ['query'],
32
+ }),
33
+ execute: async ({ query, limit = 5 }: { query: string; limit?: number }) => {
34
+ try {
35
+ const results = await searchEmbeddings({ classId, query, limit });
36
+ return results.map((r) => ({
37
+ sourceType: r.sourceType,
38
+ sourceId: r.sourceId,
39
+ content: r.content.slice(0, 500),
40
+ similarity: Math.round(r.similarity * 100) / 100,
41
+ metadata: r.metadata,
42
+ }));
43
+ } catch (error) {
44
+ logger.error('search_materials tool failed', { error, classId, query });
45
+ return [];
46
+ }
47
+ },
48
+ },
49
+
50
+ search_assignments: {
51
+ description: 'Search existing assignments by title, instructions, or type. Returns assignment details including due dates and submission types.',
52
+ inputSchema: jsonSchema({
53
+ type: 'object',
54
+ properties: {
55
+ query: { type: 'string', description: 'Search query for assignments' },
56
+ limit: { type: 'number', description: 'Maximum results (1-10)', default: 5 },
57
+ },
58
+ required: ['query'],
59
+ }),
60
+ execute: async ({ query, limit = 5 }: { query: string; limit?: number }) => {
61
+ try {
62
+ const results = await searchEmbeddings({
63
+ classId,
64
+ query,
65
+ limit,
66
+ sourceTypes: ['ASSIGNMENT'],
67
+ });
68
+
69
+ const assignmentIds = results.map((r) => r.sourceId);
70
+ const assignments = await prisma.assignment.findMany({
71
+ where: { id: { in: assignmentIds } },
72
+ select: {
73
+ id: true,
74
+ title: true,
75
+ instructions: true,
76
+ type: true,
77
+ dueDate: true,
78
+ maxGrade: true,
79
+ section: { select: { name: true } },
80
+ },
81
+ });
82
+
83
+ return assignments.map((a) => ({
84
+ id: a.id,
85
+ title: a.title,
86
+ instructions: a.instructions?.slice(0, 200) ?? null,
87
+ type: a.type,
88
+ dueDate: a.dueDate.toISOString(),
89
+ maxGrade: a.maxGrade,
90
+ section: a.section?.name ?? null,
91
+ }));
92
+ } catch (error) {
93
+ logger.error('search_assignments tool failed', { error, classId, query });
94
+ return [];
95
+ }
96
+ },
97
+ },
98
+
99
+ get_student_progress: {
100
+ description: 'Get progress summary for a specific student in this class, including grades and submission status.',
101
+ inputSchema: jsonSchema({
102
+ type: 'object',
103
+ properties: {
104
+ studentId: { type: 'string', description: 'The student user ID' },
105
+ },
106
+ required: ['studentId'],
107
+ }),
108
+ execute: async ({ studentId }: { studentId: string }) => {
109
+ try {
110
+ const submissions = await prisma.submission.findMany({
111
+ where: {
112
+ studentId,
113
+ assignment: { classId },
114
+ },
115
+ select: {
116
+ id: true,
117
+ gradeReceived: true,
118
+ submittedAt: true,
119
+ assignment: {
120
+ select: {
121
+ title: true,
122
+ maxGrade: true,
123
+ type: true,
124
+ },
125
+ },
126
+ },
127
+ orderBy: { submittedAt: 'desc' },
128
+ take: 20,
129
+ });
130
+
131
+ const totalAssignments = await prisma.assignment.count({
132
+ where: { classId },
133
+ });
134
+
135
+ const submitted = submissions.filter((s) => s.submittedAt);
136
+ const graded = submissions.filter((s) => s.gradeReceived !== null);
137
+ const avgGrade = graded.length > 0
138
+ ? graded.reduce((sum, s) => sum + (s.gradeReceived || 0), 0) / graded.length
139
+ : null;
140
+
141
+ return {
142
+ studentId,
143
+ totalAssignments,
144
+ submittedCount: submitted.length,
145
+ gradedCount: graded.length,
146
+ averageGrade: avgGrade ? Math.round(avgGrade * 10) / 10 : null,
147
+ recentSubmissions: submissions.slice(0, 5).map((s) => ({
148
+ assignment: s.assignment.title,
149
+ gradeReceived: s.gradeReceived,
150
+ maxGrade: s.assignment.maxGrade,
151
+ submittedAt: s.submittedAt?.toISOString() ?? null,
152
+ })),
153
+ };
154
+ } catch (error) {
155
+ logger.error('get_student_progress tool failed', { error, classId, studentId });
156
+ return { error: 'Failed to fetch student progress' };
157
+ }
158
+ },
159
+ },
160
+
161
+ get_worksheet_details: {
162
+ description: 'Get full details of a worksheet including all questions, answers, and mark schemes.',
163
+ inputSchema: jsonSchema({
164
+ type: 'object',
165
+ properties: {
166
+ worksheetId: { type: 'string', description: 'The worksheet ID to look up' },
167
+ },
168
+ required: ['worksheetId'],
169
+ }),
170
+ execute: async ({ worksheetId }: { worksheetId: string }) => {
171
+ try {
172
+ const worksheet = await prisma.worksheet.findUnique({
173
+ where: { id: worksheetId, classId },
174
+ include: {
175
+ questions: {
176
+ orderBy: { order: 'asc' },
177
+ },
178
+ },
179
+ });
180
+
181
+ if (!worksheet) {
182
+ return { error: 'Worksheet not found' };
183
+ }
184
+
185
+ return {
186
+ id: worksheet.id,
187
+ name: worksheet.name,
188
+ questions: worksheet.questions.map((q) => ({
189
+ id: q.id,
190
+ type: q.type,
191
+ question: q.question,
192
+ answer: q.answer,
193
+ options: q.options,
194
+ points: q.points,
195
+ order: q.order,
196
+ })),
197
+ };
198
+ } catch (error) {
199
+ logger.error('get_worksheet_details tool failed', { error, classId, worksheetId });
200
+ return { error: 'Failed to fetch worksheet' };
201
+ }
202
+ },
203
+ },
204
+
205
+ get_mark_scheme: {
206
+ description: 'Get a mark scheme (rubric) by ID, including criteria and point allocations.',
207
+ inputSchema: jsonSchema({
208
+ type: 'object',
209
+ properties: {
210
+ markSchemeId: { type: 'string', description: 'The mark scheme ID' },
211
+ },
212
+ required: ['markSchemeId'],
213
+ }),
214
+ execute: async ({ markSchemeId }: { markSchemeId: string }) => {
215
+ try {
216
+ const markScheme = await prisma.markScheme.findUnique({
217
+ where: { id: markSchemeId },
218
+ select: { id: true, name: true, structured: true },
219
+ });
220
+
221
+ if (!markScheme) {
222
+ return { error: 'Mark scheme not found' };
223
+ }
224
+
225
+ let parsed: unknown = {};
226
+ try { parsed = JSON.parse(markScheme.structured || '{}'); } catch { /* noop */ }
227
+
228
+ return {
229
+ id: markScheme.id,
230
+ name: markScheme.name,
231
+ structured: parsed,
232
+ };
233
+ } catch (error) {
234
+ logger.error('get_mark_scheme tool failed', { error, classId, markSchemeId });
235
+ return { error: 'Failed to fetch mark scheme' };
236
+ }
237
+ },
238
+ },
239
+
240
+ web_search: {
241
+ description: 'Search the web for current information on a topic. Use this when you need up-to-date facts, definitions, explanations, or context beyond the class materials.',
242
+ inputSchema: jsonSchema({
243
+ type: 'object',
244
+ properties: {
245
+ query: { type: 'string', description: 'The search query to look up on the web' },
246
+ },
247
+ required: ['query'],
248
+ }),
249
+ execute: async ({ query }: { query: string }) => {
250
+ try {
251
+ const client = new OpenAI();
252
+ const response = await client.responses.create({
253
+ model: 'gpt-4o-mini',
254
+ tools: [{ type: 'web_search_preview' }],
255
+ input: query,
256
+ });
257
+
258
+ const messageOutput = response.output.find(
259
+ (item) => item.type === 'message',
260
+ );
261
+ if (!messageOutput || messageOutput.type !== 'message') {
262
+ return { summary: 'No results found.', sources: [] };
263
+ }
264
+
265
+ const textContent = messageOutput.content.find(
266
+ (c) => c.type === 'output_text',
267
+ );
268
+ if (!textContent || textContent.type !== 'output_text') {
269
+ return { summary: 'No results found.', sources: [] };
270
+ }
271
+
272
+ const sources = textContent.annotations
273
+ .filter(
274
+ (a): a is Extract<typeof a, { type: 'url_citation' }> =>
275
+ a.type === 'url_citation',
276
+ )
277
+ .map((a) => ({ url: a.url, title: a.title }));
278
+
279
+ return {
280
+ summary: textContent.text.slice(0, 2000),
281
+ sources: sources.slice(0, 5),
282
+ };
283
+ } catch (error) {
284
+ logger.error('web_search tool failed', { error, query });
285
+ return { summary: 'Web search failed. Please try again.', sources: [] };
286
+ }
287
+ },
288
+ },
289
+ };
290
+ }
@@ -16,6 +16,7 @@ import { createDirectUploadFiles, type DirectUploadFile } from "../lib/fileUploa
16
16
  import { deleteFile } from "../lib/googleCloudStorage.js";
17
17
  import { sendToMultiple } from "./notification.js";
18
18
  import { logger } from "../utils/logger.js";
19
+ import { enqueueEmbedding } from "./embedding.js";
19
20
 
20
21
  export async function getAllAnnouncements(classId: string) {
21
22
  const announcements = await findAnnouncementsByClassId(classId);
@@ -127,6 +128,9 @@ export async function createAnnouncementRecord(
127
128
  logger.error("Failed to send announcement notifications:", error);
128
129
  });
129
130
 
131
+ // Auto-index announcement for RAG search
132
+ enqueueEmbedding(classId, 'ANNOUNCEMENT', announcement.id);
133
+
130
134
  return {
131
135
  announcement: announcementWithAttachments || announcement,
132
136
  uploadFiles: directUploadFiles.length > 0 ? directUploadFiles : undefined,
@@ -263,6 +267,9 @@ export async function updateAnnouncementRecord(
263
267
  },
264
268
  });
265
269
 
270
+ // Re-index announcement for RAG search after update
271
+ enqueueEmbedding(input.classId, 'ANNOUNCEMENT', input.id);
272
+
266
273
  return {
267
274
  announcement: updatedAnnouncement,
268
275
  uploadFiles: directUploadFiles.length > 0 ? directUploadFiles : undefined,
@@ -27,6 +27,7 @@ import { sendToMultiple } from "./notification.js";
27
27
  import { enqueueRunJob } from "../workers/queues.js";
28
28
  import { logger } from "../utils/logger.js";
29
29
  import { invalidateStudentProgressInferenceCache } from "./studentProgressInferenceCache.js";
30
+ import { enqueueEmbedding } from "./embedding.js";
30
31
 
31
32
  export async function assignmentExists(id: string) {
32
33
  const assignment = await findAssignmentById(id);
@@ -311,13 +312,18 @@ export async function createAssignmentRecord(
311
312
  }
312
313
  await Promise.all(fileOperations);
313
314
 
314
- sendToMultiple({
315
- receiverIds: (studentIds && studentIds.length > 0) ? studentIds : classData.students.map((s) => s.id),
316
- title: `New assignment in ${classData.name}`,
317
- content: `"${title}" is due ${new Date(dueDate).toLocaleDateString()}.\n\n[View assignment](/class/${classId}/assignments/${assignment.id})`,
318
- }).catch((error) => {
319
- logger.error("Failed to send assignment notifications:", error);
320
- });
315
+ if (!inProgress) {
316
+ sendToMultiple({
317
+ receiverIds: (studentIds && studentIds.length > 0) ? studentIds : classData.students.map((s) => s.id),
318
+ title: `New assignment in ${classData.name}`,
319
+ content: `"${title}" is due ${new Date(dueDate).toLocaleDateString()}.\n\n[View assignment](/class/${classId}/assignments/${assignment.id})`,
320
+ }).catch((error) => {
321
+ logger.error("Failed to send assignment notifications:", error);
322
+ });
323
+ }
324
+
325
+ // Auto-index assignment for RAG search
326
+ enqueueEmbedding(classId, 'ASSIGNMENT', assignment.id);
321
327
 
322
328
  return assignment;
323
329
  }
@@ -591,6 +597,21 @@ export async function updateAssignmentRecord(
591
597
  .catch((error) => logger.error("Failed to update max grade from rubric:", error));
592
598
  }
593
599
 
600
+ // Re-index assignment for RAG search after update
601
+ enqueueEmbedding(input.classId, 'ASSIGNMENT', input.id);
602
+ if (assignment.inProgress && input.inProgress === false && classData) {
603
+ const receiverIds = input.studentIds?.length
604
+ ? input.studentIds
605
+ : classData.students.map((s) => s.id);
606
+ sendToMultiple({
607
+ receiverIds,
608
+ title: `New assignment in ${classData.name}`,
609
+ content: `"${updatedAssignment.title}" is due ${new Date(updatedAssignment.dueDate).toLocaleDateString()}.\n\n[View assignment](/class/${classData.id}/assignments/${updatedAssignment.id})`,
610
+ }).catch((error) => {
611
+ logger.error("Failed to send assignment publish notifications:", error);
612
+ });
613
+ }
614
+
594
615
  return updatedAssignment;
595
616
  }
596
617
 
@@ -27,6 +27,8 @@ import {
27
27
  } from "../models/class.js";
28
28
  import { Class, Folder } from "@prisma/client";
29
29
  import { invalidateStudentProgressInferenceCache } from "./studentProgressInferenceCache.js";
30
+ import { sendToMultiple } from "./notification.js";
31
+ import { logger } from "../utils/logger.js";
30
32
 
31
33
  async function requireActiveSchoolMembers(schoolId: string, userIds: string[]) {
32
34
  const uniqueUserIds = [...new Set(userIds)];
@@ -748,7 +750,7 @@ export async function publishLabDraft(
748
750
  });
749
751
  }
750
752
 
751
- return prisma.assignment.update({
753
+ const published = await prisma.assignment.update({
752
754
  where: { id: draftId },
753
755
  data: {
754
756
  inProgress: false,
@@ -759,6 +761,23 @@ export async function publishLabDraft(
759
761
  modifiedAt: new Date(),
760
762
  },
761
763
  });
764
+
765
+ const classData = await prisma.class.findUnique({
766
+ where: { id: classId },
767
+ select: { name: true, students: { select: { id: true } } },
768
+ });
769
+
770
+ if (classData) {
771
+ sendToMultiple({
772
+ receiverIds: classData.students.map((s) => s.id),
773
+ title: `New assignment in ${classData.name}`,
774
+ content: `"${published.title}" is due ${new Date(published.dueDate).toLocaleDateString()}.\n\n[View assignment](/class/${classId}/assignments/${published.id})`,
775
+ }).catch((error) => {
776
+ logger.error("Failed to send lab draft publish notifications:", error);
777
+ });
778
+ }
779
+
780
+ return published;
762
781
  }
763
782
 
764
783
  export async function getFiles(classId: string) {
@@ -1,4 +1,5 @@
1
1
  import type { DiscussionType, Prisma, ReactionType } from "@prisma/client";
2
+ import type { AppTransactionClient } from "../lib/prisma.js";
2
3
  import { TRPCError } from "@trpc/server";
3
4
  import { prisma } from "../lib/prisma.js";
4
5
  import { discussionChannel, pusher } from "../lib/pusher.js";
@@ -225,7 +226,7 @@ async function getDiscussionForUser(discussionId: string, userId: string) {
225
226
  }
226
227
 
227
228
  async function claimDiscussionAttachments(
228
- tx: Prisma.TransactionClient,
229
+ tx: AppTransactionClient,
229
230
  userId: string,
230
231
  discussionId: string,
231
232
  attachmentIds: string[] = []
@@ -242,7 +243,7 @@ async function claimDiscussionAttachments(
242
243
  }
243
244
 
244
245
  async function claimReplyAttachments(
245
- tx: Prisma.TransactionClient,
246
+ tx: AppTransactionClient,
246
247
  userId: string,
247
248
  replyId: string,
248
249
  attachmentIds: string[] = []
@@ -674,28 +675,28 @@ export async function toggleDiscussionReaction(userId: string, input: { discussi
674
675
 
675
676
  export async function listDiscussionTags(userId: string, classId: string) {
676
677
  await getDiscussionClassAccess(classId, userId);
677
- return prisma.discussionTag.findMany({ where: { classId }, orderBy: { name: "asc" } });
678
+ return prisma.classTag.findMany({ where: { classId }, orderBy: { name: "asc" } });
678
679
  }
679
680
 
680
681
  export async function createDiscussionTag(userId: string, input: { classId: string; name: string; color: string }) {
681
682
  const isTeacher = await prisma.class.findFirst({ where: { id: input.classId, ...classTeacherOrSchoolAdminWhere(userId) }, select: { id: true, discussionsEnabled: true } });
682
683
  if (!isTeacher?.discussionsEnabled) throw new TRPCError({ code: "FORBIDDEN", message: "Only teachers can manage discussion tags" });
683
- return prisma.discussionTag.create({ data: { classId: input.classId, name: input.name.trim(), color: input.color } });
684
+ return prisma.classTag.create({ data: { classId: input.classId, name: input.name.trim(), color: input.color } });
684
685
  }
685
686
 
686
687
  export async function updateDiscussionTag(userId: string, input: { tagId: string; name?: string; color?: string }) {
687
- const tag = await prisma.discussionTag.findUnique({ where: { id: input.tagId }, select: { classId: true } });
688
+ const tag = await prisma.classTag.findUnique({ where: { id: input.tagId }, select: { classId: true } });
688
689
  if (!tag) throw new TRPCError({ code: "NOT_FOUND", message: "Tag not found" });
689
690
  const isTeacher = await prisma.class.findFirst({ where: { id: tag.classId, ...classTeacherOrSchoolAdminWhere(userId) }, select: { id: true, discussionsEnabled: true } });
690
691
  if (!isTeacher?.discussionsEnabled) throw new TRPCError({ code: "FORBIDDEN", message: "Only teachers can manage discussion tags" });
691
- return prisma.discussionTag.update({ where: { id: input.tagId }, data: { name: input.name?.trim(), color: input.color } });
692
+ return prisma.classTag.update({ where: { id: input.tagId }, data: { name: input.name?.trim(), color: input.color } });
692
693
  }
693
694
 
694
695
  export async function deleteDiscussionTag(userId: string, tagId: string) {
695
- const tag = await prisma.discussionTag.findUnique({ where: { id: tagId }, select: { classId: true } });
696
+ const tag = await prisma.classTag.findUnique({ where: { id: tagId }, select: { classId: true } });
696
697
  if (!tag) throw new TRPCError({ code: "NOT_FOUND", message: "Tag not found" });
697
698
  const isTeacher = await prisma.class.findFirst({ where: { id: tag.classId, ...classTeacherOrSchoolAdminWhere(userId) }, select: { id: true, discussionsEnabled: true } });
698
699
  if (!isTeacher?.discussionsEnabled) throw new TRPCError({ code: "FORBIDDEN", message: "Only teachers can manage discussion tags" });
699
- await prisma.discussionTag.delete({ where: { id: tagId } });
700
+ await prisma.classTag.delete({ where: { id: tagId } });
700
701
  return { success: true };
701
702
  }