@studious-lms/server 1.16.0 → 1.17.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/chatAttachmentPrompt.d.ts +47 -0
- package/dist/lib/chatAttachmentPrompt.d.ts.map +1 -0
- package/dist/lib/chatAttachmentPrompt.js +190 -0
- package/dist/lib/chatAttachmentPrompt.js.map +1 -0
- package/dist/lib/inferDocumentFormat.js +3 -3
- package/dist/lib/inferDocumentFormat.js.map +1 -1
- 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 +2 -0
- package/dist/models/announcement.d.ts.map +1 -1
- 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/event.d.ts +2 -0
- package/dist/models/event.d.ts.map +1 -1
- package/dist/models/file.d.ts +24 -0
- package/dist/models/file.d.ts.map +1 -1
- 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/pipelines/aiLabChat.d.ts.map +1 -1
- package/dist/pipelines/aiLabChat.js +39 -59
- package/dist/pipelines/aiLabChat.js.map +1 -1
- package/dist/pipelines/aiLabChatContract.d.ts +2 -2
- package/dist/pipelines/aiNewtonChat.d.ts +1 -1
- package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
- package/dist/pipelines/aiNewtonChat.js +20 -19
- package/dist/pipelines/aiNewtonChat.js.map +1 -1
- package/dist/pipelines/gradeWorksheet.d.ts +1 -0
- package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
- package/dist/pipelines/gradeWorksheet.js +84 -13
- package/dist/pipelines/gradeWorksheet.js.map +1 -1
- package/dist/pipelines/labChatPrompt.d.ts +4 -20
- package/dist/pipelines/labChatPrompt.d.ts.map +1 -1
- package/dist/pipelines/labChatPrompt.js +24 -64
- package/dist/pipelines/labChatPrompt.js.map +1 -1
- package/dist/routers/_app.d.ts +440 -26
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/assignment.d.ts +10 -0
- 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 +4 -0
- package/dist/routers/class.d.ts.map +1 -1
- package/dist/routers/event.d.ts +2 -0
- package/dist/routers/event.d.ts.map +1 -1
- package/dist/routers/marketing.d.ts +0 -13
- package/dist/routers/marketing.d.ts.map +1 -1
- package/dist/routers/school.d.ts +106 -0
- package/dist/routers/school.d.ts.map +1 -1
- package/dist/routers/school.js +11 -3
- package/dist/routers/school.js.map +1 -1
- 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 +7 -0
- package/dist/routers/worksheet.d.ts.map +1 -1
- package/dist/routers/worksheet.js +8 -3
- package/dist/routers/worksheet.js.map +1 -1
- package/dist/seedDatabase.d.ts +2 -0
- package/dist/seedDatabase.d.ts.map +1 -1
- package/dist/services/aiChatStream.d.ts +2 -0
- package/dist/services/aiChatStream.d.ts.map +1 -1
- package/dist/services/aiChatStream.js +31 -4
- package/dist/services/aiChatStream.js.map +1 -1
- package/dist/services/aiProposalTools.d.ts.map +1 -1
- package/dist/services/aiProposalTools.js +9 -9
- 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/assignment.d.ts +10 -0
- package/dist/services/assignment.d.ts.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 +4 -0
- package/dist/services/class.d.ts.map +1 -1
- package/dist/services/event.d.ts +2 -0
- package/dist/services/event.d.ts.map +1 -1
- 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/newtonChat.d.ts.map +1 -1
- package/dist/services/newtonChat.js +2 -8
- package/dist/services/newtonChat.js.map +1 -1
- package/dist/services/openaiDocumentGen.d.ts +12 -0
- package/dist/services/openaiDocumentGen.d.ts.map +1 -1
- package/dist/services/openaiDocumentGen.js +173 -65
- package/dist/services/openaiDocumentGen.js.map +1 -1
- package/dist/services/school.d.ts +116 -0
- package/dist/services/school.d.ts.map +1 -1
- package/dist/services/school.js +213 -2
- package/dist/services/school.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 +1 -0
- package/dist/services/worksheet.d.ts.map +1 -1
- package/dist/services/worksheet.js +13 -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/aiNewtonChat.d.ts.map +1 -1
- package/dist/workers/processors/aiNewtonChat.js +3 -3
- package/dist/workers/processors/aiNewtonChat.js.map +1 -1
- package/package.json +2 -1
- package/prisma/migrations/20260611120000_add_user_terms_acceptance/migration.sql +3 -0
- package/prisma/schema.prisma +3 -0
- package/src/instrument.ts +1 -1
- package/src/lib/chatAttachmentPrompt.ts +270 -0
- package/src/lib/inferDocumentFormat.ts +1 -1
- 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 +46 -71
- package/src/pipelines/aiNewtonChat.ts +20 -23
- package/src/pipelines/gradeWorksheet.ts +87 -11
- package/src/pipelines/labChatPrompt.ts +29 -98
- package/src/routers/auth.ts +13 -0
- package/src/routers/school.ts +12 -0
- package/src/routers/user.ts +26 -0
- package/src/routers/worksheet.ts +11 -0
- package/src/services/aiChatStream.ts +30 -3
- package/src/services/aiProposalTools.ts +8 -7
- package/src/services/aiTools.ts +1 -1
- package/src/services/auth.ts +14 -2
- package/src/services/marketing.ts +3 -3
- package/src/services/newtonChat.ts +0 -7
- package/src/services/openaiDocumentGen.ts +190 -66
- package/src/services/school.ts +231 -0
- package/src/services/userPrivacy.ts +196 -0
- package/src/services/worksheet.ts +20 -1
- package/src/utils/email.ts +5 -1
- package/src/workers/processors/aiNewtonChat.ts +0 -1
- 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/integration/routers/newtonChat.test.ts +9 -8
- package/tests/unit/lib/chatAttachmentPrompt.test.ts +135 -0
- package/tests/unit/lib/inferDocumentFormat.test.ts +10 -0
- package/tests/unit/pipelines/labChatPrompt.test.ts +6 -6
- package/tests/unit/services/aiChatStream.test.ts +92 -0
- package/tests/unit/services/openaiDocumentGen.test.ts +65 -0
|
@@ -6,12 +6,36 @@ import OpenAI from 'openai';
|
|
|
6
6
|
import type { Response } from 'openai/resources/responses/responses.js';
|
|
7
7
|
import { logger } from '../utils/logger.js';
|
|
8
8
|
|
|
9
|
-
const DOCUMENT_GEN_MODEL = 'gpt-
|
|
9
|
+
const DOCUMENT_GEN_MODEL = 'gpt-5.5';
|
|
10
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
|
+
*/
|
|
11
15
|
export const SUPPORTED_DOCUMENT_FORMATS = [
|
|
12
|
-
'pdf',
|
|
13
|
-
'
|
|
14
|
-
'
|
|
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',
|
|
15
39
|
] as const;
|
|
16
40
|
|
|
17
41
|
export type DocumentFormat = (typeof SUPPORTED_DOCUMENT_FORMATS)[number];
|
|
@@ -43,34 +67,75 @@ const FORMAT_MIME: Record<DocumentFormat, string> = {
|
|
|
43
67
|
};
|
|
44
68
|
|
|
45
69
|
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: '
|
|
68
|
-
sh: 'shell script',
|
|
70
|
+
pdf: 'fpdf2 or reportlab — polished document with headings, body text, and tables',
|
|
71
|
+
docx: 'python-docx — Word document with heading styles, paragraphs, and lists',
|
|
72
|
+
doc: 'python-docx saving as .doc if supported, otherwise .docx',
|
|
73
|
+
xlsx: 'openpyxl or pandas — spreadsheet with labelled columns, formulas where useful',
|
|
74
|
+
pptx: 'python-pptx — full slide deck: title slide, section slides, bullets, diagrams on slides',
|
|
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, networkx, or Pillow — render the described chart/diagram as a finished image',
|
|
83
|
+
jpeg: 'matplotlib or Pillow — high-quality visual saved as JPEG',
|
|
84
|
+
jpg: 'matplotlib or Pillow — high-quality visual saved 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, runnable Python source with comments',
|
|
89
|
+
js: 'valid JavaScript source with comments',
|
|
90
|
+
ts: 'valid TypeScript source with comments',
|
|
91
|
+
css: 'stylesheet file',
|
|
92
|
+
sh: 'shell script file',
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** Extra quality rules appended for formats that need more than a one-line library hint. */
|
|
96
|
+
const FORMAT_QUALITY_RULES: Partial<Record<DocumentFormat, string[]>> = {
|
|
97
|
+
png: [
|
|
98
|
+
'Interpret the source content as a visual spec — draw it, do not dump text into the image.',
|
|
99
|
+
'Charts: labelled axes, legend, title, readable font sizes, 150+ DPI.',
|
|
100
|
+
'Diagrams/flowcharts: use matplotlib patches, networkx, or Pillow drawing — boxes, arrows, labels.',
|
|
101
|
+
'Geometry/maps: accurate relative positions; label every important part.',
|
|
102
|
+
'Use color meaningfully; avoid blank or placeholder images.',
|
|
103
|
+
],
|
|
104
|
+
jpeg: [
|
|
105
|
+
'Same visual quality rules as PNG — polished chart or diagram, not a screenshot of text.',
|
|
106
|
+
],
|
|
107
|
+
jpg: [
|
|
108
|
+
'Same visual quality rules as PNG — polished chart or diagram, not a screenshot of text.',
|
|
109
|
+
],
|
|
110
|
+
gif: [
|
|
111
|
+
'Render a clear educational visual; static GIF is fine unless animation is described.',
|
|
112
|
+
],
|
|
113
|
+
pptx: [
|
|
114
|
+
'Split slides on "---" lines or "Slide N:" / "# " headings in the source content.',
|
|
115
|
+
'Title slide first, then content slides with concise bullets (not walls of text).',
|
|
116
|
+
'If the content describes a diagram, draw it on a slide (shapes/arrows) or embed a generated chart.',
|
|
117
|
+
'Use distinct slide layouts where appropriate (title, title+content).',
|
|
118
|
+
],
|
|
119
|
+
xlsx: [
|
|
120
|
+
'Parse markdown tables or tabular lists in the source into real rows/columns.',
|
|
121
|
+
'Bold header row; auto-fit column widths where possible.',
|
|
122
|
+
],
|
|
123
|
+
pdf: [
|
|
124
|
+
'If the source describes a figure/chart, generate and embed it — do not leave a "[diagram]" placeholder.',
|
|
125
|
+
],
|
|
126
|
+
docx: [
|
|
127
|
+
'Use Heading 1/2 styles for section titles; bullets and numbered lists for enumerations.',
|
|
128
|
+
],
|
|
129
|
+
html: [
|
|
130
|
+
'Include embedded CSS for readable typography; use tables or simple layout for structured content.',
|
|
131
|
+
],
|
|
69
132
|
};
|
|
70
133
|
|
|
71
134
|
export interface GenerateDocumentInput {
|
|
72
135
|
title: string;
|
|
136
|
+
/** Markdown or plain-text source material for the output file. */
|
|
73
137
|
content: string;
|
|
138
|
+
/** Output format. Defaults to pdf. */
|
|
74
139
|
format?: DocumentFormat | string;
|
|
75
140
|
}
|
|
76
141
|
|
|
@@ -87,7 +152,9 @@ export interface ContainerGeneratedFile {
|
|
|
87
152
|
filename: string;
|
|
88
153
|
}
|
|
89
154
|
|
|
90
|
-
export function normalizeDocumentFormat(
|
|
155
|
+
export function normalizeDocumentFormat(
|
|
156
|
+
format?: string | null,
|
|
157
|
+
): DocumentFormat {
|
|
91
158
|
const raw = (format ?? 'pdf').toLowerCase().replace(/^\./, '');
|
|
92
159
|
if ((SUPPORTED_DOCUMENT_FORMATS as readonly string[]).includes(raw)) {
|
|
93
160
|
return raw as DocumentFormat;
|
|
@@ -99,13 +166,25 @@ export function mimeTypeForFormat(format: DocumentFormat): string {
|
|
|
99
166
|
return FORMAT_MIME[format];
|
|
100
167
|
}
|
|
101
168
|
|
|
169
|
+
export function extensionForFormat(format: DocumentFormat): string {
|
|
170
|
+
return format === 'jpg' ? 'jpg' : format;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Pull generated-file citations for a given extension out of a Responses output. */
|
|
102
174
|
export function extractContainerGeneratedFiles(
|
|
103
175
|
response: Response,
|
|
104
176
|
extension: DocumentFormat,
|
|
105
177
|
): ContainerGeneratedFile[] {
|
|
106
|
-
const suffix = `.${extension}`;
|
|
107
178
|
const seen = new Set<string>();
|
|
108
179
|
const files: ContainerGeneratedFile[] = [];
|
|
180
|
+
const suffix = `.${extension}`;
|
|
181
|
+
|
|
182
|
+
const add = (containerId: string, fileId: string, filename: string) => {
|
|
183
|
+
const key = `${containerId}:${fileId}`;
|
|
184
|
+
if (seen.has(key)) return;
|
|
185
|
+
seen.add(key);
|
|
186
|
+
files.push({ containerId, fileId, filename });
|
|
187
|
+
};
|
|
109
188
|
|
|
110
189
|
for (const item of response.output ?? []) {
|
|
111
190
|
if (item.type !== 'message') continue;
|
|
@@ -114,18 +193,13 @@ export function extractContainerGeneratedFiles(
|
|
|
114
193
|
for (const ann of block.annotations ?? []) {
|
|
115
194
|
if (ann.type !== 'container_file_citation') continue;
|
|
116
195
|
const name = ann.filename?.toLowerCase() ?? '';
|
|
117
|
-
if (
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
seen.add(key);
|
|
121
|
-
files.push({
|
|
122
|
-
containerId: ann.container_id,
|
|
123
|
-
fileId: ann.file_id,
|
|
124
|
-
filename: ann.filename,
|
|
125
|
-
});
|
|
196
|
+
if (name.endsWith(suffix)) {
|
|
197
|
+
add(ann.container_id, ann.file_id, ann.filename);
|
|
198
|
+
}
|
|
126
199
|
}
|
|
127
200
|
}
|
|
128
201
|
}
|
|
202
|
+
|
|
129
203
|
return files;
|
|
130
204
|
}
|
|
131
205
|
|
|
@@ -158,13 +232,50 @@ async function listContainerGeneratedFiles(
|
|
|
158
232
|
return files;
|
|
159
233
|
}
|
|
160
234
|
|
|
235
|
+
async function downloadContainerFile(
|
|
236
|
+
client: OpenAI,
|
|
237
|
+
file: ContainerGeneratedFile,
|
|
238
|
+
): Promise<Buffer | null> {
|
|
239
|
+
try {
|
|
240
|
+
const res = await client.containers.files.content.retrieve(file.fileId, {
|
|
241
|
+
container_id: file.containerId,
|
|
242
|
+
});
|
|
243
|
+
const bytes = await res.arrayBuffer();
|
|
244
|
+
return Buffer.from(bytes);
|
|
245
|
+
} catch (error) {
|
|
246
|
+
logger.error('Failed to download container file', { error, file });
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
161
251
|
function safeBasename(title: string): string {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
252
|
+
const base = title
|
|
253
|
+
.replace(/[^a-zA-Z0-9\s\-_]/g, '')
|
|
254
|
+
.replace(/\s+/g, '_')
|
|
255
|
+
.substring(0, 60);
|
|
256
|
+
return base || 'document';
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function buildDocumentGenInstructions(format: DocumentFormat): string {
|
|
260
|
+
const qualityRules = FORMAT_QUALITY_RULES[format] ?? [];
|
|
261
|
+
return [
|
|
262
|
+
'You are a document generator for K-12 and university teachers.',
|
|
263
|
+
'When given a title, output format, and source content:',
|
|
264
|
+
'1. Use the python tool to create exactly ONE output file of the requested type.',
|
|
265
|
+
`2. For this format use: ${FORMAT_HINTS[format]}.`,
|
|
266
|
+
`3. You MUST output a ${format.toUpperCase()} file — not any other format.`,
|
|
267
|
+
`4. The saved filename MUST end with .${extensionForFormat(format)}.`,
|
|
268
|
+
'5. Faithfully adapt ALL source content to the output medium — do not summarize away detail.',
|
|
269
|
+
'6. Include the document title prominently where the format allows.',
|
|
270
|
+
'7. Put ONLY the educational content in the file — no meta commentary.',
|
|
271
|
+
'8. You MUST save the file to disk in the working directory before you finish.',
|
|
272
|
+
...(format !== 'pdf'
|
|
273
|
+
? [`9. Do NOT create a PDF when ${format.toUpperCase()} was requested.`]
|
|
274
|
+
: []),
|
|
275
|
+
...(qualityRules.length > 0
|
|
276
|
+
? ['', 'Format-specific quality:', ...qualityRules.map((r, i) => `${i + 1}. ${r}`)]
|
|
277
|
+
: []),
|
|
278
|
+
].join('\n');
|
|
168
279
|
}
|
|
169
280
|
|
|
170
281
|
/** Generate a file via OpenAI Code Interpreter. */
|
|
@@ -173,33 +284,31 @@ export async function generateDocumentWithOpenAI(
|
|
|
173
284
|
): Promise<GeneratedDocumentResult | null> {
|
|
174
285
|
const client = new OpenAI();
|
|
175
286
|
const format = normalizeDocumentFormat(input.format);
|
|
176
|
-
const ext = format
|
|
287
|
+
const ext = extensionForFormat(format);
|
|
177
288
|
const filename = `${safeBasename(input.title)}.${ext}`;
|
|
178
289
|
|
|
179
290
|
try {
|
|
180
291
|
const response = await client.responses.create({
|
|
181
292
|
model: DOCUMENT_GEN_MODEL,
|
|
182
|
-
instructions:
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
...(format !== 'pdf' ? [`Do NOT create a PDF when ${ext.toUpperCase()} was requested.`] : []),
|
|
190
|
-
].join(' '),
|
|
191
|
-
tools: [{ type: 'code_interpreter', container: { type: 'auto' } }],
|
|
293
|
+
instructions: buildDocumentGenInstructions(format),
|
|
294
|
+
tools: [
|
|
295
|
+
{
|
|
296
|
+
type: 'code_interpreter',
|
|
297
|
+
container: { type: 'auto' },
|
|
298
|
+
},
|
|
299
|
+
],
|
|
192
300
|
tool_choice: 'required',
|
|
193
301
|
input: [
|
|
194
|
-
`Create a ${ext.toUpperCase()} file.`,
|
|
195
|
-
`
|
|
196
|
-
`
|
|
197
|
-
|
|
198
|
-
input.content,
|
|
302
|
+
`Create a ${ext.toUpperCase()} file using the python tool.`,
|
|
303
|
+
`Output filename (exact): ${filename}`,
|
|
304
|
+
`Output format: ${ext}`,
|
|
305
|
+
`Document title: ${input.title}`,
|
|
306
|
+
['Source content — adapt fully to the output medium (do not paste raw markdown into slides/images):', input.content].join('\n'),
|
|
199
307
|
].join('\n\n'),
|
|
200
308
|
});
|
|
201
309
|
|
|
202
310
|
let matches = extractContainerGeneratedFiles(response, format);
|
|
311
|
+
|
|
203
312
|
if (matches.length === 0) {
|
|
204
313
|
const containerId = containerIdFromResponse(response);
|
|
205
314
|
if (containerId) {
|
|
@@ -208,9 +317,10 @@ export async function generateDocumentWithOpenAI(
|
|
|
208
317
|
}
|
|
209
318
|
|
|
210
319
|
if (matches.length === 0) {
|
|
211
|
-
logger.error('OpenAI document gen
|
|
320
|
+
logger.error('OpenAI document gen produced no matching files', {
|
|
212
321
|
title: input.title,
|
|
213
322
|
format,
|
|
323
|
+
outputTypes: (response.output ?? []).map((o) => o.type),
|
|
214
324
|
});
|
|
215
325
|
return null;
|
|
216
326
|
}
|
|
@@ -219,10 +329,8 @@ export async function generateDocumentWithOpenAI(
|
|
|
219
329
|
matches.find((f) => f.filename.toLowerCase() === filename.toLowerCase()) ??
|
|
220
330
|
matches[0];
|
|
221
331
|
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
});
|
|
225
|
-
const bytes = Buffer.from(await res.arrayBuffer());
|
|
332
|
+
const bytes = await downloadContainerFile(client, preferred);
|
|
333
|
+
if (!bytes) return null;
|
|
226
334
|
|
|
227
335
|
return {
|
|
228
336
|
bytes,
|
|
@@ -231,7 +339,23 @@ export async function generateDocumentWithOpenAI(
|
|
|
231
339
|
format,
|
|
232
340
|
};
|
|
233
341
|
} catch (error) {
|
|
234
|
-
logger.error('OpenAI document generation failed', {
|
|
342
|
+
logger.error('OpenAI document generation failed', {
|
|
343
|
+
error,
|
|
344
|
+
title: input.title,
|
|
345
|
+
format,
|
|
346
|
+
});
|
|
235
347
|
return null;
|
|
236
348
|
}
|
|
237
349
|
}
|
|
350
|
+
|
|
351
|
+
/** @deprecated Use {@link generateDocumentWithOpenAI} */
|
|
352
|
+
export async function generatePdfWithOpenAI(
|
|
353
|
+
input: Omit<GenerateDocumentInput, 'format'>,
|
|
354
|
+
): Promise<Buffer | null> {
|
|
355
|
+
const result = await generateDocumentWithOpenAI({ ...input, format: 'pdf' });
|
|
356
|
+
return result?.bytes ?? null;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** @deprecated Use {@link extractContainerGeneratedFiles} */
|
|
360
|
+
export const extractContainerPdfFiles = (response: Response) =>
|
|
361
|
+
extractContainerGeneratedFiles(response, 'pdf');
|
package/src/services/school.ts
CHANGED
|
@@ -540,6 +540,237 @@ export async function getStudentTimeline(
|
|
|
540
540
|
};
|
|
541
541
|
}
|
|
542
542
|
|
|
543
|
+
/**
|
|
544
|
+
* Role-aware overview used by `/view/[userId]`.
|
|
545
|
+
*
|
|
546
|
+
* - For STUDENT members it returns the same timeline as `getStudentTimeline`
|
|
547
|
+
* (events, assignments, attendance) so teachers/admins can audit progress.
|
|
548
|
+
* - For TEACHER/ADMIN members it returns their teaching agenda: every event in
|
|
549
|
+
* a class they teach plus their personal events, alongside the classes they
|
|
550
|
+
* run with enrolment counts.
|
|
551
|
+
*
|
|
552
|
+
* Permissions:
|
|
553
|
+
* - School admins can view anyone in the institution.
|
|
554
|
+
* - A teacher can view a STUDENT they share a class with.
|
|
555
|
+
* - A teacher/admin can always view their own overview.
|
|
556
|
+
*/
|
|
557
|
+
export async function getMemberTimeline(
|
|
558
|
+
userId: string,
|
|
559
|
+
input: {
|
|
560
|
+
schoolId: string;
|
|
561
|
+
memberId: string;
|
|
562
|
+
from?: Date;
|
|
563
|
+
to?: Date;
|
|
564
|
+
}
|
|
565
|
+
) {
|
|
566
|
+
const callerIsAdmin = Boolean(
|
|
567
|
+
await findActiveSchoolAdminMembership(input.schoolId, userId)
|
|
568
|
+
);
|
|
569
|
+
|
|
570
|
+
const membership = await prisma.schoolMembership.findFirst({
|
|
571
|
+
where: {
|
|
572
|
+
schoolId: input.schoolId,
|
|
573
|
+
userId: input.memberId,
|
|
574
|
+
status: "ACTIVE",
|
|
575
|
+
},
|
|
576
|
+
select: {
|
|
577
|
+
role: true,
|
|
578
|
+
user: {
|
|
579
|
+
select: {
|
|
580
|
+
id: true,
|
|
581
|
+
username: true,
|
|
582
|
+
profile: { select: { displayName: true, profilePicture: true } },
|
|
583
|
+
},
|
|
584
|
+
},
|
|
585
|
+
},
|
|
586
|
+
});
|
|
587
|
+
if (!membership) {
|
|
588
|
+
throw new TRPCError({
|
|
589
|
+
code: "NOT_FOUND",
|
|
590
|
+
message: "This person is not in this institution",
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const range = {
|
|
595
|
+
...(input.from ? { gte: input.from } : {}),
|
|
596
|
+
...(input.to ? { lte: input.to } : {}),
|
|
597
|
+
};
|
|
598
|
+
const hasRange = Object.keys(range).length > 0;
|
|
599
|
+
|
|
600
|
+
if (membership.role === "STUDENT") {
|
|
601
|
+
if (!callerIsAdmin) {
|
|
602
|
+
const sharedClass = await prisma.class.findFirst({
|
|
603
|
+
where: {
|
|
604
|
+
schoolId: input.schoolId,
|
|
605
|
+
teachers: { some: { id: userId } },
|
|
606
|
+
students: { some: { id: input.memberId } },
|
|
607
|
+
},
|
|
608
|
+
select: { id: true },
|
|
609
|
+
});
|
|
610
|
+
if (!sharedClass) {
|
|
611
|
+
throw new TRPCError({
|
|
612
|
+
code: "FORBIDDEN",
|
|
613
|
+
message: "You don't share a class with this student",
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const [events, assignments, attendance] = await Promise.all([
|
|
619
|
+
prisma.event.findMany({
|
|
620
|
+
where: {
|
|
621
|
+
userId: input.memberId,
|
|
622
|
+
...(hasRange ? { startTime: range } : {}),
|
|
623
|
+
},
|
|
624
|
+
select: {
|
|
625
|
+
id: true,
|
|
626
|
+
name: true,
|
|
627
|
+
startTime: true,
|
|
628
|
+
endTime: true,
|
|
629
|
+
location: true,
|
|
630
|
+
remarks: true,
|
|
631
|
+
color: true,
|
|
632
|
+
class: { select: { id: true, name: true, color: true } },
|
|
633
|
+
},
|
|
634
|
+
orderBy: { startTime: "asc" },
|
|
635
|
+
}),
|
|
636
|
+
prisma.assignment.findMany({
|
|
637
|
+
where: {
|
|
638
|
+
class: { students: { some: { id: input.memberId } } },
|
|
639
|
+
template: false,
|
|
640
|
+
...(hasRange ? { dueDate: range } : {}),
|
|
641
|
+
},
|
|
642
|
+
select: {
|
|
643
|
+
id: true,
|
|
644
|
+
title: true,
|
|
645
|
+
dueDate: true,
|
|
646
|
+
type: true,
|
|
647
|
+
graded: true,
|
|
648
|
+
maxGrade: true,
|
|
649
|
+
weight: true,
|
|
650
|
+
class: {
|
|
651
|
+
select: {
|
|
652
|
+
id: true,
|
|
653
|
+
name: true,
|
|
654
|
+
section: true,
|
|
655
|
+
subject: true,
|
|
656
|
+
color: true,
|
|
657
|
+
},
|
|
658
|
+
},
|
|
659
|
+
submissions: {
|
|
660
|
+
where: { studentId: input.memberId },
|
|
661
|
+
select: {
|
|
662
|
+
id: true,
|
|
663
|
+
submitted: true,
|
|
664
|
+
submittedAt: true,
|
|
665
|
+
returned: true,
|
|
666
|
+
gradeReceived: true,
|
|
667
|
+
},
|
|
668
|
+
},
|
|
669
|
+
},
|
|
670
|
+
orderBy: { dueDate: "asc" },
|
|
671
|
+
}),
|
|
672
|
+
prisma.attendance.findMany({
|
|
673
|
+
where: {
|
|
674
|
+
OR: [
|
|
675
|
+
{ present: { some: { id: input.memberId } } },
|
|
676
|
+
{ late: { some: { id: input.memberId } } },
|
|
677
|
+
{ absent: { some: { id: input.memberId } } },
|
|
678
|
+
],
|
|
679
|
+
...(hasRange ? { date: range } : {}),
|
|
680
|
+
},
|
|
681
|
+
select: {
|
|
682
|
+
id: true,
|
|
683
|
+
date: true,
|
|
684
|
+
class: { select: { id: true, name: true, color: true } },
|
|
685
|
+
present: { where: { id: input.memberId }, select: { id: true } },
|
|
686
|
+
late: { where: { id: input.memberId }, select: { id: true } },
|
|
687
|
+
absent: { where: { id: input.memberId }, select: { id: true } },
|
|
688
|
+
},
|
|
689
|
+
orderBy: { date: "asc" },
|
|
690
|
+
}),
|
|
691
|
+
]);
|
|
692
|
+
|
|
693
|
+
const attendanceShaped = attendance.map((record) => ({
|
|
694
|
+
id: record.id,
|
|
695
|
+
date: record.date,
|
|
696
|
+
class: record.class,
|
|
697
|
+
status: record.present.length
|
|
698
|
+
? ("PRESENT" as const)
|
|
699
|
+
: record.late.length
|
|
700
|
+
? ("LATE" as const)
|
|
701
|
+
: ("ABSENT" as const),
|
|
702
|
+
}));
|
|
703
|
+
|
|
704
|
+
return {
|
|
705
|
+
role: "STUDENT" as const,
|
|
706
|
+
member: membership.user,
|
|
707
|
+
events,
|
|
708
|
+
assignments,
|
|
709
|
+
attendance: attendanceShaped,
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// TEACHER or ADMIN: only admins (or the member themselves) may view.
|
|
714
|
+
if (!callerIsAdmin && userId !== input.memberId) {
|
|
715
|
+
throw new TRPCError({
|
|
716
|
+
code: "FORBIDDEN",
|
|
717
|
+
message: "Only school admins can view this member",
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const [events, classes] = await Promise.all([
|
|
722
|
+
prisma.event.findMany({
|
|
723
|
+
where: {
|
|
724
|
+
OR: [
|
|
725
|
+
{ userId: input.memberId },
|
|
726
|
+
{ class: { teachers: { some: { id: input.memberId } } } },
|
|
727
|
+
],
|
|
728
|
+
...(hasRange ? { startTime: range } : {}),
|
|
729
|
+
},
|
|
730
|
+
select: {
|
|
731
|
+
id: true,
|
|
732
|
+
name: true,
|
|
733
|
+
startTime: true,
|
|
734
|
+
endTime: true,
|
|
735
|
+
location: true,
|
|
736
|
+
remarks: true,
|
|
737
|
+
color: true,
|
|
738
|
+
class: { select: { id: true, name: true, color: true } },
|
|
739
|
+
},
|
|
740
|
+
orderBy: { startTime: "asc" },
|
|
741
|
+
}),
|
|
742
|
+
prisma.class.findMany({
|
|
743
|
+
where: {
|
|
744
|
+
schoolId: input.schoolId,
|
|
745
|
+
teachers: { some: { id: input.memberId } },
|
|
746
|
+
},
|
|
747
|
+
select: {
|
|
748
|
+
id: true,
|
|
749
|
+
name: true,
|
|
750
|
+
section: true,
|
|
751
|
+
subject: true,
|
|
752
|
+
color: true,
|
|
753
|
+
_count: { select: { students: true } },
|
|
754
|
+
},
|
|
755
|
+
orderBy: { name: "asc" },
|
|
756
|
+
}),
|
|
757
|
+
]);
|
|
758
|
+
|
|
759
|
+
return {
|
|
760
|
+
role: membership.role as "TEACHER" | "ADMIN",
|
|
761
|
+
member: membership.user,
|
|
762
|
+
events,
|
|
763
|
+
classes: classes.map((c) => ({
|
|
764
|
+
id: c.id,
|
|
765
|
+
name: c.name,
|
|
766
|
+
section: c.section,
|
|
767
|
+
subject: c.subject,
|
|
768
|
+
color: c.color,
|
|
769
|
+
studentCount: c._count.students,
|
|
770
|
+
})),
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
|
|
543
774
|
export async function createInvite(
|
|
544
775
|
userId: string,
|
|
545
776
|
input: {
|