@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
@@ -23,7 +23,6 @@ import type {
23
23
  SectionToCreate,
24
24
  DocToCreate,
25
25
  } from '../pipelines/aiLabChatContract.js';
26
- import type { DocumentBlock } from '../lib/jsonStyles.js';
27
26
 
28
27
  export interface LabProposals {
29
28
  assignments: AssignmentToCreate[];
@@ -85,7 +84,8 @@ interface ProposeAssignmentArgs {
85
84
 
86
85
  interface GenerateDocumentArgs {
87
86
  title: string;
88
- blocks: DocumentBlock[];
87
+ content: string;
88
+ format?: string;
89
89
  }
90
90
 
91
91
  /**
@@ -237,35 +237,37 @@ export function labProposalTools(proposals: LabProposals): ToolSet {
237
237
 
238
238
  generate_document: {
239
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.',
240
+ 'Generate a downloadable file and attach it to this message. You are NOT limited to PDF — pick the best format for the request (pptx for slides/presentations, xlsx/csv for data, docx for editable handouts, pdf for print-ready docs, png for diagrams, etc.). Always set format explicitly.',
241
241
  inputSchema: jsonSchema({
242
242
  type: 'object',
243
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
- },
244
+ title: { type: 'string', description: 'Document title' },
245
+ content: {
246
+ type: 'string',
247
+ description:
248
+ 'Full body — markdown or plain text with headings, lists, tables, slide notes, etc.',
249
+ },
250
+ format: {
251
+ type: 'string',
252
+ description:
253
+ 'Output file type. REQUIRED. pptx=slides/presentation, xlsx/csv=spreadsheet/data, docx=Word handout, pdf=printable doc, md/txt/html=text, png/jpeg/gif=image, py/js/ts=code.',
254
+ enum: [
255
+ 'pdf', 'docx', 'doc', 'xlsx', 'pptx', 'csv', 'md', 'html', 'txt',
256
+ 'json', 'xml', 'tex', 'png', 'jpeg', 'jpg', 'gif', 'zip', 'tar',
257
+ 'py', 'js', 'ts', 'css', 'sh',
258
+ ],
262
259
  },
263
260
  },
264
- required: ['title', 'blocks'],
261
+ required: ['title', 'content', 'format'],
265
262
  }),
266
- execute: async ({ title, blocks }: GenerateDocumentArgs) => {
267
- proposals.docs.push({ title, blocks } as unknown as DocToCreate);
268
- return `Document "${title}" queued for generation.`;
263
+ execute: async ({ title, content, format }: GenerateDocumentArgs) => {
264
+ proposals.docs.push({
265
+ title,
266
+ content: content.trim(),
267
+ format: (format ?? 'pdf') as DocToCreate['format'],
268
+ });
269
+ const ext = (format ?? 'pdf').toUpperCase();
270
+ return `Document "${title}" queued for ${ext} generation.`;
269
271
  },
270
272
  },
271
273
  };
@@ -251,7 +251,7 @@ export function aiTools(classId: string): ToolSet {
251
251
  const client = new OpenAI();
252
252
  const response = await client.responses.create({
253
253
  model: 'gpt-4o-mini',
254
- tools: [{ type: 'web_search_preview' }],
254
+ tools: [{ type: 'web_search' }],
255
255
  input: query,
256
256
  });
257
257
 
@@ -937,6 +937,9 @@ export async function updateSubmissionAsTeacherRecord(
937
937
  );
938
938
  }
939
939
 
940
+ // Index submission for RAG search after teacher grades/feedback
941
+ enqueueEmbedding(input.classId, 'SUBMISSION', submissionId);
942
+
940
943
  return updated;
941
944
  }
942
945
 
@@ -10,6 +10,7 @@ import { sendMail } from "../utils/email.js";
10
10
  import { prismaWrapper } from "../utils/prismaWrapper.js";
11
11
  import { env } from "../lib/config/env.js";
12
12
  import { logger } from "../utils/logger.js";
13
+ import { CURRENT_TERMS_VERSION } from "../lib/legal.js";
13
14
  import {
14
15
  findUserByUsername,
15
16
  findUserByUsernameOrEmail,
@@ -32,8 +33,17 @@ export async function register(input: {
32
33
  username: string;
33
34
  email: string;
34
35
  password: string;
36
+ agreeToTerms: true;
37
+ termsVersion: string;
35
38
  }) {
36
- const { username, email, password } = input;
39
+ const { username, email, password, termsVersion } = input;
40
+
41
+ if (termsVersion !== CURRENT_TERMS_VERSION) {
42
+ throw new TRPCError({
43
+ code: "BAD_REQUEST",
44
+ message: "Please accept the current Terms of Service and Privacy Policy.",
45
+ });
46
+ }
37
47
 
38
48
  const existingUser = await prismaWrapper.findFirst(
39
49
  () => findUserByUsernameOrEmail(username, email),
@@ -70,7 +80,9 @@ export async function register(input: {
70
80
  username,
71
81
  email,
72
82
  password: await hash(password, 10),
73
- verified: true,
83
+ verified: false,
84
+ termsAcceptedAt: new Date(),
85
+ termsVersion,
74
86
  }),
75
87
  "creating new user during registration"
76
88
  );
@@ -6,6 +6,7 @@ import { SessionType } from "@prisma/client";
6
6
  import { TRPCError } from "@trpc/server";
7
7
  import { prisma } from "../lib/prisma.js";
8
8
  import { generateInviteCode } from "../utils/generateInviteCode.js";
9
+ import { enqueueEmbedding } from "./embedding.js";
9
10
  import {
10
11
  findTeacherClasses,
11
12
  findStudentClasses,
@@ -626,10 +627,15 @@ export async function updateSyllabus(classId: string, contents: string) {
626
627
  message: "Missing key contents",
627
628
  });
628
629
  }
629
- return prisma.class.update({
630
+ const updated = await prisma.class.update({
630
631
  where: { id: classId },
631
632
  data: { syllabus: contents },
632
633
  });
634
+
635
+ // Index syllabus for RAG search
636
+ enqueueEmbedding(classId, 'SYLLABUS', classId);
637
+
638
+ return updated;
633
639
  }
634
640
 
635
641
  export async function listLabDrafts(classId: string, teacherId: string) {
@@ -6,6 +6,7 @@ import { discussionChannel, pusher } from "../lib/pusher.js";
6
6
  import { logger } from "../utils/logger.js";
7
7
  import { classMemberOrSchoolAdminWhere, classTeacherOrSchoolAdminWhere } from "../models/access.js";
8
8
  import { sendNotification } from "./notification.js";
9
+ import { enqueueEmbedding } from "./embedding.js";
9
10
 
10
11
  const authorSelect = {
11
12
  id: true,
@@ -441,6 +442,10 @@ export async function createDiscussion(userId: string, input: {
441
442
 
442
443
  triggerDiscussionEvent(input.classId, "discussion-created", { discussion: serializeDiscussion(discussion, userId) });
443
444
  await notifyMentionedUsers(input.classId, userId, input.body, `/class/${input.classId}/discussions/${discussion.id}`);
445
+
446
+ // Auto-index discussion for RAG search
447
+ enqueueEmbedding(input.classId, 'DISCUSSION', discussion.id);
448
+
444
449
  return { discussion: serializeDiscussion(discussion, userId) };
445
450
  }
446
451
 
@@ -471,6 +476,10 @@ export async function updateDiscussion(userId: string, input: {
471
476
  include: discussionInclude,
472
477
  });
473
478
  triggerDiscussionEvent(updated.classId, "discussion-updated", { discussion: serializeDiscussion(updated, userId) });
479
+
480
+ // Re-index discussion for RAG search after update
481
+ enqueueEmbedding(updated.classId, 'DISCUSSION', updated.id);
482
+
474
483
  return { discussion: serializeDiscussion(updated, userId) };
475
484
  }
476
485
 
@@ -526,6 +535,10 @@ export async function replyToDiscussion(userId: string, input: {
526
535
  notifyMentionedUsers(discussion.classId, userId, input.body, link),
527
536
  ]);
528
537
  triggerDiscussionEvent(discussion.classId, "reply-created", { discussionId: discussion.id, reply: serializeReply(reply, userId) });
538
+
539
+ // Re-index discussion for RAG search (includes new reply)
540
+ enqueueEmbedding(discussion.classId, 'DISCUSSION', discussion.id);
541
+
529
542
  return { reply: serializeReply(reply, userId) };
530
543
  }
531
544
 
@@ -193,28 +193,38 @@ export async function removeEmbeddings(params: {
193
193
  }
194
194
 
195
195
  /**
196
- * Index an assignment (title + instructions) into the vector store.
196
+ * Index an assignment (title + instructions + attached file names) into the vector store.
197
197
  */
198
198
  export async function indexAssignment(classId: string, assignmentId: string): Promise<void> {
199
199
  const assignment = await prisma.assignment.findUnique({
200
200
  where: { id: assignmentId },
201
- select: { title: true, instructions: true, type: true },
201
+ select: {
202
+ title: true,
203
+ instructions: true,
204
+ type: true,
205
+ attachments: { select: { name: true, type: true } },
206
+ },
202
207
  });
203
208
 
204
209
  if (!assignment) return;
205
210
 
206
- const text = [
211
+ const parts = [
207
212
  `Title: ${assignment.title}`,
208
213
  `Type: ${assignment.type}`,
209
214
  assignment.instructions ? `Instructions: ${assignment.instructions}` : '',
210
- ].filter(Boolean).join('\n');
215
+ ];
216
+
217
+ if (assignment.attachments.length > 0) {
218
+ const fileList = assignment.attachments.map((f) => `${f.name} (${f.type})`).join(', ');
219
+ parts.push(`Attached files: ${fileList}`);
220
+ }
211
221
 
212
222
  await indexContent({
213
223
  classId,
214
224
  sourceType: 'ASSIGNMENT',
215
225
  sourceId: assignmentId,
216
- text,
217
- metadata: { title: assignment.title, type: assignment.type },
226
+ text: parts.filter(Boolean).join('\n'),
227
+ metadata: { title: assignment.title, type: assignment.type, fileCount: assignment.attachments.length },
218
228
  });
219
229
  }
220
230
 
@@ -269,6 +279,157 @@ export async function indexAnnouncement(classId: string, announcementId: string)
269
279
  });
