@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@studious-lms/server",
3
- "version": "1.14.0",
3
+ "version": "1.16.0",
4
4
  "description": "Backend server for Studious application",
5
5
  "main": "dist/exportType.js",
6
6
  "types": "dist/exportType.d.ts",
@@ -0,0 +1,4 @@
1
+ -- AlterEnum
2
+ ALTER TYPE "EmbeddingSourceType" ADD VALUE 'DISCUSSION';
3
+ ALTER TYPE "EmbeddingSourceType" ADD VALUE 'SUBMISSION';
4
+ ALTER TYPE "EmbeddingSourceType" ADD VALUE 'SYLLABUS';
@@ -977,6 +977,9 @@ enum EmbeddingSourceType {
977
977
  WORKSHEET
978
978
  ANNOUNCEMENT
979
979
  SECTION
980
+ DISCUSSION
981
+ SUBMISSION
982
+ SYLLABUS
980
983
  }
981
984
 
982
985
  model Embedding {
@@ -0,0 +1,58 @@
1
+ import type { DocumentFormat } from '../services/openaiDocumentGen.js';
2
+ import { normalizeDocumentFormat } from '../services/openaiDocumentGen.js';
3
+
4
+ type FormatRule = { format: DocumentFormat; pattern: RegExp };
5
+
6
+ /** Ordered most-specific first. */
7
+ const FORMAT_RULES: FormatRule[] = [
8
+ { format: 'pptx', pattern: /\b(presentation|powerpoint|power point|slides?|slide deck|deck|pptx|\.pptx)\b/i },
9
+ { format: 'xlsx', pattern: /\b(spreadsheet|excel|workbook|xlsx|gradebook|grade book|\.xlsx)\b/i },
10
+ { format: 'csv', pattern: /\b(csv|comma[- ]separated|\.csv)\b/i },
11
+ { format: 'docx', pattern: /\b(word doc(ument)?|docx|editable handout|\.docx)\b/i },
12
+ { format: 'png', pattern: /\b(diagram|chart|graph|plot|figure|infographic|\.png)\b/i },
13
+ { format: 'md', pattern: /\b(markdown|readme|\.md)\b/i },
14
+ { format: 'html', pattern: /\b(html|web ?page|\.html)\b/i },
15
+ { format: 'txt', pattern: /\b(plain text|text file|\.txt)\b/i },
16
+ ];
17
+
18
+ function inferFromText(...parts: Array<string | null | undefined>): DocumentFormat | null {
19
+ const hay = parts.filter(Boolean).join('\n');
20
+ if (!hay.trim()) return null;
21
+
22
+ for (const { format, pattern } of FORMAT_RULES) {
23
+ if (pattern.test(hay)) return format;
24
+ }
25
+ return null;
26
+ }
27
+
28
+ /**
29
+ * Resolve the output format for a generated document.
30
+ *
31
+ * The lab model often passes `format: "pdf"` even for presentations/spreadsheets.
32
+ * Keyword signals from the teacher request, title, and body take precedence over
33
+ * a generic pdf default; a non-pdf explicit format is always respected.
34
+ */
35
+ export function inferDocumentFormat(
36
+ title: string,
37
+ content: string,
38
+ explicit?: string | null,
39
+ userRequest?: string | null,
40
+ ): DocumentFormat {
41
+ const explicitNorm = explicit?.trim()
42
+ ? normalizeDocumentFormat(explicit)
43
+ : null;
44
+
45
+ const fromKeywords = inferFromText(userRequest, title, content);
46
+
47
+ // Non-pdf explicit choice wins (model or tool args).
48
+ if (explicitNorm && explicitNorm !== 'pdf') {
49
+ return explicitNorm;
50
+ }
51
+
52
+ // Override pdf when the request clearly asks for slides, spreadsheets, etc.
53
+ if (fromKeywords && fromKeywords !== 'pdf') {
54
+ return fromKeywords;
55
+ }
56
+
57
+ return explicitNorm ?? 'pdf';
58
+ }
@@ -1,23 +1,31 @@
1
1
  /**
2
2
  * AI lab chat pipeline – generates lab introductions and responses.
3
- * Can create worksheets, sections, assignments, and PDF docs from AI output.
3
+ * Can create worksheets, sections, assignments, and generated files from AI output.
4
4
  */
5
5
  import { isAIUser } from "../utils/aiUser.js";
6
6
  import { prisma } from "../lib/prisma.js";
7
- import { pusher, teacherChannel } from "../lib/pusher.js";
7
+ import { pusher, chatChannel, teacherChannel } from "../lib/pusher.js";
8
8
  import type { Assignment, Class, File, Section, User } from "@prisma/client";
9
9
  import {
10
10
  completePendingAIMessage,
11
11
  failPendingAIMessage,
12
12
  } from "../utils/inference.js";
13
13
  import { logger } from "../utils/logger.js";
14
- import { createPdf } from "../lib/jsonConversion.js";
15
14
  import { v4 } from "uuid";
16
- import { bucket } from "../lib/googleCloudStorage.js";
17
- import { DocumentBlock } from "../lib/jsonStyles.js";
15
+ import { bucket, getSignedUrl } from "../lib/googleCloudStorage.js";
16
+ import { generateDocumentWithOpenAI } from "../services/openaiDocumentGen.js";
17
+ import { inferDocumentFormat } from "../lib/inferDocumentFormat.js";
18
+ import { normalizeDocumentFormat } from "../services/openaiDocumentGen.js";
18
19
  import { broadcastTextChunks, broadcastStreamComplete } from "../utils/streamBroadcast.js";
19
20
  import { buildLabChatAgentMessages } from "./labChatPrompt.js";
20
- import { streamChatResponse } from "../services/aiChatStream.js";
21
+ import type { ConversationMessage } from "./labChatPrompt.js";
22
+ import {
23
+ applyResolvedFormatsToStreamParts,
24
+ enrichStreamPartsWithGeneratedFiles,
25
+ streamChatResponse,
26
+ type GeneratedDocFileLink,
27
+ type StreamPart,
28
+ } from "../services/aiChatStream.js";
21
29
  import { aiTools } from "../services/aiTools.js";
22
30
  import { labProposalTools, createLabProposals } from "../services/aiProposalTools.js";
23
31
 
@@ -74,6 +82,12 @@ type RecentLabChatMessage = {
74
82
  displayName: string | null;
75
83
  } | null;
76
84
  } | null;
