@studious-lms/server 1.25.0 → 1.27.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/.env.example +3 -1
- package/.github/workflows/npm-publish.yml +1 -0
- package/AGENTS.md +84 -0
- package/dist/models/cloudStorage.d.ts +11 -0
- package/dist/models/cloudStorage.d.ts.map +1 -1
- package/dist/models/cloudStorage.js +18 -2
- package/dist/models/cloudStorage.js.map +1 -1
- package/dist/models/conversation.d.ts +1 -0
- package/dist/models/conversation.d.ts.map +1 -1
- package/dist/models/labChat.d.ts +40 -0
- package/dist/models/labChat.d.ts.map +1 -1
- package/dist/models/labChat.js +32 -2
- package/dist/models/labChat.js.map +1 -1
- package/dist/models/marketplace.d.ts +35 -0
- package/dist/models/marketplace.d.ts.map +1 -1
- package/dist/models/marketplace.js +9 -2
- package/dist/models/marketplace.js.map +1 -1
- package/dist/models/message.d.ts +3 -0
- package/dist/models/message.d.ts.map +1 -1
- package/dist/models/module.d.ts +228 -0
- package/dist/models/module.d.ts.map +1 -0
- package/dist/models/module.js +69 -0
- package/dist/models/module.js.map +1 -0
- package/dist/pipelines/aiLabChat.d.ts +2 -0
- package/dist/pipelines/aiLabChat.d.ts.map +1 -1
- package/dist/pipelines/aiLabChat.js +60 -16
- package/dist/pipelines/aiLabChat.js.map +1 -1
- package/dist/pipelines/aiLabChatContract.d.ts +170 -2
- package/dist/pipelines/aiLabChatContract.d.ts.map +1 -1
- package/dist/pipelines/aiLabChatContract.js +30 -2
- package/dist/pipelines/aiLabChatContract.js.map +1 -1
- package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
- package/dist/pipelines/aiNewtonChat.js +30 -5
- package/dist/pipelines/aiNewtonChat.js.map +1 -1
- package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
- package/dist/pipelines/gradeWorksheet.js +4 -2
- package/dist/pipelines/gradeWorksheet.js.map +1 -1
- package/dist/pipelines/labChatPrompt.d.ts +1 -1
- package/dist/pipelines/labChatPrompt.d.ts.map +1 -1
- package/dist/pipelines/labChatPrompt.js +13 -14
- package/dist/pipelines/labChatPrompt.js.map +1 -1
- package/dist/routers/_app.d.ts +1185 -215
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/_app.js +4 -2
- package/dist/routers/_app.js.map +1 -1
- package/dist/routers/admin.d.ts +0 -1
- package/dist/routers/admin.d.ts.map +1 -1
- package/dist/routers/conversation.d.ts +1 -0
- package/dist/routers/conversation.d.ts.map +1 -1
- package/dist/routers/labChat.d.ts +10 -0
- package/dist/routers/labChat.d.ts.map +1 -1
- package/dist/routers/labChat.js +6 -3
- package/dist/routers/labChat.js.map +1 -1
- package/dist/routers/marketplace.d.ts +41 -0
- package/dist/routers/marketplace.d.ts.map +1 -1
- package/dist/routers/marketplace.js +13 -3
- package/dist/routers/marketplace.js.map +1 -1
- package/dist/routers/message.d.ts +37 -4
- package/dist/routers/message.d.ts.map +1 -1
- package/dist/routers/message.js +24 -7
- package/dist/routers/message.js.map +1 -1
- package/dist/routers/module.d.ts +345 -0
- package/dist/routers/module.d.ts.map +1 -0
- package/dist/routers/module.js +82 -0
- package/dist/routers/module.js.map +1 -0
- package/dist/routers/parent.d.ts +18 -0
- package/dist/routers/parent.d.ts.map +1 -1
- package/dist/routers/parent.js +10 -3
- package/dist/routers/parent.js.map +1 -1
- package/dist/routers/studentProgress.d.ts +44 -0
- package/dist/routers/studentProgress.d.ts.map +1 -1
- package/dist/routers/studentProgress.js +12 -3
- package/dist/routers/studentProgress.js.map +1 -1
- package/dist/routes/aiStream.js +32 -2
- package/dist/routes/aiStream.js.map +1 -1
- package/dist/seedDatabase.d.ts.map +1 -1
- package/dist/seedDatabase.js +107 -3
- package/dist/seedDatabase.js.map +1 -1
- package/dist/services/adminUser.d.ts +0 -1
- package/dist/services/adminUser.d.ts.map +1 -1
- package/dist/services/adminUser.js +7 -4
- package/dist/services/adminUser.js.map +1 -1
- package/dist/services/aiChatStream.d.ts +15 -0
- package/dist/services/aiChatStream.d.ts.map +1 -1
- package/dist/services/aiChatStream.js +144 -74
- package/dist/services/aiChatStream.js.map +1 -1
- package/dist/services/aiGeneration.d.ts +4 -0
- package/dist/services/aiGeneration.d.ts.map +1 -0
- package/dist/services/aiGeneration.js +88 -0
- package/dist/services/aiGeneration.js.map +1 -0
- package/dist/services/aiProposalTools.d.ts +4 -1
- package/dist/services/aiProposalTools.d.ts.map +1 -1
- package/dist/services/aiProposalTools.js +128 -3
- package/dist/services/aiProposalTools.js.map +1 -1
- package/dist/services/aiProposals.d.ts +70 -0
- package/dist/services/aiProposals.d.ts.map +1 -0
- package/dist/services/aiProposals.js +363 -0
- package/dist/services/aiProposals.js.map +1 -0
- package/dist/services/assignment.d.ts.map +1 -1
- package/dist/services/assignment.js +8 -3
- package/dist/services/assignment.js.map +1 -1
- package/dist/services/conversation.d.ts +1 -0
- package/dist/services/conversation.d.ts.map +1 -1
- package/dist/services/labChat.d.ts +10 -0
- package/dist/services/labChat.d.ts.map +1 -1
- package/dist/services/labChat.js +52 -3
- package/dist/services/labChat.js.map +1 -1
- package/dist/services/marketplace.d.ts +36 -0
- package/dist/services/marketplace.d.ts.map +1 -1
- package/dist/services/marketplace.js +30 -3
- package/dist/services/marketplace.js.map +1 -1
- package/dist/services/message.d.ts +24 -8
- package/dist/services/message.d.ts.map +1 -1
- package/dist/services/message.js +170 -117
- package/dist/services/message.js.map +1 -1
- package/dist/services/module.d.ts +288 -0
- package/dist/services/module.d.ts.map +1 -0
- package/dist/services/module.js +518 -0
- package/dist/services/module.js.map +1 -0
- package/dist/services/parentPortal.d.ts +14 -0
- package/dist/services/parentPortal.d.ts.map +1 -1
- package/dist/services/parentPortal.js +72 -2
- package/dist/services/parentPortal.js.map +1 -1
- package/dist/services/school.d.ts.map +1 -1
- package/dist/services/school.js +17 -5
- package/dist/services/school.js.map +1 -1
- package/dist/services/studentProgress.d.ts +50 -0
- package/dist/services/studentProgress.d.ts.map +1 -1
- package/dist/services/studentProgress.js +107 -2
- package/dist/services/studentProgress.js.map +1 -1
- package/dist/utils/inference.d.ts +10 -0
- package/dist/utils/inference.d.ts.map +1 -1
- package/dist/utils/inference.js +77 -2
- package/dist/utils/inference.js.map +1 -1
- package/dist/workers/processors/createRunJobWorker.js +17 -16
- package/dist/workers/processors/createRunJobWorker.js.map +1 -1
- package/package.json +1 -1
- package/prisma/migrations/20260708013000_add_modules/migration.sql +100 -0
- package/prisma/migrations/20260811130000_add_ai_message_feedback/migration.sql +7 -0
- package/prisma/schema.prisma +101 -0
- package/src/models/cloudStorage.ts +19 -0
- package/src/models/labChat.ts +34 -0
- package/src/models/marketplace.ts +16 -0
- package/src/models/module.ts +77 -0
- package/src/pipelines/aiLabChat.ts +79 -18
- package/src/pipelines/aiLabChatContract.ts +35 -0
- package/src/pipelines/aiNewtonChat.ts +34 -2
- package/src/pipelines/gradeWorksheet.ts +6 -0
- package/src/pipelines/labChatPrompt.ts +11 -12
- package/src/routers/_app.ts +2 -0
- package/src/routers/labChat.ts +5 -0
- package/src/routers/marketplace.ts +16 -0
- package/src/routers/message.ts +34 -5
- package/src/routers/module.ts +118 -0
- package/src/routers/parent.ts +11 -0
- package/src/routers/studentProgress.ts +15 -0
- package/src/routes/aiStream.ts +32 -0
- package/src/seedDatabase.ts +107 -2
- package/src/services/adminUser.ts +5 -2
- package/src/services/aiChatStream.ts +83 -7
- package/src/services/aiGeneration.ts +92 -0
- package/src/services/aiProposalTools.ts +166 -1
- package/src/services/aiProposals.ts +428 -0
- package/src/services/assignment.ts +12 -1
- package/src/services/labChat.ts +65 -0
- package/src/services/marketplace.ts +39 -0
- package/src/services/message.ts +205 -142
- package/src/services/module.ts +683 -0
- package/src/services/parentPortal.ts +85 -0
- package/src/services/school.ts +15 -3
- package/src/services/studentProgress.ts +135 -0
- package/src/utils/inference.ts +85 -0
- package/src/workers/processors/createRunJobWorker.ts +17 -17
- package/tests/unit/services/adminUser.test.ts +75 -0
- package/tests/unit/utils/adminPanelAccess.test.ts +26 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { CourseModuleItemType as ModuleItemType, ModuleItemRequirementType } from "@prisma/client";
|
|
3
|
+
import {
|
|
4
|
+
createTRPCRouter,
|
|
5
|
+
protectedClassMemberProcedure,
|
|
6
|
+
protectedTeacherProcedure,
|
|
7
|
+
} from "../trpc.js";
|
|
8
|
+
import {
|
|
9
|
+
listModulesRecord,
|
|
10
|
+
createModuleRecord,
|
|
11
|
+
updateModuleRecord,
|
|
12
|
+
deleteModuleRecord,
|
|
13
|
+
reorderModulesRecord,
|
|
14
|
+
createModuleItemRecord,
|
|
15
|
+
updateModuleItemRecord,
|
|
16
|
+
deleteModuleItemRecord,
|
|
17
|
+
reorderModuleItemsRecord,
|
|
18
|
+
getStudentModulesState,
|
|
19
|
+
markItemViewedRecord,
|
|
20
|
+
} from "../services/module.js";
|
|
21
|
+
|
|
22
|
+
const itemTypeSchema = z.nativeEnum(ModuleItemType);
|
|
23
|
+
const requirementTypeSchema = z.nativeEnum(ModuleItemRequirementType);
|
|
24
|
+
|
|
25
|
+
export const moduleRouter = createTRPCRouter({
|
|
26
|
+
/** Teacher view: every module (published or not) with its items. */
|
|
27
|
+
list: protectedTeacherProcedure
|
|
28
|
+
.input(z.object({ classId: z.string() }))
|
|
29
|
+
.query(({ ctx, input }) => listModulesRecord(ctx.user!.id, input.classId)),
|
|
30
|
+
|
|
31
|
+
/** Student view: published modules with lock state and progress. */
|
|
32
|
+
getStudentState: protectedClassMemberProcedure
|
|
33
|
+
.input(z.object({ classId: z.string() }))
|
|
34
|
+
.query(({ ctx, input }) =>
|
|
35
|
+
getStudentModulesState(input.classId, ctx.user!.id)
|
|
36
|
+
),
|
|
37
|
+
|
|
38
|
+
create: protectedTeacherProcedure
|
|
39
|
+
.input(
|
|
40
|
+
z.object({
|
|
41
|
+
classId: z.string(),
|
|
42
|
+
title: z.string().min(1),
|
|
43
|
+
unlockAt: z.coerce.date().nullish(),
|
|
44
|
+
requireSequential: z.boolean().optional(),
|
|
45
|
+
})
|
|
46
|
+
)
|
|
47
|
+
.mutation(({ ctx, input }) => createModuleRecord(ctx.user!.id, input)),
|
|
48
|
+
|
|
49
|
+
update: protectedTeacherProcedure
|
|
50
|
+
.input(
|
|
51
|
+
z.object({
|
|
52
|
+
classId: z.string(),
|
|
53
|
+
id: z.string(),
|
|
54
|
+
title: z.string().min(1).optional(),
|
|
55
|
+
unlockAt: z.coerce.date().nullable().optional(),
|
|
56
|
+
requireSequential: z.boolean().optional(),
|
|
57
|
+
published: z.boolean().optional(),
|
|
58
|
+
prerequisiteIds: z.array(z.string()).optional(),
|
|
59
|
+
})
|
|
60
|
+
)
|
|
61
|
+
.mutation(({ ctx, input }) => updateModuleRecord(ctx.user!.id, input)),
|
|
62
|
+
|
|
63
|
+
delete: protectedTeacherProcedure
|
|
64
|
+
.input(z.object({ classId: z.string(), id: z.string() }))
|
|
65
|
+
.mutation(({ ctx, input }) => deleteModuleRecord(ctx.user!.id, input)),
|
|
66
|
+
|
|
67
|
+
reorder: protectedTeacherProcedure
|
|
68
|
+
.input(z.object({ classId: z.string(), orderedIds: z.array(z.string()) }))
|
|
69
|
+
.mutation(({ ctx, input }) => reorderModulesRecord(ctx.user!.id, input)),
|
|
70
|
+
|
|
71
|
+
createItem: protectedTeacherProcedure
|
|
72
|
+
.input(
|
|
73
|
+
z.object({
|
|
74
|
+
classId: z.string(),
|
|
75
|
+
moduleId: z.string(),
|
|
76
|
+
itemType: itemTypeSchema,
|
|
77
|
+
itemId: z.string().optional(),
|
|
78
|
+
externalUrl: z.string().url().optional(),
|
|
79
|
+
title: z.string().optional(),
|
|
80
|
+
indent: z.number().int().min(0).max(4).optional(),
|
|
81
|
+
requirementType: requirementTypeSchema.optional(),
|
|
82
|
+
requiredScore: z.number().min(0).optional(),
|
|
83
|
+
})
|
|
84
|
+
)
|
|
85
|
+
.mutation(({ ctx, input }) => createModuleItemRecord(ctx.user!.id, input)),
|
|
86
|
+
|
|
87
|
+
updateItem: protectedTeacherProcedure
|
|
88
|
+
.input(
|
|
89
|
+
z.object({
|
|
90
|
+
classId: z.string(),
|
|
91
|
+
id: z.string(),
|
|
92
|
+
title: z.string().nullable().optional(),
|
|
93
|
+
externalUrl: z.string().url().optional(),
|
|
94
|
+
indent: z.number().int().min(0).max(4).optional(),
|
|
95
|
+
requirementType: requirementTypeSchema.nullable().optional(),
|
|
96
|
+
requiredScore: z.number().min(0).nullable().optional(),
|
|
97
|
+
})
|
|
98
|
+
)
|
|
99
|
+
.mutation(({ ctx, input }) => updateModuleItemRecord(ctx.user!.id, input)),
|
|
100
|
+
|
|
101
|
+
deleteItem: protectedTeacherProcedure
|
|
102
|
+
.input(z.object({ classId: z.string(), id: z.string() }))
|
|
103
|
+
.mutation(({ ctx, input }) => deleteModuleItemRecord(ctx.user!.id, input)),
|
|
104
|
+
|
|
105
|
+
reorderItems: protectedTeacherProcedure
|
|
106
|
+
.input(
|
|
107
|
+
z.object({
|
|
108
|
+
classId: z.string(),
|
|
109
|
+
items: z.array(z.object({ id: z.string(), moduleId: z.string() })),
|
|
110
|
+
})
|
|
111
|
+
)
|
|
112
|
+
.mutation(({ ctx, input }) => reorderModuleItemsRecord(ctx.user!.id, input)),
|
|
113
|
+
|
|
114
|
+
/** Record that a student viewed an item (completes VIEW requirements). */
|
|
115
|
+
markItemViewed: protectedClassMemberProcedure
|
|
116
|
+
.input(z.object({ classId: z.string(), itemId: z.string() }))
|
|
117
|
+
.mutation(({ ctx, input }) => markItemViewedRecord(ctx.user!.id, input)),
|
|
118
|
+
});
|
package/src/routers/parent.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { createTRPCRouter, protectedProcedure } from "../trpc.js";
|
|
3
3
|
import {
|
|
4
|
+
addParentToStudent,
|
|
4
5
|
getChildAttendance,
|
|
5
6
|
getChildGrades,
|
|
6
7
|
linkParentToStudent,
|
|
@@ -20,6 +21,16 @@ export const parentRouter = createTRPCRouter({
|
|
|
20
21
|
)
|
|
21
22
|
.mutation(({ ctx, input }) => linkParentToStudent(ctx.user!.id, input)),
|
|
22
23
|
|
|
24
|
+
addToStudent: protectedProcedure
|
|
25
|
+
.input(
|
|
26
|
+
z.object({
|
|
27
|
+
schoolId: z.string(),
|
|
28
|
+
studentUserId: z.string(),
|
|
29
|
+
parentIdentifier: z.string().min(1),
|
|
30
|
+
})
|
|
31
|
+
)
|
|
32
|
+
.mutation(({ ctx, input }) => addParentToStudent(ctx.user!.id, input)),
|
|
33
|
+
|
|
23
34
|
unlinkStudent: protectedProcedure
|
|
24
35
|
.input(
|
|
25
36
|
z.object({
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
import {
|
|
8
8
|
chatAboutStudentProgress,
|
|
9
9
|
dismissStudentRecommendation,
|
|
10
|
+
generateFollowUpAssignment,
|
|
10
11
|
getClassProgressRecommendations,
|
|
11
12
|
getStudentProgressFocus,
|
|
12
13
|
getStudentProgressForecast,
|
|
@@ -66,6 +67,20 @@ export const studentProgressRouter = createTRPCRouter({
|
|
|
66
67
|
dismissStudentRecommendation(ctx.user!.id, input),
|
|
67
68
|
),
|
|
68
69
|
|
|
70
|
+
generateFollowUpAssignment: protectedTeacherProcedure
|
|
71
|
+
.input(
|
|
72
|
+
z.object({
|
|
73
|
+
classId: z.string(),
|
|
74
|
+
studentId: z.string(),
|
|
75
|
+
submissionId: z.string(),
|
|
76
|
+
prompt: z.string().min(1).max(2000),
|
|
77
|
+
dueDate: z.string().datetime().optional(),
|
|
78
|
+
}),
|
|
79
|
+
)
|
|
80
|
+
.mutation(({ ctx, input }) =>
|
|
81
|
+
generateFollowUpAssignment(ctx.user!.id, input),
|
|
82
|
+
),
|
|
83
|
+
|
|
69
84
|
chat: protectedClassMemberProcedure
|
|
70
85
|
.input(
|
|
71
86
|
progressInputSchema.extend({
|
package/src/routes/aiStream.ts
CHANGED
|
@@ -453,6 +453,33 @@ aiStreamRouter.post('/modify/stream', async (req: Request, res: Response) => {
|
|
|
453
453
|
Connection: 'keep-alive',
|
|
454
454
|
});
|
|
455
455
|
|
|
456
|
+
// Narration is mirrored to other open editors as batched `modifier-chunk`
|
|
457
|
+
// events so their overlay streams live without a Pusher trigger per token.
|
|
458
|
+
const CHUNK_FLUSH_INTERVAL_MS = 250;
|
|
459
|
+
const CHUNK_FLUSH_MAX_CHARS = 500;
|
|
460
|
+
let chunkBuffer = '';
|
|
461
|
+
let lastChunkFlush = 0;
|
|
462
|
+
const flushChunks = async (force = false) => {
|
|
463
|
+
if (!chunkBuffer) return;
|
|
464
|
+
const now = Date.now();
|
|
465
|
+
if (
|
|
466
|
+
!force &&
|
|
467
|
+
now - lastChunkFlush < CHUNK_FLUSH_INTERVAL_MS &&
|
|
468
|
+
chunkBuffer.length < CHUNK_FLUSH_MAX_CHARS
|
|
469
|
+
) {
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
const payload = chunkBuffer;
|
|
473
|
+
chunkBuffer = '';
|
|
474
|
+
lastChunkFlush = now;
|
|
475
|
+
await pusher.trigger(channel, 'modifier-chunk', {
|
|
476
|
+
resourceId,
|
|
477
|
+
chunk: payload,
|
|
478
|
+
}).catch((err: unknown) => {
|
|
479
|
+
logger.warn('Failed to broadcast modifier-chunk', { err, resourceId });
|
|
480
|
+
});
|
|
481
|
+
};
|
|
482
|
+
|
|
456
483
|
// Called after a tool persists a single field — apply it live everywhere.
|
|
457
484
|
const onFieldUpdate = async (update: ModifierFieldUpdate) => {
|
|
458
485
|
await pusher.trigger(channel, 'modifier-field-updated', {
|
|
@@ -466,6 +493,8 @@ aiStreamRouter.post('/modify/stream', async (req: Request, res: Response) => {
|
|
|
466
493
|
});
|
|
467
494
|
// Human-readable progress for the requesting client's bar.
|
|
468
495
|
res.write(`Updated ${update.label}.\n`);
|
|
496
|
+
chunkBuffer += `Updated ${update.label}.\n`;
|
|
497
|
+
await flushChunks();
|
|
469
498
|
};
|
|
470
499
|
|
|
471
500
|
try {
|
|
@@ -490,7 +519,10 @@ aiStreamRouter.post('/modify/stream', async (req: Request, res: Response) => {
|
|
|
490
519
|
// call onFieldUpdate); any narration text is forwarded to the SSE client.
|
|
491
520
|
for await (const chunk of result.textStream) {
|
|
492
521
|
res.write(chunk);
|
|
522
|
+
chunkBuffer += chunk;
|
|
523
|
+
await flushChunks();
|
|
493
524
|
}
|
|
525
|
+
await flushChunks(true);
|
|
494
526
|
|
|
495
527
|
// Clean up Redis flag and broadcast completion
|
|
496
528
|
if (redis) {
|
package/src/seedDatabase.ts
CHANGED
|
@@ -1653,14 +1653,119 @@ export const seedDatabase = async () => {
|
|
|
1653
1653
|
createdAt: hoursFromNow(-3, now),
|
|
1654
1654
|
},
|
|
1655
1655
|
});
|
|
1656
|
+
// AI reply with creation proposals in all three states (pending card,
|
|
1657
|
+
// approved row, dismissed row) so the approval flow is demo-able.
|
|
1658
|
+
const labIntroText =
|
|
1659
|
+
"Here's what I put together for the organelle functions unit. I drafted a 10-question quiz worksheet, and I'm proposing a graded quiz assignment plus a new section to keep the unit organized:";
|
|
1660
|
+
const labProposals = [
|
|
1661
|
+
{
|
|
1662
|
+
id: "seed-proposal-worksheet",
|
|
1663
|
+
kind: "worksheet",
|
|
1664
|
+
title: "Organelle Functions Quiz (10 MCQs)",
|
|
1665
|
+
status: "approved",
|
|
1666
|
+
createdId: geneticsWorksheet.id,
|
|
1667
|
+
payload: {
|
|
1668
|
+
title: "Organelle Functions Quiz (10 MCQs)",
|
|
1669
|
+
questions: Array.from({ length: 10 }, (_, i) => ({
|
|
1670
|
+
type: "MULTIPLE_CHOICE",
|
|
1671
|
+
question: `Organelle question ${i + 1}`,
|
|
1672
|
+
answer: "A",
|
|
1673
|
+
options: [],
|
|
1674
|
+
markScheme: [],
|
|
1675
|
+
points: 1,
|
|
1676
|
+
order: i,
|
|
1677
|
+
})),
|
|
1678
|
+
},
|
|
1679
|
+
},
|
|
1680
|
+
{
|
|
1681
|
+
id: "seed-proposal-assignment",
|
|
1682
|
+
kind: "assignment",
|
|
1683
|
+
title: "Organelle Functions Quiz",
|
|
1684
|
+
status: "pending",
|
|
1685
|
+
payload: {
|
|
1686
|
+
title: "Organelle Functions Quiz",
|
|
1687
|
+
instructions:
|
|
1688
|
+
"Complete the 10-question multiple choice quiz on organelle functions. One attempt, 20 minutes.",
|
|
1689
|
+
dueDate: hoursFromNow(7 * 24, now).toISOString(),
|
|
1690
|
+
acceptFiles: false,
|
|
1691
|
+
acceptExtendedResponse: false,
|
|
1692
|
+
acceptWorksheet: true,
|
|
1693
|
+
maxGrade: 10,
|
|
1694
|
+
worksheetIds: [],
|
|
1695
|
+
studentIds: [],
|
|
1696
|
+
type: "QUIZ",
|
|
1697
|
+
attachments: [],
|
|
1698
|
+
},
|
|
1699
|
+
},
|
|
1700
|
+
{
|
|
1701
|
+
id: "seed-proposal-section",
|
|
1702
|
+
kind: "section",
|
|
1703
|
+
title: "Unit 3: Cell Structure",
|
|
1704
|
+
status: "dismissed",
|
|
1705
|
+
payload: { name: "Unit 3: Cell Structure" },
|
|
1706
|
+
},
|
|
1707
|
+
{
|
|
1708
|
+
id: "seed-proposal-assignment-update",
|
|
1709
|
+
kind: "assignmentUpdate",
|
|
1710
|
+
title: geneticsAssignment.title,
|
|
1711
|
+
status: "pending",
|
|
1712
|
+
payload: {
|
|
1713
|
+
id: geneticsAssignment.id,
|
|
1714
|
+
targetTitle: geneticsAssignment.title,
|
|
1715
|
+
dueDate: hoursFromNow(10 * 24, now).toISOString(),
|
|
1716
|
+
maxGrade: 15,
|
|
1717
|
+
},
|
|
1718
|
+
},
|
|
1719
|
+
];
|
|
1656
1720
|
await prisma.message.create({
|
|
1657
1721
|
data: {
|
|
1658
|
-
content:
|
|
1659
|
-
"Sure! Here are 10 AP-level MCQs on organelle functions, with explanations on each. Want me to also generate a 4-criterion rubric for grading short-answer follow-ups?",
|
|
1722
|
+
content: labIntroText,
|
|
1660
1723
|
senderId: aiUserId,
|
|
1661
1724
|
conversationId: labConversation.id,
|
|
1662
1725
|
createdAt: hoursFromNow(-2.95, now),
|
|
1663
1726
|
status: JobStatus.COMPLETED,
|
|
1727
|
+
meta: {
|
|
1728
|
+
pendingCreations: labProposals,
|
|
1729
|
+
streamParts: [
|
|
1730
|
+
{ type: "text", text: labIntroText },
|
|
1731
|
+
{
|
|
1732
|
+
type: "tool",
|
|
1733
|
+
toolCallId: "seed-tool-worksheet",
|
|
1734
|
+
toolName: "propose_worksheet",
|
|
1735
|
+
title: "Organelle Functions Quiz (10 MCQs)",
|
|
1736
|
+
proposalId: "seed-proposal-worksheet",
|
|
1737
|
+
status: "completed",
|
|
1738
|
+
},
|
|
1739
|
+
{
|
|
1740
|
+
type: "tool",
|
|
1741
|
+
toolCallId: "seed-tool-assignment",
|
|
1742
|
+
toolName: "propose_assignment",
|
|
1743
|
+
title: "Organelle Functions Quiz",
|
|
1744
|
+
proposalId: "seed-proposal-assignment",
|
|
1745
|
+
status: "completed",
|
|
1746
|
+
},
|
|
1747
|
+
{
|
|
1748
|
+
type: "tool",
|
|
1749
|
+
toolCallId: "seed-tool-section",
|
|
1750
|
+
toolName: "propose_section",
|
|
1751
|
+
title: "Unit 3: Cell Structure",
|
|
1752
|
+
proposalId: "seed-proposal-section",
|
|
1753
|
+
status: "completed",
|
|
1754
|
+
},
|
|
1755
|
+
{
|
|
1756
|
+
type: "tool",
|
|
1757
|
+
toolCallId: "seed-tool-assignment-update",
|
|
1758
|
+
toolName: "propose_update_assignment",
|
|
1759
|
+
title: geneticsAssignment.title,
|
|
1760
|
+
proposalId: "seed-proposal-assignment-update",
|
|
1761
|
+
status: "completed",
|
|
1762
|
+
},
|
|
1763
|
+
{
|
|
1764
|
+
type: "text",
|
|
1765
|
+
text: "Approve whichever you'd like me to create — nothing is added to the class until you do.",
|
|
1766
|
+
},
|
|
1767
|
+
],
|
|
1768
|
+
},
|
|
1664
1769
|
},
|
|
1665
1770
|
});
|
|
1666
1771
|
|
|
@@ -70,15 +70,18 @@ export async function getUserForAdmin(id: string) {
|
|
|
70
70
|
},
|
|
71
71
|
},
|
|
72
72
|
sessions: {
|
|
73
|
+
where: { type: 'AUTH' },
|
|
73
74
|
orderBy: { createdAt: 'desc' },
|
|
74
75
|
take: 10,
|
|
75
|
-
|
|
76
|
+
// Session.id is the bearer token — never return it from admin APIs.
|
|
77
|
+
select: { type: true, createdAt: true, expiresAt: true },
|
|
76
78
|
},
|
|
77
79
|
_count: {
|
|
78
80
|
select: {
|
|
79
81
|
teacherIn: true,
|
|
80
82
|
studentIn: true,
|
|
81
|
-
|
|
83
|
+
// Keep the count consistent with the AUTH-only session list above.
|
|
84
|
+
sessions: { where: { type: 'AUTH' } },
|
|
82
85
|
submissions: true,
|
|
83
86
|
files: true,
|
|
84
87
|
},
|
|
@@ -14,12 +14,34 @@
|
|
|
14
14
|
import { streamText, stepCountIs, type ModelMessage, type ToolSet } from 'ai';
|
|
15
15
|
import { openai } from '@ai-sdk/openai';
|
|
16
16
|
import { getRedis } from '../lib/redis.js';
|
|
17
|
+
import { prisma } from '../lib/prisma.js';
|
|
17
18
|
import { pusher, chatChannel } from '../lib/pusher.js';
|
|
18
19
|
import { logger } from '../utils/logger.js';
|
|
19
20
|
import { logInferenceUsage, type AiUsageAttribution } from '../utils/inference.js';
|
|
20
21
|
|
|
21
22
|
const STREAM_REDIS_TTL = 600; // 10 minutes
|
|
22
23
|
const STREAM_KEY_PREFIX = 'stream:';
|
|
24
|
+
const CANCEL_KEY_PREFIX = 'stream:cancel:';
|
|
25
|
+
const CANCEL_CHECK_INTERVAL_MS = 1_000;
|
|
26
|
+
|
|
27
|
+
function streamCancelKey(aiMessageId: string): string {
|
|
28
|
+
return `${CANCEL_KEY_PREFIX}${aiMessageId}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Flag an in-flight stream for cancellation. `streamChatResponse` polls this
|
|
33
|
+
* key between chunks and aborts the model stream when it appears. The worker
|
|
34
|
+
* may run in a different process from the API, so the signal goes through
|
|
35
|
+
* Redis rather than an in-memory AbortController registry.
|
|
36
|
+
*/
|
|
37
|
+
export async function requestStreamCancellation(
|
|
38
|
+
aiMessageId: string,
|
|
39
|
+
): Promise<void> {
|
|
40
|
+
const redis = getRedis();
|
|
41
|
+
if (redis) {
|
|
42
|
+
await redis.set(streamCancelKey(aiMessageId), '1', 'EX', STREAM_REDIS_TTL);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
23
45
|
|
|
24
46
|
/** Default streaming model — matches the existing streamAIResponse path. */
|
|
25
47
|
export const DEFAULT_STREAM_MODEL = 'gpt-4o-mini';
|
|
@@ -53,6 +75,10 @@ export type StreamPart =
|
|
|
53
75
|
/** Set after generated files are persisted on the message. */
|
|
54
76
|
fileId?: string;
|
|
55
77
|
fileName?: string;
|
|
78
|
+
/** Links a propose_* tool call to its pending creation in message meta. */
|
|
79
|
+
proposalId?: string;
|
|
80
|
+
/** Class-files folder the generated file was saved into, if any. */
|
|
81
|
+
folderId?: string;
|
|
56
82
|
status: 'executing' | 'completed' | 'failed';
|
|
57
83
|
};
|
|
58
84
|
|
|
@@ -61,6 +87,7 @@ export interface GeneratedDocFileLink {
|
|
|
61
87
|
fileId: string;
|
|
62
88
|
fileName: string;
|
|
63
89
|
format?: string;
|
|
90
|
+
folderId?: string;
|
|
64
91
|
}
|
|
65
92
|
|
|
66
93
|
/** Apply pipeline-resolved formats onto generate_document tool parts. */
|
|
@@ -136,6 +163,7 @@ export function enrichStreamPartsWithGeneratedFiles(
|
|
|
136
163
|
fileName: file.fileName,
|
|
137
164
|
status: 'completed',
|
|
138
165
|
...(file.format ? { format: file.format } : {}),
|
|
166
|
+
...(file.folderId ? { folderId: file.folderId } : {}),
|
|
139
167
|
};
|
|
140
168
|
});
|
|
141
169
|
}
|
|
@@ -162,7 +190,7 @@ function toolFieldFromInput(input: unknown, key: string): string | undefined {
|
|
|
162
190
|
|
|
163
191
|
/** Pull a human-meaningful label out of a tool's input args, if present. */
|
|
164
192
|
function toolTitleFromInput(input: unknown): string | undefined {
|
|
165
|
-
for (const key of ['title', 'name', 'query'] as const) {
|
|
193
|
+
for (const key of ['targetTitle', 'title', 'name', 'query'] as const) {
|
|
166
194
|
const val = toolFieldFromInput(input, key);
|
|
167
195
|
if (val) return val;
|
|
168
196
|
}
|
|
@@ -191,6 +219,9 @@ export interface StreamChatResult {
|
|
|
191
219
|
toolCalls: StreamedToolCall[];
|
|
192
220
|
/** Ordered text/tool parts, in the exact order the model produced them. */
|
|
193
221
|
parts: StreamPart[];
|
|
222
|
+
/** True when the stream was stopped by a cancellation request; `text` and
|
|
223
|
+
* `parts` hold whatever was produced before the abort. */
|
|
224
|
+
cancelled: boolean;
|
|
194
225
|
}
|
|
195
226
|
|
|
196
227
|
/**
|
|
@@ -218,11 +249,39 @@ export async function streamChatResponse(
|
|
|
218
249
|
|
|
219
250
|
const redis = getRedis();
|
|
220
251
|
const streamKey = `${STREAM_KEY_PREFIX}${aiMessageId}`;
|
|
252
|
+
const cancelKey = streamCancelKey(aiMessageId);
|
|
221
253
|
if (redis) {
|
|
222
254
|
await redis.set(streamKey, '', 'EX', STREAM_REDIS_TTL);
|
|
255
|
+
// A stale flag from a previously cancelled run on the same message id
|
|
256
|
+
// (e.g. a rerun) must not kill this stream instantly.
|
|
257
|
+
await redis.del(cancelKey).catch(() => {});
|
|
223
258
|
}
|
|
224
259
|
|
|
225
260
|
let fullText = '';
|
|
261
|
+
let cancelled = false;
|
|
262
|
+
let lastCancelCheck = 0;
|
|
263
|
+
const abortController = new AbortController();
|
|
264
|
+
|
|
265
|
+
// Poll the cancel flag at most once a second: Redis when available,
|
|
266
|
+
// otherwise the message row (the cancel mutation flips it to CANCELLED).
|
|
267
|
+
const checkCancelled = async (): Promise<boolean> => {
|
|
268
|
+
if (cancelled) return true;
|
|
269
|
+
const now = Date.now();
|
|
270
|
+
if (now - lastCancelCheck < CANCEL_CHECK_INTERVAL_MS) return false;
|
|
271
|
+
lastCancelCheck = now;
|
|
272
|
+
if (redis) {
|
|
273
|
+
const flag = await redis.get(cancelKey).catch(() => null);
|
|
274
|
+
if (flag) cancelled = true;
|
|
275
|
+
} else {
|
|
276
|
+
const row = await prisma.message
|
|
277
|
+
.findUnique({ where: { id: aiMessageId }, select: { status: true } })
|
|
278
|
+
.catch(() => null);
|
|
279
|
+
if (row?.status === 'CANCELLED') cancelled = true;
|
|
280
|
+
}
|
|
281
|
+
if (cancelled) abortController.abort();
|
|
282
|
+
return cancelled;
|
|
283
|
+
};
|
|
284
|
+
|
|
226
285
|
const parts: StreamPart[] = [];
|
|
227
286
|
const toolCalls: StreamedToolCall[] = [];
|
|
228
287
|
// Map AI SDK toolCallId -> index in `parts` so the executing indicator can
|
|
@@ -256,13 +315,20 @@ export async function streamChatResponse(
|
|
|
256
315
|
temperature,
|
|
257
316
|
maxOutputTokens,
|
|
258
317
|
stopWhen: stepCountIs(maxSteps),
|
|
318
|
+
abortSignal: abortController.signal,
|
|
259
319
|
});
|
|
260
320
|
|
|
261
321
|
// `fullStream` yields text deltas AND tool events in a single, correctly
|
|
262
322
|
// ordered sequence — this is what makes inline tool calls possible. We
|
|
263
323
|
// build an ordered `parts` list and broadcast each event as it happens.
|
|
264
|
-
|
|
265
|
-
|
|
324
|
+
try {
|
|
325
|
+
for await (const part of result.fullStream) {
|
|
326
|
+
if (await checkCancelled()) break;
|
|
327
|
+
if (part.type === 'abort') {
|
|
328
|
+
cancelled = true;
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
switch (part.type) {
|
|
266
332
|
case 'text-delta': {
|
|
267
333
|
appendText(part.text);
|
|
268
334
|
if (redis) {
|
|
@@ -333,10 +399,19 @@ export async function streamChatResponse(
|
|
|
333
399
|
});
|
|
334
400
|
break;
|
|
335
401
|
}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
402
|
+
default:
|
|
403
|
+
// ignore reasoning/source/step/finish/raw parts
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
339
406
|
}
|
|
407
|
+
} catch (streamError) {
|
|
408
|
+
// Aborting the model stream mid-iteration surfaces as an error from
|
|
409
|
+
// `fullStream`; when it is our own cancellation, keep the partials.
|
|
410
|
+
if (!cancelled) throw streamError;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (cancelled) {
|
|
414
|
+
logger.info('AI chat stream cancelled', { aiMessageId, conversationId });
|
|
340
415
|
}
|
|
341
416
|
|
|
342
417
|
const usage = await Promise.resolve(result.totalUsage).catch(() => undefined);
|
|
@@ -356,7 +431,7 @@ export async function streamChatResponse(
|
|
|
356
431
|
attribution,
|
|
357
432
|
});
|
|
358
433
|
|
|
359
|
-
return { text: fullText, toolCalls, parts };
|
|
434
|
+
return { text: fullText, toolCalls, parts, cancelled };
|
|
360
435
|
} catch (error) {
|
|
361
436
|
await logInferenceUsage({
|
|
362
437
|
model,
|
|
@@ -371,6 +446,7 @@ export async function streamChatResponse(
|
|
|
371
446
|
} finally {
|
|
372
447
|
if (redis) {
|
|
373
448
|
await redis.del(streamKey).catch(() => {});
|
|
449
|
+
await redis.del(cancelKey).catch(() => {});
|
|
374
450
|
}
|
|
375
451
|
}
|
|
376
452
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI generation cancellation service.
|
|
3
|
+
*
|
|
4
|
+
* Cancels an in-flight AI chat generation (lab chat / Newton chat): flags the
|
|
5
|
+
* stream for abort via Redis so the worker's `streamChatResponse` loop stops,
|
|
6
|
+
* marks the linked RunJob CANCELLED (so a still-queued job never runs), and
|
|
7
|
+
* flips the placeholder message to CANCELLED immediately. The pipeline then
|
|
8
|
+
* persists whatever partial text/tool parts had already streamed.
|
|
9
|
+
*/
|
|
10
|
+
import { TRPCError } from "@trpc/server";
|
|
11
|
+
import { JobStatus } from "@prisma/client";
|
|
12
|
+
import { prisma } from "../lib/prisma.js";
|
|
13
|
+
import { chatChannel, pusher } from "../lib/pusher.js";
|
|
14
|
+
import { isAIUser } from "../utils/aiUser.js";
|
|
15
|
+
import { requestStreamCancellation } from "./aiChatStream.js";
|
|
16
|
+
import { logger } from "../utils/logger.js";
|
|
17
|
+
|
|
18
|
+
export async function cancelAiGeneration(userId: string, messageId: string) {
|
|
19
|
+
const message = await prisma.message.findFirst({
|
|
20
|
+
where: {
|
|
21
|
+
id: messageId,
|
|
22
|
+
conversation: { members: { some: { userId } } },
|
|
23
|
+
},
|
|
24
|
+
select: {
|
|
25
|
+
id: true,
|
|
26
|
+
senderId: true,
|
|
27
|
+
status: true,
|
|
28
|
+
conversationId: true,
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (!message || !isAIUser(message.senderId)) {
|
|
33
|
+
throw new TRPCError({
|
|
34
|
+
code: "NOT_FOUND",
|
|
35
|
+
message: "AI message not found or access denied",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (message.status !== JobStatus.PENDING) {
|
|
40
|
+
throw new TRPCError({
|
|
41
|
+
code: "BAD_REQUEST",
|
|
42
|
+
message: "No generation in progress for this message",
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Signal the in-flight stream (worker process) to abort.
|
|
47
|
+
await requestStreamCancellation(messageId);
|
|
48
|
+
|
|
49
|
+
// Mark the backing RunJob CANCELLED so a queued-but-not-started job is
|
|
50
|
+
// skipped by the worker, and the audit record reflects the stop.
|
|
51
|
+
await prisma.runJob.updateMany({
|
|
52
|
+
where: {
|
|
53
|
+
status: { in: ["PENDING", "RUNNING"] },
|
|
54
|
+
input: { path: ["aiMessageId"], equals: messageId },
|
|
55
|
+
},
|
|
56
|
+
data: {
|
|
57
|
+
status: "CANCELLED",
|
|
58
|
+
error: "Cancelled by user",
|
|
59
|
+
finishedAt: new Date(),
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Flip the placeholder to CANCELLED right away: this is both the instant
|
|
64
|
+
// UI signal and the durable cancel marker the stream loop falls back to
|
|
65
|
+
// when Redis is unavailable. The pipeline later fills in partial content.
|
|
66
|
+
const updated = await prisma.message.update({
|
|
67
|
+
where: { id: messageId },
|
|
68
|
+
data: { status: JobStatus.CANCELLED },
|
|
69
|
+
select: { id: true, conversationId: true, status: true },
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
logger.info("AI generation cancellation requested", {
|
|
73
|
+
messageId,
|
|
74
|
+
conversationId: message.conversationId,
|
|
75
|
+
userId,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
await pusher.trigger(chatChannel(updated.conversationId), "message-updated", {
|
|
80
|
+
id: updated.id,
|
|
81
|
+
conversationId: updated.conversationId,
|
|
82
|
+
status: updated.status,
|
|
83
|
+
});
|
|
84
|
+
} catch (broadcastError) {
|
|
85
|
+
logger.error("Failed to broadcast cancelled AI generation:", {
|
|
86
|
+
error: broadcastError,
|
|
87
|
+
messageId,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { success: true };
|
|
92
|
+
}
|