@studious-lms/server 1.14.0 → 1.15.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/inferDocumentFormat.d.ts +10 -0
- package/dist/lib/inferDocumentFormat.d.ts.map +1 -0
- package/dist/lib/inferDocumentFormat.js +48 -0
- package/dist/lib/inferDocumentFormat.js.map +1 -0
- 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 +47 -45
- package/dist/models/announcement.d.ts.map +1 -1
- package/dist/models/assignment.d.ts +75 -75
- 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/class.d.ts +13 -13
- package/dist/models/comment.d.ts +6 -6
- package/dist/models/conversation.d.ts +23 -23
- package/dist/models/event.d.ts +20 -18
- package/dist/models/event.d.ts.map +1 -1
- package/dist/models/file.d.ts +33 -9
- package/dist/models/file.d.ts.map +1 -1
- package/dist/models/labChat.d.ts +4 -4
- 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/models/message.d.ts +13 -13
- package/dist/models/newtonChat.d.ts +3 -3
- package/dist/models/notification.d.ts +4 -4
- package/dist/models/worksheet.d.ts +121 -121
- package/dist/pipelines/aiLabChat.d.ts +7 -1
- package/dist/pipelines/aiLabChat.d.ts.map +1 -1
- package/dist/pipelines/aiLabChat.js +153 -67
- package/dist/pipelines/aiLabChat.js.map +1 -1
- package/dist/pipelines/aiLabChatContract.d.ts +34 -195
- package/dist/pipelines/aiLabChatContract.d.ts.map +1 -1
- package/dist/pipelines/aiLabChatContract.js +9 -19
- package/dist/pipelines/aiLabChatContract.js.map +1 -1
- package/dist/pipelines/gradeWorksheet.d.ts +6 -6
- package/dist/pipelines/labChatPrompt.d.ts +8 -3
- package/dist/pipelines/labChatPrompt.d.ts.map +1 -1
- package/dist/pipelines/labChatPrompt.js +52 -14
- package/dist/pipelines/labChatPrompt.js.map +1 -1
- package/dist/pipelines/pdfToWorksheet.d.ts +1 -1
- package/dist/routers/_app.d.ts +750 -546
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/announcement.d.ts +40 -40
- package/dist/routers/assignment.d.ts +131 -121
- 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 +12 -8
- package/dist/routers/class.d.ts.map +1 -1
- package/dist/routers/comment.d.ts +5 -5
- package/dist/routers/conversation.d.ts +6 -6
- package/dist/routers/discussion.d.ts +3 -3
- package/dist/routers/embedding.d.ts +6 -2
- package/dist/routers/embedding.d.ts.map +1 -1
- package/dist/routers/embedding.js +44 -36
- package/dist/routers/embedding.js.map +1 -1
- package/dist/routers/event.d.ts +20 -18
- package/dist/routers/event.d.ts.map +1 -1
- package/dist/routers/file.d.ts +2 -2
- package/dist/routers/labChat.d.ts +3 -3
- package/dist/routers/marketing.d.ts +0 -13
- package/dist/routers/marketing.d.ts.map +1 -1
- package/dist/routers/notifications.d.ts +4 -4
- package/dist/routers/school.d.ts +4 -0
- package/dist/routers/school.d.ts.map +1 -1
- package/dist/routers/search.d.ts +2 -2
- 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 +44 -44
- package/dist/routes/aiStream.d.ts.map +1 -1
- package/dist/routes/aiStream.js +41 -11
- package/dist/routes/aiStream.js.map +1 -1
- package/dist/seedDatabase.d.ts +3 -1
- package/dist/seedDatabase.d.ts.map +1 -1
- package/dist/services/aiChatStream.d.ts +20 -0
- package/dist/services/aiChatStream.d.ts.map +1 -1
- package/dist/services/aiChatStream.js +110 -13
- package/dist/services/aiChatStream.js.map +1 -1
- package/dist/services/aiModifierTools.d.ts +0 -16
- package/dist/services/aiModifierTools.d.ts.map +1 -1
- package/dist/services/aiModifierTools.js +112 -3
- package/dist/services/aiModifierTools.js.map +1 -1
- package/dist/services/aiProposalTools.d.ts.map +1 -1
- package/dist/services/aiProposalTools.js +25 -25
- 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/announcement.d.ts +34 -34
- package/dist/services/assignment.d.ts +121 -111
- package/dist/services/assignment.d.ts.map +1 -1
- package/dist/services/assignment.js +4 -2
- package/dist/services/assignment.js.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 +10 -6
- package/dist/services/class.d.ts.map +1 -1
- package/dist/services/class.js +7 -3
- package/dist/services/class.js.map +1 -1
- package/dist/services/comment.d.ts +5 -5
- package/dist/services/conversation.d.ts +6 -6
- package/dist/services/discussion.d.ts.map +1 -1
- package/dist/services/discussion.js +9 -2
- package/dist/services/discussion.js.map +1 -1
- package/dist/services/embedding.d.ts +19 -1
- package/dist/services/embedding.d.ts.map +1 -1
- package/dist/services/embedding.js +154 -8
- package/dist/services/embedding.js.map +1 -1
- package/dist/services/event.d.ts +20 -18
- package/dist/services/event.d.ts.map +1 -1
- package/dist/services/file.d.ts +4 -4
- package/dist/services/folder.d.ts.map +1 -1
- package/dist/services/folder.js +32 -2
- package/dist/services/folder.js.map +1 -1
- package/dist/services/labChat.d.ts +3 -3
- 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/notification.d.ts +4 -4
- package/dist/services/openaiDocumentGen.d.ts +37 -0
- package/dist/services/openaiDocumentGen.d.ts.map +1 -0
- package/dist/services/openaiDocumentGen.js +262 -0
- package/dist/services/openaiDocumentGen.js.map +1 -0
- package/dist/services/school.d.ts +4 -0
- package/dist/services/school.d.ts.map +1 -1
- package/dist/services/search.d.ts +1 -1
- package/dist/services/search.d.ts.map +1 -1
- package/dist/services/search.js +27 -2
- package/dist/services/search.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 +66 -49
- package/dist/services/worksheet.d.ts.map +1 -1
- package/dist/services/worksheet.js +48 -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/embedContent.d.ts.map +1 -1
- package/dist/workers/processors/embedContent.js +16 -25
- package/dist/workers/processors/embedContent.js.map +1 -1
- package/package.json +1 -1
- package/prisma/migrations/20260610140000_expand_embedding_source_types/migration.sql +4 -0
- package/prisma/migrations/20260611120000_add_user_terms_acceptance/migration.sql +3 -0
- package/prisma/schema.prisma +6 -0
- package/src/instrument.ts +1 -1
- package/src/lib/inferDocumentFormat.ts +58 -0
- 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 +198 -74
- package/src/pipelines/aiLabChatContract.ts +7 -17
- package/src/pipelines/labChatPrompt.ts +64 -14
- package/src/routers/auth.ts +13 -0
- package/src/routers/embedding.ts +44 -39
- package/src/routers/user.ts +26 -0
- package/src/routes/aiStream.ts +47 -9
- package/src/services/aiChatStream.ts +133 -11
- package/src/services/aiModifierTools.ts +154 -1
- package/src/services/aiProposalTools.ts +27 -25
- package/src/services/aiTools.ts +1 -1
- package/src/services/assignment.ts +3 -0
- package/src/services/auth.ts +14 -2
- package/src/services/class.ts +7 -1
- package/src/services/discussion.ts +13 -0
- package/src/services/embedding.ts +167 -6
- package/src/services/folder.ts +31 -0
- package/src/services/marketing.ts +3 -3
- package/src/services/openaiDocumentGen.ts +319 -0
- package/src/services/search.ts +25 -1
- package/src/services/userPrivacy.ts +196 -0
- package/src/services/worksheet.ts +68 -1
- package/src/utils/email.ts +5 -1
- package/src/workers/processors/embedContent.ts +18 -22
- 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/unit/lib/inferDocumentFormat.test.ts +33 -0
- package/tests/unit/services/aiChatStream.test.ts +92 -0
- package/tests/unit/services/openaiDocumentGen.test.ts +65 -0
package/src/routers/embedding.ts
CHANGED
|
@@ -4,12 +4,17 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { z } from 'zod';
|
|
6
6
|
import { createTRPCRouter, protectedProcedure } from '../trpc.js';
|
|
7
|
-
import { searchEmbeddings
|
|
7
|
+
import { searchEmbeddings } from '../services/embedding.js';
|
|
8
8
|
import { enqueueRunJob } from '../workers/queues.js';
|
|
9
9
|
import { prisma } from '../lib/prisma.js';
|
|
10
10
|
import { TRPCError } from '@trpc/server';
|
|
11
11
|
import type { EmbeddingSourceType } from '@prisma/client';
|
|
12
12
|
|
|
13
|
+
const sourceTypeEnum = z.enum([
|
|
14
|
+
'FILE', 'ASSIGNMENT', 'WORKSHEET', 'ANNOUNCEMENT',
|
|
15
|
+
'SECTION', 'DISCUSSION', 'SUBMISSION', 'SYLLABUS',
|
|
16
|
+
]);
|
|
17
|
+
|
|
13
18
|
export const embeddingRouter = createTRPCRouter({
|
|
14
19
|
/**
|
|
15
20
|
* Semantic search over class materials.
|
|
@@ -19,10 +24,9 @@ export const embeddingRouter = createTRPCRouter({
|
|
|
19
24
|
classId: z.string(),
|
|
20
25
|
query: z.string().min(1).max(500),
|
|
21
26
|
limit: z.number().min(1).max(20).default(5),
|
|
22
|
-
sourceTypes: z.array(
|
|
27
|
+
sourceTypes: z.array(sourceTypeEnum).optional(),
|
|
23
28
|
}))
|
|
24
29
|
.query(async ({ input, ctx }) => {
|
|
25
|
-
// Verify user has access to the class
|
|
26
30
|
const userInClass = await prisma.class.findFirst({
|
|
27
31
|
where: {
|
|
28
32
|
id: input.classId,
|
|
@@ -54,11 +58,10 @@ export const embeddingRouter = createTRPCRouter({
|
|
|
54
58
|
reindex: protectedProcedure
|
|
55
59
|
.input(z.object({
|
|
56
60
|
classId: z.string(),
|
|
57
|
-
sourceType:
|
|
61
|
+
sourceType: sourceTypeEnum,
|
|
58
62
|
sourceId: z.string(),
|
|
59
63
|
}))
|
|
60
64
|
.mutation(async ({ input, ctx }) => {
|
|
61
|
-
// Verify teacher access
|
|
62
65
|
const teacherInClass = await prisma.class.findFirst({
|
|
63
66
|
where: {
|
|
64
67
|
id: input.classId,
|
|
@@ -102,48 +105,46 @@ export const embeddingRouter = createTRPCRouter({
|
|
|
102
105
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Teacher access required' });
|
|
103
106
|
}
|
|
104
107
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
where: { classId: input.classId },
|
|
108
|
-
select: { id: true },
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
select: { id: true },
|
|
121
|
-
});
|
|
108
|
+
const [assignments, worksheets, announcements, discussions, submissions, classData, classFiles] = await Promise.all([
|
|
109
|
+
prisma.assignment.findMany({ where: { classId: input.classId }, select: { id: true } }),
|
|
110
|
+
prisma.worksheet.findMany({ where: { classId: input.classId }, select: { id: true } }),
|
|
111
|
+
prisma.announcement.findMany({ where: { classId: input.classId }, select: { id: true } }),
|
|
112
|
+
prisma.discussion.findMany({ where: { classId: input.classId }, select: { id: true } }),
|
|
113
|
+
prisma.submission.findMany({
|
|
114
|
+
where: { assignment: { classId: input.classId }, gradeReceived: { not: null } },
|
|
115
|
+
select: { id: true },
|
|
116
|
+
}),
|
|
117
|
+
prisma.class.findUnique({ where: { id: input.classId }, select: { syllabus: true } }),
|
|
118
|
+
prisma.folder.findUnique({
|
|
119
|
+
where: { classId: input.classId },
|
|
120
|
+
select: { files: { select: { id: true }, where: { uploadStatus: 'COMPLETED' } } },
|
|
121
|
+
}),
|
|
122
|
+
]);
|
|
122
123
|
|
|
123
124
|
const jobs: Promise<unknown>[] = [];
|
|
125
|
+
const opts = { classId: input.classId, userId: ctx.user!.id };
|
|
124
126
|
|
|
125
127
|
for (const a of assignments) {
|
|
126
|
-
jobs.push(enqueueRunJob('EMBED_CONTENT', {
|
|
127
|
-
classId: input.classId,
|
|
128
|
-
sourceType: 'ASSIGNMENT',
|
|
129
|
-
sourceId: a.id,
|
|
130
|
-
}, { classId: input.classId, userId: ctx.user!.id }));
|
|
128
|
+
jobs.push(enqueueRunJob('EMBED_CONTENT', { classId: input.classId, sourceType: 'ASSIGNMENT', sourceId: a.id }, opts));
|
|
131
129
|
}
|
|
132
|
-
|
|
133
130
|
for (const w of worksheets) {
|
|
134
|
-
jobs.push(enqueueRunJob('EMBED_CONTENT', {
|
|
135
|
-
classId: input.classId,
|
|
136
|
-
sourceType: 'WORKSHEET',
|
|
137
|
-
sourceId: w.id,
|
|
138
|
-
}, { classId: input.classId, userId: ctx.user!.id }));
|
|
131
|
+
jobs.push(enqueueRunJob('EMBED_CONTENT', { classId: input.classId, sourceType: 'WORKSHEET', sourceId: w.id }, opts));
|
|
139
132
|
}
|
|
140
|
-
|
|
141
133
|
for (const ann of announcements) {
|
|
142
|
-
jobs.push(enqueueRunJob('EMBED_CONTENT', {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
134
|
+
jobs.push(enqueueRunJob('EMBED_CONTENT', { classId: input.classId, sourceType: 'ANNOUNCEMENT', sourceId: ann.id }, opts));
|
|
135
|
+
}
|
|
136
|
+
for (const d of discussions) {
|
|
137
|
+
jobs.push(enqueueRunJob('EMBED_CONTENT', { classId: input.classId, sourceType: 'DISCUSSION', sourceId: d.id }, opts));
|
|
138
|
+
}
|
|
139
|
+
for (const s of submissions) {
|
|
140
|
+
jobs.push(enqueueRunJob('EMBED_CONTENT', { classId: input.classId, sourceType: 'SUBMISSION', sourceId: s.id }, opts));
|
|
141
|
+
}
|
|
142
|
+
if (classData?.syllabus) {
|
|
143
|
+
jobs.push(enqueueRunJob('EMBED_CONTENT', { classId: input.classId, sourceType: 'SYLLABUS', sourceId: input.classId }, opts));
|
|
144
|
+
}
|
|
145
|
+
const files = classFiles?.files ?? [];
|
|
146
|
+
for (const f of files) {
|
|
147
|
+
jobs.push(enqueueRunJob('EMBED_CONTENT', { classId: input.classId, sourceType: 'FILE', sourceId: f.id }, opts));
|
|
147
148
|
}
|
|
148
149
|
|
|
149
150
|
await Promise.allSettled(jobs);
|
|
@@ -153,6 +154,10 @@ export const embeddingRouter = createTRPCRouter({
|
|
|
153
154
|
assignments: assignments.length,
|
|
154
155
|
worksheets: worksheets.length,
|
|
155
156
|
announcements: announcements.length,
|
|
157
|
+
discussions: discussions.length,
|
|
158
|
+
submissions: submissions.length,
|
|
159
|
+
syllabus: classData?.syllabus ? 1 : 0,
|
|
160
|
+
files: files.length,
|
|
156
161
|
},
|
|
157
162
|
};
|
|
158
163
|
}),
|
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/routes/aiStream.ts
CHANGED
|
@@ -20,7 +20,11 @@ import { pusher, chatChannel, teacherChannel } from '../lib/pusher.js';
|
|
|
20
20
|
import { getAIUserId, ensureAIUserExists, isAIUser } from '../utils/aiUser.js';
|
|
21
21
|
import { JobStatus } from '@prisma/client';
|
|
22
22
|
import { updateAssignmentRecord } from '../services/assignment.js';
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
restoreWorksheetQuestions,
|
|
25
|
+
updateWorksheetRecord,
|
|
26
|
+
type WorksheetQuestionSnapshot,
|
|
27
|
+
} from '../services/worksheet.js';
|
|
24
28
|
|
|
25
29
|
export const aiStreamRouter = Router();
|
|
26
30
|
|
|
@@ -105,7 +109,7 @@ aiStreamRouter.post('/chat/stream', async (req: Request, res: Response) => {
|
|
|
105
109
|
return res.status(400).json({ error: 'Unable to determine class context' });
|
|
106
110
|
}
|
|
107
111
|
|
|
108
|
-
// Build message history from DB
|
|
112
|
+
// Build message history from DB (include attachments so the LLM can see them)
|
|
109
113
|
const recentMessages = await prisma.message.findMany({
|
|
110
114
|
where: { conversationId, status: { not: JobStatus.PENDING } },
|
|
111
115
|
orderBy: { createdAt: 'asc' },
|
|
@@ -114,15 +118,23 @@ aiStreamRouter.post('/chat/stream', async (req: Request, res: Response) => {
|
|
|
114
118
|
content: true,
|
|
115
119
|
senderId: true,
|
|
116
120
|
status: true,
|
|
121
|
+
attachments: {
|
|
122
|
+
select: { id: true, name: true, type: true, path: true },
|
|
123
|
+
},
|
|
117
124
|
},
|
|
118
125
|
});
|
|
119
126
|
|
|
120
127
|
const messages: ModelMessage[] = recentMessages
|
|
121
128
|
.filter((m) => m.content && m.content.length > 0)
|
|
122
|
-
.map((m) =>
|
|
123
|
-
role
|
|
124
|
-
|
|
125
|
-
|
|
129
|
+
.map((m) => {
|
|
130
|
+
const role = isAIUser(m.senderId) ? 'assistant' as const : 'user' as const;
|
|
131
|
+
if (role === 'assistant' || m.attachments.length === 0) {
|
|
132
|
+
return { role, content: m.content };
|
|
133
|
+
}
|
|
134
|
+
// Append file descriptions to user messages
|
|
135
|
+
const fileList = m.attachments.map((a) => `${a.name} (${a.type})`).join(', ');
|
|
136
|
+
return { role, content: `${m.content}\n[Attached files: ${fileList}]` };
|
|
137
|
+
});
|
|
126
138
|
|
|
127
139
|
// Build system prompt based on context type
|
|
128
140
|
let systemPrompt: string;
|
|
@@ -370,6 +382,16 @@ aiStreamRouter.post('/modify/stream', async (req: Request, res: Response) => {
|
|
|
370
382
|
resourceContext = buildWorksheetContext(worksheet);
|
|
371
383
|
preEditSnapshot = {
|
|
372
384
|
name: worksheet.name,
|
|
385
|
+
questions: worksheet.questions.map((q) => ({
|
|
386
|
+
id: q.id,
|
|
387
|
+
type: q.type,
|
|
388
|
+
question: q.question,
|
|
389
|
+
answer: q.answer,
|
|
390
|
+
options: q.options,
|
|
391
|
+
markScheme: q.markScheme,
|
|
392
|
+
points: q.points,
|
|
393
|
+
order: q.order,
|
|
394
|
+
})),
|
|
373
395
|
};
|
|
374
396
|
}
|
|
375
397
|
|
|
@@ -461,7 +483,7 @@ aiStreamRouter.post('/modify/stream', async (req: Request, res: Response) => {
|
|
|
461
483
|
tools,
|
|
462
484
|
temperature: 0.3,
|
|
463
485
|
maxOutputTokens: 8192,
|
|
464
|
-
stopWhen: stepCountIs(
|
|
486
|
+
stopWhen: stepCountIs(16),
|
|
465
487
|
});
|
|
466
488
|
|
|
467
489
|
// Drain the stream. Field writes happen inside the tools' execute (which
|
|
@@ -601,7 +623,15 @@ aiStreamRouter.post('/modify/undo', async (req: Request, res: Response) => {
|
|
|
601
623
|
...fields,
|
|
602
624
|
});
|
|
603
625
|
} else {
|
|
604
|
-
|
|
626
|
+
const { questions, ...fields } = parsed.snapshot;
|
|
627
|
+
await updateWorksheetRecord(resourceId, user.id, fields);
|
|
628
|
+
if (Array.isArray(questions)) {
|
|
629
|
+
await restoreWorksheetQuestions(
|
|
630
|
+
resourceId,
|
|
631
|
+
user.id,
|
|
632
|
+
questions as WorksheetQuestionSnapshot[],
|
|
633
|
+
);
|
|
634
|
+
}
|
|
605
635
|
}
|
|
606
636
|
|
|
607
637
|
// Remove the snapshot so undo can only be used once
|
|
@@ -707,14 +737,21 @@ RULES:
|
|
|
707
737
|
|
|
708
738
|
HOW TO EDIT: You change the worksheet by CALLING TOOLS — never by printing the new value as text.
|
|
709
739
|
- Call \`update_worksheet_field\` for the field the teacher wants changed (currently: name).
|
|
740
|
+
- Call \`add_worksheet_question\` once per new question. Question types:
|
|
741
|
+
MULTIPLE_CHOICE|TRUE_FALSE|SHORT_ANSWER|LONG_ANSWER|MATH_EXPRESSION|ESSAY.
|
|
742
|
+
For MULTIPLE_CHOICE provide \`options\` with exactly one \`isCorrect: true\`.
|
|
743
|
+
- Call \`update_worksheet_question\` / \`delete_worksheet_question\` with the question's
|
|
744
|
+
Q_ID (shown in the worksheet context) to edit or remove existing questions.
|
|
710
745
|
- Use \`search_materials\` first only if you need class context.
|
|
711
746
|
|
|
712
747
|
RULES:
|
|
713
748
|
- Only change what the teacher asked about.
|
|
714
749
|
- Each tool call saves immediately and is shown to the teacher live.
|
|
750
|
+
- Write real, pedagogically sound question content with correct answers — never placeholders.
|
|
715
751
|
- After making all the changes, write a brief conversational summary of what you did.
|
|
716
752
|
Be human and specific — mention the actual values. For example:
|
|
717
|
-
"Done — I've renamed the worksheet to 'Unit 3 Practice Problems'
|
|
753
|
+
"Done — I've renamed the worksheet to 'Unit 3 Practice Problems' and added two
|
|
754
|
+
questions on volumes of revolution."
|
|
718
755
|
Do NOT dump the full worksheet contents — just summarize naturally.`;
|
|
719
756
|
}
|
|
720
757
|
|
|
@@ -771,6 +808,7 @@ function buildWorksheetContext(worksheet: any): string {
|
|
|
771
808
|
let context = `WORKSHEET_NAME: ${worksheet.name}\nQUESTIONS_START`;
|
|
772
809
|
|
|
773
810
|
for (const q of worksheet.questions) {
|
|
811
|
+
context += `\nQ_ID: ${q.id}`;
|
|
774
812
|
context += `\nQ_TYPE: ${q.type}`;
|
|
775
813
|
context += `\nQ_TEXT: ${q.question}`;
|
|
776
814
|
context += `\nQ_ANSWER: ${q.answer}`;
|
|
@@ -47,17 +47,123 @@ export type StreamPart =
|
|
|
47
47
|
toolName: string;
|
|
48
48
|
/** Concrete entity label (e.g. assignment title / search query), if any. */
|
|
49
49
|
title?: string;
|
|
50
|
+
/** Requested output type from the tool args (pdf, pptx, xlsx, …). */
|
|
51
|
+
format?: string;
|
|
52
|
+
/** Set after generated files are persisted on the message. */
|
|
53
|
+
fileId?: string;
|
|
54
|
+
fileName?: string;
|
|
50
55
|
status: 'executing' | 'completed' | 'failed';
|
|
51
56
|
};
|
|
52
57
|
|
|
58
|
+
export interface GeneratedDocFileLink {
|
|
59
|
+
title: string;
|
|
60
|
+
fileId: string;
|
|
61
|
+
fileName: string;
|
|
62
|
+
format?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Apply pipeline-resolved formats onto generate_document tool parts. */
|
|
66
|
+
export function applyResolvedFormatsToStreamParts(
|
|
67
|
+
parts: StreamPart[],
|
|
68
|
+
docs: Array<{ title: string; format: string }>,
|
|
69
|
+
): StreamPart[] {
|
|
70
|
+
if (docs.length === 0) return parts;
|
|
71
|
+
|
|
72
|
+
const pending = [...docs];
|
|
73
|
+
const takeDoc = (title?: string) => {
|
|
74
|
+
if (title) {
|
|
75
|
+
const idx = pending.findIndex(
|
|
76
|
+
(d) => d.title.trim().toLowerCase() === title.trim().toLowerCase(),
|
|
77
|
+
);
|
|
78
|
+
if (idx >= 0) return pending.splice(idx, 1)[0];
|
|
79
|
+
}
|
|
80
|
+
return pending.shift();
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
return parts.map((part) => {
|
|
84
|
+
if (part.type !== 'tool' || part.toolName !== 'generate_document') return part;
|
|
85
|
+
const doc = takeDoc(part.title);
|
|
86
|
+
return doc ? { ...part, format: doc.format } : part;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
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
|
+
|
|
97
|
+
/** Attach file IDs to `generate_document` tool parts for inline download chips. */
|
|
98
|
+
export function enrichStreamPartsWithGeneratedFiles(
|
|
99
|
+
parts: StreamPart[],
|
|
100
|
+
files: GeneratedDocFileLink[],
|
|
101
|
+
): StreamPart[] {
|
|
102
|
+
if (files.length === 0) return parts;
|
|
103
|
+
|
|
104
|
+
const docToolIndices = parts
|
|
105
|
+
.map((part, index) => ({ part, index }))
|
|
106
|
+
.filter(
|
|
107
|
+
({ part }) =>
|
|
108
|
+
part.type === 'tool' && part.toolName === 'generate_document',
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const unmatched = [...files];
|
|
112
|
+
const fileForTool = (toolTitle?: string): GeneratedDocFileLink | undefined => {
|
|
113
|
+
if (toolTitle) {
|
|
114
|
+
const idx = unmatched.findIndex(
|
|
115
|
+
(f) => f.title.trim().toLowerCase() === toolTitle.trim().toLowerCase(),
|
|
116
|
+
);
|
|
117
|
+
if (idx >= 0) return unmatched.splice(idx, 1)[0];
|
|
118
|
+
}
|
|
119
|
+
return unmatched.shift();
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const fileByIndex = new Map<number, GeneratedDocFileLink>();
|
|
123
|
+
for (const { part, index } of docToolIndices) {
|
|
124
|
+
if (part.type !== 'tool') continue;
|
|
125
|
+
const file = fileForTool(part.title);
|
|
126
|
+
if (file) fileByIndex.set(index, file);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return parts.map((part, index) => {
|
|
130
|
+
const file = fileByIndex.get(index);
|
|
131
|
+
if (!file || part.type !== 'tool') return part;
|
|
132
|
+
return {
|
|
133
|
+
...part,
|
|
134
|
+
fileId: file.fileId,
|
|
135
|
+
fileName: file.fileName,
|
|
136
|
+
status: 'completed',
|
|
137
|
+
...(file.format ? { format: file.format } : {}),
|
|
138
|
+
};
|
|
139
|
+
});
|
|
140
|
+
}
|
|
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
|
+
|
|
154
|
+
function toolFieldFromInput(input: unknown, key: string): string | undefined {
|
|
155
|
+
if (input && typeof input === 'object') {
|
|
156
|
+
const val = (input as Record<string, unknown>)[key];
|
|
157
|
+
if (typeof val === 'string' && val.trim()) return val.trim();
|
|
158
|
+
}
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
|
|
53
162
|
/** Pull a human-meaningful label out of a tool's input args, if present. */
|
|
54
163
|
function toolTitleFromInput(input: unknown): string | undefined {
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
const val = obj[key];
|
|
59
|
-
if (typeof val === 'string' && val.trim()) return val.trim();
|
|
60
|
-
}
|
|
164
|
+
for (const key of ['title', 'name', 'query'] as const) {
|
|
165
|
+
const val = toolFieldFromInput(input, key);
|
|
166
|
+
if (val) return val;
|
|
61
167
|
}
|
|
62
168
|
return undefined;
|
|
63
169
|
}
|
|
@@ -163,12 +269,14 @@ export async function streamChatResponse(
|
|
|
163
269
|
}
|
|
164
270
|
case 'tool-call': {
|
|
165
271
|
const title = toolTitleFromInput(part.input);
|
|
272
|
+
const format = toolFieldFromInput(part.input, 'format');
|
|
166
273
|
toolPartIndexById.set(part.toolCallId, parts.length);
|
|
167
274
|
parts.push({
|
|
168
275
|
type: 'tool',
|
|
169
276
|
toolCallId: part.toolCallId,
|
|
170
277
|
toolName: part.toolName,
|
|
171
278
|
...(title ? { title } : {}),
|
|
279
|
+
...(format ? { format } : {}),
|
|
172
280
|
status: 'executing',
|
|
173
281
|
});
|
|
174
282
|
await broadcast('message-tool-call', {
|
|
@@ -176,30 +284,44 @@ export async function streamChatResponse(
|
|
|
176
284
|
toolCallId: part.toolCallId,
|
|
177
285
|
toolName: part.toolName,
|
|
178
286
|
...(title ? { title } : {}),
|
|
287
|
+
...(format ? { format } : {}),
|
|
179
288
|
status: 'executing',
|
|
180
289
|
});
|
|
181
290
|
break;
|
|
182
291
|
}
|
|
183
292
|
case 'tool-result':
|
|
184
293
|
case 'tool-error': {
|
|
185
|
-
const
|
|
186
|
-
|
|
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';
|
|
303
|
+
const title = toolTitleFromInput(part.input);
|
|
304
|
+
const format = toolFieldFromInput(part.input, 'format');
|
|
187
305
|
const idx = toolPartIndexById.get(part.toolCallId);
|
|
188
306
|
if (idx !== undefined) {
|
|
189
307
|
const existing = parts[idx];
|
|
190
|
-
if (existing && existing.type === 'tool')
|
|
308
|
+
if (existing && existing.type === 'tool') {
|
|
309
|
+
existing.status = status;
|
|
310
|
+
if (format) existing.format = format;
|
|
311
|
+
}
|
|
191
312
|
}
|
|
192
313
|
toolCalls.push({
|
|
193
314
|
toolName: part.toolName,
|
|
194
315
|
input: part.input,
|
|
195
316
|
output: part.type === 'tool-result' ? part.output : undefined,
|
|
196
|
-
status,
|
|
317
|
+
status: failed ? 'failed' : 'completed',
|
|
197
318
|
});
|
|
198
319
|
await broadcast('message-tool-call', {
|
|
199
320
|
messageId: aiMessageId,
|
|
200
321
|
toolCallId: part.toolCallId,
|
|
201
322
|
toolName: part.toolName,
|
|
202
|
-
...(
|
|
323
|
+
...(title ? { title } : {}),
|
|
324
|
+
...(format ? { format } : {}),
|
|
203
325
|
status,
|
|
204
326
|
});
|
|
205
327
|
break;
|
|
@@ -14,9 +14,17 @@
|
|
|
14
14
|
* `jsonSchema()` rather than the zod `tool()` helper to avoid the TS2589
|
|
15
15
|
* "excessively deep" compiler blow-up on nested schemas.
|
|
16
16
|
*/
|
|
17
|
+
import { randomUUID } from 'node:crypto';
|
|
17
18
|
import { jsonSchema, type ToolSet } from 'ai';
|
|
19
|
+
import type { JSONSchema7Definition } from 'json-schema';
|
|
20
|
+
import type { WorksheetQuestionType } from '@prisma/client';
|
|
18
21
|
import { updateAssignmentRecord } from './assignment.js';
|
|
19
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
addQuestionToWorksheet,
|
|
24
|
+
deleteWorksheetQuestionRecord,
|
|
25
|
+
updateWorksheetQuestionRecord,
|
|
26
|
+
updateWorksheetRecord,
|
|
27
|
+
} from './worksheet.js';
|
|
20
28
|
|
|
21
29
|
/** A single field write that was applied to a resource. */
|
|
22
30
|
export interface ModifierFieldUpdate {
|
|
@@ -56,6 +64,52 @@ const WORKSHEET_FIELDS: Record<string, { label: string; type: 'string' }> = {
|
|
|
56
64
|
name: { label: 'name', type: 'string' },
|
|
57
65
|
};
|
|
58
66
|
|
|
67
|
+
const WORKSHEET_QUESTION_TYPES = [
|
|
68
|
+
'MULTIPLE_CHOICE', 'TRUE_FALSE', 'SHORT_ANSWER', 'LONG_ANSWER', 'MATH_EXPRESSION', 'ESSAY',
|
|
69
|
+
] as const;
|
|
70
|
+
|
|
71
|
+
interface QuestionOptionInput {
|
|
72
|
+
text: string;
|
|
73
|
+
isCorrect?: boolean;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Attach stable ids to model-supplied MCQ options (matches the editor's shape). */
|
|
77
|
+
function buildOptions(options: QuestionOptionInput[] | undefined) {
|
|
78
|
+
if (!options || options.length === 0) return undefined;
|
|
79
|
+
return options.map((o) => ({
|
|
80
|
+
id: randomUUID(),
|
|
81
|
+
text: o.text,
|
|
82
|
+
isCorrect: Boolean(o.isCorrect),
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const QUESTION_PROPERTIES: Record<string, JSONSchema7Definition> = {
|
|
87
|
+
question: { type: 'string', description: 'The question text shown to students.' },
|
|
88
|
+
answer: {
|
|
89
|
+
type: 'string',
|
|
90
|
+
description:
|
|
91
|
+
'The correct answer. For TRUE_FALSE use "true"/"false". For MULTIPLE_CHOICE leave it empty and mark the correct option instead.',
|
|
92
|
+
},
|
|
93
|
+
type: {
|
|
94
|
+
type: 'string',
|
|
95
|
+
enum: [...WORKSHEET_QUESTION_TYPES],
|
|
96
|
+
description: 'The question type.',
|
|
97
|
+
},
|
|
98
|
+
points: { type: 'number', description: 'Points the question is worth.' },
|
|
99
|
+
options: {
|
|
100
|
+
type: 'array',
|
|
101
|
+
description: 'Answer choices, only for MULTIPLE_CHOICE questions. Mark exactly one as correct.',
|
|
102
|
+
items: {
|
|
103
|
+
type: 'object',
|
|
104
|
+
properties: {
|
|
105
|
+
text: { type: 'string', description: 'The option text.' },
|
|
106
|
+
isCorrect: { type: 'boolean', description: 'Whether this option is the correct answer.' },
|
|
107
|
+
},
|
|
108
|
+
required: ['text'],
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
|
|
59
113
|
/** Coerce a model-supplied string value into the type a field expects. */
|
|
60
114
|
function coerceValue(
|
|
61
115
|
raw: string,
|
|
@@ -184,5 +238,104 @@ export function worksheetModifierTools(
|
|
|
184
238
|
return `Updated ${spec.label}.`;
|
|
185
239
|
},
|
|
186
240
|
},
|
|
241
|
+
add_worksheet_question: {
|
|
242
|
+
description:
|
|
243
|
+
'Add a new question to the worksheet and save it immediately. ' +
|
|
244
|
+
'Call this once per question to add.',
|
|
245
|
+
inputSchema: jsonSchema({
|
|
246
|
+
type: 'object',
|
|
247
|
+
properties: QUESTION_PROPERTIES,
|
|
248
|
+
required: ['question', 'answer', 'type'],
|
|
249
|
+
}),
|
|
250
|
+
execute: async ({
|
|
251
|
+
question,
|
|
252
|
+
answer,
|
|
253
|
+
type,
|
|
254
|
+
points,
|
|
255
|
+
options,
|
|
256
|
+
}: {
|
|
257
|
+
question: string;
|
|
258
|
+
answer: string;
|
|
259
|
+
type: WorksheetQuestionType;
|
|
260
|
+
points?: number;
|
|
261
|
+
options?: QuestionOptionInput[];
|
|
262
|
+
}) => {
|
|
263
|
+
const created = await addQuestionToWorksheet(worksheetId, userId, {
|
|
264
|
+
question,
|
|
265
|
+
answer,
|
|
266
|
+
type,
|
|
267
|
+
points,
|
|
268
|
+
options: buildOptions(options),
|
|
269
|
+
});
|
|
270
|
+
await onFieldUpdate({
|
|
271
|
+
field: 'questions',
|
|
272
|
+
value: { action: 'added', questionId: created.id },
|
|
273
|
+
label: `question "${question.slice(0, 60)}"`,
|
|
274
|
+
});
|
|
275
|
+
return `Added question (id ${created.id}).`;
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
update_worksheet_question: {
|
|
279
|
+
description:
|
|
280
|
+
'Update an existing worksheet question by its Q_ID and save it immediately. ' +
|
|
281
|
+
'Only pass the fields that should change.',
|
|
282
|
+
inputSchema: jsonSchema({
|
|
283
|
+
type: 'object',
|
|
284
|
+
properties: {
|
|
285
|
+
questionId: { type: 'string', description: 'The Q_ID of the question to update.' },
|
|
286
|
+
...QUESTION_PROPERTIES,
|
|
287
|
+
},
|
|
288
|
+
required: ['questionId'],
|
|
289
|
+
}),
|
|
290
|
+
execute: async ({
|
|
291
|
+
questionId,
|
|
292
|
+
question,
|
|
293
|
+
answer,
|
|
294
|
+
type,
|
|
295
|
+
points,
|
|
296
|
+
options,
|
|
297
|
+
}: {
|
|
298
|
+
questionId: string;
|
|
299
|
+
question?: string;
|
|
300
|
+
answer?: string;
|
|
301
|
+
type?: WorksheetQuestionType;
|
|
302
|
+
points?: number;
|
|
303
|
+
options?: QuestionOptionInput[];
|
|
304
|
+
}) => {
|
|
305
|
+
await updateWorksheetQuestionRecord(worksheetId, questionId, userId, {
|
|
306
|
+
...(question !== undefined && { question }),
|
|
307
|
+
...(answer !== undefined && { answer }),
|
|
308
|
+
...(type !== undefined && { type }),
|
|
309
|
+
...(points !== undefined && { points }),
|
|
310
|
+
...(options !== undefined && { options: buildOptions(options) }),
|
|
311
|
+
});
|
|
312
|
+
await onFieldUpdate({
|
|
313
|
+
field: 'questions',
|
|
314
|
+
value: { action: 'updated', questionId },
|
|
315
|
+
label: 'a question',
|
|
316
|
+
});
|
|
317
|
+
return `Updated question ${questionId}.`;
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
delete_worksheet_question: {
|
|
321
|
+
description:
|
|
322
|
+
'Delete a worksheet question by its Q_ID. This is permanent.',
|
|
323
|
+
inputSchema: jsonSchema({
|
|
324
|
+
type: 'object',
|
|
325
|
+
properties: {
|
|
326
|
+
questionId: { type: 'string', description: 'The Q_ID of the question to delete.' },
|
|
327
|
+
},
|
|
328
|
+
required: ['questionId'],
|
|
329
|
+
}),
|
|
330
|
+
execute: async ({ questionId }: { questionId: string }) => {
|
|
331
|
+
await deleteWorksheetQuestionRecord(worksheetId, questionId, userId);
|
|
332
|
+
await onFieldUpdate({
|
|
333
|
+
field: 'questions',
|
|
334
|
+
value: { action: 'deleted', questionId },
|
|
335
|
+
label: 'a question',
|
|
336
|
+
});
|
|
337
|
+
return `Deleted question ${questionId}.`;
|
|
338
|
+
},
|
|
339
|
+
},
|
|
187
340
|
};
|
|
188
341
|
}
|