@studious-lms/server 1.14.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/.github/workflows/deploy.yml +1 -1
  2. package/dist/instrument.js +3 -3
  3. package/dist/instrument.js.map +1 -1
  4. package/dist/lib/inferDocumentFormat.d.ts +10 -0
  5. package/dist/lib/inferDocumentFormat.d.ts.map +1 -0
  6. package/dist/lib/inferDocumentFormat.js +48 -0
  7. package/dist/lib/inferDocumentFormat.js.map +1 -0
  8. package/dist/lib/legal.d.ts +3 -0
  9. package/dist/lib/legal.d.ts.map +1 -0
  10. package/dist/lib/legal.js +6 -0
  11. package/dist/lib/legal.js.map +1 -0
  12. package/dist/models/announcement.d.ts +47 -45
  13. package/dist/models/announcement.d.ts.map +1 -1
  14. package/dist/models/assignment.d.ts +75 -75
  15. package/dist/models/auth.d.ts +8 -0
  16. package/dist/models/auth.d.ts.map +1 -1
  17. package/dist/models/auth.js +4 -2
  18. package/dist/models/auth.js.map +1 -1
  19. package/dist/models/class.d.ts +13 -13
  20. package/dist/models/comment.d.ts +6 -6
  21. package/dist/models/conversation.d.ts +23 -23
  22. package/dist/models/event.d.ts +20 -18
  23. package/dist/models/event.d.ts.map +1 -1
  24. package/dist/models/file.d.ts +33 -9
  25. package/dist/models/file.d.ts.map +1 -1
  26. package/dist/models/labChat.d.ts +4 -4
  27. package/dist/models/marketing.d.ts +2 -15
  28. package/dist/models/marketing.d.ts.map +1 -1
  29. package/dist/models/marketing.js +12 -4
  30. package/dist/models/marketing.js.map +1 -1
  31. package/dist/models/message.d.ts +13 -13
  32. package/dist/models/newtonChat.d.ts +3 -3
  33. package/dist/models/notification.d.ts +4 -4
  34. package/dist/models/worksheet.d.ts +121 -121
  35. package/dist/pipelines/aiLabChat.d.ts +7 -1
  36. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  37. package/dist/pipelines/aiLabChat.js +153 -67
  38. package/dist/pipelines/aiLabChat.js.map +1 -1
  39. package/dist/pipelines/aiLabChatContract.d.ts +34 -195
  40. package/dist/pipelines/aiLabChatContract.d.ts.map +1 -1
  41. package/dist/pipelines/aiLabChatContract.js +9 -19
  42. package/dist/pipelines/aiLabChatContract.js.map +1 -1
  43. package/dist/pipelines/gradeWorksheet.d.ts +6 -6
  44. package/dist/pipelines/labChatPrompt.d.ts +8 -3
  45. package/dist/pipelines/labChatPrompt.d.ts.map +1 -1
  46. package/dist/pipelines/labChatPrompt.js +52 -14
  47. package/dist/pipelines/labChatPrompt.js.map +1 -1
  48. package/dist/pipelines/pdfToWorksheet.d.ts +1 -1
  49. package/dist/routers/_app.d.ts +750 -546
  50. package/dist/routers/_app.d.ts.map +1 -1
  51. package/dist/routers/announcement.d.ts +40 -40
  52. package/dist/routers/assignment.d.ts +131 -121
  53. package/dist/routers/assignment.d.ts.map +1 -1
  54. package/dist/routers/auth.d.ts +2 -0
  55. package/dist/routers/auth.d.ts.map +1 -1
  56. package/dist/routers/auth.js +15 -2
  57. package/dist/routers/auth.js.map +1 -1
  58. package/dist/routers/class.d.ts +12 -8
  59. package/dist/routers/class.d.ts.map +1 -1
  60. package/dist/routers/comment.d.ts +5 -5
  61. package/dist/routers/conversation.d.ts +6 -6
  62. package/dist/routers/discussion.d.ts +3 -3
  63. package/dist/routers/embedding.d.ts +6 -2
  64. package/dist/routers/embedding.d.ts.map +1 -1
  65. package/dist/routers/embedding.js +44 -36
  66. package/dist/routers/embedding.js.map +1 -1
  67. package/dist/routers/event.d.ts +20 -18
  68. package/dist/routers/event.d.ts.map +1 -1
  69. package/dist/routers/file.d.ts +2 -2
  70. package/dist/routers/labChat.d.ts +3 -3
  71. package/dist/routers/marketing.d.ts +0 -13
  72. package/dist/routers/marketing.d.ts.map +1 -1
  73. package/dist/routers/notifications.d.ts +4 -4
  74. package/dist/routers/school.d.ts +4 -0
  75. package/dist/routers/school.d.ts.map +1 -1
  76. package/dist/routers/search.d.ts +2 -2
  77. package/dist/routers/user.d.ts +89 -0
  78. package/dist/routers/user.d.ts.map +1 -1
  79. package/dist/routers/user.js +19 -2
  80. package/dist/routers/user.js.map +1 -1
  81. package/dist/routers/worksheet.d.ts +44 -44
  82. package/dist/routes/aiStream.d.ts.map +1 -1
  83. package/dist/routes/aiStream.js +41 -11
  84. package/dist/routes/aiStream.js.map +1 -1
  85. package/dist/seedDatabase.d.ts +3 -1
  86. package/dist/seedDatabase.d.ts.map +1 -1
  87. package/dist/services/aiChatStream.d.ts +20 -0
  88. package/dist/services/aiChatStream.d.ts.map +1 -1
  89. package/dist/services/aiChatStream.js +110 -13
  90. package/dist/services/aiChatStream.js.map +1 -1
  91. package/dist/services/aiModifierTools.d.ts +0 -16
  92. package/dist/services/aiModifierTools.d.ts.map +1 -1
  93. package/dist/services/aiModifierTools.js +112 -3
  94. package/dist/services/aiModifierTools.js.map +1 -1
  95. package/dist/services/aiProposalTools.d.ts.map +1 -1
  96. package/dist/services/aiProposalTools.js +25 -25
  97. package/dist/services/aiProposalTools.js.map +1 -1
  98. package/dist/services/aiTools.js +3 -3
  99. package/dist/services/aiTools.js.map +1 -1
  100. package/dist/services/announcement.d.ts +34 -34
  101. package/dist/services/assignment.d.ts +121 -111
  102. package/dist/services/assignment.d.ts.map +1 -1
  103. package/dist/services/assignment.js +4 -2
  104. package/dist/services/assignment.js.map +1 -1
  105. package/dist/services/auth.d.ts +2 -0
  106. package/dist/services/auth.d.ts.map +1 -1
  107. package/dist/services/auth.js +13 -4
  108. package/dist/services/auth.js.map +1 -1
  109. package/dist/services/class.d.ts +10 -6
  110. package/dist/services/class.d.ts.map +1 -1
  111. package/dist/services/class.js +7 -3
  112. package/dist/services/class.js.map +1 -1
  113. package/dist/services/comment.d.ts +5 -5
  114. package/dist/services/conversation.d.ts +6 -6
  115. package/dist/services/discussion.d.ts.map +1 -1
  116. package/dist/services/discussion.js +9 -2
  117. package/dist/services/discussion.js.map +1 -1
  118. package/dist/services/embedding.d.ts +19 -1
  119. package/dist/services/embedding.d.ts.map +1 -1
  120. package/dist/services/embedding.js +154 -8
  121. package/dist/services/embedding.js.map +1 -1
  122. package/dist/services/event.d.ts +20 -18
  123. package/dist/services/event.d.ts.map +1 -1
  124. package/dist/services/file.d.ts +4 -4
  125. package/dist/services/folder.d.ts.map +1 -1
  126. package/dist/services/folder.js +32 -2
  127. package/dist/services/folder.js.map +1 -1
  128. package/dist/services/labChat.d.ts +3 -3
  129. package/dist/services/marketing.d.ts +1 -14
  130. package/dist/services/marketing.d.ts.map +1 -1
  131. package/dist/services/marketing.js +5 -5
  132. package/dist/services/marketing.js.map +1 -1
  133. package/dist/services/notification.d.ts +4 -4
  134. package/dist/services/openaiDocumentGen.d.ts +37 -0
  135. package/dist/services/openaiDocumentGen.d.ts.map +1 -0
  136. package/dist/services/openaiDocumentGen.js +262 -0
  137. package/dist/services/openaiDocumentGen.js.map +1 -0
  138. package/dist/services/school.d.ts +4 -0
  139. package/dist/services/school.d.ts.map +1 -1
  140. package/dist/services/search.d.ts +1 -1
  141. package/dist/services/search.d.ts.map +1 -1
  142. package/dist/services/search.js +27 -2
  143. package/dist/services/search.js.map +1 -1
  144. package/dist/services/userPrivacy.d.ts +86 -0
  145. package/dist/services/userPrivacy.d.ts.map +1 -0
  146. package/dist/services/userPrivacy.js +184 -0
  147. package/dist/services/userPrivacy.js.map +1 -0
  148. package/dist/services/worksheet.d.ts +66 -49
  149. package/dist/services/worksheet.d.ts.map +1 -1
  150. package/dist/services/worksheet.js +48 -3
  151. package/dist/services/worksheet.js.map +1 -1
  152. package/dist/utils/email.d.ts.map +1 -1
  153. package/dist/utils/email.js +7 -3
  154. package/dist/utils/email.js.map +1 -1
  155. package/dist/workers/processors/embedContent.d.ts.map +1 -1
  156. package/dist/workers/processors/embedContent.js +16 -25
  157. package/dist/workers/processors/embedContent.js.map +1 -1
  158. package/package.json +1 -1
  159. package/prisma/migrations/20260610140000_expand_embedding_source_types/migration.sql +4 -0
  160. package/prisma/migrations/20260611120000_add_user_terms_acceptance/migration.sql +3 -0
  161. package/prisma/schema.prisma +6 -0
  162. package/src/instrument.ts +1 -1
  163. package/src/lib/inferDocumentFormat.ts +58 -0
  164. package/src/lib/legal.ts +2 -0
  165. package/src/models/auth.ts +4 -0
  166. package/src/models/marketing.ts +10 -2
  167. package/src/pipelines/aiLabChat.ts +198 -74
  168. package/src/pipelines/aiLabChatContract.ts +7 -17
  169. package/src/pipelines/labChatPrompt.ts +64 -14
  170. package/src/routers/auth.ts +13 -0
  171. package/src/routers/embedding.ts +44 -39
  172. package/src/routers/user.ts +26 -0
  173. package/src/routes/aiStream.ts +47 -9
  174. package/src/services/aiChatStream.ts +133 -11
  175. package/src/services/aiModifierTools.ts +154 -1
  176. package/src/services/aiProposalTools.ts +27 -25
  177. package/src/services/aiTools.ts +1 -1
  178. package/src/services/assignment.ts +3 -0
  179. package/src/services/auth.ts +14 -2
  180. package/src/services/class.ts +7 -1
  181. package/src/services/discussion.ts +13 -0
  182. package/src/services/embedding.ts +167 -6
  183. package/src/services/folder.ts +31 -0
  184. package/src/services/marketing.ts +3 -3
  185. package/src/services/openaiDocumentGen.ts +319 -0
  186. package/src/services/search.ts +25 -1
  187. package/src/services/userPrivacy.ts +196 -0
  188. package/src/services/worksheet.ts +68 -1
  189. package/src/utils/email.ts +5 -1
  190. package/src/workers/processors/embedContent.ts +18 -22
  191. package/tests/helpers/registerInput.ts +20 -0
  192. package/tests/integration/globalSetup.ts +10 -18
  193. package/tests/integration/routers/auth.test.ts +13 -18
  194. package/tests/unit/lib/inferDocumentFormat.test.ts +33 -0
  195. package/tests/unit/services/aiChatStream.test.ts +92 -0
  196. package/tests/unit/services/openaiDocumentGen.test.ts +65 -0