85
+ attachments: {
86
+ id: string;
87
+ name: string;
88
+ type: string;
89
+ path: string;
90
+ }[];
77
91
  };
78
92
 
79
93
  /**
@@ -118,6 +132,14 @@ const loadRecentLabChatMessages = async (
118
132
  },
119
133
  },
120
134
  },
135
+ attachments: {
136
+ select: {
137
+ id: true,
138
+ name: true,
139
+ type: true,
140
+ path: true,
141
+ },
142
+ },
121
143
  };
122
144
 
123
145
  const newestMessages = await prisma.message.findMany({
@@ -188,6 +210,42 @@ const loadRecentLabChatMessages = async (
188
210
  .slice(0, limit);
189
211
  };
190
212
 
213
+ /**
214
+ * Resolves signed URLs for image attachments in messages so the LLM can see them.
215
+ * Non-image files are passed as text descriptions only.
216
+ */
217
+ async function resolveAttachmentUrls(
218
+ messages: RecentLabChatMessage[],
219
+ ): Promise<ConversationMessage[]> {
220
+ const resolved: ConversationMessage[] = [];
221
+
222
+ for (const msg of messages) {
223
+ const attachments = msg.attachments ?? [];
224
+ if (attachments.length === 0) {
225
+ resolved.push({ ...msg, attachments: [] });
226
+ continue;
227
+ }
228
+
229
+ const withUrls = await Promise.all(
230
+ attachments.map(async (a) => {
231
+ if (a.type.startsWith('image/')) {
232
+ try {
233
+ const url = await getSignedUrl(a.path);
234
+ return { id: a.id, name: a.name, type: a.type, url };
235
+ } catch {
236
+ return { id: a.id, name: a.name, type: a.type };
237
+ }
238
+ }
239
+ return { id: a.id, name: a.name, type: a.type };
240
+ }),
241
+ );
242
+
243
+ resolved.push({ ...msg, attachments: withUrls });
244
+ }
245
+
246
+ return resolved;
247
+ }
248
+
191
249
  /**
192
250
  * Builds schema-aware context for the AI from class data.
193
251
  * Formats entities with IDs so the model can reference them when creating assignments.
@@ -320,7 +378,7 @@ export const generateAndSendLabIntroduction = async (
320
378
  aiMessageId: string,
321
379
  ): Promise<void> => {
322
380
  const introMessage =
323
- "Hello teaching team! I'm your AI assistant for course material development. I'll help you create educational content - when you have a clear request, I'll produce it directly. I only ask questions when I need to clarify the topic or scope. What would you like to work on?";
381
+ "Hello teaching team! I'm your AI assistant for course material development. I can create worksheets, assignments, and downloadable files — PDFs, Word docs, PowerPoints, spreadsheets, and more. When you have a clear request, I'll produce it directly. I only ask questions when I need to clarify the topic or scope. What would you like to work on?";
324
382
 
325
383
  try {
326
384
  await broadcastTextChunks(conversationId, aiMessageId, introMessage);
@@ -352,7 +410,7 @@ export const generateAndSendLabIntroduction = async (
352
410
  */
