@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.
Files changed (157) hide show
  1. package/.github/workflows/deploy.yml +1 -1
  2. package/dist/instrument.js +3 -3
  3. package/dist/instrument.js.map +1 -1
  4. package/dist/lib/chatAttachmentPrompt.d.ts +47 -0
  5. package/dist/lib/chatAttachmentPrompt.d.ts.map +1 -0
  6. package/dist/lib/chatAttachmentPrompt.js +190 -0
  7. package/dist/lib/chatAttachmentPrompt.js.map +1 -0
  8. package/dist/lib/inferDocumentFormat.js +3 -3
  9. package/dist/lib/inferDocumentFormat.js.map +1 -1
  10. package/dist/lib/legal.d.ts +3 -0
  11. package/dist/lib/legal.d.ts.map +1 -0
  12. package/dist/lib/legal.js +6 -0
  13. package/dist/lib/legal.js.map +1 -0
  14. package/dist/models/announcement.d.ts +2 -0
  15. package/dist/models/announcement.d.ts.map +1 -1
  16. package/dist/models/auth.d.ts +8 -0
  17. package/dist/models/auth.d.ts.map +1 -1
  18. package/dist/models/auth.js +4 -2
  19. package/dist/models/auth.js.map +1 -1
  20. package/dist/models/event.d.ts +2 -0
  21. package/dist/models/event.d.ts.map +1 -1
  22. package/dist/models/file.d.ts +24 -0
  23. package/dist/models/file.d.ts.map +1 -1
  24. package/dist/models/marketing.d.ts +2 -15
  25. package/dist/models/marketing.d.ts.map +1 -1
  26. package/dist/models/marketing.js +12 -4
  27. package/dist/models/marketing.js.map +1 -1
  28. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  29. package/dist/pipelines/aiLabChat.js +39 -59
  30. package/dist/pipelines/aiLabChat.js.map +1 -1
  31. package/dist/pipelines/aiLabChatContract.d.ts +2 -2
  32. package/dist/pipelines/aiNewtonChat.d.ts +1 -1
  33. package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
  34. package/dist/pipelines/aiNewtonChat.js +20 -19
  35. package/dist/pipelines/aiNewtonChat.js.map +1 -1
  36. package/dist/pipelines/gradeWorksheet.d.ts +1 -0
  37. package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
  38. package/dist/pipelines/gradeWorksheet.js +84 -13
  39. package/dist/pipelines/gradeWorksheet.js.map +1 -1
  40. package/dist/pipelines/labChatPrompt.d.ts +4 -20
  41. package/dist/pipelines/labChatPrompt.d.ts.map +1 -1
  42. package/dist/pipelines/labChatPrompt.js +24 -64
  43. package/dist/pipelines/labChatPrompt.js.map +1 -1
  44. package/dist/routers/_app.d.ts +440 -26
  45. package/dist/routers/_app.d.ts.map +1 -1
  46. package/dist/routers/assignment.d.ts +10 -0
  47. package/dist/routers/assignment.d.ts.map +1 -1
  48. package/dist/routers/auth.d.ts +2 -0
  49. package/dist/routers/auth.d.ts.map +1 -1
  50. package/dist/routers/auth.js +15 -2
  51. package/dist/routers/auth.js.map +1 -1
  52. package/dist/routers/class.d.ts +4 -0
  53. package/dist/routers/class.d.ts.map +1 -1
  54. package/dist/routers/event.d.ts +2 -0
  55. package/dist/routers/event.d.ts.map +1 -1
  56. package/dist/routers/marketing.d.ts +0 -13
  57. package/dist/routers/marketing.d.ts.map +1 -1
  58. package/dist/routers/school.d.ts +106 -0
  59. package/dist/routers/school.d.ts.map +1 -1
  60. package/dist/routers/school.js +11 -3
  61. package/dist/routers/school.js.map +1 -1
  62. package/dist/routers/user.d.ts +89 -0
  63. package/dist/routers/user.d.ts.map +1 -1
  64. package/dist/routers/user.js +19 -2
  65. package/dist/routers/user.js.map +1 -1
  66. package/dist/routers/worksheet.d.ts +7 -0
  67. package/dist/routers/worksheet.d.ts.map +1 -1
  68. package/dist/routers/worksheet.js +8 -3
  69. package/dist/routers/worksheet.js.map +1 -1
  70. package/dist/seedDatabase.d.ts +2 -0
  71. package/dist/seedDatabase.d.ts.map +1 -1
  72. package/dist/services/aiChatStream.d.ts +2 -0
  73. package/dist/services/aiChatStream.d.ts.map +1 -1
  74. package/dist/services/aiChatStream.js +31 -4
  75. package/dist/services/aiChatStream.js.map +1 -1
  76. package/dist/services/aiProposalTools.d.ts.map +1 -1
  77. package/dist/services/aiProposalTools.js +9 -9
  78. package/dist/services/aiProposalTools.js.map +1 -1
  79. package/dist/services/aiTools.js +3 -3
  80. package/dist/services/aiTools.js.map +1 -1
  81. package/dist/services/assignment.d.ts +10 -0
  82. package/dist/services/assignment.d.ts.map +1 -1
  83. package/dist/services/auth.d.ts +2 -0
  84. package/dist/services/auth.d.ts.map +1 -1
  85. package/dist/services/auth.js +13 -4
  86. package/dist/services/auth.js.map +1 -1
  87. package/dist/services/class.d.ts +4 -0
  88. package/dist/services/class.d.ts.map +1 -1
  89. package/dist/services/event.d.ts +2 -0
  90. package/dist/services/event.d.ts.map +1 -1
  91. package/dist/services/marketing.d.ts +1 -14
  92. package/dist/services/marketing.d.ts.map +1 -1
  93. package/dist/services/marketing.js +5 -5
  94. package/dist/services/marketing.js.map +1 -1
  95. package/dist/services/newtonChat.d.ts.map +1 -1
  96. package/dist/services/newtonChat.js +2 -8
  97. package/dist/services/newtonChat.js.map +1 -1
  98. package/dist/services/openaiDocumentGen.d.ts +12 -0
  99. package/dist/services/openaiDocumentGen.d.ts.map +1 -1
  100. package/dist/services/openaiDocumentGen.js +173 -65
  101. package/dist/services/openaiDocumentGen.js.map +1 -1
  102. package/dist/services/school.d.ts +116 -0
  103. package/dist/services/school.d.ts.map +1 -1
  104. package/dist/services/school.js +213 -2
  105. package/dist/services/school.js.map +1 -1
  106. package/dist/services/userPrivacy.d.ts +86 -0
  107. package/dist/services/userPrivacy.d.ts.map +1 -0
  108. package/dist/services/userPrivacy.js +184 -0
  109. package/dist/services/userPrivacy.js.map +1 -0
  110. package/dist/services/worksheet.d.ts +1 -0
  111. package/dist/services/worksheet.d.ts.map +1 -1
  112. package/dist/services/worksheet.js +13 -3
  113. package/dist/services/worksheet.js.map +1 -1
  114. package/dist/utils/email.d.ts.map +1 -1
  115. package/dist/utils/email.js +7 -3
  116. package/dist/utils/email.js.map +1 -1
  117. package/dist/workers/processors/aiNewtonChat.d.ts.map +1 -1
  118. package/dist/workers/processors/aiNewtonChat.js +3 -3
  119. package/dist/workers/processors/aiNewtonChat.js.map +1 -1
  120. package/package.json +2 -1
  121. package/prisma/migrations/20260611120000_add_user_terms_acceptance/migration.sql +3 -0
  122. package/prisma/schema.prisma +3 -0
  123. package/src/instrument.ts +1 -1
  124. package/src/lib/chatAttachmentPrompt.ts +270 -0
  125. package/src/lib/inferDocumentFormat.ts +1 -1
  126. package/src/lib/legal.ts +2 -0
  127. package/src/models/auth.ts +4 -0
  128. package/src/models/marketing.ts +10 -2
  129. package/src/pipelines/aiLabChat.ts +46 -71
  130. package/src/pipelines/aiNewtonChat.ts +20 -23
  131. package/src/pipelines/gradeWorksheet.ts +87 -11
  132. package/src/pipelines/labChatPrompt.ts +29 -98
  133. package/src/routers/auth.ts +13 -0
  134. package/src/routers/school.ts +12 -0
  135. package/src/routers/user.ts +26 -0
  136. package/src/routers/worksheet.ts +11 -0
  137. package/src/services/aiChatStream.ts +30 -3
  138. package/src/services/aiProposalTools.ts +8 -7
  139. package/src/services/aiTools.ts +1 -1
  140. package/src/services/auth.ts +14 -2
  141. package/src/services/marketing.ts +3 -3
  142. package/src/services/newtonChat.ts +0 -7
  143. package/src/services/openaiDocumentGen.ts +190 -66
  144. package/src/services/school.ts +231 -0
  145. package/src/services/userPrivacy.ts +196 -0
  146. package/src/services/worksheet.ts +20 -1
  147. package/src/utils/email.ts +5 -1
  148. package/src/workers/processors/aiNewtonChat.ts +0 -1
  149. package/tests/helpers/registerInput.ts +20 -0
  150. package/tests/integration/globalSetup.ts +10 -18
  151. package/tests/integration/routers/auth.test.ts +13 -18
  152. package/tests/integration/routers/newtonChat.test.ts +9 -8
  153. package/tests/unit/lib/chatAttachmentPrompt.test.ts +135 -0
  154. package/tests/unit/lib/inferDocumentFormat.test.ts +10 -0
  155. package/tests/unit/pipelines/labChatPrompt.test.ts +6 -6
  156. package/tests/unit/services/aiChatStream.test.ts +92 -0
  157. 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 },
