@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.
Files changed (151) 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 +236 -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 +4 -0
  59. package/dist/routers/school.d.ts.map +1 -1
  60. package/dist/routers/user.d.ts +89 -0
  61. package/dist/routers/user.d.ts.map +1 -1
  62. package/dist/routers/user.js +19 -2
  63. package/dist/routers/user.js.map +1 -1
  64. package/dist/routers/worksheet.d.ts +7 -0
  65. package/dist/routers/worksheet.d.ts.map +1 -1
  66. package/dist/routers/worksheet.js +8 -3
  67. package/dist/routers/worksheet.js.map +1 -1
  68. package/dist/seedDatabase.d.ts +2 -0
  69. package/dist/seedDatabase.d.ts.map +1 -1
  70. package/dist/services/aiChatStream.d.ts +2 -0
  71. package/dist/services/aiChatStream.d.ts.map +1 -1
  72. package/dist/services/aiChatStream.js +31 -4
  73. package/dist/services/aiChatStream.js.map +1 -1
  74. package/dist/services/aiProposalTools.d.ts.map +1 -1
  75. package/dist/services/aiProposalTools.js +9 -9
  76. package/dist/services/aiProposalTools.js.map +1 -1
  77. package/dist/services/aiTools.js +3 -3
  78. package/dist/services/aiTools.js.map +1 -1
  79. package/dist/services/assignment.d.ts +10 -0
  80. package/dist/services/assignment.d.ts.map +1 -1
  81. package/dist/services/auth.d.ts +2 -0
  82. package/dist/services/auth.d.ts.map +1 -1
  83. package/dist/services/auth.js +13 -4
  84. package/dist/services/auth.js.map +1 -1
  85. package/dist/services/class.d.ts +4 -0
  86. package/dist/services/class.d.ts.map +1 -1
  87. package/dist/services/event.d.ts +2 -0
  88. package/dist/services/event.d.ts.map +1 -1
  89. package/dist/services/marketing.d.ts +1 -14
  90. package/dist/services/marketing.d.ts.map +1 -1
  91. package/dist/services/marketing.js +5 -5
  92. package/dist/services/marketing.js.map +1 -1
  93. package/dist/services/newtonChat.d.ts.map +1 -1
  94. package/dist/services/newtonChat.js +2 -8
  95. package/dist/services/newtonChat.js.map +1 -1
  96. package/dist/services/openaiDocumentGen.d.ts +12 -0
  97. package/dist/services/openaiDocumentGen.d.ts.map +1 -1
  98. package/dist/services/openaiDocumentGen.js +173 -65
  99. package/dist/services/openaiDocumentGen.js.map +1 -1
  100. package/dist/services/school.d.ts +4 -0
  101. package/dist/services/school.d.ts.map +1 -1
  102. package/dist/services/userPrivacy.d.ts +86 -0
  103. package/dist/services/userPrivacy.d.ts.map +1 -0
  104. package/dist/services/userPrivacy.js +184 -0
  105. package/dist/services/userPrivacy.js.map +1 -0
  106. package/dist/services/worksheet.d.ts +1 -0
  107. package/dist/services/worksheet.d.ts.map +1 -1
  108. package/dist/services/worksheet.js +13 -3
  109. package/dist/services/worksheet.js.map +1 -1
  110. package/dist/utils/email.d.ts.map +1 -1
  111. package/dist/utils/email.js +7 -3
  112. package/dist/utils/email.js.map +1 -1
  113. package/dist/workers/processors/aiNewtonChat.d.ts.map +1 -1
  114. package/dist/workers/processors/aiNewtonChat.js +3 -3
  115. package/dist/workers/processors/aiNewtonChat.js.map +1 -1
  116. package/package.json +2 -1
  117. package/prisma/migrations/20260611120000_add_user_terms_acceptance/migration.sql +3 -0
  118. package/prisma/schema.prisma +3 -0
  119. package/src/instrument.ts +1 -1
  120. package/src/lib/chatAttachmentPrompt.ts +270 -0
  121. package/src/lib/inferDocumentFormat.ts +1 -1
  122. package/src/lib/legal.ts +2 -0
  123. package/src/models/auth.ts +4 -0
  124. package/src/models/marketing.ts +10 -2
  125. package/src/pipelines/aiLabChat.ts +46 -71
  126. package/src/pipelines/aiNewtonChat.ts +20 -23
  127. package/src/pipelines/gradeWorksheet.ts +87 -11
  128. package/src/pipelines/labChatPrompt.ts +29 -98
  129. package/src/routers/auth.ts +13 -0
  130. package/src/routers/user.ts +26 -0
  131. package/src/routers/worksheet.ts +11 -0
  132. package/src/services/aiChatStream.ts +30 -3
  133. package/src/services/aiProposalTools.ts +8 -7
  134. package/src/services/aiTools.ts +1 -1
  135. package/src/services/auth.ts +14 -2
  136. package/src/services/marketing.ts +3 -3
  137. package/src/services/newtonChat.ts +0 -7
  138. package/src/services/openaiDocumentGen.ts +190 -66
  139. package/src/services/userPrivacy.ts +196 -0
  140. package/src/services/worksheet.ts +20 -1
  141. package/src/utils/email.ts +5 -1
  142. package/src/workers/processors/aiNewtonChat.ts +0 -1
  143. package/tests/helpers/registerInput.ts +20 -0
  144. package/tests/integration/globalSetup.ts +10 -18
  145. package/tests/integration/routers/auth.test.ts +13 -18
  146. package/tests/integration/routers/newtonChat.test.ts +9 -8
  147. package/tests/unit/lib/chatAttachmentPrompt.test.ts +135 -0
  148. package/tests/unit/lib/inferDocumentFormat.test.ts +10 -0
  149. package/tests/unit/pipelines/labChatPrompt.test.ts +6 -6
  150. package/tests/unit/services/aiChatStream.test.ts +92 -0
  151. package/tests/unit/services/openaiDocumentGen.test.ts +65 -0
