@studious-lms/server 1.16.0 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/deploy.yml +1 -1
- package/dist/instrument.js +3 -3
- package/dist/instrument.js.map +1 -1
- package/dist/lib/chatAttachmentPrompt.d.ts +47 -0
- package/dist/lib/chatAttachmentPrompt.d.ts.map +1 -0
- package/dist/lib/chatAttachmentPrompt.js +190 -0
- package/dist/lib/chatAttachmentPrompt.js.map +1 -0
- package/dist/lib/inferDocumentFormat.js +3 -3
- package/dist/lib/inferDocumentFormat.js.map +1 -1
- package/dist/lib/legal.d.ts +3 -0
- package/dist/lib/legal.d.ts.map +1 -0
- package/dist/lib/legal.js +6 -0
- package/dist/lib/legal.js.map +1 -0
- package/dist/models/announcement.d.ts +2 -0
- package/dist/models/announcement.d.ts.map +1 -1
- package/dist/models/auth.d.ts +8 -0
- package/dist/models/auth.d.ts.map +1 -1
- package/dist/models/auth.js +4 -2
- package/dist/models/auth.js.map +1 -1
- package/dist/models/event.d.ts +2 -0
- package/dist/models/event.d.ts.map +1 -1
- package/dist/models/file.d.ts +24 -0
- package/dist/models/file.d.ts.map +1 -1
- package/dist/models/marketing.d.ts +2 -15
- package/dist/models/marketing.d.ts.map +1 -1
- package/dist/models/marketing.js +12 -4
- package/dist/models/marketing.js.map +1 -1
- package/dist/pipelines/aiLabChat.d.ts.map +1 -1
- package/dist/pipelines/aiLabChat.js +39 -59
- package/dist/pipelines/aiLabChat.js.map +1 -1
- package/dist/pipelines/aiLabChatContract.d.ts +2 -2
- package/dist/pipelines/aiNewtonChat.d.ts +1 -1
- package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
- package/dist/pipelines/aiNewtonChat.js +20 -19
- package/dist/pipelines/aiNewtonChat.js.map +1 -1
- package/dist/pipelines/gradeWorksheet.d.ts +1 -0
- package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
- package/dist/pipelines/gradeWorksheet.js +84 -13
- package/dist/pipelines/gradeWorksheet.js.map +1 -1
- package/dist/pipelines/labChatPrompt.d.ts +4 -20
- package/dist/pipelines/labChatPrompt.d.ts.map +1 -1
- package/dist/pipelines/labChatPrompt.js +24 -64
- package/dist/pipelines/labChatPrompt.js.map +1 -1
- package/dist/routers/_app.d.ts +440 -26
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/assignment.d.ts +10 -0
- package/dist/routers/assignment.d.ts.map +1 -1
- package/dist/routers/auth.d.ts +2 -0
- package/dist/routers/auth.d.ts.map +1 -1
- package/dist/routers/auth.js +15 -2
- package/dist/routers/auth.js.map +1 -1
- package/dist/routers/class.d.ts +4 -0
- package/dist/routers/class.d.ts.map +1 -1
- package/dist/routers/event.d.ts +2 -0
- package/dist/routers/event.d.ts.map +1 -1
- package/dist/routers/marketing.d.ts +0 -13
- package/dist/routers/marketing.d.ts.map +1 -1
- package/dist/routers/school.d.ts +106 -0
- package/dist/routers/school.d.ts.map +1 -1
- package/dist/routers/school.js +11 -3
- package/dist/routers/school.js.map +1 -1
- package/dist/routers/user.d.ts +89 -0
- package/dist/routers/user.d.ts.map +1 -1
- package/dist/routers/user.js +19 -2
- package/dist/routers/user.js.map +1 -1
- package/dist/routers/worksheet.d.ts +7 -0
- package/dist/routers/worksheet.d.ts.map +1 -1
- package/dist/routers/worksheet.js +8 -3
- package/dist/routers/worksheet.js.map +1 -1
- package/dist/seedDatabase.d.ts +2 -0
- package/dist/seedDatabase.d.ts.map +1 -1
- package/dist/services/aiChatStream.d.ts +2 -0
- package/dist/services/aiChatStream.d.ts.map +1 -1
- package/dist/services/aiChatStream.js +31 -4
- package/dist/services/aiChatStream.js.map +1 -1
- package/dist/services/aiProposalTools.d.ts.map +1 -1
- package/dist/services/aiProposalTools.js +9 -9
- package/dist/services/aiProposalTools.js.map +1 -1
- package/dist/services/aiTools.js +3 -3
- package/dist/services/aiTools.js.map +1 -1
- package/dist/services/assignment.d.ts +10 -0
- package/dist/services/assignment.d.ts.map +1 -1
- package/dist/services/auth.d.ts +2 -0
- package/dist/services/auth.d.ts.map +1 -1
- package/dist/services/auth.js +13 -4
- package/dist/services/auth.js.map +1 -1
- package/dist/services/class.d.ts +4 -0
- package/dist/services/class.d.ts.map +1 -1
- package/dist/services/event.d.ts +2 -0
- package/dist/services/event.d.ts.map +1 -1
- package/dist/services/marketing.d.ts +1 -14
- package/dist/services/marketing.d.ts.map +1 -1
- package/dist/services/marketing.js +5 -5
- package/dist/services/marketing.js.map +1 -1
- package/dist/services/newtonChat.d.ts.map +1 -1
- package/dist/services/newtonChat.js +2 -8
- package/dist/services/newtonChat.js.map +1 -1
- package/dist/services/openaiDocumentGen.d.ts +12 -0
- package/dist/services/openaiDocumentGen.d.ts.map +1 -1
- package/dist/services/openaiDocumentGen.js +173 -65
- package/dist/services/openaiDocumentGen.js.map +1 -1
- package/dist/services/school.d.ts +116 -0
- package/dist/services/school.d.ts.map +1 -1
- package/dist/services/school.js +213 -2
- package/dist/services/school.js.map +1 -1
- package/dist/services/userPrivacy.d.ts +86 -0
- package/dist/services/userPrivacy.d.ts.map +1 -0
- package/dist/services/userPrivacy.js +184 -0
- package/dist/services/userPrivacy.js.map +1 -0
- package/dist/services/worksheet.d.ts +1 -0
- package/dist/services/worksheet.d.ts.map +1 -1
- package/dist/services/worksheet.js +13 -3
- package/dist/services/worksheet.js.map +1 -1
- package/dist/utils/email.d.ts.map +1 -1
- package/dist/utils/email.js +7 -3
- package/dist/utils/email.js.map +1 -1
- package/dist/workers/processors/aiNewtonChat.d.ts.map +1 -1
- package/dist/workers/processors/aiNewtonChat.js +3 -3
- package/dist/workers/processors/aiNewtonChat.js.map +1 -1
- package/package.json +2 -1
- package/prisma/migrations/20260611120000_add_user_terms_acceptance/migration.sql +3 -0
- package/prisma/schema.prisma +3 -0
- package/src/instrument.ts +1 -1
- package/src/lib/chatAttachmentPrompt.ts +270 -0
- package/src/lib/inferDocumentFormat.ts +1 -1
- package/src/lib/legal.ts +2 -0
- package/src/models/auth.ts +4 -0
- package/src/models/marketing.ts +10 -2
- package/src/pipelines/aiLabChat.ts +46 -71
- package/src/pipelines/aiNewtonChat.ts +20 -23
- package/src/pipelines/gradeWorksheet.ts +87 -11
- package/src/pipelines/labChatPrompt.ts +29 -98
- package/src/routers/auth.ts +13 -0
- package/src/routers/school.ts +12 -0
- package/src/routers/user.ts +26 -0
- package/src/routers/worksheet.ts +11 -0
- package/src/services/aiChatStream.ts +30 -3
- package/src/services/aiProposalTools.ts +8 -7
- package/src/services/aiTools.ts +1 -1
- package/src/services/auth.ts +14 -2
- package/src/services/marketing.ts +3 -3
- package/src/services/newtonChat.ts +0 -7
- package/src/services/openaiDocumentGen.ts +190 -66
- package/src/services/school.ts +231 -0
- package/src/services/userPrivacy.ts +196 -0
- package/src/services/worksheet.ts +20 -1
- package/src/utils/email.ts +5 -1
- package/src/workers/processors/aiNewtonChat.ts +0 -1
- package/tests/helpers/registerInput.ts +20 -0
- package/tests/integration/globalSetup.ts +10 -18
- package/tests/integration/routers/auth.test.ts +13 -18
- package/tests/integration/routers/newtonChat.test.ts +9 -8
- package/tests/unit/lib/chatAttachmentPrompt.test.ts +135 -0
- package/tests/unit/lib/inferDocumentFormat.test.ts +10 -0
- package/tests/unit/pipelines/labChatPrompt.test.ts +6 -6
- package/tests/unit/services/aiChatStream.test.ts +92 -0
- package/tests/unit/services/openaiDocumentGen.test.ts +65 -0
|
@@ -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
|
|
166
|
-
|
|
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
|
|
170
|
+
const aiMarkschemeState = (apiResponse as {
|
|
170
171
|
markschemeState: { id: string; correct: boolean }[];
|
|
171
|
-
}).markschemeState
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
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
|
-
"
|
|
59
|
-
"-
|
|
60
|
-
"-
|
|
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
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
};
|
package/src/routers/auth.ts
CHANGED
|
@@ -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
|
|
package/src/routers/school.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
getAdminOverview,
|
|
15
15
|
getCurrentSchool,
|
|
16
16
|
getInvite,
|
|
17
|
+
getMemberTimeline,
|
|
17
18
|
getSchoolAssetUploadUrl,
|
|
18
19
|
getStudentTimeline,
|
|
19
20
|
listClasses,
|
|
@@ -126,6 +127,17 @@ export const schoolRouter = createTRPCRouter({
|
|
|
126
127
|
getStudentTimeline(ctx.user!.id, input)
|
|
127
128
|
),
|
|
128
129
|
|
|
130
|
+
getMemberTimeline: protectedProcedure
|
|
131
|
+
.input(
|
|
132
|
+
z.object({
|
|
133
|
+
schoolId: z.string(),
|
|
134
|
+
memberId: z.string(),
|
|
135
|
+
from: z.date().optional(),
|
|
136
|
+
to: z.date().optional(),
|
|
137
|
+
})
|
|
138
|
+
)
|
|
139
|
+
.query(({ ctx, input }) => getMemberTimeline(ctx.user!.id, input)),
|
|
140
|
+
|
|
129
141
|
getAdminOverview: protectedProcedure
|
|
130
142
|
.input(schoolIdSchema)
|
|
131
143
|
.query(({ ctx, input }) => getAdminOverview(ctx.user!.id, input.schoolId)),
|
package/src/routers/user.ts
CHANGED
|
@@ -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
|
});
|
package/src/routers/worksheet.ts
CHANGED
|
@@ -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
|
|
275
|
-
|
|
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.
|
|
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
|
|
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
|
}),
|
package/src/services/aiTools.ts
CHANGED
|
@@ -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: '
|
|
254
|
+
tools: [{ type: 'web_search' }],
|
|
255
255
|
input: query,
|
|
256
256
|
});
|
|
257
257
|
|
package/src/services/auth.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
/**
|
|
45
|
+
/** Public application status lookup (no contact PII). */
|
|
46
46
|
export async function searchSchoolDevelopmentPrograms(id: string) {
|
|
47
|
-
return
|
|
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: {
|