@studious-lms/server 1.14.0 → 1.16.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 (129) hide show
  1. package/dist/lib/inferDocumentFormat.d.ts +10 -0
  2. package/dist/lib/inferDocumentFormat.d.ts.map +1 -0
  3. package/dist/lib/inferDocumentFormat.js +48 -0
  4. package/dist/lib/inferDocumentFormat.js.map +1 -0
  5. package/dist/models/announcement.d.ts +45 -45
  6. package/dist/models/assignment.d.ts +75 -75
  7. package/dist/models/class.d.ts +13 -13
  8. package/dist/models/comment.d.ts +6 -6
  9. package/dist/models/conversation.d.ts +23 -23
  10. package/dist/models/event.d.ts +18 -18
  11. package/dist/models/file.d.ts +9 -9
  12. package/dist/models/labChat.d.ts +4 -4
  13. package/dist/models/message.d.ts +13 -13
  14. package/dist/models/newtonChat.d.ts +3 -3
  15. package/dist/models/notification.d.ts +4 -4
  16. package/dist/models/worksheet.d.ts +121 -121
  17. package/dist/pipelines/aiLabChat.d.ts +7 -1
  18. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  19. package/dist/pipelines/aiLabChat.js +145 -67
  20. package/dist/pipelines/aiLabChat.js.map +1 -1
  21. package/dist/pipelines/aiLabChatContract.d.ts +34 -195
  22. package/dist/pipelines/aiLabChatContract.d.ts.map +1 -1
  23. package/dist/pipelines/aiLabChatContract.js +9 -19
  24. package/dist/pipelines/aiLabChatContract.js.map +1 -1
  25. package/dist/pipelines/gradeWorksheet.d.ts +6 -6
  26. package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
  27. package/dist/pipelines/gradeWorksheet.js +75 -36
  28. package/dist/pipelines/gradeWorksheet.js.map +1 -1
  29. package/dist/pipelines/labChatPrompt.d.ts +8 -3
  30. package/dist/pipelines/labChatPrompt.d.ts.map +1 -1
  31. package/dist/pipelines/labChatPrompt.js +49 -14
  32. package/dist/pipelines/labChatPrompt.js.map +1 -1
  33. package/dist/pipelines/pdfToWorksheet.d.ts +1 -1
  34. package/dist/routers/_app.d.ts +524 -516
  35. package/dist/routers/_app.d.ts.map +1 -1
  36. package/dist/routers/announcement.d.ts +40 -40
  37. package/dist/routers/assignment.d.ts +121 -121
  38. package/dist/routers/class.d.ts +8 -8
  39. package/dist/routers/comment.d.ts +5 -5
  40. package/dist/routers/conversation.d.ts +6 -6
  41. package/dist/routers/discussion.d.ts +3 -3
  42. package/dist/routers/embedding.d.ts +6 -2
  43. package/dist/routers/embedding.d.ts.map +1 -1
  44. package/dist/routers/embedding.js +44 -36
  45. package/dist/routers/embedding.js.map +1 -1
  46. package/dist/routers/event.d.ts +18 -18
  47. package/dist/routers/file.d.ts +2 -2
  48. package/dist/routers/labChat.d.ts +3 -3
  49. package/dist/routers/notifications.d.ts +4 -4
  50. package/dist/routers/search.d.ts +2 -2
  51. package/dist/routers/worksheet.d.ts +44 -44
  52. package/dist/routes/aiStream.d.ts.map +1 -1
  53. package/dist/routes/aiStream.js +41 -11
  54. package/dist/routes/aiStream.js.map +1 -1
  55. package/dist/seedDatabase.d.ts +1 -1
  56. package/dist/services/aiChatStream.d.ts +18 -0
  57. package/dist/services/aiChatStream.d.ts.map +1 -1
  58. package/dist/services/aiChatStream.js +81 -11
  59. package/dist/services/aiChatStream.js.map +1 -1
  60. package/dist/services/aiModifierTools.d.ts +0 -16
  61. package/dist/services/aiModifierTools.d.ts.map +1 -1
  62. package/dist/services/aiModifierTools.js +112 -3
  63. package/dist/services/aiModifierTools.js.map +1 -1
  64. package/dist/services/aiProposalTools.d.ts.map +1 -1
  65. package/dist/services/aiProposalTools.js +25 -25
  66. package/dist/services/aiProposalTools.js.map +1 -1
  67. package/dist/services/announcement.d.ts +34 -34
  68. package/dist/services/assignment.d.ts +111 -111
  69. package/dist/services/assignment.d.ts.map +1 -1
  70. package/dist/services/assignment.js +4 -2
  71. package/dist/services/assignment.js.map +1 -1
  72. package/dist/services/class.d.ts +6 -6
  73. package/dist/services/class.d.ts.map +1 -1
  74. package/dist/services/class.js +7 -3
  75. package/dist/services/class.js.map +1 -1
  76. package/dist/services/comment.d.ts +5 -5
  77. package/dist/services/conversation.d.ts +6 -6
  78. package/dist/services/discussion.d.ts.map +1 -1
  79. package/dist/services/discussion.js +9 -2
  80. package/dist/services/discussion.js.map +1 -1
  81. package/dist/services/embedding.d.ts +19 -1
  82. package/dist/services/embedding.d.ts.map +1 -1
  83. package/dist/services/embedding.js +154 -8
  84. package/dist/services/embedding.js.map +1 -1
  85. package/dist/services/event.d.ts +18 -18
  86. package/dist/services/file.d.ts +4 -4
  87. package/dist/services/folder.d.ts.map +1 -1
  88. package/dist/services/folder.js +32 -2
  89. package/dist/services/folder.js.map +1 -1
  90. package/dist/services/labChat.d.ts +3 -3
  91. package/dist/services/notification.d.ts +4 -4
  92. package/dist/services/openaiDocumentGen.d.ts +25 -0
  93. package/dist/services/openaiDocumentGen.d.ts.map +1 -0
  94. package/dist/services/openaiDocumentGen.js +195 -0
  95. package/dist/services/openaiDocumentGen.js.map +1 -0
  96. package/dist/services/search.d.ts +1 -1
  97. package/dist/services/search.d.ts.map +1 -1
  98. package/dist/services/search.js +27 -2
  99. package/dist/services/search.js.map +1 -1
  100. package/dist/services/worksheet.d.ts +66 -49
  101. package/dist/services/worksheet.d.ts.map +1 -1
  102. package/dist/services/worksheet.js +48 -3
  103. package/dist/services/worksheet.js.map +1 -1
  104. package/dist/workers/processors/embedContent.d.ts.map +1 -1
  105. package/dist/workers/processors/embedContent.js +16 -25
  106. package/dist/workers/processors/embedContent.js.map +1 -1
  107. package/package.json +1 -1
  108. package/prisma/migrations/20260610140000_expand_embedding_source_types/migration.sql +4 -0
  109. package/prisma/schema.prisma +3 -0
  110. package/src/lib/inferDocumentFormat.ts +58 -0
  111. package/src/pipelines/aiLabChat.ts +185 -74
  112. package/src/pipelines/aiLabChatContract.ts +7 -17
  113. package/src/pipelines/gradeWorksheet.ts +95 -38
  114. package/src/pipelines/labChatPrompt.ts +61 -14
  115. package/src/routers/embedding.ts +44 -39
  116. package/src/routes/aiStream.ts +47 -9
  117. package/src/services/aiChatStream.ts +103 -8
  118. package/src/services/aiModifierTools.ts +154 -1
  119. package/src/services/aiProposalTools.ts +26 -25
  120. package/src/services/assignment.ts +3 -0
  121. package/src/services/class.ts +7 -1
  122. package/src/services/discussion.ts +13 -0
  123. package/src/services/embedding.ts +167 -6
  124. package/src/services/folder.ts +31 -0
  125. package/src/services/openaiDocumentGen.ts +237 -0
  126. package/src/services/search.ts +25 -1
  127. package/src/services/worksheet.ts +68 -1
  128. package/src/workers/processors/embedContent.ts +18 -22
  129. package/tests/unit/lib/inferDocumentFormat.test.ts +33 -0
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Generate files via OpenAI Code Interpreter (Responses API).
3
+ * Supports every format the sandbox can produce (pdf, docx, xlsx, pptx, …).
4
+ */
5
+ import OpenAI from 'openai';
6
+ import type { Response } from 'openai/resources/responses/responses.js';
7
+ import { logger } from '../utils/logger.js';
8
+
9
+ const DOCUMENT_GEN_MODEL = 'gpt-4o-mini';
10
+
11
+ export const SUPPORTED_DOCUMENT_FORMATS = [
12
+ 'pdf', 'docx', 'doc', 'xlsx', 'pptx', 'csv', 'md', 'html', 'txt', 'json',
13
+ 'xml', 'tex', 'png', 'jpeg', 'jpg', 'gif', 'zip', 'tar', 'py', 'js', 'ts',
14
+ 'css', 'sh',
15
+ ] as const;
16
+
17
+ export type DocumentFormat = (typeof SUPPORTED_DOCUMENT_FORMATS)[number];
18
+
19
+ const FORMAT_MIME: Record<DocumentFormat, string> = {
20
+ pdf: 'application/pdf',
21
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
22
+ doc: 'application/msword',
23
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
24
+ pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
25
+ csv: 'text/csv',
26
+ md: 'text/markdown',
27
+ html: 'text/html',
28
+ txt: 'text/plain',
29
+ json: 'application/json',
30
+ xml: 'application/xml',
31
+ tex: 'text/x-tex',
32
+ png: 'image/png',
33
+ jpeg: 'image/jpeg',
34
+ jpg: 'image/jpeg',
35
+ gif: 'image/gif',
36
+ zip: 'application/zip',
37
+ tar: 'application/x-tar',
38
+ py: 'text/x-python',
39
+ js: 'text/javascript',
40
+ ts: 'application/typescript',
41
+ css: 'text/css',
42
+ sh: 'application/x-sh',
43
+ };
44
+
45
+ const FORMAT_HINTS: Record<DocumentFormat, string> = {
46
+ pdf: 'fpdf2 or reportlab',
47
+ docx: 'python-docx',
48
+ doc: 'python-docx',
49
+ xlsx: 'openpyxl or pandas',
50
+ pptx: 'python-pptx',
51
+ csv: 'pandas or csv module',
52
+ md: 'write markdown directly',
53
+ html: 'write a complete HTML page',
54
+ txt: 'write plain text',
55
+ json: 'json.dumps with indentation',
56
+ xml: 'well-formed XML',
57
+ tex: 'LaTeX source',
58
+ png: 'matplotlib or Pillow',
59
+ jpeg: 'matplotlib or Pillow',
60
+ jpg: 'matplotlib or Pillow',
61
+ gif: 'matplotlib or Pillow',
62
+ zip: 'zipfile',
63
+ tar: 'tarfile',
64
+ py: 'Python source',
65
+ js: 'JavaScript source',
66
+ ts: 'TypeScript source',
67
+ css: 'CSS stylesheet',
68
+ sh: 'shell script',
69
+ };
70
+
71
+ export interface GenerateDocumentInput {
72
+ title: string;
73
+ content: string;
74
+ format?: DocumentFormat | string;
75
+ }
76
+
77
+ export interface GeneratedDocumentResult {
78
+ bytes: Buffer;
79
+ filename: string;
80
+ mimeType: string;
81
+ format: DocumentFormat;
82
+ }
83
+
84
+ export interface ContainerGeneratedFile {
85
+ containerId: string;
86
+ fileId: string;
87
+ filename: string;
88
+ }
89
+
90
+ export function normalizeDocumentFormat(format?: string | null): DocumentFormat {
91
+ const raw = (format ?? 'pdf').toLowerCase().replace(/^\./, '');
92
+ if ((SUPPORTED_DOCUMENT_FORMATS as readonly string[]).includes(raw)) {
93
+ return raw as DocumentFormat;
94
+ }
95
+ return 'pdf';
96
+ }
97
+
98
+ export function mimeTypeForFormat(format: DocumentFormat): string {
99
+ return FORMAT_MIME[format];
100
+ }
101
+
102
+ export function extractContainerGeneratedFiles(
103
+ response: Response,
104
+ extension: DocumentFormat,
105
+ ): ContainerGeneratedFile[] {
106
+ const suffix = `.${extension}`;
107
+ const seen = new Set<string>();
108
+ const files: ContainerGeneratedFile[] = [];
109
+
110
+ for (const item of response.output ?? []) {
111
+ if (item.type !== 'message') continue;
112
+ for (const block of item.content ?? []) {
113
+ if (block.type !== 'output_text') continue;
114
+ for (const ann of block.annotations ?? []) {
115
+ if (ann.type !== 'container_file_citation') continue;
116
+ const name = ann.filename?.toLowerCase() ?? '';
117
+ if (!name.endsWith(suffix)) continue;
118
+ const key = `${ann.container_id}:${ann.file_id}`;
119
+ if (seen.has(key)) continue;
120
+ seen.add(key);
121
+ files.push({
122
+ containerId: ann.container_id,
123
+ fileId: ann.file_id,
124
+ filename: ann.filename,
125
+ });
126
+ }
127
+ }
128
+ }
129
+ return files;
130
+ }
131
+
132
+ function containerIdFromResponse(response: Response): string | undefined {
133
+ for (const item of response.output ?? []) {
134
+ if (item.type === 'code_interpreter_call' && item.container_id) {
135
+ return item.container_id;
136
+ }
137
+ }
138
+ return undefined;
139
+ }
140
+
141
+ async function listContainerGeneratedFiles(
142
+ client: OpenAI,
143
+ containerId: string,
144
+ extension: DocumentFormat,
145
+ ): Promise<ContainerGeneratedFile[]> {
146
+ const suffix = `.${extension}`;
147
+ const files: ContainerGeneratedFile[] = [];
148
+ for await (const file of client.containers.files.list(containerId)) {
149
+ const path = file.path?.toLowerCase() ?? '';
150
+ if (path.endsWith(suffix) && file.source === 'assistant') {
151
+ files.push({
152
+ containerId,
153
+ fileId: file.id,
154
+ filename: file.path.split('/').pop() ?? file.path,
155
+ });
156
+ }
157
+ }
158
+ return files;
159
+ }
160
+
161
+ function safeBasename(title: string): string {
162
+ return (
163
+ title
164
+ .replace(/[^a-zA-Z0-9\s\-_]/g, '')
165
+ .replace(/\s+/g, '_')
166
+ .substring(0, 60) || 'document'
167
+ );
168
+ }
169
+
170
+ /** Generate a file via OpenAI Code Interpreter. */
171
+ export async function generateDocumentWithOpenAI(
172
+ input: GenerateDocumentInput,
173
+ ): Promise<GeneratedDocumentResult | null> {
174
+ const client = new OpenAI();
175
+ const format = normalizeDocumentFormat(input.format);
176
+ const ext = format === 'jpg' ? 'jpg' : format;
177
+ const filename = `${safeBasename(input.title)}.${ext}`;
178
+
179
+ try {
180
+ const response = await client.responses.create({
181
+ model: DOCUMENT_GEN_MODEL,
182
+ instructions: [
183
+ 'You generate classroom-ready files for teachers using the python tool.',
184
+ `You MUST output a ${ext.toUpperCase()} file — not any other format.`,
185
+ `Use: ${FORMAT_HINTS[format]}.`,
186
+ `The saved filename MUST end with .${ext}.`,
187
+ 'Save exactly one output file with the requested filename.',
188
+ 'Include only educational content — no chat commentary.',
189
+ ...(format !== 'pdf' ? [`Do NOT create a PDF when ${ext.toUpperCase()} was requested.`] : []),
190
+ ].join(' '),
191
+ tools: [{ type: 'code_interpreter', container: { type: 'auto' } }],
192
+ tool_choice: 'required',
193
+ input: [
194
+ `Create a ${ext.toUpperCase()} file.`,
195
+ `Filename (exact): ${filename}`,
196
+ `Title: ${input.title}`,
197
+ 'Content:',
198
+ input.content,
199
+ ].join('\n\n'),
200
+ });
201
+
202
+ let matches = extractContainerGeneratedFiles(response, format);
203
+ if (matches.length === 0) {
204
+ const containerId = containerIdFromResponse(response);
205
+ if (containerId) {
206
+ matches = await listContainerGeneratedFiles(client, containerId, format);
207
+ }
208
+ }
209
+
210
+ if (matches.length === 0) {
211
+ logger.error('OpenAI document gen: no output file', {
212
+ title: input.title,
213
+ format,
214
+ });
215
+ return null;
216
+ }
217
+
218
+ const preferred =
219
+ matches.find((f) => f.filename.toLowerCase() === filename.toLowerCase()) ??
220
+ matches[0];
221
+
222
+ const res = await client.containers.files.content.retrieve(preferred.fileId, {
223
+ container_id: preferred.containerId,
224
+ });
225
+ const bytes = Buffer.from(await res.arrayBuffer());
226
+
227
+ return {
228
+ bytes,
229
+ filename: preferred.filename,
230
+ mimeType: mimeTypeForFormat(format),
231
+ format,
232
+ };
233
+ } catch (error) {
234
+ logger.error('OpenAI document generation failed', { error, title: input.title, format });
235
+ return null;
236
+ }
237
+ }
@@ -11,7 +11,9 @@ type SearchScope =
11
11
  | "worksheet"