@@ -0,0 +1,2 @@
1
+ /** Must match the "Last updated" month in the client privacy/terms copy. */
2
+ export const CURRENT_TERMS_VERSION = "2025-11";
@@ -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: {
@@ -27,10 +27,18 @@ export function createSchoolDevelopmentProgram(data: {
27
27
  });
28
28
  }
29
29
 
30
- /** @returns School development program by ID. */
31
- export function findSchoolDevelopmentProgramById(id: string) {
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, getSignedUrl } from "../lib/googleCloudStorage.js";
16
- import { generateDocumentWithOpenAI } from "../services/openaiDocumentGen.js";
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
- // Resolve signed URLs for image attachments so the LLM can see them
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: messagesWithAttachments,
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 (const doc of proposals.docs) {
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('Document missing title or content', { labChatId, doc });
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('OpenAI document generation returned null', {
554
+ logger.error(`Document ${i + 1} generation returned null`, {
589
555
  labChatId,
590
556
  title: doc.title,
591
- format: doc.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('Document generation failed', {
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[] = enrichStreamPartsWithGeneratedFiles(
643
- applyResolvedFormatsToStreamParts(rawStreamParts, resolvedDocs),
644
- generatedDocFiles,
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
- part.type === 'tool' &&
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 type { ModelMessage } from "ai";
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
- // Add the new student message
322
- modelMessages.push({
323
- role: 'user',
324
- content: `Student: ${studentMessage}`,
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 } : {}),