@@ -71,6 +71,8 @@ ${responseText}
71
71
  INSTRUCTIONS:
72
72
  - Evaluate the student response against each mark scheme criterion.
73
73
  - Total points must not exceed ${maxPoints}.
74
+ - In markschemeState, use the EXACT "id" string from the MARK SCHEME items above (e.g. "${markSchemeItems[0]?.id || 'ms-xxx'}"). Do NOT invent new IDs.
75
+ - In annotations, use the same EXACT mark scheme id for "markSchemeId".
74
76
  - For each criterion, find the exact substring in the student's response that is relevant (the "quote" field). Copy the text verbatim — do not paraphrase.
75
77
  - If no part of the response addresses a criterion, still include an annotation with an empty quote and met=false.
76
78
  - Provide constructive feedback comments.
@@ -162,24 +164,44 @@ const gradeWorksheetQuestion = async (worksheetResponseId: string, worksheetQues
162
164
  const maxPoints = question.points || 0;
163
165
  const clampedPoints = Math.max(0, Math.min(Math.round((apiResponse as { points: number }).points), maxPoints));
164
166
 
165
- const validMarkSchemeIds = new Set(
166
- (Array.isArray(question.markScheme) ? question.markScheme as MarkSchemeItem[] : []).map(item => item.id),
167
- );
167
+ const markSchemeItems = Array.isArray(question.markScheme) ? question.markScheme as MarkSchemeItem[] : [];
168
+ const validMarkSchemeIds = new Set(markSchemeItems.map(item => item.id));
168
169
 
169
- const validatedMarkschemeState = (apiResponse as {
170
+ const aiMarkschemeState = (apiResponse as {
170
171
  markschemeState: { id: string; correct: boolean }[];
171
- }).markschemeState
172
- .filter(item => validMarkSchemeIds.has(item.id))
173
- .reduce((acc, curr) => {
172
+ }).markschemeState;
173
+
174
+ const allIdsValid = aiMarkschemeState.length > 0 &&
175
+ aiMarkschemeState.every(entry => validMarkSchemeIds.has(entry.id));
176
+
177
+ let resolvedState: Record<string, boolean | unknown[]> = {};
178
+
179
+ if (allIdsValid) {
180
+ resolvedState = aiMarkschemeState.reduce((acc, curr) => {
174
181
  acc["item-" + curr.id] = curr.correct;
175
182
  return acc;
176
183
  }, {} as Record<string, boolean>);
184
+ } else if (markSchemeItems.length > 0) {
185
+ logger.warn('AI returned mismatched markscheme IDs, falling back to index mapping', {
186
+ expected: markSchemeItems.map(i => i.id),
187
+ received: aiMarkschemeState.map(i => i.id),
188
+ });
189
+ for (let i = 0; i < markSchemeItems.length; i++) {
190
+ const aiEntry = aiMarkschemeState[i];
191
+ resolvedState["item-" + markSchemeItems[i].id] = aiEntry?.correct ?? false;
192
+ }
193
+ }
177
194
 
178
195
  const annotations = (apiResponse as {
179
196
  annotations: { quote: string; markSchemeId: string; criterion: string; met: boolean }[];
180
197
  }).annotations
181
198
  .filter(a => a.quote.length === 0 || responseText.includes(a.quote));
182
199
 
200
+ // Store annotations separately so the boolean-only markschemeState
201
+ // isn't broken by non-boolean values (the frontend parser rejects
202
+ // the entire object if any value isn't a boolean).
203
+ resolvedState['_annotations'] = annotations;
204
+
183
205
  const updatedStudentQuestionProgress = await prisma.studentQuestionProgress.update({
184
206
  where: { id: studentQuestionProgress.id, status: {
185
207
  not: {
@@ -190,10 +212,7 @@ const gradeWorksheetQuestion = async (worksheetResponseId: string, worksheetQues
190
212
  status: JobStatus.COMPLETED,
191
213
  isCorrect: (apiResponse as { isCorrect: boolean }).isCorrect,
192
214
  points: clampedPoints,
193
- markschemeState: {
194
- ...validatedMarkschemeState,
195
- _annotations: annotations,
196
- },
215
+ markschemeState: resolvedState,
197
216
  comments: {
198
217
  create: (apiResponse as {
199
218
  comments: string[];
@@ -368,6 +387,63 @@ export const cancelGradePipeline = async (worksheetResponseId: string, worksheet
368
387
  return updatedStudentQuestionProgress;
369
388
  };
370
389
 
390
+ export const regradeAllPipeline = async (worksheetResponseId: string) => {
391
+ logger.info('Regrading all questions for worksheet response', { worksheetResponseId });
392
+ const worksheetResponse = await prisma.studentWorksheetResponse.findUnique({
393
+ where: { id: worksheetResponseId },
394
+ include: {
395
+ worksheet: true,
396
+ responses: {
397
+ where: {
398
+ question: {
399
+ type: {
400
+ not: {
401
+ in: [WorksheetQuestionType.MULTIPLE_CHOICE, WorksheetQuestionType.TRUE_FALSE],
402
+ },
403
+ },
404
+ },
405
+ },
406
+ include: {
407
+ question: true,
408
+ comments: true,
409
+ },
410
+ },
411
+ },
412
+ });
413
+
414
+ if (!worksheetResponse) {
415
+ logger.error('Worksheet response not found');
416
+ throw new Error('Worksheet response not found');
417
+ }
418
+
419
+ // Reset all non-cancelled responses to PENDING
420
+ for (const response of worksheetResponse.responses) {
421
+ await prisma.studentQuestionProgress.updateMany({
422
+ where: {
423
+ id: response.id,
424
+ status: { not: JobStatus.CANCELLED },
425
+ },
426
+ data: { status: JobStatus.PENDING },
427
+ });
428
+
429
+ pusher.trigger(worksheetChannel(worksheetResponse.id), `set-pending`, {
430
+ id: response.id,
431
+ });
432
+ }
433
+
434
+ // Re-grade each question sequentially
435
+ for (const response of worksheetResponse.responses) {
436
+ try {
437
+ await gradeWorksheetQuestion(worksheetResponseId, response.id);
438
+ } catch (questionError) {
439
+ logger.error('Failed to regrade question, continuing with remaining', {
440
+ questionId: response.questionId,
441
+ error: questionError,
442
+ });
443
+ }
444
+ }
445
+ };
446
+
371
447
  export const regradeWorksheetPipeline = async (worksheetResponseId: string, worksheetQuestionProgressId: string) => {
372
448
  logger.info('Regrading worksheet response', { worksheetResponseId, worksheetQuestionProgressId });
373
449
  try {
@@ -1,23 +1,10 @@
1
1
  import type { ModelMessage } from "ai";
2
+ import {
3
+ buildChatModelMessages,
4
+ type ChatHistoryMessage,
5
+ } from "../lib/chatAttachmentPrompt.js";
2
6
 
3
- export type ConversationMessage = {
4
- id?: string;
5
- content: string;
6
- senderId: string;
7
- createdAt?: Date | string | number;
8
- sender?: {
9
- username?: string | null;
10
- profile?: {
11
- displayName?: string | null;
12
- } | null;
13
- } | null;
14
- attachments?: {
15
- id: string;
16
- name: string;
17
- type: string;
18
- url?: string;
19
- }[];
20
- };
7
+ export type ConversationMessage = ChatHistoryMessage;
21
8
 
22
9
  const LAB_CHAT_BASE_INSTRUCTIONS = [
23
10
  "IMPORTANT INSTRUCTIONS:",
@@ -47,17 +34,27 @@ const LAB_CHAT_REFERENCE_RULES = [
47
34
 
48
35
  const LAB_CHAT_DOC_RULES = [
49
36
  "WHEN CREATING FILES (generate_document tool):",
37
+ "- Call generate_document with title, content, and format.",
50
38
  "- You can generate MANY file types — not just PDF. Always pass format explicitly.",
51
39
  "- Match format to what the teacher asked for:",
52
- " * slides / presentation / deck → pptx",
53
- " * spreadsheet / grade table / data → xlsx or csv",
40
+ " * slides / presentation / deck → pptx (separate slides with --- or Slide N: headers in content)",
41
+ " * spreadsheet / grade table / data → xlsx or csv (include markdown tables or row data in content)",
54
42
  " * editable handout / letter → docx",
55
43
  " * printable worksheet / reading → pdf",
56
- " * diagram / chart image → png",
44
+ " * diagram / chart / flowchart / figure / graph / visual → png (describe the visual in detail: labels, arrows, shapes, data — NOT pdf)",
57
45
  " * notes / readme → md or txt",
58
- "- Supported: pdf, docx, xlsx, pptx, csv, md, html, txt, json, xml, png, jpeg, gif, zip, py, js, ts, …",
59
- "- Write complete classroom-ready content — no placeholders.",
60
- "- In your spoken reply, name the actual format (e.g. \"PowerPoint\", \"spreadsheet\") — never say you only make PDFs.",
46
+ " * source code → py/js/ts only when the teacher explicitly wants code files",
47
+ "- Supported: pdf, docx, xlsx, pptx, csv, md, html, txt, json, xml, tex, png, jpeg, gif, zip, py, js, ts, css, sh.",
48
+ "- Write complete classroom-ready content — no placeholders like [diagram here].",
49
+ "- One file per tool call; split long packs into logical separate documents if needed.",
50
+ "- In your spoken reply, name the actual format (e.g. \"PowerPoint\", \"spreadsheet\", \"diagram image\") — never say you only make PDFs.",
51
+ "- Never include download links or URLs; the file appears as an attachment on this message automatically after you call generate_document.",
52
+ ];
53
+
54
+ const LAB_CHAT_ATTACHMENT_RULES = [
55
+ "WHEN THE TEACHER ATTACHES FILES:",
56
+ "- Read attached PDFs, Word docs, PowerPoints, spreadsheets, images, and text files — content is provided inline.",
57
+ "- Reference specifics from the attachment when answering or creating materials.",
61
58
  ];
62
59
 
63
60
  const LAB_CHAT_WORKSHEET_RULES = [
@@ -97,33 +94,6 @@ FILE GENERATION: You can attach pdf, docx, xlsx, pptx, csv, images, and more via
97
94
 
98
95
  const buildPrompt = (sections: string[]): string => sections.filter(Boolean).join("\n\n");
99
96
 
100
- const sortMessagesChronologically = (
101
- recentMessages: ConversationMessage[],
102
- ): ConversationMessage[] =>
103
- recentMessages
104
- .map((message, index) => ({ message, index }))
105
- .sort((left, right) => {
106
- const leftTime = left.message.createdAt
107
- ? new Date(left.message.createdAt).getTime()
108
- : null;
109
- const rightTime = right.message.createdAt
110
- ? new Date(right.message.createdAt).getTime()
111
- : null;
112
-
113
- if (leftTime != null && rightTime != null) {
114
- return leftTime - rightTime;
115
- }
116
- if (leftTime != null) {
117
- return -1;
118
- }
119
- if (rightTime != null) {
120
- return 1;
121
- }
122
-
123
- return left.index - right.index;
124
- })
125
- .map(({ message }) => message);
126
-
127
97
  const LAB_CHAT_TOOL_RULES = [
128
98
  "HOW TO CREATE CONTENT (TOOLS):",
129
99
  "- You have tools to search class materials and to PROPOSE content for the teacher to review and accept.",
@@ -144,6 +114,7 @@ export const buildLabChatAgentSystemPrompt = (context: string): string =>
144
114
  LAB_CHAT_REFERENCE_RULES.join("\n"),
145
115
  LAB_CHAT_TOOL_RULES.join("\n"),
146
116
  LAB_CHAT_DOC_RULES.join("\n"),
117
+ LAB_CHAT_ATTACHMENT_RULES.join("\n"),
147
118
  LAB_CHAT_WORKSHEET_RULES.join("\n"),
148
119
  LAB_CHAT_SECTION_RULES.join("\n"),
149
120
  LAB_CHAT_ASSIGNMENT_RULES.join("\n"),
@@ -155,7 +126,7 @@ export const buildLabChatAgentSystemPrompt = (context: string): string =>
155
126
  * Returns a `system` string and AI-SDK `ModelMessage[]` history. Per-request
156
127
  * class context (IDs) is folded into the system string.
157
128
  */
158
- export const buildLabChatAgentMessages = ({
129
+ export const buildLabChatAgentMessages = async ({
159
130
  context,
160
131
  classContext,
161
132
  recentMessages,
@@ -165,54 +136,14 @@ export const buildLabChatAgentMessages = ({
165
136
  classContext: string;
166
137
  recentMessages: ConversationMessage[];
167
138
  isAIUser: (senderId: string) => boolean;
168
- }): { system: string; messages: ModelMessage[] } => {
139
+ }): Promise<{ system: string; messages: ModelMessage[] }> => {
169
140
  const system = `${buildLabChatAgentSystemPrompt(context)}\n\nCLASS CONTEXT (use these IDs when proposing assignments, worksheets, or attaching files):\n${classContext}`;
170
141
 
171
- const messages: ModelMessage[] = sortMessagesChronologically(recentMessages).map(
172
- (message) => {
173
- const role = isAIUser(message.senderId) ? "assistant" : "user";
174
- const senderName =
175
- message.sender?.profile?.displayName || message.sender?.username || "Teacher";
176
-
177
- if (role === "assistant") {
178
- return { role, content: message.content };
179
- }
180
-
181
- // Build user message with attachments
182
- const textContent = `${senderName}: ${message.content}`;
183
- const attachments = message.attachments ?? [];
184
-
185
- if (attachments.length === 0) {
186
- return { role, content: textContent };
187
- }
188
-
189
- // Separate images (multimodal) from other files (text description)
190
- const imageAttachments = attachments.filter((a) => a.type.startsWith('image/'));
191
- const otherAttachments = attachments.filter((a) => !a.type.startsWith('image/'));
192
-
193
- const parts: Array<{ type: string; text?: string; image?: string }> = [];
194
-
195
- // Add text content with non-image file descriptions
196
- let fullText = textContent;
197
- if (otherAttachments.length > 0) {
198
- const fileList = otherAttachments.map((a) => `${a.name} (${a.type})`).join(', ');
199
- fullText += `\n[Attached files: ${fileList}]`;
200
- }
201
- parts.push({ type: 'text', text: fullText });
202
-
203
- // Add image attachments as image parts for multimodal
204
- for (const img of imageAttachments) {
205
- if (img.url) {
206
- parts.push({ type: 'image', image: img.url });
207
- } else {
208
- // Fallback: mention the image name in text if no URL available
209
- parts.push({ type: 'text', text: `[Image attached: ${img.name}]` });
210
- }
211
- }
212
-
213
- return { role, content: parts } as ModelMessage;
214
- },
215
- );
142
+ const messages = await buildChatModelMessages({
143
+ messages: recentMessages,
144
+ isAIUser,
145
+ defaultSenderLabel: 'Teacher',
146
+ });
216
147
 
217
148
  return { system, messages };
218
149
  };
@@ -5,6 +5,7 @@ import {
5
5
  protectedProcedure,
6
6
  publicProcedure,
7
7
  } from "../trpc.js";
8
+ import { CURRENT_TERMS_VERSION } from "../lib/legal.js";
8
9
  import {
9
10
  register,
10
11
  login,
@@ -27,6 +28,16 @@ const registerSchema = z
27
28
  email: z.string().email("Invalid email address"),
28
29
  password: z.string().min(6, "Password must be at least 6 characters"),
29
30
  confirmPassword: z.string(),
31
+ agreeToTerms: z.literal(true, {
32
+ errorMap: () => ({
33
+ message: "You must accept the Terms of Service and Privacy Policy",
34
+ }),
35
+ }),
36
+ termsVersion: z.literal(CURRENT_TERMS_VERSION, {
37
+ errorMap: () => ({
38
+ message: "Please accept the current Terms of Service and Privacy Policy",
39
+ }),
40
+ }),
30
41
  })
31
42
  .refine((data) => data.password === data.confirmPassword, {
32
43
  message: "Passwords don't match",
@@ -41,6 +52,8 @@ export const authRouter = createTRPCRouter({
41
52
  username: input.username,
42
53
  email: input.email,
43
54
  password: input.password,
55
+ agreeToTerms: input.agreeToTerms,
56
+ termsVersion: input.termsVersion,
44
57
  })
45
58
  ),
46
59
 
@@ -1,3 +1,4 @@
1
+ import { TRPCError } from "@trpc/server";
1
2
  import { z } from "zod";
2
3
  import { createTRPCRouter, protectedProcedure } from "../trpc.js";
3
4
  import {
@@ -5,6 +6,10 @@ import {
5
6
  updateProfile,
6
7
  getUploadUrl,
7
8
  } from "../services/user.js";
9
+ import {
10
+ deleteUserAccount,
11
+ exportUserData,
12
+ } from "../services/userPrivacy.js";
8
13
 
9
14
  const fileUploadSchema = z.object({
10
15
  filePath: z.string().min(1, "File path is required"),
@@ -79,4 +84,25 @@ export const userRouter = createTRPCRouter({
79
84
  .mutation(({ ctx, input }) =>
80
85
  getUploadUrl(ctx.user!.id, input.fileName, input.fileType)
81
86
  ),
87
+
88
+ exportData: protectedProcedure.query(({ ctx }) =>
89
+ exportUserData(ctx.user!.id),
90
+ ),
91
+
92
+ deleteAccount: protectedProcedure
93
+ .input(
94
+ z.object({
95
+ confirmUsername: z.string().min(1),
96
+ }),
97
+ )
98
+ .mutation(async ({ ctx, input }) => {
99
+ const profile = await getProfile(ctx.user!.id);
100
+ if (profile.username !== input.confirmUsername) {
101
+ throw new TRPCError({
102
+ code: "BAD_REQUEST",
103
+ message: "Username confirmation does not match",
104
+ });
105
+ }
106
+ return deleteUserAccount(ctx.user!.id);
107
+ }),
82
108
  });
@@ -30,6 +30,7 @@ import {
30
30
  answerWorksheetQuestion,
31
31
  cancelGrading,
32
32
  regradeQuestion,
33
+ regradeAll,
33
34
  gradeAnswer,
34
35
  addCommentToResponse,
35
36
  } from "../services/worksheet.js";
@@ -370,6 +371,16 @@ export const worksheetRouter = createTRPCRouter({
370
371
  regradeQuestion(input.worksheetResponseId, input.progressId, ctx.user!.id)
371
372
  ),
372
373
 
374
+ regradeAll: protectedProcedure
375
+ .input(
376
+ z.object({
377
+ worksheetResponseId: z.string(),
378
+ })
379
+ )
380
+ .mutation(({ ctx, input }) =>
381
+ regradeAll(input.worksheetResponseId, ctx.user!.id)
382
+ ),
383
+
373
384
  gradeAnswer: protectedProcedure
374
385
  .input(
375
386
  z.object({
@@ -87,6 +87,13 @@ export function applyResolvedFormatsToStreamParts(
87
87
  });
88
88
  }
89
89
 
90
+ /** Tools that queue async work after the model stream; UI stays loading until the pipeline finishes. */
91
+ const DEFERRED_COMPLETION_TOOLS = new Set(['generate_document']);
92
+
93
+ function isDeferredCompletionTool(toolName: string): boolean {
94
+ return DEFERRED_COMPLETION_TOOLS.has(toolName);
95
+ }
96
+
90
97
  /** Attach file IDs to `generate_document` tool parts for inline download chips. */
91
98
  export function enrichStreamPartsWithGeneratedFiles(
92
99
  parts: StreamPart[],
@@ -126,11 +133,24 @@ export function enrichStreamPartsWithGeneratedFiles(
126
133
  ...part,
127
134
  fileId: file.fileId,
128
135
  fileName: file.fileName,
136
+ status: 'completed',
129
137
  ...(file.format ? { format: file.format } : {}),
130
138
  };
131
139
  });
132
140
  }
133
141
 
142
+ /** Mark deferred tools completed (file linked) or failed (generation did not produce a file). */
143
+ export function finalizeDeferredToolParts(parts: StreamPart[]): StreamPart[] {
144
+ return parts.map((part) => {
145
+ if (part.type !== 'tool' || !isDeferredCompletionTool(part.toolName)) {
146
+ return part;
147
+ }
148
+ if (part.fileId) return { ...part, status: 'completed' };
149
+ if (part.status === 'executing') return { ...part, status: 'failed' };
150
+ return part;
151
+ });
152
+ }
153
+
134
154
  function toolFieldFromInput(input: unknown, key: string): string | undefined {
135
155
  if (input && typeof input === 'object') {
136
156
  const val = (input as Record<string, unknown>)[key];
@@ -271,8 +291,15 @@ export async function streamChatResponse(
271
291
  }
272
292
  case 'tool-result':
273
293
  case 'tool-error': {
274
- const status: 'completed' | 'failed' =
275
- part.type === 'tool-error' ? 'failed' : 'completed';
294
+ const failed = part.type === 'tool-error';
295
+ const deferred = isDeferredCompletionTool(part.toolName);
296
+ // Deferred tools (e.g. generate_document) only queue work during the
297
+ // stream — keep the spinner until the pipeline attaches a file.
298
+ const status: 'executing' | 'completed' | 'failed' = failed
299
+ ? 'failed'
300
+ : deferred
301
+ ? 'executing'
302
+ : 'completed';
276
303
  const title = toolTitleFromInput(part.input);
277
304
  const format = toolFieldFromInput(part.input, 'format');
278
305
  const idx = toolPartIndexById.get(part.toolCallId);
@@ -287,7 +314,7 @@ export async function streamChatResponse(
287
314
  toolName: part.toolName,
288
315
  input: part.input,
289
316
  output: part.type === 'tool-result' ? part.output : undefined,
290
- status,
317
+ status: failed ? 'failed' : 'completed',
291
318
  });
292
319
  await broadcast('message-tool-call', {
293
320
  messageId: aiMessageId,
@@ -237,25 +237,26 @@ export function labProposalTools(proposals: LabProposals): ToolSet {
237
237
 
238
238
  generate_document: {
239
239
  description:
240
- 'Generate a downloadable file and attach it to this message. You are NOT limited to PDF — pick the best format for the request (pptx for slides/presentations, xlsx/csv for data, docx for editable handouts, pdf for print-ready docs, png for diagrams, etc.). Always set format explicitly.',
240
+ 'Generate a downloadable file and attach it to this message. Pick the best format for the request — pptx for slides, xlsx/csv for data, docx for editable handouts, pdf for print-ready docs, png/jpeg for charts/diagrams/figures (NOT pdf for visuals). Use py/js/ts only when the teacher explicitly wants source code. Always set format explicitly.',
241
241
  inputSchema: jsonSchema({
242
242
  type: 'object',
243
243
  properties: {
244
+ title: { type: 'string', description: 'Document title' },
245
+ content: {
246
+ type: 'string',
247
+ description:
248
+ 'Full body. Structure by format: pptx — separate slides with "---" or "Slide N:" headers; xlsx/csv — markdown tables or row data; png/jpeg — describe the visual in detail (shapes, labels, arrows, data points, colors); pdf/docx — headings, lists, tables; code formats — complete runnable source.',
249
+ },
244
250
  format: {
245
251
  type: 'string',
246
252
  description:
247
- 'Output file type. REQUIRED. pptx=slides/presentation, xlsx/csv=spreadsheet/data, docx=Word handout, pdf=printable doc, md/txt/html=text, png/jpeg/gif=image, py/js/ts=code.',
253
+ 'Output file type. REQUIRED. pptx=slides, xlsx/csv=spreadsheet, docx=Word, pdf=printable doc, png/jpeg/gif=chart/diagram/figure image, md/txt/html=text. py/js/ts/css/sh=source code only when requested.',
248
254
  enum: [
249
255
  'pdf', 'docx', 'doc', 'xlsx', 'pptx', 'csv', 'md', 'html', 'txt',
250
256
  'json', 'xml', 'tex', 'png', 'jpeg', 'jpg', 'gif', 'zip', 'tar',
251
257
  'py', 'js', 'ts', 'css', 'sh',
252
258
  ],
253
259
  },
254
- title: { type: 'string', description: 'Document title' },
255
- content: {
256
- type: 'string',
257
- description: 'Full body — markdown or plain text with headings, lists, tables, slide notes, etc.',
258
- },
259
260
  },
260
261
  required: ['title', 'content', 'format'],
261
262
  }),
@@ -251,7 +251,7 @@ export function aiTools(classId: string): ToolSet {
251
251
  const client = new OpenAI();
252
252
  const response = await client.responses.create({
253
253
  model: 'gpt-4o-mini',
254
- tools: [{ type: 'web_search_preview' }],
254
+ tools: [{ type: 'web_search' }],
255
255
  input: query,
256
256
  });
257
257
 
@@ -10,6 +10,7 @@ import { sendMail } from "../utils/email.js";
10
10
  import { prismaWrapper } from "../utils/prismaWrapper.js";
11
11
  import { env } from "../lib/config/env.js";
12
12
  import { logger } from "../utils/logger.js";
13
+ import { CURRENT_TERMS_VERSION } from "../lib/legal.js";
13
14
  import {
14
15
  findUserByUsername,
15
16
  findUserByUsernameOrEmail,
@@ -32,8 +33,17 @@ export async function register(input: {
32
33
  username: string;
33
34
  email: string;
34
35
  password: string;
36
+ agreeToTerms: true;
37
+ termsVersion: string;
35
38
  }) {
36
- const { username, email, password } = input;
39
+ const { username, email, password, termsVersion } = input;
40
+
41
+ if (termsVersion !== CURRENT_TERMS_VERSION) {
42
+ throw new TRPCError({
43
+ code: "BAD_REQUEST",
44
+ message: "Please accept the current Terms of Service and Privacy Policy.",
45
+ });
46
+ }
37
47
 
38
48
  const existingUser = await prismaWrapper.findFirst(
39
49
  () => findUserByUsernameOrEmail(username, email),
@@ -70,7 +80,9 @@ export async function register(input: {
70
80
  username,
71
81
  email,
72
82
  password: await hash(password, 10),
73
- verified: true,
83
+ verified: false,
84
+ termsAcceptedAt: new Date(),
85
+ termsVersion,
74
86
  }),
75
87
  "creating new user during registration"
76
88
  );
@@ -4,7 +4,7 @@
4
4
  import { v4 } from "uuid";
5
5
  import {
6
6
  createSchoolDevelopmentProgram,
7
- findSchoolDevelopmentProgramById,
7
+ findSchoolDevelopmentProgramStatusById,
8
8
  createEarlyAccessRequest,
9
9
  } from "../models/marketing.js";
10
10
 
@@ -42,9 +42,9 @@ export async function createSchoolDevelopmentProgramRecord(input: {
42
42
  return { id: program.id };
43
43
  }
44
44
 
45
- /** Look up a school development program by ID. */
45
+ /** Public application status lookup (no contact PII). */
46
46
  export async function searchSchoolDevelopmentPrograms(id: string) {
47
- return findSchoolDevelopmentProgramById(id);
47
+ return findSchoolDevelopmentProgramStatusById(id);
48
48
  }
49
49
 
50
50
  /** Create an early access request. */
@@ -159,13 +159,6 @@ export async function postToNewtonChat(
159
159
  ensureMessageHasContentOrAttachments(content, fileIds);
160
160
  const normalizedContent = content.trim();
161
161
 
162
- if (!normalizedContent) {
163
- throw new TRPCError({
164
- code: "BAD_REQUEST",
165
- message: "Newton chat messages must include text content",
166
- });
167
- }
168
-
169
162
  const result = await prisma.$transaction(async (tx) => {
170
163
  const message = await tx.message.create({
171
164
  data: {