@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,272 @@
1
+ /**
2
+ * Lab-chat "proposal" tools.
3
+ *
4
+ * In the agentic Lab pipeline the model produces create-actions by *calling
5
+ * tools* (e.g. `propose_assignment`) instead of returning a JSON blob. Each
6
+ * tool records a structured payload into a shared accumulator and returns a
7
+ * short confirmation so the model can keep narrating. After the stream ends
8
+ * the pipeline turns the accumulator into the `message.meta.*ToCreate` shape
9
+ * the frontend accept-cards already consume — so the data contract is
10
+ * preserved while the generation becomes genuine inline tool calls.
11
+ *
12
+ * NOTE: these tools intentionally use `jsonSchema()` rather than the zod
13
+ * `tool()` helper. Zod-typed `tool()` definitions with deeply nested schemas
14
+ * make the TS compiler instantiate excessively deep types (TS2589) and OOM,
15
+ * so we mirror the existing `aiTools.ts` pattern (plain JSON Schema + manually
16
+ * typed execute args).
17
+ */
18
+ import { jsonSchema, type ToolSet } from 'ai';
19
+ import { v4 } from 'uuid';
20
+ import type {
21
+ AssignmentToCreate,
22
+ WorksheetToCreate,
23
+ SectionToCreate,
24
+ DocToCreate,
25
+ } from '../pipelines/aiLabChatContract.js';
26
+ import type { DocumentBlock } from '../lib/jsonStyles.js';
27
+
28
+ export interface LabProposals {
29
+ assignments: AssignmentToCreate[];
30
+ worksheets: WorksheetToCreate[];
31
+ sections: SectionToCreate[];
32
+ docs: DocToCreate[];
33
+ }
34
+
35
+ export function createLabProposals(): LabProposals {
36
+ return { assignments: [], worksheets: [], sections: [], docs: [] };
37
+ }
38
+
39
+ function normalizeDueDate(input?: string | null): string {
40
+ if (input) {
41
+ const parsed = new Date(input);
42
+ if (!Number.isNaN(parsed.getTime())) return parsed.toISOString();
43
+ }
44
+ // Default: one week out.
45
+ return new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
46
+ }
47
+
48
+ const ASSIGNMENT_TYPES = [
49
+ 'HOMEWORK', 'QUIZ', 'TEST', 'PROJECT', 'ESSAY', 'DISCUSSION', 'PRESENTATION', 'LAB', 'OTHER',
50
+ ] as const;
51
+ type AssignmentType = (typeof ASSIGNMENT_TYPES)[number];
52
+
53
+ const QUESTION_TYPES = [
54
+ 'MULTIPLE_CHOICE', 'TRUE_FALSE', 'SHORT_ANSWER', 'LONG_ANSWER', 'MATH_EXPRESSION', 'ESSAY',
55
+ ] as const;
56
+ type QuestionType = (typeof QUESTION_TYPES)[number];
57
+
58
+ interface ProposeSectionArgs {
59
+ name: string;
60
+ color?: string | null;
61
+ }
62
+
63
+ interface ProposeWorksheetArgs {
64
+ title: string;
65
+ questions: Array<{
66
+ type: QuestionType;
67
+ question: string;
68
+ answer?: string;
69
+ options?: Array<{ text: string; isCorrect: boolean }>;
70
+ markScheme?: Array<{ points: number; description: string }>;
71
+ points?: number;
72
+ }>;
73
+ }
74
+
75
+ interface ProposeAssignmentArgs {
76
+ title: string;
77
+ instructions: string;
78
+ dueDate?: string;
79
+ type?: AssignmentType;
80
+ maxGrade?: number;
81
+ acceptFiles?: boolean;
82
+ acceptExtendedResponse?: boolean;
83
+ acceptWorksheet?: boolean;
84
+ }
85
+
86
+ interface GenerateDocumentArgs {
87
+ title: string;
88
+ blocks: DocumentBlock[];
89
+ }
90
+
91
+ /**
92
+ * Build the set of proposal tools backed by the given accumulator.
93
+ * Merge these with the read-only RAG tools from `aiTools(classId)`.
94
+ */
95
+ export function labProposalTools(proposals: LabProposals): ToolSet {
96
+ return {
97
+ propose_section: {
98
+ description:
99
+ 'Propose a new section (folder) to organise class content. Call once per section you want to create.',
100
+ inputSchema: jsonSchema({
101
+ type: 'object',
102
+ properties: {
103
+ name: { type: 'string', description: 'Section name' },
104
+ color: { type: 'string', description: 'Optional hex color like #3b82f6' },
105
+ },
106
+ required: ['name'],
107
+ }),
108
+ execute: async ({ name, color }: ProposeSectionArgs) => {
109
+ proposals.sections.push({ name, color: color ?? null });
110
+ return `Section "${name}" proposed.`;
111
+ },
112
+ },
113
+
114
+ propose_worksheet: {
115
+ description:
116
+ 'Propose a worksheet of questions for the teacher to review and create. Include all questions in a single call.',
117
+ inputSchema: jsonSchema({
118
+ type: 'object',
119
+ properties: {
120
+ title: { type: 'string', description: 'Worksheet title' },
121
+ questions: {
122
+ type: 'array',
123
+ description: 'The list of questions',
124
+ items: {
125
+ type: 'object',
126
+ properties: {
127
+ type: { type: 'string', enum: QUESTION_TYPES as unknown as string[] },
128
+ question: { type: 'string' },
129
+ answer: { type: 'string' },
130
+ options: {
131
+ type: 'array',
132
+ description: 'For MULTIPLE_CHOICE/TRUE_FALSE questions',
133
+ items: {
134
+ type: 'object',
135
+ properties: {
136
+ text: { type: 'string' },
137
+ isCorrect: { type: 'boolean' },
138
+ },
139
+ required: ['text', 'isCorrect'],
140
+ },
141
+ },
142
+ markScheme: {
143
+ type: 'array',
144
+ items: {
145
+ type: 'object',
146
+ properties: {
147
+ points: { type: 'number' },
148
+ description: { type: 'string' },
149
+ },
150
+ required: ['points', 'description'],
151
+ },
152
+ },
153
+ points: { type: 'number' },
154
+ },
155
+ required: ['type', 'question'],
156
+ },
157
+ },
158
+ },
159
+ required: ['title', 'questions'],
160
+ }),
161
+ execute: async ({ title, questions }: ProposeWorksheetArgs) => {
162
+ const worksheet: WorksheetToCreate = {
163
+ title,
164
+ questions: (questions ?? []).map((q, index) => ({
165
+ type: q.type,
166
+ question: q.question,
167
+ answer: q.answer ?? '',
168
+ options: (q.options ?? []).map((o) => ({
169
+ id: v4(),
170
+ text: o.text,
171
+ isCorrect: o.isCorrect,
172
+ })),
173
+ markScheme: (q.markScheme ?? []).map((m) => ({
174
+ id: v4(),
175
+ points: m.points,
176
+ description: m.description,
177
+ })),
178
+ points: q.points ?? 0,
179
+ order: index,
180
+ })),
181
+ };
182
+ proposals.worksheets.push(worksheet);
183
+ return `Worksheet "${title}" with ${worksheet.questions.length} question(s) proposed.`;
184
+ },
185
+ },
186
+
187
+ propose_assignment: {
188
+ description:
189
+ 'Propose an assignment for the teacher to review and create. Call once per assignment.',
190
+ inputSchema: jsonSchema({
191
+ type: 'object',
192
+ properties: {
193
+ title: { type: 'string' },
194
+ instructions: { type: 'string' },
195
+ dueDate: {
196
+ type: 'string',
197
+ description: 'ISO 8601 datetime. Defaults to one week from now if omitted.',
198
+ },
199
+ type: { type: 'string', enum: ASSIGNMENT_TYPES as unknown as string[] },
200
+ maxGrade: { type: 'number' },
201
+ acceptFiles: { type: 'boolean' },
202
+ acceptExtendedResponse: { type: 'boolean' },
203
+ acceptWorksheet: { type: 'boolean' },
204
+ },
205
+ required: ['title', 'instructions'],
206
+ }),
207
+ execute: async ({
208
+ title,
209
+ instructions,
210
+ dueDate,
211
+ type,
212
+ maxGrade,
213
+ acceptFiles,
214
+ acceptExtendedResponse,
215
+ acceptWorksheet,
216
+ }: ProposeAssignmentArgs) => {
217
+ const assignment: AssignmentToCreate = {
218
+ title,
219
+ instructions,
220
+ dueDate: normalizeDueDate(dueDate),
221
+ type: type ?? 'HOMEWORK',
222
+ maxGrade: maxGrade ?? 100,
223
+ acceptFiles: acceptFiles ?? true,
224
+ acceptExtendedResponse: acceptExtendedResponse ?? false,
225
+ acceptWorksheet: acceptWorksheet ?? false,
226
+ gradingBoundaryId: null,
227
+ markSchemeId: null,
228
+ worksheetIds: [],
229
+ studentIds: [],
230
+ sectionId: null,
231
+ attachments: [],
232
+ };
233
+ proposals.assignments.push(assignment);
234
+ return `Assignment "${title}" proposed.`;
235
+ },
236
+ },
237
+
238
+ generate_document: {
239
+ description:
240
+ 'Generate a document (rendered to a PDF and attached to the message). Provide the document title and an ordered list of formatting blocks.',
241
+ inputSchema: jsonSchema({
242
+ type: 'object',
243
+ properties: {
244
+ title: { type: 'string' },
245
+ blocks: {
246
+ type: 'array',
247
+ description: 'Ordered list of document formatting blocks',
248
+ items: {
249
+ type: 'object',
250
+ properties: {
251
+ format: { type: 'number', description: 'Block format id (0-12)' },
252
+ content: {
253
+ description: 'String, or array of strings for list blocks',
254
+ },
255
+ metadata: {
256
+ type: 'object',
257
+ description: 'Optional styling metadata',
258
+ },
259
+ },
260
+ required: ['format', 'content'],
261
+ },
262
+ },
263
+ },
264
+ required: ['title', 'blocks'],
265
+ }),
266
+ execute: async ({ title, blocks }: GenerateDocumentArgs) => {
267
+ proposals.docs.push({ title, blocks } as unknown as DocToCreate);
268
+ return `Document "${title}" queued for generation.`;
269
+ },
270
+ },
271
+ };
272
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * AI Streaming service – handles streamed AI responses with tool calls.
3
+ * Uses Vercel AI SDK for streaming and Redis for temporary chunk caching.
4
+ */
5
+ import { streamText, stepCountIs, type ModelMessage } from 'ai';
6
+ import { openai } from '@ai-sdk/openai';
7
+ import { getRedis } from '../lib/redis.js';
8
+ import { prisma } from '../lib/prisma.js';
9
+ import { pusher, chatChannel } from '../lib/pusher.js';
10
+ import { getAIUserId, ensureAIUserExists } from '../utils/aiUser.js';
11
+ import { logger } from '../utils/logger.js';
12
+ import { JobStatus } from '@prisma/client';
13
+ import { aiTools, type ToolCallResult } from './aiTools.js';
14
+
15
+ const STREAM_REDIS_TTL = 600; // 10 minutes TTL for stream keys
16
+ const STREAM_KEY_PREFIX = 'stream:';
17
+
18
+ export interface StreamOptions {
19
+ conversationId: string;
20
+ aiMessageId: string;
21
+ messages: ModelMessage[];
22
+ systemPrompt: string;
23
+ classId?: string;
24
+ enableTools?: boolean;
25
+ model?: string;
26
+ temperature?: number;
27
+ maxOutputTokens?: number;
28
+ }
29
+
30
+ /**
31
+ * Stream an AI response, broadcasting chunks via Pusher and caching in Redis.
32
+ * On completion, persists the full response to the database.
33
+ */
34
+ export async function streamAIResponse(options: StreamOptions): Promise<void> {
35
+ const {
36
+ conversationId,
37
+ aiMessageId,
38
+ messages,
39
+ systemPrompt,
40
+ classId,
41
+ enableTools = true,
42
+ model = 'gpt-4o-mini',
43
+ temperature = 0.7,
44
+ maxOutputTokens = 4096,
45
+ } = options;
46
+
47
+ await ensureAIUserExists();
48
+
49
+ const redis = getRedis();
50
+ const streamKey = `${STREAM_KEY_PREFIX}${aiMessageId}`;
51
+
52
+ // Initialize Redis stream cache
53
+ if (redis) {
54
+ await redis.set(streamKey, '', 'EX', STREAM_REDIS_TTL);
55
+ }
56
+
57
+ let fullContent = '';
58
+ const toolCallResults: ToolCallResult[] = [];
59
+
60
+ try {
61
+ const toolSet = enableTools && classId ? aiTools(classId) : undefined;
62
+
63
+ const result = streamText({
64
+ model: openai(model),
65
+ system: systemPrompt,
66
+ messages,
67
+ tools: toolSet,
68
+ temperature,
69
+ maxOutputTokens,
70
+ stopWhen: stepCountIs(5),
71
+ onStepFinish: async ({ toolCalls, toolResults }) => {
72
+ if (toolCalls && toolCalls.length > 0) {
73
+ for (const tc of toolCalls) {
74
+ try {
75
+ await pusher.trigger(chatChannel(conversationId), 'message-tool-call', {
76
+ messageId: aiMessageId,
77
+ toolName: tc.toolName,
78
+ status: 'executing',
79
+ });
80
+ } catch (err) {
81
+ logger.error('Failed to broadcast tool call event', { err, aiMessageId });
82
+ }
83
+ }
84
+ }
85
+
86
+ if (toolResults) {
87
+ for (const tr of toolResults) {
88
+ toolCallResults.push({
89
+ toolName: tr.toolName,
90
+ result: tr.output,
91
+ });
92
+ }
93
+ }
94
+ },
95
+ });
96
+
97
+ // Stream text chunks to client
98
+ for await (const chunk of result.textStream) {
99
+ fullContent += chunk;
100
+
101
+ // Cache in Redis
102
+ if (redis) {
103
+ await redis.append(streamKey, chunk);
104
+ }
105
+
106
+ // Broadcast chunk to client via Pusher
107
+ try {
108
+ await pusher.trigger(chatChannel(conversationId), 'message-stream-chunk', {
109
+ messageId: aiMessageId,
110
+ chunk,
111
+ accumulated: fullContent.length,
112
+ });
113
+ } catch (err) {
114
+ logger.warn('Failed to broadcast stream chunk', { err, aiMessageId });
115
+ }
116
+ }
117
+
118
+ // Stream complete — persist to DB
119
+ const meta = toolCallResults.length > 0
120
+ ? JSON.stringify({ toolCalls: toolCallResults })
121
+ : undefined;
122
+
123
+ await prisma.message.update({
124
+ where: { id: aiMessageId },
125
+ data: {
126
+ content: fullContent,
127
+ status: JobStatus.COMPLETED,
128
+ ...(meta ? { meta: JSON.parse(meta) } : {}),
129
+ },
130
+ });
131
+
132
+ // Broadcast final message-updated event
133
+ await pusher.trigger(chatChannel(conversationId), 'message-updated', {
134
+ id: aiMessageId,
135
+ content: fullContent,
136
+ senderId: getAIUserId(),
137
+ conversationId,
138
+ status: JobStatus.COMPLETED,
139
+ sender: {
140
+ id: getAIUserId(),
141
+ username: 'Newton_AI',
142
+ profile: { displayName: 'Newton AI', profilePicture: null },
143
+ },
144
+ mentionedUserIds: [],
145
+ meta: meta ? JSON.parse(meta) : {},
146
+ attachments: [],
147
+ });
148
+
149
+ logger.info('AI stream completed', {
150
+ aiMessageId,
151
+ conversationId,
152
+ contentLength: fullContent.length,
153
+ toolCalls: toolCallResults.length,
154
+ });
155
+ } catch (error) {
156
+ logger.error('AI stream failed', { error, aiMessageId, conversationId });
157
+
158
+ await prisma.message.update({
159
+ where: { id: aiMessageId },
160
+ data: { status: JobStatus.FAILED },
161
+ });
162
+
163
+ await pusher.trigger(chatChannel(conversationId), 'message-updated', {
164
+ id: aiMessageId,
165
+ conversationId,
166
+ status: JobStatus.FAILED,
167
+ });
168
+
169
+ throw error;
170
+ } finally {
171
+ if (redis) {
172
+ await redis.del(streamKey);
173
+ }
174
+ }
175
+ }