@studious-lms/server 1.14.0 → 1.15.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/.github/workflows/deploy.yml +1 -1
- package/dist/instrument.js +3 -3
- package/dist/instrument.js.map +1 -1
- 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/lib/legal.d.ts +3 -0
- package/dist/lib/legal.d.ts.map +1 -0
- package/dist/lib/legal.js +6 -0
- package/dist/lib/legal.js.map +1 -0
- package/dist/models/announcement.d.ts +47 -45
- package/dist/models/announcement.d.ts.map +1 -1
- package/dist/models/assignment.d.ts +75 -75
- package/dist/models/auth.d.ts +8 -0
- package/dist/models/auth.d.ts.map +1 -1
- package/dist/models/auth.js +4 -2
- package/dist/models/auth.js.map +1 -1
- 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 +20 -18
- package/dist/models/event.d.ts.map +1 -1
- package/dist/models/file.d.ts +33 -9
- package/dist/models/file.d.ts.map +1 -1
- package/dist/models/labChat.d.ts +4 -4
- package/dist/models/marketing.d.ts +2 -15
- package/dist/models/marketing.d.ts.map +1 -1
- package/dist/models/marketing.js +12 -4
- package/dist/models/marketing.js.map +1 -1
- 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 +153 -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/labChatPrompt.d.ts +8 -3
- package/dist/pipelines/labChatPrompt.d.ts.map +1 -1
- package/dist/pipelines/labChatPrompt.js +52 -14
- package/dist/pipelines/labChatPrompt.js.map +1 -1
- package/dist/pipelines/pdfToWorksheet.d.ts +1 -1
- package/dist/routers/_app.d.ts +750 -546
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/announcement.d.ts +40 -40
- package/dist/routers/assignment.d.ts +131 -121
- package/dist/routers/assignment.d.ts.map +1 -1
- package/dist/routers/auth.d.ts +2 -0
- package/dist/routers/auth.d.ts.map +1 -1
- package/dist/routers/auth.js +15 -2
- package/dist/routers/auth.js.map +1 -1
- package/dist/routers/class.d.ts +12 -8
- package/dist/routers/class.d.ts.map +1 -1
- 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 +20 -18
- package/dist/routers/event.d.ts.map +1 -1
- package/dist/routers/file.d.ts +2 -2
- package/dist/routers/labChat.d.ts +3 -3
- package/dist/routers/marketing.d.ts +0 -13
- package/dist/routers/marketing.d.ts.map +1 -1
- package/dist/routers/notifications.d.ts +4 -4
- package/dist/routers/school.d.ts +4 -0
- package/dist/routers/school.d.ts.map +1 -1
- package/dist/routers/search.d.ts +2 -2
- package/dist/routers/user.d.ts +89 -0
- package/dist/routers/user.d.ts.map +1 -1
- package/dist/routers/user.js +19 -2
- package/dist/routers/user.js.map +1 -1
- 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 +3 -1
- package/dist/seedDatabase.d.ts.map +1 -1
- package/dist/services/aiChatStream.d.ts +20 -0
- package/dist/services/aiChatStream.d.ts.map +1 -1
- package/dist/services/aiChatStream.js +110 -13
- 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/aiTools.js +3 -3
- package/dist/services/aiTools.js.map +1 -1
- package/dist/services/announcement.d.ts +34 -34
- package/dist/services/assignment.d.ts +121 -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/auth.d.ts +2 -0
- package/dist/services/auth.d.ts.map +1 -1
- package/dist/services/auth.js +13 -4
- package/dist/services/auth.js.map +1 -1
- package/dist/services/class.d.ts +10 -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 +20 -18
- package/dist/services/event.d.ts.map +1 -1
- 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/marketing.d.ts +1 -14
- package/dist/services/marketing.d.ts.map +1 -1
- package/dist/services/marketing.js +5 -5
- package/dist/services/marketing.js.map +1 -1
- package/dist/services/notification.d.ts +4 -4
- package/dist/services/openaiDocumentGen.d.ts +37 -0
- package/dist/services/openaiDocumentGen.d.ts.map +1 -0
- package/dist/services/openaiDocumentGen.js +262 -0
- package/dist/services/openaiDocumentGen.js.map +1 -0
- package/dist/services/school.d.ts +4 -0
- package/dist/services/school.d.ts.map +1 -1
- 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/userPrivacy.d.ts +86 -0
- package/dist/services/userPrivacy.d.ts.map +1 -0
- package/dist/services/userPrivacy.js +184 -0
- package/dist/services/userPrivacy.js.map +1 -0
- 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/utils/email.d.ts.map +1 -1
- package/dist/utils/email.js +7 -3
- package/dist/utils/email.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/migrations/20260611120000_add_user_terms_acceptance/migration.sql +3 -0
- package/prisma/schema.prisma +6 -0
- package/src/instrument.ts +1 -1
- package/src/lib/inferDocumentFormat.ts +58 -0
- package/src/lib/legal.ts +2 -0
- package/src/models/auth.ts +4 -0
- package/src/models/marketing.ts +10 -2
- package/src/pipelines/aiLabChat.ts +198 -74
- package/src/pipelines/aiLabChatContract.ts +7 -17
- package/src/pipelines/labChatPrompt.ts +64 -14
- package/src/routers/auth.ts +13 -0
- package/src/routers/embedding.ts +44 -39
- package/src/routers/user.ts +26 -0
- package/src/routes/aiStream.ts +47 -9
- package/src/services/aiChatStream.ts +133 -11
- package/src/services/aiModifierTools.ts +154 -1
- package/src/services/aiProposalTools.ts +27 -25
- package/src/services/aiTools.ts +1 -1
- package/src/services/assignment.ts +3 -0
- package/src/services/auth.ts +14 -2
- 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/marketing.ts +3 -3
- package/src/services/openaiDocumentGen.ts +319 -0
- package/src/services/search.ts +25 -1
- package/src/services/userPrivacy.ts +196 -0
- package/src/services/worksheet.ts +68 -1
- package/src/utils/email.ts +5 -1
- package/src/workers/processors/embedContent.ts +18 -22
- package/tests/helpers/registerInput.ts +20 -0
- package/tests/integration/globalSetup.ts +10 -18
- package/tests/integration/routers/auth.test.ts +13 -18
- package/tests/unit/lib/inferDocumentFormat.test.ts +33 -0
- package/tests/unit/services/aiChatStream.test.ts +92 -0
- package/tests/unit/services/openaiDocumentGen.test.ts +65 -0
|
@@ -0,0 +1,319 @@
|
|
|
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
|
+
/**
|
|
12
|
+
* Extensions OpenAI Code Interpreter can read/write in its sandbox.
|
|
13
|
+
* @see https://developers.openai.com/api/docs/guides/tools-code-interpreter
|
|
14
|
+
*/
|
|
15
|
+
export const SUPPORTED_DOCUMENT_FORMATS = [
|
|
16
|
+
'pdf',
|
|
17
|
+
'docx',
|
|
18
|
+
'doc',
|
|
19
|
+
'xlsx',
|
|
20
|
+
'pptx',
|
|
21
|
+
'csv',
|
|
22
|
+
'md',
|
|
23
|
+
'html',
|
|
24
|
+
'txt',
|
|
25
|
+
'json',
|
|
26
|
+
'xml',
|
|
27
|
+
'tex',
|
|
28
|
+
'png',
|
|
29
|
+
'jpeg',
|
|
30
|
+
'jpg',
|
|
31
|
+
'gif',
|
|
32
|
+
'zip',
|
|
33
|
+
'tar',
|
|
34
|
+
'py',
|
|
35
|
+
'js',
|
|
36
|
+
'ts',
|
|
37
|
+
'css',
|
|
38
|
+
'sh',
|
|
39
|
+
] as const;
|
|
40
|
+
|
|
41
|
+
export type DocumentFormat = (typeof SUPPORTED_DOCUMENT_FORMATS)[number];
|
|
42
|
+
|
|
43
|
+
const FORMAT_MIME: Record<DocumentFormat, string> = {
|
|
44
|
+
pdf: 'application/pdf',
|
|
45
|
+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
46
|
+
doc: 'application/msword',
|
|
47
|
+
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
48
|
+
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
49
|
+
csv: 'text/csv',
|
|
50
|
+
md: 'text/markdown',
|
|
51
|
+
html: 'text/html',
|
|
52
|
+
txt: 'text/plain',
|
|
53
|
+
json: 'application/json',
|
|
54
|
+
xml: 'application/xml',
|
|
55
|
+
tex: 'text/x-tex',
|
|
56
|
+
png: 'image/png',
|
|
57
|
+
jpeg: 'image/jpeg',
|
|
58
|
+
jpg: 'image/jpeg',
|
|
59
|
+
gif: 'image/gif',
|
|
60
|
+
zip: 'application/zip',
|
|
61
|
+
tar: 'application/x-tar',
|
|
62
|
+
py: 'text/x-python',
|
|
63
|
+
js: 'text/javascript',
|
|
64
|
+
ts: 'application/typescript',
|
|
65
|
+
css: 'text/css',
|
|
66
|
+
sh: 'application/x-sh',
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const FORMAT_HINTS: Record<DocumentFormat, string> = {
|
|
70
|
+
pdf: 'fpdf2 or reportlab — structured document with headings and body text',
|
|
71
|
+
docx: 'python-docx — Word document with headings and paragraphs',
|
|
72
|
+
doc: 'python-docx saving as .doc if supported, otherwise .docx',
|
|
73
|
+
xlsx: 'openpyxl or pandas — spreadsheet with labelled columns/rows',
|
|
74
|
+
pptx: 'python-pptx — slides with title + bullet content per slide',
|
|
75
|
+
csv: 'pandas or csv module — tabular data with headers',
|
|
76
|
+
md: 'write the markdown file directly',
|
|
77
|
+
html: 'write a complete, styled HTML page',
|
|
78
|
+
txt: 'write plain text with clear section breaks',
|
|
79
|
+
json: 'json.dumps with readable indentation',
|
|
80
|
+
xml: 'well-formed XML document',
|
|
81
|
+
tex: 'LaTeX source that compiles to the requested content',
|
|
82
|
+
png: 'matplotlib or Pillow — chart, diagram, or visual',
|
|
83
|
+
jpeg: 'matplotlib or Pillow — save as JPEG',
|
|
84
|
+
jpg: 'matplotlib or Pillow — save as JPEG',
|
|
85
|
+
gif: 'matplotlib or Pillow — save as GIF',
|
|
86
|
+
zip: 'zipfile — bundle multiple related files if needed',
|
|
87
|
+
tar: 'tarfile — archive if multiple files are required',
|
|
88
|
+
py: 'valid Python source code file',
|
|
89
|
+
js: 'valid JavaScript source file',
|
|
90
|
+
ts: 'valid TypeScript source file',
|
|
91
|
+
css: 'stylesheet file',
|
|
92
|
+
sh: 'shell script file',
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export interface GenerateDocumentInput {
|
|
96
|
+
title: string;
|
|
97
|
+
/** Markdown or plain-text source material for the output file. */
|
|
98
|
+
content: string;
|
|
99
|
+
/** Output format. Defaults to pdf. */
|
|
100
|
+
format?: DocumentFormat | string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface GeneratedDocumentResult {
|
|
104
|
+
bytes: Buffer;
|
|
105
|
+
filename: string;
|
|
106
|
+
mimeType: string;
|
|
107
|
+
format: DocumentFormat;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface ContainerGeneratedFile {
|
|
111
|
+
containerId: string;
|
|
112
|
+
fileId: string;
|
|
113
|
+
filename: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function normalizeDocumentFormat(
|
|
117
|
+
format?: string | null,
|
|
118
|
+
): DocumentFormat {
|
|
119
|
+
const raw = (format ?? 'pdf').toLowerCase().replace(/^\./, '');
|
|
120
|
+
if ((SUPPORTED_DOCUMENT_FORMATS as readonly string[]).includes(raw)) {
|
|
121
|
+
return raw as DocumentFormat;
|
|
122
|
+
}
|
|
123
|
+
return 'pdf';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function mimeTypeForFormat(format: DocumentFormat): string {
|
|
127
|
+
return FORMAT_MIME[format];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function extensionForFormat(format: DocumentFormat): string {
|
|
131
|
+
return format === 'jpg' ? 'jpg' : format;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Pull generated-file citations for a given extension out of a Responses output. */
|
|
135
|
+
export function extractContainerGeneratedFiles(
|
|
136
|
+
response: Response,
|
|
137
|
+
extension: DocumentFormat,
|
|
138
|
+
): ContainerGeneratedFile[] {
|
|
139
|
+
const seen = new Set<string>();
|
|
140
|
+
const files: ContainerGeneratedFile[] = [];
|
|
141
|
+
const suffix = `.${extension}`;
|
|
142
|
+
|
|
143
|
+
const add = (containerId: string, fileId: string, filename: string) => {
|
|
144
|
+
const key = `${containerId}:${fileId}`;
|
|
145
|
+
if (seen.has(key)) return;
|
|
146
|
+
seen.add(key);
|
|
147
|
+
files.push({ containerId, fileId, filename });
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
for (const item of response.output ?? []) {
|
|
151
|
+
if (item.type !== 'message') continue;
|
|
152
|
+
for (const block of item.content ?? []) {
|
|
153
|
+
if (block.type !== 'output_text') continue;
|
|
154
|
+
for (const ann of block.annotations ?? []) {
|
|
155
|
+
if (ann.type !== 'container_file_citation') continue;
|
|
156
|
+
const name = ann.filename?.toLowerCase() ?? '';
|
|
157
|
+
if (name.endsWith(suffix)) {
|
|
158
|
+
add(ann.container_id, ann.file_id, ann.filename);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return files;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function containerIdFromResponse(response: Response): string | undefined {
|
|
168
|
+
for (const item of response.output ?? []) {
|
|
169
|
+
if (item.type === 'code_interpreter_call' && item.container_id) {
|
|
170
|
+
return item.container_id;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function listContainerGeneratedFiles(
|
|
177
|
+
client: OpenAI,
|
|
178
|
+
containerId: string,
|
|
179
|
+
extension: DocumentFormat,
|
|
180
|
+
): Promise<ContainerGeneratedFile[]> {
|
|
181
|
+
const suffix = `.${extension}`;
|
|
182
|
+
const files: ContainerGeneratedFile[] = [];
|
|
183
|
+
for await (const file of client.containers.files.list(containerId)) {
|
|
184
|
+
const path = file.path?.toLowerCase() ?? '';
|
|
185
|
+
if (path.endsWith(suffix) && file.source === 'assistant') {
|
|
186
|
+
files.push({
|
|
187
|
+
containerId,
|
|
188
|
+
fileId: file.id,
|
|
189
|
+
filename: file.path.split('/').pop() ?? file.path,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return files;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function downloadContainerFile(
|
|
197
|
+
client: OpenAI,
|
|
198
|
+
file: ContainerGeneratedFile,
|
|
199
|
+
): Promise<Buffer | null> {
|
|
200
|
+
try {
|
|
201
|
+
const res = await client.containers.files.content.retrieve(file.fileId, {
|
|
202
|
+
container_id: file.containerId,
|
|
203
|
+
});
|
|
204
|
+
const bytes = await res.arrayBuffer();
|
|
205
|
+
return Buffer.from(bytes);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
logger.error('Failed to download container file', { error, file });
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function safeBasename(title: string): string {
|
|
213
|
+
const base = title
|
|
214
|
+
.replace(/[^a-zA-Z0-9\s\-_]/g, '')
|
|
215
|
+
.replace(/\s+/g, '_')
|
|
216
|
+
.substring(0, 60);
|
|
217
|
+
return base || 'document';
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function buildDocumentGenInstructions(format: DocumentFormat): string {
|
|
221
|
+
return [
|
|
222
|
+
'You are a document generator for K-12 and university teachers.',
|
|
223
|
+
'When given a title, output format, and source content:',
|
|
224
|
+
'1. Use the python tool to create exactly ONE output file of the requested type.',
|
|
225
|
+
`2. For this format use: ${FORMAT_HINTS[format]}.`,
|
|
226
|
+
`3. You MUST output a ${format.toUpperCase()} file — not any other format.`,
|
|
227
|
+
`4. The saved filename MUST end with .${extensionForFormat(format)}.`,
|
|
228
|
+
'5. Structure content clearly — headings, sections, lists, tables/charts as appropriate.',
|
|
229
|
+
'6. Include the document title prominently where the format allows.',
|
|
230
|
+
'7. Put ONLY the educational content in the file — no meta commentary.',
|
|
231
|
+
'8. You MUST save the file to disk in the working directory before you finish.',
|
|
232
|
+
...(format !== 'pdf'
|
|
233
|
+
? [`9. Do NOT create a PDF when ${format.toUpperCase()} was requested.`]
|
|
234
|
+
: []),
|
|
235
|
+
].join(' ');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Generate a file via OpenAI Code Interpreter. */
|
|
239
|
+
export async function generateDocumentWithOpenAI(
|
|
240
|
+
input: GenerateDocumentInput,
|
|
241
|
+
): Promise<GeneratedDocumentResult | null> {
|
|
242
|
+
const client = new OpenAI();
|
|
243
|
+
const format = normalizeDocumentFormat(input.format);
|
|
244
|
+
const ext = extensionForFormat(format);
|
|
245
|
+
const filename = `${safeBasename(input.title)}.${ext}`;
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
const response = await client.responses.create({
|
|
249
|
+
model: DOCUMENT_GEN_MODEL,
|
|
250
|
+
instructions: buildDocumentGenInstructions(format),
|
|
251
|
+
tools: [
|
|
252
|
+
{
|
|
253
|
+
type: 'code_interpreter',
|
|
254
|
+
container: { type: 'auto' },
|
|
255
|
+
},
|
|
256
|
+
],
|
|
257
|
+
tool_choice: 'required',
|
|
258
|
+
input: [
|
|
259
|
+
`Create a ${ext.toUpperCase()} file using the python tool.`,
|
|
260
|
+
`Output filename (exact): ${filename}`,
|
|
261
|
+
`Output format: ${ext}`,
|
|
262
|
+
`Document title: ${input.title}`,
|
|
263
|
+
'Source content (markdown or plain text — adapt to the output format):',
|
|
264
|
+
input.content,
|
|
265
|
+
].join('\n\n'),
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
let matches = extractContainerGeneratedFiles(response, format);
|
|
269
|
+
|
|
270
|
+
if (matches.length === 0) {
|
|
271
|
+
const containerId = containerIdFromResponse(response);
|
|
272
|
+
if (containerId) {
|
|
273
|
+
matches = await listContainerGeneratedFiles(client, containerId, format);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (matches.length === 0) {
|
|
278
|
+
logger.error('OpenAI document gen produced no matching files', {
|
|
279
|
+
title: input.title,
|
|
280
|
+
format,
|
|
281
|
+
outputTypes: (response.output ?? []).map((o) => o.type),
|
|
282
|
+
});
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const preferred =
|
|
287
|
+
matches.find((f) => f.filename.toLowerCase() === filename.toLowerCase()) ??
|
|
288
|
+
matches[0];
|
|
289
|
+
|
|
290
|
+
const bytes = await downloadContainerFile(client, preferred);
|
|
291
|
+
if (!bytes) return null;
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
bytes,
|
|
295
|
+
filename: preferred.filename,
|
|
296
|
+
mimeType: mimeTypeForFormat(format),
|
|
297
|
+
format,
|
|
298
|
+
};
|
|
299
|
+
} catch (error) {
|
|
300
|
+
logger.error('OpenAI document generation failed', {
|
|
301
|
+
error,
|
|
302
|
+
title: input.title,
|
|
303
|
+
format,
|
|
304
|
+
});
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** @deprecated Use {@link generateDocumentWithOpenAI} */
|
|
310
|
+
export async function generatePdfWithOpenAI(
|
|
311
|
+
input: Omit<GenerateDocumentInput, 'format'>,
|
|
312
|
+
): Promise<Buffer | null> {
|
|
313
|
+
const result = await generateDocumentWithOpenAI({ ...input, format: 'pdf' });
|
|
314
|
+
return result?.bytes ?? null;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** @deprecated Use {@link extractContainerGeneratedFiles} */
|
|
318
|
+
export const extractContainerPdfFiles = (response: Response) =>
|
|
319
|
+
extractContainerGeneratedFiles(response, 'pdf');
|
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({
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User privacy service — data export and account erasure (anonymization).
|
|
3
|
+
*/
|
|
4
|
+
import { TRPCError } from "@trpc/server";
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
import { hash } from "bcryptjs";
|
|
7
|
+
import { prisma } from "../lib/prisma.js";
|
|
8
|
+
import { deleteSessionsByUserId } from "../models/auth.js";
|
|
9
|
+
|
|
10
|
+
async function assertAccountCanBeErased(userId: string) {
|
|
11
|
+
const user = await prisma.user.findUnique({
|
|
12
|
+
where: { id: userId },
|
|
13
|
+
select: {
|
|
14
|
+
id: true,
|
|
15
|
+
teacherIn: {
|
|
16
|
+
select: {
|
|
17
|
+
id: true,
|
|
18
|
+
name: true,
|
|
19
|
+
_count: { select: { students: true } },
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
_count: {
|
|
23
|
+
select: {
|
|
24
|
+
assignments: true,
|
|
25
|
+
createdLabChats: true,
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
if (!user) {
|
|
32
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const classesWithStudents = user.teacherIn.filter(
|
|
36
|
+
(cls) => cls._count.students > 0,
|
|
37
|
+
);
|
|
38
|
+
if (classesWithStudents.length > 0) {
|
|
39
|
+
throw new TRPCError({
|
|
40
|
+
code: "PRECONDITION_FAILED",
|
|
41
|
+
message:
|
|
42
|
+
"Delete or transfer your classes before deleting your account.",
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (user._count.assignments > 0) {
|
|
47
|
+
throw new TRPCError({
|
|
48
|
+
code: "PRECONDITION_FAILED",
|
|
49
|
+
message:
|
|
50
|
+
"Remove or reassign your assignments before deleting your account.",
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (user._count.createdLabChats > 0) {
|
|
55
|
+
throw new TRPCError({
|
|
56
|
+
code: "PRECONDITION_FAILED",
|
|
57
|
+
message: "Remove your AI lab chats before deleting your account.",
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Export a portable snapshot of the authenticated user's personal data. */
|
|
63
|
+
export async function exportUserData(userId: string) {
|
|
64
|
+
const user = await prisma.user.findUnique({
|
|
65
|
+
where: { id: userId },
|
|
66
|
+
select: {
|
|
67
|
+
id: true,
|
|
68
|
+
username: true,
|
|
69
|
+
email: true,
|
|
70
|
+
verified: true,
|
|
71
|
+
termsAcceptedAt: true,
|
|
72
|
+
termsVersion: true,
|
|
73
|
+
profile: true,
|
|
74
|
+
teacherIn: {
|
|
75
|
+
select: { id: true, name: true, subject: true, section: true },
|
|
76
|
+
},
|
|
77
|
+
studentIn: {
|
|
78
|
+
select: { id: true, name: true, subject: true, section: true },
|
|
79
|
+
},
|
|
80
|
+
schoolMemberships: {
|
|
81
|
+
select: {
|
|
82
|
+
role: true,
|
|
83
|
+
status: true,
|
|
84
|
+
school: { select: { id: true, name: true } },
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
submissions: {
|
|
88
|
+
select: {
|
|
89
|
+
id: true,
|
|
90
|
+
createdAt: true,
|
|
91
|
+
submittedAt: true,
|
|
92
|
+
gradeReceived: true,
|
|
93
|
+
extendedResponse: true,
|
|
94
|
+
assignment: { select: { id: true, title: true, classId: true } },
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
events: {
|
|
98
|
+
select: {
|
|
99
|
+
id: true,
|
|
100
|
+
name: true,
|
|
101
|
+
startTime: true,
|
|
102
|
+
endTime: true,
|
|
103
|
+
location: true,
|
|
104
|
+
remarks: true,
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
sentMessages: {
|
|
108
|
+
select: {
|
|
109
|
+
id: true,
|
|
110
|
+
content: true,
|
|
111
|
+
createdAt: true,
|
|
112
|
+
conversationId: true,
|
|
113
|
+
},
|
|
114
|
+
take: 500,
|
|
115
|
+
orderBy: { createdAt: "desc" },
|
|
116
|
+
},
|
|
117
|
+
notificationsReceived: {
|
|
118
|
+
select: {
|
|
119
|
+
id: true,
|
|
120
|
+
title: true,
|
|
121
|
+
content: true,
|
|
122
|
+
read: true,
|
|
123
|
+
createdAt: true,
|
|
124
|
+
},
|
|
125
|
+
take: 200,
|
|
126
|
+
orderBy: { createdAt: "desc" },
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
if (!user) {
|
|
132
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
exportedAt: new Date().toISOString(),
|
|
137
|
+
user,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Erase personal account data by anonymizing the user record and removing
|
|
143
|
+
* sessions and memberships. Educational records tied to classes (e.g.
|
|
144
|
+
* submissions) remain with an anonymized identity where required.
|
|
145
|
+
*/
|
|
146
|
+
export async function deleteUserAccount(userId: string) {
|
|
147
|
+
await assertAccountCanBeErased(userId);
|
|
148
|
+
await deleteSessionsByUserId(userId);
|
|
149
|
+
|
|
150
|
+
const anonUsername = `deleted_${userId.slice(0, 8)}`;
|
|
151
|
+
const anonEmail = `deleted+${userId}@studious.invalid`;
|
|
152
|
+
const randomPassword = await hash(randomBytes(32).toString("hex"), 10);
|
|
153
|
+
|
|
154
|
+
await prisma.$transaction([
|
|
155
|
+
prisma.conversationMember.deleteMany({ where: { userId } }),
|
|
156
|
+
prisma.notification.deleteMany({
|
|
157
|
+
where: { OR: [{ senderId: userId }, { receiverId: userId }] },
|
|
158
|
+
}),
|
|
159
|
+
prisma.schoolMembership.deleteMany({ where: { userId } }),
|
|
160
|
+
prisma.permission.deleteMany({
|
|
161
|
+
where: { OR: [{ userId }, { grantedById: userId }] },
|
|
162
|
+
}),
|
|
163
|
+
prisma.user.update({
|
|
164
|
+
where: { id: userId },
|
|
165
|
+
data: {
|
|
166
|
+
teacherIn: { set: [] },
|
|
167
|
+
studentIn: { set: [] },
|
|
168
|
+
departments: { set: [] },
|
|
169
|
+
tags: { set: [] },
|
|
170
|
+
specificAssignments: { set: [] },
|
|
171
|
+
presentAttendance: { set: [] },
|
|
172
|
+
lateAttendance: { set: [] },
|
|
173
|
+
absentAttendance: { set: [] },
|
|
174
|
+
schoolId: null,
|
|
175
|
+
username: anonUsername,
|
|
176
|
+
email: anonEmail,
|
|
177
|
+
password: randomPassword,
|
|
178
|
+
verified: false,
|
|
179
|
+
termsAcceptedAt: null,
|
|
180
|
+
termsVersion: null,
|
|
181
|
+
profile: {
|
|
182
|
+
update: {
|
|
183
|
+
displayName: null,
|
|
184
|
+
bio: null,
|
|
185
|
+
location: null,
|
|
186
|
+
website: null,
|
|
187
|
+
profilePicture: null,
|
|
188
|
+
profilePictureThumbnail: null,
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
}),
|
|
193
|
+
]);
|
|
194
|
+
|
|
195
|
+
return { success: true };
|
|
196
|
+
}
|
|
@@ -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,
|
package/src/utils/email.ts
CHANGED
|
@@ -27,7 +27,11 @@ export const transport = nodemailer.createTransport({
|
|
|
27
27
|
export const sendMail = async ({ from, to, subject, text }: sendMailProps) => {
|
|
28
28
|
// Wrapper function for sending emails
|
|
29
29
|
if (env.EMAIL_DRY_RUN == "true") {
|
|
30
|
-
logger.info(
|
|
30
|
+
logger.info("Email dry run enabled. Would have sent email", {
|
|
31
|
+
to,
|
|
32
|
+
from,
|
|
33
|
+
subject,
|
|
34
|
+
});
|
|
31
35
|
return;
|
|
32
36
|
}
|
|
33
37
|
|