12
12
  | "file"
13
13
  | "announcement"
14
- | "section";
14
+ | "section"
15
+ | "discussion"
16
+ | "submission";
15
17
 
16
18
  type SearchResult = {
17
19
  id: string;
@@ -61,6 +63,8 @@ const trimResults = (results: SearchResult[], limit: number) =>
61
63
  file: 5,
62
64
  announcement: 6,
63
65
  section: 7,
66
+ discussion: 8,
67
+ submission: 9,
64
68
  };
65
69
  const priorityDiff = priority[a.type] - priority[b.type];
66
70
  if (priorityDiff !== 0) return priorityDiff;
@@ -405,6 +409,9 @@ const SOURCE_TYPE_TO_SCOPE: Record<EmbeddingSourceType, SearchScope> = {
405
409
  ANNOUNCEMENT: "announcement",
406
410
  FILE: "file",
407
411
  SECTION: "section",
412
+ DISCUSSION: "discussion",
413
+ SUBMISSION: "submission",
414
+ SYLLABUS: "syllabus",
408
415
  };
409
416
 
410
417
  const MIN_SIMILARITY = 0.25;
@@ -517,6 +524,23 @@ export async function searchSemanticGlobal(
517
524
  });
518
525
  title = section?.name ?? title;
519
526
  href = `/class/${hit.classId}/assignments`;