@@ -4,12 +4,17 @@
4
4
  */
5
5
  import { z } from 'zod';
6
6
  import { createTRPCRouter, protectedProcedure } from '../trpc.js';
7
- import { searchEmbeddings, indexAssignment, indexWorksheet, indexAnnouncement } from '../services/embedding.js';
7
+ import { searchEmbeddings } from '../services/embedding.js';
8
8
  import { enqueueRunJob } from '../workers/queues.js';
9
9
  import { prisma } from '../lib/prisma.js';
10
10
  import { TRPCError } from '@trpc/server';
11
11
  import type { EmbeddingSourceType } from '@prisma/client';
12
12
 
13
+ const sourceTypeEnum = z.enum([
14
+ 'FILE', 'ASSIGNMENT', 'WORKSHEET', 'ANNOUNCEMENT',
15
+ 'SECTION', 'DISCUSSION', 'SUBMISSION', 'SYLLABUS',
16
+ ]);
17
+
13
18
  export const embeddingRouter = createTRPCRouter({
14
19
  /**
15
20
  * Semantic search over class materials.
@@ -19,10 +24,9 @@ export const embeddingRouter = createTRPCRouter({
19
24
  classId: z.string(),
20
25
  query: z.string().min(1).max(500),
21
26
  limit: z.number().min(1).max(20).default(5),
22
- sourceTypes: z.array(z.enum(['FILE', 'ASSIGNMENT', 'WORKSHEET', 'ANNOUNCEMENT', 'SECTION'])).optional(),
27
+ sourceTypes: z.array(sourceTypeEnum).optional(),
23
28
  }))
24
29
  .query(async ({ input, ctx }) => {
25
- // Verify user has access to the class
26
30
  const userInClass = await prisma.class.findFirst({
27
31
  where: {
28
32
  id: input.classId,
@@ -54,11 +58,10 @@ export const embeddingRouter = createTRPCRouter({
54
58
  reindex: protectedProcedure
55
59
  .input(z.object({
56
60
  classId: z.string(),
57
- sourceType: z.enum(['FILE', 'ASSIGNMENT', 'WORKSHEET', 'ANNOUNCEMENT', 'SECTION']),
61
+ sourceType: sourceTypeEnum,
58
62
  sourceId: z.string(),
59
63
  }))
60
64
  .mutation(async ({ input, ctx }) => {
61
- // Verify teacher access
62
65
  const teacherInClass = await prisma.class.findFirst({
63
66
  where: {
64
67
  id: input.classId,
@@ -102,48 +105,46 @@ export const embeddingRouter = createTRPCRouter({
102
105
  throw new TRPCError({ code: 'FORBIDDEN', message: 'Teacher access required' });
103
106
  }
104
107
 
105
- // Enqueue all assignments
106
- const assignments = await prisma.assignment.findMany({
107
- where: { classId: input.classId },
108
- select: { id: true },
109
- });
110
-
111
- // Enqueue all worksheets
112
- const worksheets = await prisma.worksheet.findMany({
113
- where: { classId: input.classId },
114
- select: { id: true },
115
- });
116
-
117
- // Enqueue all announcements
118
- const announcements = await prisma.announcement.findMany({
119
- where: { classId: input.classId },
120
- select: { id: true },
121
- });
108
+ const [assignments, worksheets, announcements, discussions, submissions, classData, classFiles] = await Promise.all([
109
+ prisma.assignment.findMany({ where: { classId: input.classId }, select: { id: true } }),
110
+ prisma.worksheet.findMany({ where: { classId: input.classId }, select: { id: true } }),
111
+ prisma.announcement.findMany({ where: { classId: input.classId }, select: { id: true } }),
112
+ prisma.discussion.findMany({ where: { classId: input.classId }, select: { id: true } }),
113
+ prisma.submission.findMany({
114
+ where: { assignment: { classId: input.classId }, gradeReceived: { not: null } },
115
+ select: { id: true },
116
+ }),
117
+ prisma.class.findUnique({ where: { id: input.classId }, select: { syllabus: true } }),
118
+ prisma.folder.findUnique({
119
+ where: { classId: input.classId },
120
+ select: { files: { select: { id: true }, where: { uploadStatus: 'COMPLETED' } } },
121
+ }),
122
+ ]);
122
123
 
123
124
  const jobs: Promise<unknown>[] = [];
125
+ const opts = { classId: input.classId, userId: ctx.user!.id };
124
126
 
125
127
  for (const a of assignments) {
126
- jobs.push(enqueueRunJob('EMBED_CONTENT', {
127
- classId: input.classId,
128
- sourceType: 'ASSIGNMENT',
129
- sourceId: a.id,
130
- }, { classId: input.classId, userId: ctx.user!.id }));
128
+ jobs.push(enqueueRunJob('EMBED_CONTENT', { classId: input.classId, sourceType: 'ASSIGNMENT', sourceId: a.id }, opts));
131
129
  }
132
-
133
130
  for (const w of worksheets) {
134
- jobs.push(enqueueRunJob('EMBED_CONTENT', {
135
- classId: input.classId,
136
- sourceType: 'WORKSHEET',
137
- sourceId: w.id,
138
- }, { classId: input.classId, userId: ctx.user!.id }));
131
+ jobs.push(enqueueRunJob('EMBED_CONTENT', { classId: input.classId, sourceType: 'WORKSHEET', sourceId: w.id }, opts));
139
132
  }
140
-
141
133
  for (const ann of announcements) {
142
- jobs.push(enqueueRunJob('EMBED_CONTENT', {
143
- classId: input.classId,
144
- sourceType: 'ANNOUNCEMENT',
145
- sourceId: ann.id,
146
- }, { classId: input.classId, userId: ctx.user!.id }));
134
+ jobs.push(enqueueRunJob('EMBED_CONTENT', { classId: input.classId, sourceType: 'ANNOUNCEMENT', sourceId: ann.id }, opts));
135
+ }
136
+ for (const d of discussions) {
137
+ jobs.push(enqueueRunJob('EMBED_CONTENT', { classId: input.classId, sourceType: 'DISCUSSION', sourceId: d.id }, opts));
138
+ }
139
+ for (const s of submissions) {
140
+ jobs.push(enqueueRunJob('EMBED_CONTENT', { classId: input.classId, sourceType: 'SUBMISSION', sourceId: s.id }, opts));
141
+ }
142
+ if (classData?.syllabus) {
143
+ jobs.push(enqueueRunJob('EMBED_CONTENT', { classId: input.classId, sourceType: 'SYLLABUS', sourceId: input.classId }, opts));
144
+ }
145
+ const files = classFiles?.files ?? [];
146
+ for (const f of files) {
147
+ jobs.push(enqueueRunJob('EMBED_CONTENT', { classId: input.classId, sourceType: 'FILE', sourceId: f.id }, opts));
147
148
  }
148
149
 
149
150
  await Promise.allSettled(jobs);
@@ -153,6 +154,10 @@ export const embeddingRouter = createTRPCRouter({
153
154
  assignments: assignments.length,
154
155
  worksheets: worksheets.length,
155
156
  announcements: announcements.length,
157
+ discussions: discussions.length,
158
+ submissions: submissions.length,
159
+ syllabus: classData?.syllabus ? 1 : 0,
160
+ files: files.length,
156
161
  },
157
162
  };
158
163
  }),
@@ -1,3 +1,4 @@
1
+ import { TRPCError } from "@trpc/server";
1
2
  import { z } from "zod";
2
3
  import { createTRPCRouter, protectedProcedure } from "../trpc.js";
3
4
  import {
@@ -5,6 +6,10 @@ import {
5
6
  updateProfile,
6
7
  getUploadUrl,
7
8
  } from "../services/user.js";
9
+ import {
10
+ deleteUserAccount,
11
+ exportUserData,
12
+ } from "../services/userPrivacy.js";
8
13
 
9
14
  const fileUploadSchema = z.object({
10
15
  filePath: z.string().min(1, "File path is required"),
@@ -79,4 +84,25 @@ export const userRouter = createTRPCRouter({
79
84
  .mutation(({ ctx, input }) =>
80
85
  getUploadUrl(ctx.user!.id, input.fileName, input.fileType)
81
86
  ),
87
+
88
+ exportData: protectedProcedure.query(({ ctx }) =>
89
+ exportUserData(ctx.user!.id),
90
+ ),
91
+
92
+ deleteAccount: protectedProcedure
93
+ .input(
94
+ z.object({
95
+ confirmUsername: z.string().min(1),
96
+ }),
97
+ )
98
+ .mutation(async ({ ctx, input }) => {
99
+ const profile = await getProfile(ctx.user!.id);
100
+ if (profile.username !== input.confirmUsername) {
101
+ throw new TRPCError({
102
+ code: "BAD_REQUEST",
103
+ message: "Username confirmation does not match",
104
+ });
105
+ }
106
+ return deleteUserAccount(ctx.user!.id);
107
+ }),
82
108
  });
@@ -20,7 +20,11 @@ import { pusher, chatChannel, teacherChannel } from '../lib/pusher.js';
20
20
  import { getAIUserId, ensureAIUserExists, isAIUser } from '../utils/aiUser.js';
21
21
  import { JobStatus } from '@prisma/client';
22
22
  import { updateAssignmentRecord } from '../services/assignment.js';
23
- import { updateWorksheetRecord } from '../services/worksheet.js';
23
+ import {
24
+ restoreWorksheetQuestions,
25
+ updateWorksheetRecord,
26
+ type WorksheetQuestionSnapshot,
27
+ } from '../services/worksheet.js';
24
28
 
25
29
  export const aiStreamRouter = Router();
26
30
 
@@ -105,7 +109,7 @@ aiStreamRouter.post('/chat/stream', async (req: Request, res: Response) => {
105
109
  return res.status(400).json({ error: 'Unable to determine class context' });
106
110
  }
107
111
 
108
- // Build message history from DB
112
+ // Build message history from DB (include attachments so the LLM can see them)
109
113
  const recentMessages = await prisma.message.findMany({
110
114
  where: { conversationId, status: { not: JobStatus.PENDING } },
111
115
  orderBy: { createdAt: 'asc' },
@@ -114,15 +118,23 @@ aiStreamRouter.post('/chat/stream', async (req: Request, res: Response) => {
114
118
  content: true,
115
119
  senderId: true,
116
120
  status: true,
121
+ attachments: {
122
+ select: { id: true, name: true, type: true, path: true },
123
+ },
117
124
  },
118
125
  });
119
126
 
120
127
  const messages: ModelMessage[] = recentMessages
121
128
  .filter((m) => m.content && m.content.length > 0)
122
- .map((m) => ({
123
- role: isAIUser(m.senderId) ? 'assistant' as const : 'user' as const,
124
- content: m.content,
125
- }));
129
+ .map((m) => {
130
+ const role = isAIUser(m.senderId) ? 'assistant' as const : 'user' as const;
131
+ if (role === 'assistant' || m.attachments.length === 0) {
132
+ return { role, content: m.content };
133
+ }
134
+ // Append file descriptions to user messages
135
+ const fileList = m.attachments.map((a) => `${a.name} (${a.type})`).join(', ');
136
+ return { role, content: `${m.content}\n[Attached files: ${fileList}]` };
137
+ });
126
138
 
127
139
  // Build system prompt based on context type
128
140
  let systemPrompt: string;
@@ -370,6 +382,16 @@ aiStreamRouter.post('/modify/stream', async (req: Request, res: Response) => {
370
382
  resourceContext = buildWorksheetContext(worksheet);
371
383
  preEditSnapshot = {
372
384
  name: worksheet.name,
385
+ questions: worksheet.questions.map((q) => ({
386
+ id: q.id,
387
+ type: q.type,
388
+ question: q.question,
389
+ answer: q.answer,
390
+ options: q.options,
391
+ markScheme: q.markScheme,
392
+ points: q.points,
393
+ order: q.order,
394
+ })),
373
395
  };
374
396
  }
375
397
 
@@ -461,7 +483,7 @@ aiStreamRouter.post('/modify/stream', async (req: Request, res: Response) => {
461
483
  tools,
462
484
  temperature: 0.3,
463
485
  maxOutputTokens: 8192,
464
- stopWhen: stepCountIs(8),
486
+ stopWhen: stepCountIs(16),
465
487
  });
466
488
 
467
489
  // Drain the stream. Field writes happen inside the tools' execute (which
@@ -601,7 +623,15 @@ aiStreamRouter.post('/modify/undo', async (req: Request, res: Response) => {
601
623
  ...fields,
602
624
  });
603
625
  } else {
604
- await updateWorksheetRecord(resourceId, user.id, parsed.snapshot);
626
+ const { questions, ...fields } = parsed.snapshot;
627
+ await updateWorksheetRecord(resourceId, user.id, fields);
628
+ if (Array.isArray(questions)) {
629
+ await restoreWorksheetQuestions(
630
+ resourceId,
631
+ user.id,
632
+ questions as WorksheetQuestionSnapshot[],
633
+ );
634
+ }
605
635
  }
606
636
 
607
637
  // Remove the snapshot so undo can only be used once
@@ -707,14 +737,21 @@ RULES:
707
737
 
708
738
  HOW TO EDIT: You change the worksheet by CALLING TOOLS — never by printing the new value as text.
709
739
  - Call \`update_worksheet_field\` for the field the teacher wants changed (currently: name).
740
+ - Call \`add_worksheet_question\` once per new question. Question types:
741
+ MULTIPLE_CHOICE|TRUE_FALSE|SHORT_ANSWER|LONG_ANSWER|MATH_EXPRESSION|ESSAY.
742
+ For MULTIPLE_CHOICE provide \`options\` with exactly one \`isCorrect: true\`.
743
+ - Call \`update_worksheet_question\` / \`delete_worksheet_question\` with the question's
744
+ Q_ID (shown in the worksheet context) to edit or remove existing questions.
710
745
  - Use \`search_materials\` first only if you need class context.
711
746
 
712
747
  RULES:
713
748
  - Only change what the teacher asked about.
714
749
  - Each tool call saves immediately and is shown to the teacher live.
750
+ - Write real, pedagogically sound question content with correct answers — never placeholders.
715
751
  - After making all the changes, write a brief conversational summary of what you did.
716
752
  Be human and specific — mention the actual values. For example:
717
- "Done — I've renamed the worksheet to 'Unit 3 Practice Problems'."
753
+ "Done — I've renamed the worksheet to 'Unit 3 Practice Problems' and added two
754
+ questions on volumes of revolution."
718
755
  Do NOT dump the full worksheet contents — just summarize naturally.`;
719
756
  }
720
757
 
@@ -771,6 +808,7 @@ function buildWorksheetContext(worksheet: any): string {
771
808
  let context = `WORKSHEET_NAME: ${worksheet.name}\nQUESTIONS_START`;
772
809
 
773
810
  for (const q of worksheet.questions) {
811
+ context += `\nQ_ID: ${q.id}`;
774
812
  context += `\nQ_TYPE: ${q.type}`;
775
813
  context += `\nQ_TEXT: ${q.question}`;
776
814
  context += `\nQ_ANSWER: ${q.answer}`;
@@ -47,17 +47,123 @@ export type StreamPart =
47
47
  toolName: string;
48
48
  /** Concrete entity label (e.g. assignment title / search query), if any. */
49
49
  title?: string;
50
+ /** Requested output type from the tool args (pdf, pptx, xlsx, …). */
51
+ format?: string;
52
+ /** Set after generated files are persisted on the message. */
53
+ fileId?: string;
54
+ fileName?: string;
50
55
  status: 'executing' | 'completed' | 'failed';
51
56
  };
52
57
 
58
+ export interface GeneratedDocFileLink {
59
+ title: string;
60
+ fileId: string;
61
+ fileName: string;
62
+ format?: string;
63
+ }
64
+
65
+ /** Apply pipeline-resolved formats onto generate_document tool parts. */
66
+ export function applyResolvedFormatsToStreamParts(
67
+ parts: StreamPart[],
68
+ docs: Array<{ title: string; format: string }>,
69
+ ): StreamPart[] {
70
+ if (docs.length === 0) return parts;
71
+
72
+ const pending = [...docs];
73
+ const takeDoc = (title?: string) => {
74
+ if (title) {
75
+ const idx = pending.findIndex(
76
+ (d) => d.title.trim().toLowerCase() === title.trim().toLowerCase(),
77
+ );
78
+ if (idx >= 0) return pending.splice(idx, 1)[0];
79
+ }
80
+ return pending.shift();
81
+ };
82
+
83
+ return parts.map((part) => {
84
+ if (part.type !== 'tool' || part.toolName !== 'generate_document') return part;
85
+ const doc = takeDoc(part.title);
86
+ return doc ? { ...part, format: doc.format } : part;
87
+ });
88
+ }
89
+
90
+ /** Tools that queue async work after the model stream; UI stays loading until the pipeline finishes. */
91
+ const DEFERRED_COMPLETION_TOOLS = new Set(['generate_document']);
92
+
93
+ function isDeferredCompletionTool(toolName: string): boolean {
94
+ return DEFERRED_COMPLETION_TOOLS.has(toolName);
95
+ }
96
+
97
+ /** Attach file IDs to `generate_document` tool parts for inline download chips. */
98
+ export function enrichStreamPartsWithGeneratedFiles(
99
+ parts: StreamPart[],
100
+ files: GeneratedDocFileLink[],
101
+ ): StreamPart[] {
102
+ if (files.length === 0) return parts;
103
+
104
+ const docToolIndices = parts
105
+ .map((part, index) => ({ part, index }))
106
+ .filter(
107
+ ({ part }) =>
108
+ part.type === 'tool' && part.toolName === 'generate_document',
109
+ );
110
+
111
+ const unmatched = [...files];
112
+ const fileForTool = (toolTitle?: string): GeneratedDocFileLink | undefined => {
113
+ if (toolTitle) {
114
+ const idx = unmatched.findIndex(
115
+ (f) => f.title.trim().toLowerCase() === toolTitle.trim().toLowerCase(),
116
+ );
117
+ if (idx >= 0) return unmatched.splice(idx, 1)[0];
118
+ }
119
+ return unmatched.shift();
120
+ };
121
+
122
+ const fileByIndex = new Map<number, GeneratedDocFileLink>();
123
+ for (const { part, index } of docToolIndices) {
124
+ if (part.type !== 'tool') continue;
125
+ const file = fileForTool(part.title);
126
+ if (file) fileByIndex.set(index, file);
127
+ }
128
+
129
+ return parts.map((part, index) => {
130
+ const file = fileByIndex.get(index);
131
+ if (!file || part.type !== 'tool') return part;
132
+ return {
133
+ ...part,
134
+ fileId: file.fileId,
135
+ fileName: file.fileName,
136
+ status: 'completed',
137
+ ...(file.format ? { format: file.format } : {}),
138
+ };
139
+ });
140
+ }
141
+
142
+ /** Mark deferred tools completed (file linked) or failed (generation did not produce a file). */
143
+ export function finalizeDeferredToolParts(parts: StreamPart[]): StreamPart[] {
144
+ return parts.map((part) => {
145
+ if (part.type !== 'tool' || !isDeferredCompletionTool(part.toolName)) {
146
+ return part;
147
+ }
148
+ if (part.fileId) return { ...part, status: 'completed' };
149
+ if (part.status === 'executing') return { ...part, status: 'failed' };
150
+ return part;
151
+ });
152
+ }
153
+
154
+ function toolFieldFromInput(input: unknown, key: string): string | undefined {
155
+ if (input && typeof input === 'object') {
156
+ const val = (input as Record<string, unknown>)[key];
157
+ if (typeof val === 'string' && val.trim()) return val.trim();
158
+ }
159
+ return undefined;
160
+ }
161
+
53
162
  /** Pull a human-meaningful label out of a tool's input args, if present. */
54
163
  function toolTitleFromInput(input: unknown): string | undefined {
55
- if (input && typeof input === 'object') {
56
- const obj = input as Record<string, unknown>;
57
- for (const key of ['title', 'name', 'query'] as const) {
58
- const val = obj[key];
59
- if (typeof val === 'string' && val.trim()) return val.trim();
60
- }
164
+ for (const key of ['title', 'name', 'query'] as const) {
165
+ const val = toolFieldFromInput(input, key);
166
+ if (val) return val;
61
167
  }
62
168
  return undefined;
63
169
  }
@@ -163,12 +269,14 @@ export async function streamChatResponse(
163
269
  }
164
270
  case 'tool-call': {
165
271
  const title = toolTitleFromInput(part.input);
272
+ const format = toolFieldFromInput(part.input, 'format');
166
273
  toolPartIndexById.set(part.toolCallId, parts.length);
167
274
  parts.push({
168
275
  type: 'tool',
169
276
  toolCallId: part.toolCallId,
170
277
  toolName: part.toolName,
171
278
  ...(title ? { title } : {}),
279
+ ...(format ? { format } : {}),
172
280
  status: 'executing',
173
281
  });
174
282
  await broadcast('message-tool-call', {
@@ -176,30 +284,44 @@ export async function streamChatResponse(
176
284
  toolCallId: part.toolCallId,
177
285
  toolName: part.toolName,
178
286
  ...(title ? { title } : {}),
287
+ ...(format ? { format } : {}),
179
288
  status: 'executing',
180
289
  });
181
290
  break;
182
291
  }
183
292
  case 'tool-result':
184
293
  case 'tool-error': {
185
- const status: 'completed' | 'failed' =
186
- part.type === 'tool-error' ? 'failed' : 'completed';
294
+ const failed = part.type === 'tool-error';
295
+ const deferred = isDeferredCompletionTool(part.toolName);
296
+ // Deferred tools (e.g. generate_document) only queue work during the
297
+ // stream — keep the spinner until the pipeline attaches a file.
298
+ const status: 'executing' | 'completed' | 'failed' = failed
299
+ ? 'failed'
300
+ : deferred
301
+ ? 'executing'
302
+ : 'completed';
303
+ const title = toolTitleFromInput(part.input);
304
+ const format = toolFieldFromInput(part.input, 'format');
187
305
  const idx = toolPartIndexById.get(part.toolCallId);
188
306
  if (idx !== undefined) {
189
307
  const existing = parts[idx];
190
- if (existing && existing.type === 'tool') existing.status = status;
308
+ if (existing && existing.type === 'tool') {
309
+ existing.status = status;
310
+ if (format) existing.format = format;
311
+ }
191
312
  }
192
313
  toolCalls.push({
193
314
  toolName: part.toolName,
194
315
  input: part.input,
195
316
  output: part.type === 'tool-result' ? part.output : undefined,
196
- status,
317
+ status: failed ? 'failed' : 'completed',
197
318
  });
198
319
  await broadcast('message-tool-call', {
199
320
  messageId: aiMessageId,
200
321
  toolCallId: part.toolCallId,
201
322
  toolName: part.toolName,
202
- ...(toolTitleFromInput(part.input) ? { title: toolTitleFromInput(part.input) } : {}),
323
+ ...(title ? { title } : {}),
324
+ ...(format ? { format } : {}),
203
325
  status,
204
326
  });
205
327
  break;
@@ -14,9 +14,17 @@
14
14
  * `jsonSchema()` rather than the zod `tool()` helper to avoid the TS2589
15
15
  * "excessively deep" compiler blow-up on nested schemas.
16
16
  */
17
+ import { randomUUID } from 'node:crypto';
17
18
  import { jsonSchema, type ToolSet } from 'ai';
19
+ import type { JSONSchema7Definition } from 'json-schema';
20
+ import type { WorksheetQuestionType } from '@prisma/client';
18
21
  import { updateAssignmentRecord } from './assignment.js';
19
- import { updateWorksheetRecord } from './worksheet.js';
22
+ import {
23
+ addQuestionToWorksheet,
24
+ deleteWorksheetQuestionRecord,
25
+ updateWorksheetQuestionRecord,
26
+ updateWorksheetRecord,
27
+ } from './worksheet.js';
20
28
 
21
29
  /** A single field write that was applied to a resource. */
22
30
  export interface ModifierFieldUpdate {
@@ -56,6 +64,52 @@ const WORKSHEET_FIELDS: Record<string, { label: string; type: 'string' }> = {
56
64
  name: { label: 'name', type: 'string' },
57
65
  };
58
66
 
67
+ const WORKSHEET_QUESTION_TYPES = [
68
+ 'MULTIPLE_CHOICE', 'TRUE_FALSE', 'SHORT_ANSWER', 'LONG_ANSWER', 'MATH_EXPRESSION', 'ESSAY',
69
+ ] as const;
70
+
71
+ interface QuestionOptionInput {
72
+ text: string;
73
+ isCorrect?: boolean;
74
+ }
75
+
76
+ /** Attach stable ids to model-supplied MCQ options (matches the editor's shape). */
77
+ function buildOptions(options: QuestionOptionInput[] | undefined) {
78
+ if (!options || options.length === 0) return undefined;
79
+ return options.map((o) => ({
80
+ id: randomUUID(),
81
+ text: o.text,
82
+ isCorrect: Boolean(o.isCorrect),
83
+ }));
84
+ }
85
+
86
+ const QUESTION_PROPERTIES: Record<string, JSONSchema7Definition> = {
87
+ question: { type: 'string', description: 'The question text shown to students.' },
88
+ answer: {
89
+ type: 'string',
90
+ description:
91
+ 'The correct answer. For TRUE_FALSE use "true"/"false". For MULTIPLE_CHOICE leave it empty and mark the correct option instead.',
92
+ },
93
+ type: {
94
+ type: 'string',
95
+ enum: [...WORKSHEET_QUESTION_TYPES],
96
+ description: 'The question type.',
97
+ },
98
+ points: { type: 'number', description: 'Points the question is worth.' },
99
+ options: {
100
+ type: 'array',
101
+ description: 'Answer choices, only for MULTIPLE_CHOICE questions. Mark exactly one as correct.',
102
+ items: {
103
+ type: 'object',
104
+ properties: {
105
+ text: { type: 'string', description: 'The option text.' },
106
+ isCorrect: { type: 'boolean', description: 'Whether this option is the correct answer.' },
107
+ },
108
+ required: ['text'],
109
+ },
110
+ },
111
+ };
112
+
59
113
  /** Coerce a model-supplied string value into the type a field expects. */
60
114
  function coerceValue(
61
115
  raw: string,
@@ -184,5 +238,104 @@ export function worksheetModifierTools(
184
238
  return `Updated ${spec.label}.`;
185
239
  },
186
240
  },
241
+ add_worksheet_question: {
242
+ description:
243
+ 'Add a new question to the worksheet and save it immediately. ' +
244
+ 'Call this once per question to add.',
245
+ inputSchema: jsonSchema({
246
+ type: 'object',
247
+ properties: QUESTION_PROPERTIES,
248
+ required: ['question', 'answer', 'type'],
249
+ }),
250
+ execute: async ({
251
+ question,
252
+ answer,
253
+ type,
254
+ points,
255
+ options,
256
+ }: {
257
+ question: string;
258
+ answer: string;
259
+ type: WorksheetQuestionType;
260
+ points?: number;
261
+ options?: QuestionOptionInput[];
262
+ }) => {
263
+ const created = await addQuestionToWorksheet(worksheetId, userId, {
264
+ question,
265
+ answer,
266
+ type,
267
+ points,
268
+ options: buildOptions(options),
269
+ });
270
+ await onFieldUpdate({
271
+ field: 'questions',
272
+ value: { action: 'added', questionId: created.id },
273
+ label: `question "${question.slice(0, 60)}"`,
274
+ });
275
+ return `Added question (id ${created.id}).`;
276
+ },
277
+ },
278
+ update_worksheet_question: {
279
+ description:
280
+ 'Update an existing worksheet question by its Q_ID and save it immediately. ' +
281
+ 'Only pass the fields that should change.',
282
+ inputSchema: jsonSchema({
283
+ type: 'object',
284
+ properties: {
285
+ questionId: { type: 'string', description: 'The Q_ID of the question to update.' },
286
+ ...QUESTION_PROPERTIES,
287
+ },
288
+ required: ['questionId'],
289
+ }),
290
+ execute: async ({
291
+ questionId,
292
+ question,
293
+ answer,
294
+ type,
295
+ points,
296
+ options,
297
+ }: {
298
+ questionId: string;
299
+ question?: string;
300
+ answer?: string;
301
+ type?: WorksheetQuestionType;
302
+ points?: number;
303
+ options?: QuestionOptionInput[];
304
+ }) => {
305
+ await updateWorksheetQuestionRecord(worksheetId, questionId, userId, {
306
+ ...(question !== undefined && { question }),
307
+ ...(answer !== undefined && { answer }),
308
+ ...(type !== undefined && { type }),
309
+ ...(points !== undefined && { points }),
310
+ ...(options !== undefined && { options: buildOptions(options) }),
311
+ });
312
+ await onFieldUpdate({
313
+ field: 'questions',
314
+ value: { action: 'updated', questionId },
315
+ label: 'a question',
316
+ });
317
+ return `Updated question ${questionId}.`;
318
+ },
319
+ },
320
+ delete_worksheet_question: {
321
+ description:
322
+ 'Delete a worksheet question by its Q_ID. This is permanent.',
323
+ inputSchema: jsonSchema({
324
+ type: 'object',
325
+ properties: {
326
+ questionId: { type: 'string', description: 'The Q_ID of the question to delete.' },
327
+ },
328
+ required: ['questionId'],
329
+ }),
330
+ execute: async ({ questionId }: { questionId: string }) => {
331
+ await deleteWorksheetQuestionRecord(worksheetId, questionId, userId);
332
+ await onFieldUpdate({
333
+ field: 'questions',
334
+ value: { action: 'deleted', questionId },
335
+ label: 'a question',
336
+ });
337
+ return `Deleted question ${questionId}.`;
338
+ },
339
+ },
187
340
  };
188
341
  }