@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
|
@@ -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
|
+
}
|
package/src/services/search.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
47
|
+
case 'DISCUSSION':
|
|
48
|
+
await indexDiscussion(classId, sourceId);
|
|
49
|
+
break;
|
|
49
50
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
+
});
|