@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.
- package/dist/lib/inferDocumentFormat.d.ts +10 -0
- package/dist/lib/inferDocumentFormat.d.ts.map +1 -0
- package/dist/lib/inferDocumentFormat.js +48 -0
- package/dist/lib/inferDocumentFormat.js.map +1 -0
- package/dist/models/announcement.d.ts +45 -45
- package/dist/models/assignment.d.ts +75 -75
- package/dist/models/class.d.ts +13 -13
- package/dist/models/comment.d.ts +6 -6
- package/dist/models/conversation.d.ts +23 -23
- package/dist/models/event.d.ts +18 -18
- package/dist/models/file.d.ts +9 -9
- package/dist/models/labChat.d.ts +4 -4
- package/dist/models/message.d.ts +13 -13
- package/dist/models/newtonChat.d.ts +3 -3
- package/dist/models/notification.d.ts +4 -4
- package/dist/models/worksheet.d.ts +121 -121
- package/dist/pipelines/aiLabChat.d.ts +7 -1
- package/dist/pipelines/aiLabChat.d.ts.map +1 -1
- package/dist/pipelines/aiLabChat.js +145 -67
- package/dist/pipelines/aiLabChat.js.map +1 -1
- package/dist/pipelines/aiLabChatContract.d.ts +34 -195
- package/dist/pipelines/aiLabChatContract.d.ts.map +1 -1
- package/dist/pipelines/aiLabChatContract.js +9 -19
- package/dist/pipelines/aiLabChatContract.js.map +1 -1
- package/dist/pipelines/gradeWorksheet.d.ts +6 -6
- package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
- package/dist/pipelines/gradeWorksheet.js +75 -36
- package/dist/pipelines/gradeWorksheet.js.map +1 -1
- package/dist/pipelines/labChatPrompt.d.ts +8 -3
- package/dist/pipelines/labChatPrompt.d.ts.map +1 -1
- package/dist/pipelines/labChatPrompt.js +49 -14
- package/dist/pipelines/labChatPrompt.js.map +1 -1
- package/dist/pipelines/pdfToWorksheet.d.ts +1 -1
- package/dist/routers/_app.d.ts +524 -516
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/announcement.d.ts +40 -40
- package/dist/routers/assignment.d.ts +121 -121
- package/dist/routers/class.d.ts +8 -8
- package/dist/routers/comment.d.ts +5 -5
- package/dist/routers/conversation.d.ts +6 -6
- package/dist/routers/discussion.d.ts +3 -3
- package/dist/routers/embedding.d.ts +6 -2
- package/dist/routers/embedding.d.ts.map +1 -1
- package/dist/routers/embedding.js +44 -36
- package/dist/routers/embedding.js.map +1 -1
- package/dist/routers/event.d.ts +18 -18
- package/dist/routers/file.d.ts +2 -2
- package/dist/routers/labChat.d.ts +3 -3
- package/dist/routers/notifications.d.ts +4 -4
- package/dist/routers/search.d.ts +2 -2
- package/dist/routers/worksheet.d.ts +44 -44
- package/dist/routes/aiStream.d.ts.map +1 -1
- package/dist/routes/aiStream.js +41 -11
- package/dist/routes/aiStream.js.map +1 -1
- package/dist/seedDatabase.d.ts +1 -1
- package/dist/services/aiChatStream.d.ts +18 -0
- package/dist/services/aiChatStream.d.ts.map +1 -1
- package/dist/services/aiChatStream.js +81 -11
- package/dist/services/aiChatStream.js.map +1 -1
- package/dist/services/aiModifierTools.d.ts +0 -16
- package/dist/services/aiModifierTools.d.ts.map +1 -1
- package/dist/services/aiModifierTools.js +112 -3
- package/dist/services/aiModifierTools.js.map +1 -1
- package/dist/services/aiProposalTools.d.ts.map +1 -1
- package/dist/services/aiProposalTools.js +25 -25
- package/dist/services/aiProposalTools.js.map +1 -1
- package/dist/services/announcement.d.ts +34 -34
- package/dist/services/assignment.d.ts +111 -111
- package/dist/services/assignment.d.ts.map +1 -1
- package/dist/services/assignment.js +4 -2
- package/dist/services/assignment.js.map +1 -1
- package/dist/services/class.d.ts +6 -6
- package/dist/services/class.d.ts.map +1 -1
- package/dist/services/class.js +7 -3
- package/dist/services/class.js.map +1 -1
- package/dist/services/comment.d.ts +5 -5
- package/dist/services/conversation.d.ts +6 -6
- package/dist/services/discussion.d.ts.map +1 -1
- package/dist/services/discussion.js +9 -2
- package/dist/services/discussion.js.map +1 -1
- package/dist/services/embedding.d.ts +19 -1
- package/dist/services/embedding.d.ts.map +1 -1
- package/dist/services/embedding.js +154 -8
- package/dist/services/embedding.js.map +1 -1
- package/dist/services/event.d.ts +18 -18
- package/dist/services/file.d.ts +4 -4
- package/dist/services/folder.d.ts.map +1 -1
- package/dist/services/folder.js +32 -2
- package/dist/services/folder.js.map +1 -1
- package/dist/services/labChat.d.ts +3 -3
- package/dist/services/notification.d.ts +4 -4
- package/dist/services/openaiDocumentGen.d.ts +25 -0
- package/dist/services/openaiDocumentGen.d.ts.map +1 -0
- package/dist/services/openaiDocumentGen.js +195 -0
- package/dist/services/openaiDocumentGen.js.map +1 -0
- package/dist/services/search.d.ts +1 -1
- package/dist/services/search.d.ts.map +1 -1
- package/dist/services/search.js +27 -2
- package/dist/services/search.js.map +1 -1
- package/dist/services/worksheet.d.ts +66 -49
- package/dist/services/worksheet.d.ts.map +1 -1
- package/dist/services/worksheet.js +48 -3
- package/dist/services/worksheet.js.map +1 -1
- package/dist/workers/processors/embedContent.d.ts.map +1 -1
- package/dist/workers/processors/embedContent.js +16 -25
- package/dist/workers/processors/embedContent.js.map +1 -1
- package/package.json +1 -1
- package/prisma/migrations/20260610140000_expand_embedding_source_types/migration.sql +4 -0
- package/prisma/schema.prisma +3 -0
- package/src/lib/inferDocumentFormat.ts +58 -0
- package/src/pipelines/aiLabChat.ts +185 -74
- package/src/pipelines/aiLabChatContract.ts +7 -17
- package/src/pipelines/gradeWorksheet.ts +95 -38
- package/src/pipelines/labChatPrompt.ts +61 -14
- package/src/routers/embedding.ts +44 -39
- package/src/routes/aiStream.ts +47 -9
- package/src/services/aiChatStream.ts +103 -8
- package/src/services/aiModifierTools.ts +154 -1
- package/src/services/aiProposalTools.ts +26 -25
- package/src/services/assignment.ts +3 -0
- package/src/services/class.ts +7 -1
- package/src/services/discussion.ts +13 -0
- package/src/services/embedding.ts +167 -6
- package/src/services/folder.ts +31 -0
- package/src/services/openaiDocumentGen.ts +237 -0
- package/src/services/search.ts +25 -1
- package/src/services/worksheet.ts +68 -1
- package/src/workers/processors/embedContent.ts +18 -22
- package/tests/unit/lib/inferDocumentFormat.test.ts +33 -0
package/package.json
CHANGED
package/prisma/schema.prisma
CHANGED
|
@@ -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
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
//
|
|
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
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
-
|
|
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
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
141
|
-
markschemeState:
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
};
|