353
411
  export const generateAndSendLabResponse = async (
354
412
  labChatId: string,
355
- _teacherMessage: string,
413
+ teacherMessage: string,
356
414
  emitOptions: { classId: string; aiMessageId: string }
357
415
  ): Promise<void> => {
358
416
  try {
@@ -450,10 +508,13 @@ export const generateAndSendLabIntroduction = async (
450
508
  assignments: classData.assignments,
451
509
  });
452
510
 
511
+ // Resolve signed URLs for image attachments so the LLM can see them
512
+ const messagesWithAttachments = await resolveAttachmentUrls(recentMessages.reverse());
513
+
453
514
  const { system, messages } = buildLabChatAgentMessages({
454
515
  context: fullLabChat.context,
455
516
  classContext,
456
- recentMessages: recentMessages.reverse(),
517
+ recentMessages: messagesWithAttachments,
457
518
  isAIUser,
458
519
  });
459
520
 
@@ -467,7 +528,7 @@ export const generateAndSendLabIntroduction = async (
467
528
  ...labProposalTools(proposals),
468
529
  };
469
530
 
470
- const { text: streamedText, parts: streamParts } = await withTimeout(
531
+ const { text: streamedText, parts: rawStreamParts } = await withTimeout(
471
532
  streamChatResponse({
472
533
  conversationId,
473
534
  aiMessageId: emitOptions.aiMessageId,
@@ -486,78 +547,128 @@ export const generateAndSendLabIntroduction = async (
486
547
  // during the stream by `streamChatResponse`. Just close the stream.
487
548
  await broadcastStreamComplete(conversationId, emitOptions.aiMessageId);
488
549
 
489
- // Generate PDFs for any proposed documents and persist the message.
550
+ // `generate_document` is an inline tool that only QUEUES into `proposals.docs`
551
+ // during the stream. OpenAI Code Interpreter renders files here, after the
552
+ // stream ends, then we link them to this message.
490
553
  try {
491
554
  const attachmentIds: string[] = [];
492
- // Generate PDFs if docs were proposed
493
- if (proposals.docs.length > 0) {
494
-
495
-
496
- for (let i = 0; i < proposals.docs.length; i++) {
497
- const doc = proposals.docs[i];
498
- if (!doc.title || !doc.blocks || !Array.isArray(doc.blocks)) {
499
- logger.error(`Document ${i + 1} is missing title or blocks`);
500
- continue;
501
- }
502
-
503
-
504
- try {
505
- let pdfBytes = await createPdf(doc.blocks as DocumentBlock[]);
506
- if (pdfBytes) {
507
- // Sanitize filename - remove special characters and limit length
508
- const sanitizedTitle = doc.title
509
- .replace(/[^a-zA-Z0-9\s\-_]/g, '')
510
- .replace(/\s+/g, '_')
511
- .substring(0, 50);
512
-
513
- const filename = `${sanitizedTitle}_${v4().substring(0, 8)}.pdf`;
514
- const filePath = `class/generated/${fullLabChat.classId}/${filename}`;
515
-
516
- logger.info(`PDF ${i + 1} generated successfully`, { labChatId, title: doc.title });
517
-
518
- // Upload directly to Google Cloud Storage
519
- const gcsFile = bucket.file(filePath);
520
- await gcsFile.save(Buffer.from(pdfBytes), {
521
- metadata: {
522
- contentType: 'application/pdf',
523
- }
524
- });
525
-
526
- logger.info(`PDF ${i + 1} uploaded successfully`, { labChatId, filename });
527
-
528
- const file = await prisma.file.create({
529
- data: {
530
- name: filename,
531
- path: filePath,
532
- type: 'application/pdf',
533
- size: pdfBytes.length,
534
- userId: fullLabChat.createdById,
535
- uploadStatus: 'COMPLETED',
536
- uploadedAt: new Date(),
537
- },
538
- });
539
- attachmentIds.push(file.id);
540
- } else {
541
- logger.error(`PDF ${i + 1} creation returned undefined/null`, { labChatId, title: doc.title });
542
- }
543
- } catch (pdfError) {
544
- logger.error(`PDF creation threw an error for document ${i + 1}:`, {
545
- error: pdfError instanceof Error ? {
546
- message: pdfError.message,
547
- stack: pdfError.stack,
548
- name: pdfError.name
549
- } : pdfError,
555
+ const generatedDocFiles: GeneratedDocFileLink[] = [];
556
+ const resolvedDocs: Array<{ title: string; format: string }> = [];
557
+
558
+ for (const doc of proposals.docs) {
559
+ if (!doc.title || !doc.content?.trim()) {
560
+ logger.error('Document missing title or content', { labChatId, doc });
561
+ continue;
562
+ }
563
+
564
+ const format = inferDocumentFormat(
565
+ doc.title,
566
+ doc.content,
567
+ doc.format,
568
+ teacherMessage,
569
+ );
570
+ resolvedDocs.push({ title: doc.title, format });
571
+
572
+ if (format !== normalizeDocumentFormat(doc.format)) {
573
+ logger.info('Document format overridden from model default', {
574
+ labChatId,
575
+ title: doc.title,
576
+ modelFormat: doc.format,
577
+ resolvedFormat: format,
578
+ });
579
+ }
580
+
581
+ try {
582
+ const generated = await generateDocumentWithOpenAI({
583
+ title: doc.title,
584
+ content: doc.content,
585
+ format,
586
+ });
587
+ if (!generated) {
588
+ logger.error('OpenAI document generation returned null', {
550
589
  labChatId,
551
- title: doc.title
590
+ title: doc.title,
591
+ format: doc.format,
552
592
  });
593
+ continue;
553
594
  }
595
+
596
+ const sanitizedTitle = doc.title
597
+ .replace(/[^a-zA-Z0-9\s\-_]/g, '')
598
+ .replace(/\s+/g, '_')
599
+ .substring(0, 50);
600
+ const filename = `${sanitizedTitle}_${v4().substring(0, 8)}.${generated.format}`;
601
+ const filePath = `class/generated/${fullLabChat.classId}/${filename}`;
602
+
603
+ await bucket.file(filePath).save(generated.bytes, {
604
+ metadata: { contentType: generated.mimeType },
605
+ });
606
+
607
+ const file = await prisma.file.create({
608
+ data: {
609
+ name: filename,
610
+ path: filePath,
611
+ type: generated.mimeType,
612
+ size: generated.bytes.length,
613
+ userId: fullLabChat.createdById,
614
+ conversationId,
615
+ messageId: emitOptions.aiMessageId,
616
+ uploadStatus: 'COMPLETED',
617
+ uploadedAt: new Date(),
618
+ },
619
+ });
620
+
621
+ attachmentIds.push(file.id);
622
+ generatedDocFiles.push({
623
+ title: doc.title,
624
+ fileId: file.id,
625
+ fileName: filename,
626
+ format: generated.format,
627
+ });
628
+ } catch (docError) {
629
+ logger.error('Document generation failed', {
630
+ error: docError instanceof Error ? {
631
+ message: docError.message,
632
+ stack: docError.stack,
633
+ name: docError.name,
634
+ } : docError,
635
+ labChatId,
636
+ title: doc.title,
637
+ format: doc.format,
638
+ });
554
639
  }
555
640
  }
556
-
641
+
642
+ const streamParts: StreamPart[] = enrichStreamPartsWithGeneratedFiles(
643
+ applyResolvedFormatsToStreamParts(rawStreamParts, resolvedDocs),
644
+ generatedDocFiles,
645
+ );
646
+
647
+ for (const part of streamParts) {
648
+ if (
649
+ part.type === 'tool' &&
650
+ part.toolName === 'generate_document' &&
651
+ part.fileId
652
+ ) {
653
+ await pusher
654
+ .trigger(chatChannel(conversationId), 'message-tool-call', {
655
+ messageId: emitOptions.aiMessageId,
656
+ toolCallId: part.toolCallId,
657
+ toolName: part.toolName,
658
+ ...(part.title ? { title: part.title } : {}),
659
+ ...(part.format ? { format: part.format } : {}),
660
+ fileId: part.fileId,
661
+ fileName: part.fileName,
662
+ status: 'completed',
663
+ })
664
+ .catch(() => {});
665
+ }
666
+ }
667
+
557
668
  await completePendingAIMessage(emitOptions.aiMessageId, streamedText, {
558
- attachments: {
559
- connect: attachmentIds.map(id => ({ id })),
560
- },
669
+ ...(attachmentIds.length > 0 && {
670
+ attachments: { connect: attachmentIds.map((id) => ({ id })) },
671
+ }),
561
672
  meta: {
562
673
  assignmentsToCreate: proposals.assignments.length > 0
563
674
  ? proposals.assignments.map(assignment => ({ ...assignment, id: v4() }))
@@ -47,26 +47,16 @@ export const assignmentToCreateSchema = z.object({
47
47
  })),
48
48
  });
49
49
 
50
- export const docBlockSchema = z.object({
51
- format: z.number().int().min(0).max(12),
52
- content: z.union([z.string(), z.array(z.string())]),
53
- metadata: z.object({
54
- fontSize: z.number().min(6).nullable().optional(),
55
- lineHeight: z.number().min(0.6).nullable().optional(),
56
- paragraphSpacing: z.number().min(0).nullable().optional(),
57
- indentWidth: z.number().min(0).nullable().optional(),
58
- paddingX: z.number().min(0).nullable().optional(),
59
- paddingY: z.number().min(0).nullable().optional(),
60
- font: z.number().int().min(0).max(5).nullable().optional(),
61
- color: z.string().regex(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/).nullable().optional(),
62
- background: z.string().regex(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/).nullable().optional(),
63
- align: z.enum(["left", "center", "right"]).nullable().optional(),
64
- }).nullable().optional(),
65
- });
50
+ export const documentFormatSchema = z.enum([
51
+ 'pdf', 'docx', 'doc', 'xlsx', 'pptx', 'csv', 'md', 'html', 'txt', 'json',
52
+ 'xml', 'tex', 'png', 'jpeg', 'jpg', 'gif', 'zip', 'tar', 'py', 'js', 'ts',
53
+ 'css', 'sh',
54
+ ]);
66
55
 
67
56
  export const docToCreateSchema = z.object({
68
57
  title: z.string(),
69
- blocks: z.array(docBlockSchema),
58
+ content: z.string().min(1),
59
+ format: documentFormatSchema.optional().default('pdf'),
70
60
  });
71
61
 
72
62
  export type WorksheetToCreate = z.infer<typeof worksheetToCreateSchema>;
@@ -20,6 +20,63 @@ const removeAllPreviousAIComments = async (worksheetQuestionProgressId: string)
20
20
  });
21
21
  };
22
22
 
23
+ type MarkSchemeItem = { id: string; points: number; description?: string; text?: string };
24
+
25
+ const gradingResponseSchema = z.object({
26
+ isCorrect: z.boolean(),
27
+ points: z.number(),
28
+ markschemeState: z.array(z.object({
29
+ id: z.string(),
30
+ correct: z.boolean(),
31
+ })),
32
+ comments: z.array(z.string()),
33
+ annotations: z.array(z.object({
34
+ quote: z.string().describe('An exact substring from the student response that is relevant to this criterion.'),
35
+ markSchemeId: z.string().describe('The id of the mark scheme criterion this quote relates to.'),
36
+ criterion: z.string().describe('Short label describing what this mark scheme criterion evaluates.'),
37
+ met: z.boolean().describe('Whether the quoted text satisfies this criterion.'),
38
+ })).describe('Highlight specific parts of the student response and map each to a mark scheme criterion.'),
39
+ });
40
+
41
+ function buildGradingPrompt(
42
+ question: { question: string; answer: string; type: WorksheetQuestionType; points: number; markScheme: unknown },
43
+ responseText: string,
44
+ ): string {
45
+ const markSchemeItems = Array.isArray(question.markScheme) ? question.markScheme as MarkSchemeItem[] : [];
46
+ const markSchemeBlock = markSchemeItems.length > 0
47
+ ? markSchemeItems.map((item, i) =>
48
+ ` ${i + 1}. [id="${item.id}", ${item.points} pts] ${item.description || item.text || `Criterion ${i + 1}`}`
49
+ ).join('\n')
50
+ : ' No mark scheme provided — grade based on correctness and completeness.';
51
+
52
+ const maxPoints = question.points || markSchemeItems.reduce((sum, item) => sum + (item.points || 0), 0);
53
+
54
+ return `Grade the following student response.
55
+
56
+ Question type: ${question.type}
57
+ Maximum points: ${maxPoints}
58
+
59
+ QUESTION:
60
+ ${question.question}
61
+
62
+ CORRECT ANSWER:
63
+ ${question.answer || 'No answer key provided.'}
64
+
65
+ MARK SCHEME:
66
+ ${markSchemeBlock}
67
+
68
+ STUDENT RESPONSE:
69
+ ${responseText}
70
+
71
+ INSTRUCTIONS:
72
+ - Evaluate the student response against each mark scheme criterion.
73
+ - Total points must not exceed ${maxPoints}.
74
+ - For each criterion, find the exact substring in the student's response that is relevant (the "quote" field). Copy the text verbatim — do not paraphrase.
75
+ - If no part of the response addresses a criterion, still include an annotation with an empty quote and met=false.
76
+ - Provide constructive feedback comments.
77
+ - Mark isCorrect=true only if the student earned full or near-full marks.`;
78
+ }
79
+
23
80
  const gradeWorksheetQuestion = async (worksheetResponseId: string, worksheetQuestionProgressId: string) => {
24
81
 
25
82
  await removeAllPreviousAIComments(worksheetQuestionProgressId);
@@ -83,38 +140,12 @@ const gradeWorksheetQuestion = async (worksheetResponseId: string, worksheetQues
83
140
  return updatedProgress;
84
141
  }
85
142
 
86
- const comments = studentQuestionProgress.comments;
87
-
88
143
  try {
144
+ const prompt = buildGradingPrompt(question, responseText);
145
+
89
146
  const apiResponse = await inference(
90
- `Grade the following worksheet response:
91
-
92
- Question: ${question.question}
93
- Response: ${responseText}
94
-
95
- Comments: ${comments.map((comment) => comment.content).join('\n')}
96
- Mark Scheme: ${JSON.stringify(question.markScheme)}
97
-
98
- Justify your reasoning by including comment(s) and mark the question please.
99
- Return ONLY JSON in the following format (fill in the values as per the question):
100
- {
101
- "isCorrect": <boolean>,
102
- "points": <number>,
103
- "markschemeState": [
104
- { "id": <string>, "correct": <boolean> }
105
- ],
106
- "comments": [<string>, ...]
107
- }
108
- `,
109
- z.object({
110
- isCorrect: z.boolean(),
111
- points: z.number(),
112
- markschemeState: z.array(z.object({
113
- id: z.string(),
114
- correct: z.boolean(),
115
- })), // @note: this has to be converted to [id: string]: correct boolean
116
- comments: z.array(z.string()),
117
- }),
147
+ prompt,
148
+ gradingResponseSchema,
118
149
  {
119
150
  reason: 'worksheet_autograde',
120
151
  metadata: {
@@ -128,6 +159,27 @@ const gradeWorksheetQuestion = async (worksheetResponseId: string, worksheetQues
128
159
  throw error;
129
160
  });
130
161
 
162
+ const maxPoints = question.points || 0;
163
+ const clampedPoints = Math.max(0, Math.min(Math.round((apiResponse as { points: number }).points), maxPoints));
164
+
165
+ const validMarkSchemeIds = new Set(
166
+ (Array.isArray(question.markScheme) ? question.markScheme as MarkSchemeItem[] : []).map(item => item.id),
167
+ );
168
+
169
+ const validatedMarkschemeState = (apiResponse as {
170
+ markschemeState: { id: string; correct: boolean }[];
171
+ }).markschemeState
172
+ .filter(item => validMarkSchemeIds.has(item.id))
173
+ .reduce((acc, curr) => {
174
+ acc["item-" + curr.id] = curr.correct;
175
+ return acc;
176
+ }, {} as Record<string, boolean>);
177
+
178
+ const annotations = (apiResponse as {
179
+ annotations: { quote: string; markSchemeId: string; criterion: string; met: boolean }[];
180
+ }).annotations
181
+ .filter(a => a.quote.length === 0 || responseText.includes(a.quote));
182
+
131
183
  const updatedStudentQuestionProgress = await prisma.studentQuestionProgress.update({
132
184
  where: { id: studentQuestionProgress.id, status: {
133
185
  not: {
@@ -137,13 +189,11 @@ const gradeWorksheetQuestion = async (worksheetResponseId: string, worksheetQues
137
189
  data: {
138
190
  status: JobStatus.COMPLETED,
139
191
  isCorrect: (apiResponse as { isCorrect: boolean }).isCorrect,
140
- points: (apiResponse as { points: number }).points,
141
- markschemeState: (apiResponse as {
142
- markschemeState: { id: string; correct: boolean }[];
143
- }).markschemeState.reduce((acc, curr) => {
144
- acc["item-" + curr.id] = curr.correct;
145
- return acc;
146
- }, {} as Record<string, boolean>),
192
+ points: clampedPoints,
193
+ markschemeState: {
194
+ ...validatedMarkschemeState,
195
+ _annotations: annotations,
196
+ },
147
197
  comments: {
148
198
  create: (apiResponse as {
149
199
  comments: string[];
@@ -279,7 +329,14 @@ export const gradeWorksheetPipeline = async (worksheetResponseId: string) => {
279
329
  continue;
280
330
  }
281
331
 
282
- await gradeWorksheetQuestion(worksheetResponseId, response.id);
332
+ try {
333
+ await gradeWorksheetQuestion(worksheetResponseId, response.id);
334
+ } catch (questionError) {
335
+ logger.error('Failed to grade individual question, continuing with remaining questions', {
336
+ questionId: response.questionId,
337
+ error: questionError,
338
+ });
339
+ }
283
340
 
284
341
  };
285
342
  };