@studious-lms/server 1.16.0 → 1.16.1
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 +236 -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 +4 -0
- package/dist/routers/school.d.ts.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 +4 -0
- package/dist/services/school.d.ts.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/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/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
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared chat attachment handling for Newton + Lab pipelines.
|
|
3
|
+
*
|
|
4
|
+
* - Images + PDFs: inlined as multimodal parts (bytes from GCS).
|
|
5
|
+
* - Office docs, spreadsheets, slides, text, csv, json, html: text extracted
|
|
6
|
+
* via officeparser / UTF-8 decode and appended to the user message.
|
|
7
|
+
* - Audio/video/zip: filename mention only (no extraction).
|
|
8
|
+
*/
|
|
9
|
+
import type { FilePart, ImagePart, ModelMessage, TextPart, UserContent } from 'ai';
|
|
10
|
+
import { OfficeParser } from 'officeparser';
|
|
11
|
+
import { bucket } from './googleCloudStorage.js';
|
|
12
|
+
import { logger } from '../utils/logger.js';
|
|
13
|
+
|
|
14
|
+
/** Model used for AI chats that may include document attachments. */
|
|
15
|
+
export const AI_CHAT_MODEL = 'gpt-5.5';
|
|
16
|
+
|
|
17
|
+
const MAX_EXTRACTED_CHARS = 80_000;
|
|
18
|
+
const IMAGE_TYPE_PREFIX = 'image/';
|
|
19
|
+
|
|
20
|
+
export type ChatAttachmentRef = {
|
|
21
|
+
id: string;
|
|
22
|
+
name: string;
|
|
23
|
+
type: string;
|
|
24
|
+
path: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type LoadedChatAttachment = ChatAttachmentRef & {
|
|
28
|
+
data?: Uint8Array;
|
|
29
|
+
extractedText?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type ChatHistoryMessage = {
|
|
33
|
+
id?: string;
|
|
34
|
+
content: string;
|
|
35
|
+
senderId: string;
|
|
36
|
+
createdAt?: Date | string | number;
|
|
37
|
+
sender?: {
|
|
38
|
+
username?: string | null;
|
|
39
|
+
profile?: { displayName?: string | null } | null;
|
|
40
|
+
} | null;
|
|
41
|
+
attachments?: ChatAttachmentRef[];
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type UserContentPart = TextPart | ImagePart | FilePart;
|
|
45
|
+
|
|
46
|
+
const TEXT_DECODE_TYPES = new Set([
|
|
47
|
+
'text/plain',
|
|
48
|
+
'text/csv',
|
|
49
|
+
'text/markdown',
|
|
50
|
+
'text/html',
|
|
51
|
+
'application/json',
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
const MENTION_ONLY_PREFIXES = ['audio/', 'video/'];
|
|
55
|
+
const MENTION_ONLY_TYPES = new Set(['application/zip']);
|
|
56
|
+
|
|
57
|
+
function extension(name: string): string {
|
|
58
|
+
return name.split('.').pop()?.toLowerCase() ?? '';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isPdf(attachment: { type: string; name: string }): boolean {
|
|
62
|
+
return attachment.type === 'application/pdf' || extension(attachment.name) === 'pdf';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isImage(attachment: { type: string }): boolean {
|
|
66
|
+
return attachment.type.startsWith(IMAGE_TYPE_PREFIX);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isMentionOnly(attachment: { type: string; name: string }): boolean {
|
|
70
|
+
if (MENTION_ONLY_TYPES.has(attachment.type)) return true;
|
|
71
|
+
return MENTION_ONLY_PREFIXES.some((prefix) => attachment.type.startsWith(prefix));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isUtf8Text(attachment: { type: string; name: string }): boolean {
|
|
75
|
+
if (TEXT_DECODE_TYPES.has(attachment.type)) return true;
|
|
76
|
+
const ext = extension(attachment.name);
|
|
77
|
+
return ['txt', 'csv', 'md', 'html', 'json'].includes(ext);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function shouldDownload(attachment: ChatAttachmentRef): boolean {
|
|
81
|
+
return Boolean(attachment.path) && !isMentionOnly(attachment);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function extractTextFromBytes(
|
|
85
|
+
bytes: Uint8Array,
|
|
86
|
+
name: string,
|
|
87
|
+
type: string,
|
|
88
|
+
): Promise<string | null> {
|
|
89
|
+
if (isUtf8Text({ type, name })) {
|
|
90
|
+
return new TextDecoder('utf-8', { fatal: false })
|
|
91
|
+
.decode(bytes)
|
|
92
|
+
.slice(0, MAX_EXTRACTED_CHARS);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (isImage({ type }) || isPdf({ type, name })) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (isMentionOnly({ type, name })) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const ast = await OfficeParser.parseOffice(Buffer.from(bytes), {
|
|
105
|
+
includeRawContent: false,
|
|
106
|
+
});
|
|
107
|
+
const text = ast.toText().trim();
|
|
108
|
+
return text ? text.slice(0, MAX_EXTRACTED_CHARS) : null;
|
|
109
|
+
} catch (error) {
|
|
110
|
+
logger.warn('Failed to extract chat attachment text', { name, type, error });
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Download bytes from GCS and extract text for every supported attachment type. */
|
|
116
|
+
export async function loadChatAttachments(
|
|
117
|
+
attachments: ChatAttachmentRef[],
|
|
118
|
+
): Promise<LoadedChatAttachment[]> {
|
|
119
|
+
return Promise.all(
|
|
120
|
+
attachments.map(async (attachment) => {
|
|
121
|
+
if (!shouldDownload(attachment)) {
|
|
122
|
+
return { ...attachment };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
const [bytes] = await bucket.file(attachment.path).download();
|
|
127
|
+
const data = new Uint8Array(bytes);
|
|
128
|
+
const extractedText =
|
|
129
|
+
(await extractTextFromBytes(data, attachment.name, attachment.type)) ??
|
|
130
|
+
undefined;
|
|
131
|
+
return { ...attachment, data, extractedText };
|
|
132
|
+
} catch (error) {
|
|
133
|
+
logger.warn('Failed to load chat attachment from storage', {
|
|
134
|
+
attachmentId: attachment.id,
|
|
135
|
+
path: attachment.path,
|
|
136
|
+
type: attachment.type,
|
|
137
|
+
error,
|
|
138
|
+
});
|
|
139
|
+
return { ...attachment };
|
|
140
|
+
}
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** @deprecated Use {@link loadChatAttachments} */
|
|
146
|
+
export const loadPromptAttachments = loadChatAttachments;
|
|
147
|
+
|
|
148
|
+
export function buildUserMessageContentWithAttachments(
|
|
149
|
+
textContent: string,
|
|
150
|
+
attachments: LoadedChatAttachment[],
|
|
151
|
+
): UserContent {
|
|
152
|
+
if (attachments.length === 0) {
|
|
153
|
+
return textContent;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const parts: UserContentPart[] = [];
|
|
157
|
+
let fullText = textContent;
|
|
158
|
+
|
|
159
|
+
for (const attachment of attachments) {
|
|
160
|
+
if (attachment.extractedText) {
|
|
161
|
+
fullText += `\n\n[Content of ${attachment.name}]:\n${attachment.extractedText}`;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const unhandled = attachments.filter((a) => {
|
|
166
|
+
if (a.extractedText) return false;
|
|
167
|
+
if (isImage(a) && a.data) return false;
|
|
168
|
+
if (isPdf(a) && a.data) return false;
|
|
169
|
+
return true;
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
if (unhandled.length > 0) {
|
|
173
|
+
const fileList = unhandled
|
|
174
|
+
.map((a) => `${a.name} (${a.type})`)
|
|
175
|
+
.join(', ');
|
|
176
|
+
fullText = fullText
|
|
177
|
+
? `${fullText}\n[Attached files (content not extracted): ${fileList}]`
|
|
178
|
+
: `[Attached files (content not extracted): ${fileList}]`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (fullText.trim()) {
|
|
182
|
+
parts.push({ type: 'text', text: fullText });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
for (const image of attachments) {
|
|
186
|
+
if (isImage(image) && image.data) {
|
|
187
|
+
parts.push({ type: 'image', image: image.data });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
for (const file of attachments) {
|
|
192
|
+
if (isPdf(file) && file.data) {
|
|
193
|
+
parts.push({
|
|
194
|
+
type: 'file',
|
|
195
|
+
data: file.data,
|
|
196
|
+
mediaType: 'application/pdf',
|
|
197
|
+
filename: file.name,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (parts.length === 0) {
|
|
203
|
+
return textContent || '[Attached files]';
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (parts.length === 1 && parts[0].type === 'text') {
|
|
207
|
+
return parts[0].text;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return parts;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function sortChatMessagesChronologically<T extends ChatHistoryMessage>(
|
|
214
|
+
messages: T[],
|
|
215
|
+
): T[] {
|
|
216
|
+
return messages
|
|
217
|
+
.map((message, index) => ({ message, index }))
|
|
218
|
+
.sort((left, right) => {
|
|
219
|
+
const leftTime = left.message.createdAt
|
|
220
|
+
? new Date(left.message.createdAt).getTime()
|
|
221
|
+
: null;
|
|
222
|
+
const rightTime = right.message.createdAt
|
|
223
|
+
? new Date(right.message.createdAt).getTime()
|
|
224
|
+
: null;
|
|
225
|
+
|
|
226
|
+
if (leftTime != null && rightTime != null) {
|
|
227
|
+
return leftTime - rightTime;
|
|
228
|
+
}
|
|
229
|
+
if (leftTime != null) return -1;
|
|
230
|
+
if (rightTime != null) return 1;
|
|
231
|
+
return left.index - right.index;
|
|
232
|
+
})
|
|
233
|
+
.map(({ message }) => message);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Build ModelMessage[] with shared attachment loading for Newton + Lab. */
|
|
237
|
+
export async function buildChatModelMessages({
|
|
238
|
+
messages,
|
|
239
|
+
isAIUser,
|
|
240
|
+
defaultSenderLabel,
|
|
241
|
+
}: {
|
|
242
|
+
messages: ChatHistoryMessage[];
|
|
243
|
+
isAIUser: (senderId: string) => boolean;
|
|
244
|
+
defaultSenderLabel: string;
|
|
245
|
+
}): Promise<ModelMessage[]> {
|
|
246
|
+
const modelMessages: ModelMessage[] = [];
|
|
247
|
+
|
|
248
|
+
for (const message of sortChatMessagesChronologically(messages)) {
|
|
249
|
+
if (isAIUser(message.senderId)) {
|
|
250
|
+
modelMessages.push({ role: 'assistant', content: message.content });
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const senderName =
|
|
255
|
+
message.sender?.profile?.displayName ||
|
|
256
|
+
message.sender?.username ||
|
|
257
|
+
defaultSenderLabel;
|
|
258
|
+
const label = message.content.trim()
|
|
259
|
+
? `${senderName}: ${message.content}`
|
|
260
|
+
: senderName;
|
|
261
|
+
const attachments = await loadChatAttachments(message.attachments ?? []);
|
|
262
|
+
|
|
263
|
+
modelMessages.push({
|
|
264
|
+
role: 'user',
|
|
265
|
+
content: buildUserMessageContentWithAttachments(label, attachments),
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return modelMessages;
|
|
270
|
+
}
|
|
@@ -9,7 +9,7 @@ const FORMAT_RULES: FormatRule[] = [
|
|
|
9
9
|
{ format: 'xlsx', pattern: /\b(spreadsheet|excel|workbook|xlsx|gradebook|grade book|\.xlsx)\b/i },
|
|
10
10
|
{ format: 'csv', pattern: /\b(csv|comma[- ]separated|\.csv)\b/i },
|
|
11
11
|
{ format: 'docx', pattern: /\b(word doc(ument)?|docx|editable handout|\.docx)\b/i },
|
|
12
|
-
{ format: 'png', pattern: /\b(diagram|chart|graph|plot|figure|infographic|\.png)\b/i },
|
|
12
|
+
{ format: 'png', pattern: /\b(diagram|flowchart|chart|graph|plot|figure|infographic|visual|illustration|mind ?map|timeline|\.png)\b/i },
|
|
13
13
|
{ format: 'md', pattern: /\b(markdown|readme|\.md)\b/i },
|
|
14
14
|
{ format: 'html', pattern: /\b(html|web ?page|\.html)\b/i },
|
|
15
15
|
{ format: 'txt', pattern: /\b(plain text|text file|\.txt)\b/i },
|
package/src/lib/legal.ts
ADDED
package/src/models/auth.ts
CHANGED
|
@@ -102,6 +102,8 @@ export function createUser(data: {
|
|
|
102
102
|
email: string;
|
|
103
103
|
password: string;
|
|
104
104
|
verified?: boolean;
|
|
105
|
+
termsAcceptedAt?: Date;
|
|
106
|
+
termsVersion?: string;
|
|
105
107
|
}) {
|
|
106
108
|
return prisma.user.create({
|
|
107
109
|
data: {
|
|
@@ -109,6 +111,8 @@ export function createUser(data: {
|
|
|
109
111
|
email: data.email,
|
|
110
112
|
password: data.password,
|
|
111
113
|
verified: data.verified ?? false,
|
|
114
|
+
termsAcceptedAt: data.termsAcceptedAt,
|
|
115
|
+
termsVersion: data.termsVersion,
|
|
112
116
|
profile: { create: {} },
|
|
113
117
|
},
|
|
114
118
|
select: {
|
package/src/models/marketing.ts
CHANGED
|
@@ -27,10 +27,18 @@ export function createSchoolDevelopmentProgram(data: {
|
|
|
27
27
|
});
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
/**
|
|
31
|
-
export function
|
|
30
|
+
/** Public status lookup — omits contact PII (email, phone, address). */
|
|
31
|
+
export function findSchoolDevelopmentProgramStatusById(id: string) {
|
|
32
32
|
return prisma.schoolDevelopementProgram.findUnique({
|
|
33
33
|
where: { id },
|
|
34
|
+
select: {
|
|
35
|
+
id: true,
|
|
36
|
+
name: true,
|
|
37
|
+
city: true,
|
|
38
|
+
country: true,
|
|
39
|
+
status: true,
|
|
40
|
+
submittedAt: true,
|
|
41
|
+
},
|
|
34
42
|
});
|
|
35
43
|
}
|
|
36
44
|
|
|
@@ -12,16 +12,19 @@ import {
|
|
|
12
12
|
} from "../utils/inference.js";
|
|
13
13
|
import { logger } from "../utils/logger.js";
|
|
14
14
|
import { v4 } from "uuid";
|
|
15
|
-
import { bucket
|
|
16
|
-
import {
|
|
15
|
+
import { bucket } from "../lib/googleCloudStorage.js";
|
|
16
|
+
import {
|
|
17
|
+
generateDocumentWithOpenAI,
|
|
18
|
+
normalizeDocumentFormat,
|
|
19
|
+
} from "../services/openaiDocumentGen.js";
|
|
17
20
|
import { inferDocumentFormat } from "../lib/inferDocumentFormat.js";
|
|
18
|
-
import { normalizeDocumentFormat } from "../services/openaiDocumentGen.js";
|
|
19
21
|
import { broadcastTextChunks, broadcastStreamComplete } from "../utils/streamBroadcast.js";
|
|
22
|
+
import { AI_CHAT_MODEL } from "../lib/chatAttachmentPrompt.js";
|
|
20
23
|
import { buildLabChatAgentMessages } from "./labChatPrompt.js";
|
|
21
|
-
import type { ConversationMessage } from "./labChatPrompt.js";
|
|
22
24
|
import {
|
|
23
25
|
applyResolvedFormatsToStreamParts,
|
|
24
26
|
enrichStreamPartsWithGeneratedFiles,
|
|
27
|
+
finalizeDeferredToolParts,
|
|
25
28
|
streamChatResponse,
|
|
26
29
|
type GeneratedDocFileLink,
|
|
27
30
|
type StreamPart,
|
|
@@ -210,42 +213,6 @@ const loadRecentLabChatMessages = async (
|
|
|
210
213
|
.slice(0, limit);
|
|
211
214
|
};
|
|
212
215
|
|
|
213
|
-
/**
|
|
214
|
-
* Resolves signed URLs for image attachments in messages so the LLM can see them.
|
|
215
|
-
* Non-image files are passed as text descriptions only.
|
|
216
|
-
*/
|
|
217
|
-
async function resolveAttachmentUrls(
|
|
218
|
-
messages: RecentLabChatMessage[],
|
|
219
|
-
): Promise<ConversationMessage[]> {
|
|
220
|
-
const resolved: ConversationMessage[] = [];
|
|
221
|
-
|
|
222
|
-
for (const msg of messages) {
|
|
223
|
-
const attachments = msg.attachments ?? [];
|
|
224
|
-
if (attachments.length === 0) {
|
|
225
|
-
resolved.push({ ...msg, attachments: [] });
|
|
226
|
-
continue;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
const withUrls = await Promise.all(
|
|
230
|
-
attachments.map(async (a) => {
|
|
231
|
-
if (a.type.startsWith('image/')) {
|
|
232
|
-
try {
|
|
233
|
-
const url = await getSignedUrl(a.path);
|
|
234
|
-
return { id: a.id, name: a.name, type: a.type, url };
|
|
235
|
-
} catch {
|
|
236
|
-
return { id: a.id, name: a.name, type: a.type };
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
return { id: a.id, name: a.name, type: a.type };
|
|
240
|
-
}),
|
|
241
|
-
);
|
|
242
|
-
|
|
243
|
-
resolved.push({ ...msg, attachments: withUrls });
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
return resolved;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
216
|
/**
|
|
250
217
|
* Builds schema-aware context for the AI from class data.
|
|
251
218
|
* Formats entities with IDs so the model can reference them when creating assignments.
|
|
@@ -508,13 +475,10 @@ export const generateAndSendLabIntroduction = async (
|
|
|
508
475
|
assignments: classData.assignments,
|
|
509
476
|
});
|
|
510
477
|
|
|
511
|
-
|
|
512
|
-
const messagesWithAttachments = await resolveAttachmentUrls(recentMessages.reverse());
|
|
513
|
-
|
|
514
|
-
const { system, messages } = buildLabChatAgentMessages({
|
|
478
|
+
const { system, messages } = await buildLabChatAgentMessages({
|
|
515
479
|
context: fullLabChat.context,
|
|
516
480
|
classContext,
|
|
517
|
-
recentMessages:
|
|
481
|
+
recentMessages: recentMessages.reverse(),
|
|
518
482
|
isAIUser,
|
|
519
483
|
});
|
|
520
484
|
|
|
@@ -532,6 +496,7 @@ export const generateAndSendLabIntroduction = async (
|
|
|
532
496
|
streamChatResponse({
|
|
533
497
|
conversationId,
|
|
534
498
|
aiMessageId: emitOptions.aiMessageId,
|
|
499
|
+
model: AI_CHAT_MODEL,
|
|
535
500
|
system,
|
|
536
501
|
messages,
|
|
537
502
|
tools,
|
|
@@ -555,9 +520,10 @@ export const generateAndSendLabIntroduction = async (
|
|
|
555
520
|
const generatedDocFiles: GeneratedDocFileLink[] = [];
|
|
556
521
|
const resolvedDocs: Array<{ title: string; format: string }> = [];
|
|
557
522
|
|
|
558
|
-
for (
|
|
523
|
+
for (let i = 0; i < proposals.docs.length; i++) {
|
|
524
|
+
const doc = proposals.docs[i];
|
|
559
525
|
if (!doc.title || !doc.content?.trim()) {
|
|
560
|
-
logger.error(
|
|
526
|
+
logger.error(`Document ${i + 1} is missing title or content`, { labChatId, doc });
|
|
561
527
|
continue;
|
|
562
528
|
}
|
|
563
529
|
|
|
@@ -585,10 +551,10 @@ export const generateAndSendLabIntroduction = async (
|
|
|
585
551
|
format,
|
|
586
552
|
});
|
|
587
553
|
if (!generated) {
|
|
588
|
-
logger.error(
|
|
554
|
+
logger.error(`Document ${i + 1} generation returned null`, {
|
|
589
555
|
labChatId,
|
|
590
556
|
title: doc.title,
|
|
591
|
-
format
|
|
557
|
+
format,
|
|
592
558
|
});
|
|
593
559
|
continue;
|
|
594
560
|
}
|
|
@@ -600,10 +566,18 @@ export const generateAndSendLabIntroduction = async (
|
|
|
600
566
|
const filename = `${sanitizedTitle}_${v4().substring(0, 8)}.${generated.format}`;
|
|
601
567
|
const filePath = `class/generated/${fullLabChat.classId}/${filename}`;
|
|
602
568
|
|
|
569
|
+
logger.info(`Document ${i + 1} generated successfully`, {
|
|
570
|
+
labChatId,
|
|
571
|
+
title: doc.title,
|
|
572
|
+
format: generated.format,
|
|
573
|
+
});
|
|
574
|
+
|
|
603
575
|
await bucket.file(filePath).save(generated.bytes, {
|
|
604
576
|
metadata: { contentType: generated.mimeType },
|
|
605
577
|
});
|
|
606
578
|
|
|
579
|
+
logger.info(`Document ${i + 1} uploaded successfully`, { labChatId, filename });
|
|
580
|
+
|
|
607
581
|
const file = await prisma.file.create({
|
|
608
582
|
data: {
|
|
609
583
|
name: filename,
|
|
@@ -626,7 +600,7 @@ export const generateAndSendLabIntroduction = async (
|
|
|
626
600
|
format: generated.format,
|
|
627
601
|
});
|
|
628
602
|
} catch (docError) {
|
|
629
|
-
logger.error(
|
|
603
|
+
logger.error(`Document generation failed for item ${i + 1}:`, {
|
|
630
604
|
error: docError instanceof Error ? {
|
|
631
605
|
message: docError.message,
|
|
632
606
|
stack: docError.stack,
|
|
@@ -638,31 +612,32 @@ export const generateAndSendLabIntroduction = async (
|
|
|
638
612
|
});
|
|
639
613
|
}
|
|
640
614
|
}
|
|
641
|
-
|
|
642
|
-
const streamParts: StreamPart[] =
|
|
643
|
-
|
|
644
|
-
|
|
615
|
+
|
|
616
|
+
const streamParts: StreamPart[] = finalizeDeferredToolParts(
|
|
617
|
+
enrichStreamPartsWithGeneratedFiles(
|
|
618
|
+
applyResolvedFormatsToStreamParts(rawStreamParts, resolvedDocs),
|
|
619
|
+
generatedDocFiles,
|
|
620
|
+
),
|
|
645
621
|
);
|
|
646
622
|
|
|
623
|
+
// While the message is still PENDING, resolve deferred tool indicators
|
|
624
|
+
// (spinner → attachment or error) before the final persist event.
|
|
647
625
|
for (const part of streamParts) {
|
|
648
|
-
if (
|
|
649
|
-
|
|
650
|
-
part.toolName === 'generate_document' &&
|
|
651
|
-
part.fileId
|
|
652
|
-
) {
|
|
653
|
-
await pusher
|
|
654
|
-
.trigger(chatChannel(conversationId), 'message-tool-call', {
|
|
655
|
-
messageId: emitOptions.aiMessageId,
|
|
656
|
-
toolCallId: part.toolCallId,
|
|
657
|
-
toolName: part.toolName,
|
|
658
|
-
...(part.title ? { title: part.title } : {}),
|
|
659
|
-
...(part.format ? { format: part.format } : {}),
|
|
660
|
-
fileId: part.fileId,
|
|
661
|
-
fileName: part.fileName,
|
|
662
|
-
status: 'completed',
|
|
663
|
-
})
|
|
664
|
-
.catch(() => {});
|
|
626
|
+
if (part.type !== 'tool' || part.toolName !== 'generate_document') {
|
|
627
|
+
continue;
|
|
665
628
|
}
|
|
629
|
+
if (part.status === 'executing') continue;
|
|
630
|
+
await pusher
|
|
631
|
+
.trigger(chatChannel(conversationId), 'message-tool-call', {
|
|
632
|
+
messageId: emitOptions.aiMessageId,
|
|
633
|
+
toolCallId: part.toolCallId,
|
|
634
|
+
toolName: part.toolName,
|
|
635
|
+
...(part.title ? { title: part.title } : {}),
|
|
636
|
+
...(part.format ? { format: part.format } : {}),
|
|
637
|
+
...(part.fileId ? { fileId: part.fileId, fileName: part.fileName } : {}),
|
|
638
|
+
status: part.status,
|
|
639
|
+
})
|
|
640
|
+
.catch(() => {});
|
|
666
641
|
}
|
|
667
642
|
|
|
668
643
|
await completePendingAIMessage(emitOptions.aiMessageId, streamedText, {
|
|
@@ -13,9 +13,10 @@ import { isAIUser } from "../utils/aiUser.js";
|
|
|
13
13
|
import { broadcastStreamComplete } from "../utils/streamBroadcast.js";
|
|
14
14
|
import { streamChatResponse } from "../services/aiChatStream.js";
|
|
15
15
|
import { aiTools } from "../services/aiTools.js";
|
|
16
|
-
import
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
import {
|
|
17
|
+
AI_CHAT_MODEL,
|
|
18
|
+
buildChatModelMessages,
|
|
19
|
+
} from "../lib/chatAttachmentPrompt.js";
|
|
19
20
|
|
|
20
21
|
// AI Policy Levels Configuration
|
|
21
22
|
// Used across assignment creation, editing, and display
|
|
@@ -148,6 +149,7 @@ export const generateAndSendNewtonIntroduction = async (
|
|
|
148
149
|
const { text: response } = await streamChatResponse({
|
|
149
150
|
conversationId,
|
|
150
151
|
aiMessageId,
|
|
152
|
+
model: AI_CHAT_MODEL,
|
|
151
153
|
system: systemPrompt,
|
|
152
154
|
messages: [
|
|
153
155
|
{
|
|
@@ -222,7 +224,6 @@ export const generateAndSendNewtonIntroduction = async (
|
|
|
222
224
|
*/
|
|
223
225
|
export const generateAndSendNewtonResponse = async (
|
|
224
226
|
newtonChatId: string,
|
|
225
|
-
studentMessage: string,
|
|
226
227
|
conversationId: string,
|
|
227
228
|
submission: {
|
|
228
229
|
id: string;
|
|
@@ -258,6 +259,14 @@ export const generateAndSendNewtonIntroduction = async (
|
|
|
258
259
|
},
|
|
259
260
|
},
|
|
260
261
|
},
|
|
262
|
+
attachments: {
|
|
263
|
+
select: {
|
|
264
|
+
id: true,
|
|
265
|
+
name: true,
|
|
266
|
+
type: true,
|
|
267
|
+
path: true,
|
|
268
|
+
},
|
|
269
|
+
},
|
|
261
270
|
},
|
|
262
271
|
orderBy: {
|
|
263
272
|
createdAt: 'desc',
|
|
@@ -302,26 +311,13 @@ export const generateAndSendNewtonIntroduction = async (
|
|
|
302
311
|
|
|
303
312
|
IMPORTANT:
|
|
304
313
|
- Keep responses conversational and educational
|
|
305
|
-
- Focus on helping the student learn, not just completing the assignment
|
|
306
|
-
|
|
307
|
-
const modelMessages: ModelMessage[] = [];
|
|
308
|
-
|
|
309
|
-
// Add recent conversation history
|
|
310
|
-
recentMessages.reverse().forEach(msg => {
|
|
311
|
-
const role = isAIUser(msg.senderId) ? 'assistant' : 'user';
|
|
312
|
-
const senderName = msg.sender?.profile?.displayName || msg.sender?.username || 'Student';
|
|
313
|
-
const content = isAIUser(msg.senderId) ? msg.content : `${senderName}: ${msg.content}`;
|
|
314
|
-
|
|
315
|
-
modelMessages.push({
|
|
316
|
-
role: role as 'user' | 'assistant',
|
|
317
|
-
content,
|
|
318
|
-
});
|
|
319
|
-
});
|
|
314
|
+
- Focus on helping the student learn, not just completing the assignment
|
|
315
|
+
- When the student attaches files (PDFs, images), read and reference them in your reply`;
|
|
320
316
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
317
|
+
const modelMessages = await buildChatModelMessages({
|
|
318
|
+
messages: [...recentMessages].reverse(),
|
|
319
|
+
isAIUser,
|
|
320
|
+
defaultSenderLabel: 'Student',
|
|
325
321
|
});
|
|
326
322
|
|
|
327
323
|
const combinedSystem = [
|
|
@@ -337,6 +333,7 @@ export const generateAndSendNewtonIntroduction = async (
|
|
|
337
333
|
const { text: response, parts: streamParts } = await streamChatResponse({
|
|
338
334
|
conversationId,
|
|
339
335
|
aiMessageId,
|
|
336
|
+
model: AI_CHAT_MODEL,
|
|
340
337
|
system: combinedSystem,
|
|
341
338
|
messages: modelMessages,
|
|
342
339
|
...(newtonClassId ? { tools: aiTools(newtonClassId), maxSteps: 5 } : {}),
|