527
+ } else if (scope === "discussion") {
528
+ const discussion = await prisma.discussion.findUnique({
529
+ where: { id: hit.sourceId },
530
+ select: { title: true },
531
+ });
532
+ title = discussion?.title ?? title;
533
+ href = `/class/${hit.classId}/discussions/${hit.sourceId}`;
534
+ } else if (scope === "submission") {
535
+ const submission = await prisma.submission.findUnique({
536
+ where: { id: hit.sourceId },
537
+ select: { assignment: { select: { title: true, id: true } } },
538
+ });
539
+ title = submission?.assignment.title ? `Submission: ${submission.assignment.title}` : title;
540
+ href = submission?.assignment.id ? `/class/${hit.classId}/assignment/${submission.assignment.id}` : href;
541
+ } else if (scope === "syllabus") {
542
+ title = `${className} Syllabus`;
543
+ href = `/class/${hit.classId}/syllabus`;
520
544
  }
521
545
 
522
546
  results.push({
@@ -3,7 +3,7 @@
3
3
  * Integrates with gradeWorksheet pipeline for auto-grading.
4
4
  */
5
5
  import { TRPCError } from "@trpc/server";
6
- import { JobStatus, type WorksheetQuestionType } from "@prisma/client";
6
+ import { JobStatus, Prisma, type WorksheetQuestionType } from "@prisma/client";
7
7
  import { prisma } from "../lib/prisma.js";
8
8
  import { getFileUrl } from "../lib/fileUpload.js";
9
9
  import { cancelGradePipeline, regradeWorksheetPipeline } from "../pipelines/gradeWorksheet.js";
@@ -323,6 +323,73 @@ export async function addQuestionToWorksheet(
323
323
  });
324
324
  }
