@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
|
@@ -14,9 +14,17 @@
|
|
|
14
14
|
* `jsonSchema()` rather than the zod `tool()` helper to avoid the TS2589
|
|
15
15
|
* "excessively deep" compiler blow-up on nested schemas.
|
|
16
16
|
*/
|
|
17
|
+
import { randomUUID } from 'node:crypto';
|
|
17
18
|
import { jsonSchema, type ToolSet } from 'ai';
|
|
19
|
+
import type { JSONSchema7Definition } from 'json-schema';
|
|
20
|
+
import type { WorksheetQuestionType } from '@prisma/client';
|
|
18
21
|
import { updateAssignmentRecord } from './assignment.js';
|
|
19
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
addQuestionToWorksheet,
|
|
24
|
+
deleteWorksheetQuestionRecord,
|
|
25
|
+
updateWorksheetQuestionRecord,
|
|
26
|
+
updateWorksheetRecord,
|
|
27
|
+
} from './worksheet.js';
|
|
20
28
|
|
|
21
29
|
/** A single field write that was applied to a resource. */
|
|
22
30
|
export interface ModifierFieldUpdate {
|
|
@@ -56,6 +64,52 @@ const WORKSHEET_FIELDS: Record<string, { label: string; type: 'string' }> = {
|
|
|
56
64
|
name: { label: 'name', type: 'string' },
|
|
57
65
|
};
|
|
58
66
|
|
|
67
|
+
const WORKSHEET_QUESTION_TYPES = [
|
|
68
|
+
'MULTIPLE_CHOICE', 'TRUE_FALSE', 'SHORT_ANSWER', 'LONG_ANSWER', 'MATH_EXPRESSION', 'ESSAY',
|
|
69
|
+
] as const;
|
|
70
|
+
|
|
71
|
+
interface QuestionOptionInput {
|
|
72
|
+
text: string;
|
|
73
|
+
isCorrect?: boolean;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Attach stable ids to model-supplied MCQ options (matches the editor's shape). */
|
|
77
|
+
function buildOptions(options: QuestionOptionInput[] | undefined) {
|
|
78
|
+
if (!options || options.length === 0) return undefined;
|
|
79
|
+
return options.map((o) => ({
|
|
80
|
+
id: randomUUID(),
|
|
81
|
+
text: o.text,
|
|
82
|
+
isCorrect: Boolean(o.isCorrect),
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const QUESTION_PROPERTIES: Record<string, JSONSchema7Definition> = {
|
|
87
|
+
question: { type: 'string', description: 'The question text shown to students.' },
|
|
88
|
+
answer: {
|
|
89
|
+
type: 'string',
|
|
90
|
+
description:
|
|
91
|
+
'The correct answer. For TRUE_FALSE use "true"/"false". For MULTIPLE_CHOICE leave it empty and mark the correct option instead.',
|
|
92
|
+
},
|
|
93
|
+
type: {
|
|
94
|
+
type: 'string',
|
|
95
|
+
enum: [...WORKSHEET_QUESTION_TYPES],
|
|
96
|
+
description: 'The question type.',
|
|
97
|
+
},
|
|
98
|
+
points: { type: 'number', description: 'Points the question is worth.' },
|
|
99
|
+
options: {
|
|
100
|
+
type: 'array',
|
|
101
|
+
description: 'Answer choices, only for MULTIPLE_CHOICE questions. Mark exactly one as correct.',
|
|
102
|
+
items: {
|
|
103
|
+
type: 'object',
|
|
104
|
+
properties: {
|
|
105
|
+
text: { type: 'string', description: 'The option text.' },
|
|
106
|
+
isCorrect: { type: 'boolean', description: 'Whether this option is the correct answer.' },
|
|
107
|
+
},
|
|
108
|
+
required: ['text'],
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
|
|
59
113
|
/** Coerce a model-supplied string value into the type a field expects. */
|
|
60
114
|
function coerceValue(
|
|
61
115
|
raw: string,
|
|
@@ -184,5 +238,104 @@ export function worksheetModifierTools(
|
|
|
184
238
|
return `Updated ${spec.label}.`;
|
|
185
239
|
},
|
|
186
240
|
},
|
|
241
|
+
add_worksheet_question: {
|
|
242
|
+
description:
|
|
243
|
+
'Add a new question to the worksheet and save it immediately. ' +
|
|
244
|
+
'Call this once per question to add.',
|
|
245
|
+
inputSchema: jsonSchema({
|
|
246
|
+
type: 'object',
|
|
247
|
+
properties: QUESTION_PROPERTIES,
|
|
248
|
+
required: ['question', 'answer', 'type'],
|
|
249
|
+
}),
|
|
250
|
+
execute: async ({
|
|
251
|
+
question,
|
|
252
|
+
answer,
|
|
253
|
+
type,
|
|
254
|
+
points,
|
|
255
|
+
options,
|
|
256
|
+
}: {
|
|
257
|
+
question: string;
|
|
258
|
+
answer: string;
|
|
259
|
+
type: WorksheetQuestionType;
|
|
260
|
+
points?: number;
|
|
261
|
+
options?: QuestionOptionInput[];
|
|
262
|
+
}) => {
|
|
263
|
+
const created = await addQuestionToWorksheet(worksheetId, userId, {
|
|
264
|
+
question,
|
|
265
|
+
answer,
|
|
266
|
+
type,
|
|
267
|
+
points,
|
|
268
|
+
options: buildOptions(options),
|
|
269
|
+
});
|
|
270
|
+
await onFieldUpdate({
|
|
271
|
+
field: 'questions',
|
|
272
|
+
value: { action: 'added', questionId: created.id },
|
|
273
|
+
label: `question "${question.slice(0, 60)}"`,
|
|
274
|
+
});
|
|
275
|
+
return `Added question (id ${created.id}).`;
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
update_worksheet_question: {
|
|
279
|
+
description:
|
|
280
|
+
'Update an existing worksheet question by its Q_ID and save it immediately. ' +
|
|
281
|
+
'Only pass the fields that should change.',
|
|
282
|
+
inputSchema: jsonSchema({
|
|
283
|
+
type: 'object',
|
|
284
|
+
properties: {
|
|
285
|
+
questionId: { type: 'string', description: 'The Q_ID of the question to update.' },
|
|
286
|
+
...QUESTION_PROPERTIES,
|
|
287
|
+
},
|
|
288
|
+
required: ['questionId'],
|
|
289
|
+
}),
|
|
290
|
+
execute: async ({
|
|
291
|
+
questionId,
|
|
292
|
+
question,
|
|
293
|
+
answer,
|
|
294
|
+
type,
|
|
295
|
+
points,
|
|
296
|
+
options,
|
|
297
|
+
}: {
|
|
298
|
+
questionId: string;
|
|
299
|
+
question?: string;
|
|
300
|
+
answer?: string;
|
|
301
|
+
type?: WorksheetQuestionType;
|
|
302
|
+
points?: number;
|
|
303
|
+
options?: QuestionOptionInput[];
|
|
304
|
+
}) => {
|
|
305
|
+
await updateWorksheetQuestionRecord(worksheetId, questionId, userId, {
|
|
306
|
+
...(question !== undefined && { question }),
|
|
307
|
+
...(answer !== undefined && { answer }),
|
|
308
|
+
...(type !== undefined && { type }),
|
|
309
|
+
...(points !== undefined && { points }),
|
|
310
|
+
...(options !== undefined && { options: buildOptions(options) }),
|
|
311
|
+
});
|
|
312
|
+
await onFieldUpdate({
|
|
313
|
+
field: 'questions',
|
|
314
|
+
value: { action: 'updated', questionId },
|
|
315
|
+
label: 'a question',
|
|
316
|
+
});
|
|
317
|
+
return `Updated question ${questionId}.`;
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
delete_worksheet_question: {
|
|
321
|
+
description:
|
|
322
|
+
'Delete a worksheet question by its Q_ID. This is permanent.',
|
|
323
|
+
inputSchema: jsonSchema({
|
|
324
|
+
type: 'object',
|
|
325
|
+
properties: {
|
|
326
|
+
questionId: { type: 'string', description: 'The Q_ID of the question to delete.' },
|
|
327
|
+
},
|
|
328
|
+
required: ['questionId'],
|
|
329
|
+
}),
|
|
330
|
+
execute: async ({ questionId }: { questionId: string }) => {
|
|
331
|
+
await deleteWorksheetQuestionRecord(worksheetId, questionId, userId);
|
|
332
|
+
await onFieldUpdate({
|
|
333
|
+
field: 'questions',
|
|
334
|
+
value: { action: 'deleted', questionId },
|
|
335
|
+
label: 'a question',
|
|
336
|
+
});
|
|
337
|
+
return `Deleted question ${questionId}.`;
|
|
338
|
+
},
|
|
339
|
+
},
|
|
187
340
|
};
|
|
188
341
|
}
|
|
@@ -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
|
-
|
|
87
|
+
content: string;
|
|
88
|
+
format?: string;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
/**
|
|
@@ -237,35 +237,36 @@ export function labProposalTools(proposals: LabProposals): ToolSet {
|
|
|
237
237
|
|
|
238
238
|
generate_document: {
|
|
239
239
|
description:
|
|
240
|
-
'Generate a
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
},
|
|
259
|
-
},
|
|
260
|
-
required: ['format', 'content'],
|
|
261
|
-
},
|
|
244
|
+
format: {
|
|
245
|
+
type: 'string',
|
|
246
|
+
description:
|
|
247
|
+
'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.',
|
|
248
|
+
enum: [
|
|
249
|
+
'pdf', 'docx', 'doc', 'xlsx', 'pptx', 'csv', 'md', 'html', 'txt',
|
|
250
|
+
'json', 'xml', 'tex', 'png', 'jpeg', 'jpg', 'gif', 'zip', 'tar',
|
|
251
|
+
'py', 'js', 'ts', 'css', 'sh',
|
|
252
|
+
],
|
|
253
|
+
},
|
|
254
|
+
title: { type: 'string', description: 'Document title' },
|
|
255
|
+
content: {
|
|
256
|
+
type: 'string',
|
|
257
|
+
description: 'Full body — markdown or plain text with headings, lists, tables, slide notes, etc.',
|
|
262
258
|
},
|
|
263
259
|
},
|
|
264
|
-
required: ['title', '
|
|
260
|
+
required: ['title', 'content', 'format'],
|
|
265
261
|
}),
|
|
266
|
-
execute: async ({ title,
|
|
267
|
-
proposals.docs.push({
|
|
268
|
-
|
|
262
|
+
execute: async ({ title, content, format }: GenerateDocumentArgs) => {
|
|
263
|
+
proposals.docs.push({
|
|
264
|
+
title,
|
|
265
|
+
content: content.trim(),
|
|
266
|
+
format: (format ?? 'pdf') as DocToCreate['format'],
|
|
267
|
+
});
|
|
268
|
+
const ext = (format ?? 'pdf').toUpperCase();
|
|
269
|
+
return `Document "${title}" queued for ${ext} generation.`;
|
|
269
270
|
},
|
|
270
271
|
},
|
|
271
272
|
};
|
package/src/services/class.ts
CHANGED
|
@@ -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
|
-
|
|
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: {
|
|
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
|
|
211
|
+
const parts = [
|
|
207
212
|
`Title: ${assignment.title}`,
|
|
208
213
|
`Type: ${assignment.type}`,
|
|
209
214
|
assignment.instructions ? `Instructions: ${assignment.instructions}` : '',
|
|
210
|
-
]
|
|
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.
|
package/src/services/folder.ts
CHANGED
|
@@ -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({
|