270
280
  }
271
281
 
282
+ /**
283
+ * Index a discussion (title + body + replies) into the vector store.
284
+ */
285
+ export async function indexDiscussion(classId: string, discussionId: string): Promise<void> {
286
+ const discussion = await prisma.discussion.findUnique({
287
+ where: { id: discussionId },
288
+ select: {
289
+ title: true,
290
+ body: true,
291
+ type: true,
292
+ author: { select: { username: true, profile: { select: { displayName: true } } } },
293
+ replies: {
294
+ select: { body: true, author: { select: { username: true, profile: { select: { displayName: true } } } } },
295
+ orderBy: { createdAt: 'asc' },
296
+ take: 20,
297
+ },
298
+ },
299
+ });
300
+
301
+ if (!discussion) return;
302
+
303
+ const authorName = discussion.author.profile?.displayName || discussion.author.username;
304
+ const parts = [
305
+ `Discussion: ${discussion.title}`,
306
+ `Type: ${discussion.type}`,
307
+ `Author: ${authorName}`,
308
+ `Body: ${discussion.body}`,
309
+ ];
310
+
311
+ if (discussion.replies.length > 0) {
312
+ parts.push('Replies:');
313
+ for (const reply of discussion.replies) {
314
+ const replyAuthor = reply.author.profile?.displayName || reply.author.username;
315
+ parts.push(` ${replyAuthor}: ${reply.body.slice(0, 300)}`);
316
+ }
317
+ }
318
+
319
+ await indexContent({
320
+ classId,
321
+ sourceType: 'DISCUSSION',
322
+ sourceId: discussionId,
323
+ text: parts.join('\n'),
324
+ metadata: { title: discussion.title, type: discussion.type, replyCount: discussion.replies.length },
325
+ });
326
+ }
327
+
328
+ /**
329
+ * Index a submission (grade + feedback + file attachments + extended response) into the vector store.
330
+ */
331
+ export async function indexSubmission(classId: string, submissionId: string): Promise<void> {
332
+ const submission = await prisma.submission.findUnique({
333
+ where: { id: submissionId },
334
+ select: {
335
+ gradeReceived: true,
336
+ teacherComments: true,
337
+ extendedResponse: true,
338
+ returned: true,
339
+ student: { select: { username: true, profile: { select: { displayName: true } } } },
340
+ assignment: { select: { title: true, maxGrade: true, type: true } },
341
+ attachments: { select: { name: true, type: true } },
342
+ annotations: { select: { name: true, type: true } },
343
+ },
344
+ });
345
+
346
+ if (!submission) return;
347
+
348
+ const studentName = submission.student.profile?.displayName || submission.student.username;
349
+ const parts = [
350
+ `Submission for: ${submission.assignment.title}`,
351
+ `Student: ${studentName}`,
352
+ `Assignment type: ${submission.assignment.type}`,
353
+ ];
354
+
355
+ if (submission.gradeReceived != null) {
356
+ parts.push(`Grade: ${submission.gradeReceived}/${submission.assignment.maxGrade ?? '?'}`);
357
+ }
358
+ if (submission.teacherComments) {
359
+ parts.push(`Teacher feedback: ${submission.teacherComments}`);
360
+ }
361
+ if (submission.extendedResponse) {
362
+ parts.push(`Student response: ${submission.extendedResponse.slice(0, 500)}`);
363
+ }
364
+ if (submission.attachments.length > 0) {
365
+ parts.push(`Submitted files: ${submission.attachments.map((f) => f.name).join(', ')}`);
366
+ }
367
+ if (submission.annotations.length > 0) {
368
+ parts.push(`Teacher annotations: ${submission.annotations.map((f) => f.name).join(', ')}`);
369
+ }
370
+
371
+ await indexContent({
372
+ classId,
373
+ sourceType: 'SUBMISSION',
374
+ sourceId: submissionId,
375
+ text: parts.join('\n'),
376
+ metadata: { studentName, assignmentTitle: submission.assignment.title, grade: submission.gradeReceived },
377
+ });
378
+ }
379
+
380
+ /**
381
+ * Index a class syllabus into the vector store.
382
+ * Uses classId as both classId and sourceId since syllabus is a field on the class.
383
+ */
384
+ export async function indexSyllabus(classId: string): Promise<void> {
385
+ const cls = await prisma.class.findUnique({
386
+ where: { id: classId },
387
+ select: { name: true, subject: true, syllabus: true },
388
+ });
389
+
390
+ if (!cls?.syllabus) return;
391
+
392
+ const text = [
393
+ `Syllabus for ${cls.name} (${cls.subject})`,
394
+ cls.syllabus,
395
+ ].join('\n\n');
396
+
397
+ await indexContent({
398
+ classId,
399
+ sourceType: 'SYLLABUS',
400
+ sourceId: classId,
401
+ text,
402
+ metadata: { className: cls.name, subject: cls.subject },
403
+ });
404
+ }
405
+
406
+ /**
407
+ * Index a file (name + type + path) into the vector store.
408
+ * For text-based content, PDF extraction would be added here in the future.
409
+ */
410
+ export async function indexFile(classId: string, fileId: string): Promise<void> {
411
+ const file = await prisma.file.findUnique({
412
+ where: { id: fileId },
413
+ select: { name: true, path: true, type: true, size: true },
414
+ });
415
+
416
+ if (!file) return;
417
+
418
+ const text = [
419
+ `File: ${file.name}`,
420
+ `Type: ${file.type}`,
421
+ `Path: ${file.path}`,
422
+ ].join('\n');
423
+
424
+ await indexContent({
425
+ classId,
426
+ sourceType: 'FILE',
427
+ sourceId: fileId,
428
+ text,
429
+ metadata: { name: file.name, type: file.type, size: file.size },
430
+ });
431
+ }
432
+
272
433
  /**
273
434
  * Fire-and-forget: enqueue a background job to embed content.
274
435
  * Safe to call from service functions after a create/update.
@@ -28,6 +28,7 @@ import {
28
28
  type DirectFileData,
29
29
  confirmDirectUpload,
30
30
  } from "../lib/fileUpload.js";
31
+ import { enqueueEmbedding } from "./embedding.js";
31
32
  import {
32
33
  type AccessLevel,
33
34
  getScopeDefault,
@@ -421,9 +422,39 @@ export async function confirmFolderUpload(
421
422
  input.uploadSuccess,
422
423
  input.errorMessage
423
424
  );
425
+
426
+ // Index file for RAG search if it belongs to a class folder
427
+ if (input.uploadSuccess) {
428
+ const file = await prisma.file.findUnique({
429
+ where: { id: input.fileId },
430
+ select: { folderId: true },
431
+ });
432
+ if (file?.folderId) {
433
+ const classId = await resolveFileClassId(file.folderId);
434
+ if (classId) {
435
+ enqueueEmbedding(classId, 'FILE', input.fileId);
436
+ }
437
+ }
438
+ }
439
+
424
440
  return { success: true };
425
441
  }
426
442
 
443
+ /** Walk up folder tree to find classId, if any. */
444
+ async function resolveFileClassId(folderId: string): Promise<string | null> {
445
+ let currentId: string | null = folderId;
446
+ while (currentId) {
447
+ const folder = await prisma.folder.findUnique({
448
+ where: { id: currentId },
449
+ select: { classId: true, parentFolderId: true },
450
+ });
451
+ if (!folder) return null;
452
+ if (folder.classId) return folder.classId;
453
+ currentId = folder.parentFolderId;
454
+ }
455
+ return null;
456
+ }
457
+
427
458
  /** What scopes can this user pick from in cloud-storage UIs? */
428
459
  export async function listUserScopes(userId: string) {
429
460
  const memberships = await prisma.schoolMembership.findMany({
@@ -4,7 +4,7 @@
4
4
  import { v4 } from "uuid";
5
5
  import {
6
6
  createSchoolDevelopmentProgram,
7
- findSchoolDevelopmentProgramById,
7
+ findSchoolDevelopmentProgramStatusById,
8
8
  createEarlyAccessRequest,
9
9
  } from "../models/marketing.js";
10
10
 
@@ -42,9 +42,9 @@ export async function createSchoolDevelopmentProgramRecord(input: {
42
42
  return { id: program.id };
43
43
  }
44
44
 
45
- /** Look up a school development program by ID. */
45
+ /** Public application status lookup (no contact PII). */
46
46
  export async function searchSchoolDevelopmentPrograms(id: string) {
47
- return findSchoolDevelopmentProgramById(id);
47
+ return findSchoolDevelopmentProgramStatusById(id);
48
48
  }
49
49
 
50
50
  /** Create an early access request. */