325
325
 
326
+ /** A question's full editable state as captured before an AI modifier run. */
327
+ export interface WorksheetQuestionSnapshot {
328
+ id: string;
329
+ type: WorksheetQuestionType;
330
+ question: string;
331
+ answer: string;
332
+ options: unknown;
333
+ markScheme: unknown;
334
+ points: number;
335
+ order: number | null;
336
+ }
337
+
338
+ const toJsonInput = (value: unknown) =>
339
+ value === null || value === undefined
340
+ ? Prisma.JsonNull
341
+ : (value as Prisma.InputJsonValue);
342
+
343
+ /**
344
+ * Restore a worksheet's questions to a snapshot: deletes questions not in the
345
+ * snapshot, re-creates deleted ones (with their original ids), and reverts
346
+ * edited ones. Used by AI modifier undo.
347
+ */
348
+ export async function restoreWorksheetQuestions(
349
+ worksheetId: string,
350
+ userId: string,
351
+ questions: WorksheetQuestionSnapshot[]
352
+ ) {
353
+ const worksheet = await findWorksheetByIdForTeacher(worksheetId, userId);
354
+ if (!worksheet) {
355
+ throw new TRPCError({
356
+ code: "FORBIDDEN",
357
+ message: "Only class teachers can modify worksheet questions",
358
+ });
359
+ }
360
+
361
+ await prisma.$transaction([
362
+ prisma.worksheetQuestion.deleteMany({
363
+ where: { worksheetId, id: { notIn: questions.map((q) => q.id) } },
364
+ }),
365
+ ...questions.map((q) =>
366
+ prisma.worksheetQuestion.upsert({
367
+ where: { id: q.id },
368
+ create: {
369
+ id: q.id,
370
+ worksheetId,
371
+ type: q.type,
372
+ question: q.question,
373
+ answer: q.answer,
374
+ options: toJsonInput(q.options),
375
+ markScheme: toJsonInput(q.markScheme),
376
+ points: q.points,
377
+ order: q.order,
378
+ },
379
+ update: {
380
+ type: q.type,
381
+ question: q.question,
382
+ answer: q.answer,
383
+ options: toJsonInput(q.options),
384
+ markScheme: toJsonInput(q.markScheme),
385
+ points: q.points,
386
+ order: q.order,
387
+ },
388
+ })
389
+ ),
390
+ ]);
391
+ }
392
+
326
393
  export async function reorderWorksheetQuestions(
327
394
  worksheetId: string,
328
395
  userId: string,
@@ -1,12 +1,17 @@
1
1
  /**
2
2
  * Background worker for embedding content into pgvector.
3
- * Handles indexing of files, assignments, worksheets, and announcements.
3
+ * Handles indexing of files, assignments, worksheets, announcements,
4
+ * discussions, submissions, and syllabi.
4
5
  */
5
6
  import { createRunJobWorker } from './createRunJobWorker.js';
6
7
  import {
7
8
  indexAssignment,
8
9
  indexWorksheet,
9
10
  indexAnnouncement,
11
+ indexDiscussion,
12
+ indexSubmission,
13
+ indexSyllabus,
14
+ indexFile,
10
15
  indexContent,
11
16
  } from '../../services/embedding.js';
12
17
  import { prisma } from '../../lib/prisma.js';
@@ -35,30 +40,21 @@ export const embedContentWorker = createRunJobWorker({
35
40
  await indexAnnouncement(classId, sourceId);
36
41
  break;
37
42
 
38
- case 'FILE': {
39
- // For files, fetch the file metadata and content
40
- const file = await prisma.file.findUnique({
41
- where: { id: sourceId },
42
- select: { name: true, path: true, type: true },
43
- });
43
+ case 'FILE':
44
+ await indexFile(classId, sourceId);
45
+ break;
44
46
 
45
- if (!file) {
46
- logger.warn('File not found for embedding', { sourceId });
47
- return { skipped: true, reason: 'file_not_found' };
48
- }
47
+ case 'DISCUSSION':
48
+ await indexDiscussion(classId, sourceId);
49
+ break;
49
50
 
50
- // For now, index the filename and any text-based content.
51
- // PDF extraction would be added here with pdf-parse.
52
- const text = `File: ${file.name}\nType: ${file.type}\nPath: ${file.path}`;
53
- await indexContent({
54
- classId,
55
- sourceType: 'FILE',
56
- sourceId,
57
- text,
58
- metadata: { name: file.name, type: file.type },
59
- });
51
+ case 'SUBMISSION':
52
+ await indexSubmission(classId, sourceId);
53
+ break;
54
+
55
+ case 'SYLLABUS':
56
+ await indexSyllabus(sourceId);
60
57
  break;
61
- }
62
58
 
63
59
  case 'SECTION': {
64
60
  const section = await prisma.section.findUnique({
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { inferDocumentFormat } from '../../../src/lib/inferDocumentFormat.js';
3
+
4
+ describe('inferDocumentFormat', () => {
5
+ it('respects explicit non-pdf format', () => {
6
+ expect(inferDocumentFormat('Title', 'body', 'xlsx')).toBe('xlsx');
7
+ });
8
+
9
+ it('overrides model pdf when title says presentation', () => {
10
+ expect(
11
+ inferDocumentFormat(
12
+ 'Volume of Revolution Presentation',
13
+ 'Slide 1: intro',
14
+ 'pdf',
15
+ ),
16
+ ).toBe('pptx');
17
+ });
18
+
19
+ it('uses teacher message for format when model picked pdf', () => {
20
+ expect(
21
+ inferDocumentFormat(
22
+ 'Calculus Unit 3',
23
+ 'Content here',
24
+ 'pdf',
25
+ 'Can you make a PowerPoint for this lesson?',
26
+ ),
27
+ ).toBe('pptx');
28
+ });
29
+
30
+ it('defaults to pdf for generic handouts', () => {
31
+ expect(inferDocumentFormat('Reading Guide', 'Chapter summary', 'pdf')).toBe('pdf');
32
+ });
33
+ });