@studious-lms/server 1.4.2 → 1.4.4
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/.coderabbit.yaml +4 -1
- package/.vscode/settings.json +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +68 -29
- package/dist/index.js.map +1 -1
- package/dist/lib/fileUpload.d.ts +10 -2
- package/dist/lib/fileUpload.d.ts.map +1 -1
- package/dist/lib/fileUpload.js +38 -6
- package/dist/lib/fileUpload.js.map +1 -1
- package/dist/middleware/auth.d.ts.map +1 -1
- package/dist/middleware/auth.js +18 -5
- package/dist/middleware/auth.js.map +1 -1
- package/dist/models/auth.d.ts +19 -14
- package/dist/models/auth.d.ts.map +1 -1
- package/dist/models/auth.js +53 -30
- package/dist/models/auth.js.map +1 -1
- package/dist/models/class.d.ts +3 -1
- package/dist/models/class.d.ts.map +1 -1
- package/dist/models/class.js +16 -4
- package/dist/models/class.js.map +1 -1
- package/dist/models/notification.d.ts +11 -10
- package/dist/models/notification.d.ts.map +1 -1
- package/dist/models/notification.js +13 -9
- package/dist/models/notification.js.map +1 -1
- package/dist/models/worksheet.d.ts +379 -7
- package/dist/models/worksheet.d.ts.map +1 -1
- package/dist/models/worksheet.js +168 -2
- package/dist/models/worksheet.js.map +1 -1
- package/dist/pipelines/aiLabChat.d.ts.map +1 -1
- package/dist/pipelines/aiLabChat.js +11 -3
- package/dist/pipelines/aiLabChat.js.map +1 -1
- package/dist/pipelines/aiLabChatContract.d.ts +3 -3
- package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
- package/dist/pipelines/aiNewtonChat.js +25 -14
- package/dist/pipelines/aiNewtonChat.js.map +1 -1
- package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
- package/dist/pipelines/gradeWorksheet.js +10 -3
- package/dist/pipelines/gradeWorksheet.js.map +1 -1
- package/dist/pipelines/pdfToWorksheet.d.ts +12 -0
- package/dist/pipelines/pdfToWorksheet.d.ts.map +1 -0
- package/dist/pipelines/pdfToWorksheet.js +155 -0
- package/dist/pipelines/pdfToWorksheet.js.map +1 -0
- package/dist/routers/_app.d.ts +264 -16
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/class.d.ts +2 -1
- package/dist/routers/class.d.ts.map +1 -1
- package/dist/routers/class.js +3 -3
- package/dist/routers/class.js.map +1 -1
- package/dist/routers/labChat.d.ts +6 -0
- package/dist/routers/labChat.d.ts.map +1 -1
- package/dist/routers/labChat.js +5 -3
- package/dist/routers/labChat.js.map +1 -1
- package/dist/routers/message.d.ts +31 -0
- package/dist/routers/message.d.ts.map +1 -1
- package/dist/routers/message.js +27 -4
- package/dist/routers/message.js.map +1 -1
- package/dist/routers/newtonChat.d.ts +6 -0
- package/dist/routers/newtonChat.d.ts.map +1 -1
- package/dist/routers/newtonChat.js +5 -4
- package/dist/routers/newtonChat.js.map +1 -1
- package/dist/routers/notifications.d.ts +9 -2
- package/dist/routers/notifications.d.ts.map +1 -1
- package/dist/routers/notifications.js +4 -4
- package/dist/routers/notifications.js.map +1 -1
- package/dist/routers/worksheet.d.ts +78 -5
- package/dist/routers/worksheet.d.ts.map +1 -1
- package/dist/routers/worksheet.js +126 -17
- package/dist/routers/worksheet.js.map +1 -1
- package/dist/services/auth.d.ts +1 -1
- package/dist/services/auth.d.ts.map +1 -1
- package/dist/services/auth.js +19 -12
- package/dist/services/auth.js.map +1 -1
- package/dist/services/class.d.ts +1 -1
- package/dist/services/class.d.ts.map +1 -1
- package/dist/services/class.js +6 -3
- package/dist/services/class.js.map +1 -1
- package/dist/services/file.d.ts.map +1 -1
- package/dist/services/file.js +7 -2
- package/dist/services/file.js.map +1 -1
- package/dist/services/labChat.d.ts +6 -0
- package/dist/services/labChat.d.ts.map +1 -1
- package/dist/services/labChat.js +30 -18
- package/dist/services/labChat.js.map +1 -1
- package/dist/services/message.d.ts +30 -0
- package/dist/services/message.d.ts.map +1 -1
- package/dist/services/message.js +97 -21
- package/dist/services/message.js.map +1 -1
- package/dist/services/newtonChat.d.ts +6 -1
- package/dist/services/newtonChat.d.ts.map +1 -1
- package/dist/services/newtonChat.js +36 -18
- package/dist/services/newtonChat.js.map +1 -1
- package/dist/services/notification.d.ts +12 -5
- package/dist/services/notification.d.ts.map +1 -1
- package/dist/services/notification.js +24 -9
- package/dist/services/notification.js.map +1 -1
- package/dist/services/studentProgress.d.ts.map +1 -1
- package/dist/services/studentProgress.js +6 -3
- package/dist/services/studentProgress.js.map +1 -1
- package/dist/services/worksheet.d.ts +36 -15
- package/dist/services/worksheet.d.ts.map +1 -1
- package/dist/services/worksheet.js +186 -54
- package/dist/services/worksheet.js.map +1 -1
- package/dist/trpc.d.ts.map +1 -1
- package/dist/trpc.js +11 -17
- package/dist/trpc.js.map +1 -1
- package/dist/utils/inference.d.ts +10 -1
- package/dist/utils/inference.d.ts.map +1 -1
- package/dist/utils/inference.js +186 -50
- package/dist/utils/inference.js.map +1 -1
- package/package.json +1 -1
- package/prisma/migrations/20260415103500_add_session_type/migration.sql +33 -0
- package/prisma/migrations/20260415133000_add_ai_usage_logs/migration.sql +19 -0
- package/prisma/schema.prisma +27 -1
- package/scripts/test-pre-push.ts +6 -2
- package/src/index.ts +83 -30
- package/src/lib/fileUpload.ts +55 -5
- package/src/middleware/auth.ts +19 -4
- package/src/models/auth.ts +59 -29
- package/src/models/class.ts +15 -4
- package/src/models/notification.ts +13 -7
- package/src/models/worksheet.ts +203 -0
- package/src/pipelines/aiLabChat.ts +9 -1
- package/src/pipelines/aiNewtonChat.ts +24 -14
- package/src/pipelines/gradeWorksheet.ts +8 -0
- package/src/pipelines/pdfToWorksheet.ts +177 -0
- package/src/routers/class.ts +2 -2
- package/src/routers/labChat.ts +3 -1
- package/src/routers/message.ts +38 -1
- package/src/routers/newtonChat.ts +4 -2
- package/src/routers/notifications.ts +2 -2
- package/src/routers/worksheet.ts +174 -19
- package/src/services/auth.ts +26 -12
- package/src/services/class.ts +4 -1
- package/src/services/file.ts +6 -0
- package/src/services/labChat.ts +42 -18
- package/src/services/message.ts +163 -18
- package/src/services/newtonChat.ts +49 -18
- package/src/services/notification.ts +27 -8
- package/src/services/studentProgress.ts +4 -1
- package/src/services/worksheet.ts +252 -51
- package/src/trpc.ts +12 -18
- package/src/utils/inference.ts +280 -56
- package/tests/globalSetup.ts +13 -4
- package/tests/pipelines/pdfToWorksheet.test.ts +176 -0
- package/tests/routers/auth.test.ts +120 -17
- package/tests/routers/class.test.ts +7 -1
- package/tests/routers/message.test.ts +236 -9
- package/tests/routers/newtonChat.test.ts +81 -0
- package/tests/routers/notification.test.ts +12 -0
- package/tests/routers/worksheet.test.ts +194 -0
- package/tests/setup.ts +22 -5
- package/tests/utils/inference.test.ts +255 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from "vitest";
|
|
2
|
+
import { user1Caller, user2Caller, user3Caller } from "../setup";
|
|
3
|
+
|
|
4
|
+
const expectTRPCError = async (promise: Promise<unknown>, code: string) => {
|
|
5
|
+
await expect(promise).rejects.toMatchObject({ code });
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
describe("Worksheet Router Security", () => {
|
|
9
|
+
let testClass: any;
|
|
10
|
+
let worksheet: any;
|
|
11
|
+
let question: any;
|
|
12
|
+
let multipleChoiceQuestion: any;
|
|
13
|
+
let submission: any;
|
|
14
|
+
let worksheetResponse: any;
|
|
15
|
+
let progressId: string;
|
|
16
|
+
|
|
17
|
+
beforeEach(async () => {
|
|
18
|
+
testClass = await user1Caller.class.create({
|
|
19
|
+
name: `Worksheet Security ${Date.now()}`,
|
|
20
|
+
subject: "Physics",
|
|
21
|
+
section: "12th Grade",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const user2Profile = await user2Caller.user.getProfile();
|
|
25
|
+
await user1Caller.class.addStudent({
|
|
26
|
+
classId: testClass.id,
|
|
27
|
+
studentId: user2Profile.id,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
worksheet = await user1Caller.worksheet.create({
|
|
31
|
+
classId: testClass.id,
|
|
32
|
+
name: "Security Worksheet",
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
question = await user1Caller.worksheet.addQuestion({
|
|
36
|
+
worksheetId: worksheet.id,
|
|
37
|
+
question: "What is force?",
|
|
38
|
+
answer: "Mass times acceleration",
|
|
39
|
+
points: 5,
|
|
40
|
+
type: "SHORT_ANSWER",
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
multipleChoiceQuestion = await user1Caller.worksheet.addQuestion({
|
|
44
|
+
worksheetId: worksheet.id,
|
|
45
|
+
question: "What is the SI unit of force?",
|
|
46
|
+
answer: "newton",
|
|
47
|
+
points: 1,
|
|
48
|
+
type: "MULTIPLE_CHOICE",
|
|
49
|
+
options: [
|
|
50
|
+
{ id: "a", text: "Joule", isCorrect: false },
|
|
51
|
+
{ id: "b", text: "Newton", isCorrect: true },
|
|
52
|
+
],
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const dueDate = new Date();
|
|
56
|
+
dueDate.setDate(dueDate.getDate() + 7);
|
|
57
|
+
|
|
58
|
+
const assignment = await user1Caller.assignment.create({
|
|
59
|
+
classId: testClass.id,
|
|
60
|
+
title: "Worksheet Assignment",
|
|
61
|
+
instructions: "Complete the worksheet.",
|
|
62
|
+
dueDate: dueDate.toISOString(),
|
|
63
|
+
acceptWorksheet: true,
|
|
64
|
+
gradeWithAI: true,
|
|
65
|
+
worksheetIds: [worksheet.id],
|
|
66
|
+
type: "HOMEWORK",
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
submission = await user2Caller.assignment.getSubmission({
|
|
70
|
+
classId: testClass.id,
|
|
71
|
+
assignmentId: assignment.id,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
worksheetResponse = await user2Caller.worksheet.getWorksheetSubmission({
|
|
75
|
+
worksheetId: worksheet.id,
|
|
76
|
+
submissionId: submission.id,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const answered = await user2Caller.worksheet.answerQuestion({
|
|
80
|
+
worksheetResponseId: worksheetResponse.id,
|
|
81
|
+
questionId: question.id,
|
|
82
|
+
response: "Mass times acceleration",
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
progressId = answered.responses[0].id;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("redacts worksheet answers and correctness metadata for students", async () => {
|
|
89
|
+
const studentWorksheet = await user2Caller.worksheet.getWorksheet({
|
|
90
|
+
worksheetId: worksheet.id,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const shortAnswerQuestion = studentWorksheet.questions.find(
|
|
94
|
+
(item: any) => item.id === question.id,
|
|
95
|
+
);
|
|
96
|
+
expect(shortAnswerQuestion.answer).toBe("");
|
|
97
|
+
expect(shortAnswerQuestion.markScheme).toBeNull();
|
|
98
|
+
|
|
99
|
+
const studentMcq = studentWorksheet.questions.find(
|
|
100
|
+
(item: any) => item.id === multipleChoiceQuestion.id,
|
|
101
|
+
);
|
|
102
|
+
expect(studentMcq.answer).toBe("");
|
|
103
|
+
expect(studentMcq.options).toEqual([
|
|
104
|
+
{ id: "a", text: "Joule" },
|
|
105
|
+
{ id: "b", text: "Newton" },
|
|
106
|
+
]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("blocks cross-class worksheet reads by id", async () => {
|
|
110
|
+
await expectTRPCError(
|
|
111
|
+
user3Caller.worksheet.getWorksheet({ worksheetId: worksheet.id }),
|
|
112
|
+
"FORBIDDEN",
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("blocks cross-class worksheet submission reads by id", async () => {
|
|
117
|
+
await expectTRPCError(
|
|
118
|
+
user3Caller.worksheet.getWorksheetSubmission({
|
|
119
|
+
worksheetId: worksheet.id,
|
|
120
|
+
submissionId: submission.id,
|
|
121
|
+
}),
|
|
122
|
+
"FORBIDDEN",
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("blocks cross-class worksheet answer mutations", async () => {
|
|
127
|
+
await expectTRPCError(
|
|
128
|
+
user3Caller.worksheet.answerQuestion({
|
|
129
|
+
worksheetResponseId: worksheetResponse.id,
|
|
130
|
+
questionId: question.id,
|
|
131
|
+
response: "Unauthorized answer",
|
|
132
|
+
}),
|
|
133
|
+
"FORBIDDEN",
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("blocks students from teacher worksheet mutations", async () => {
|
|
138
|
+
await expectTRPCError(
|
|
139
|
+
user2Caller.worksheet.updateWorksheet({
|
|
140
|
+
worksheetId: worksheet.id,
|
|
141
|
+
name: "Student update",
|
|
142
|
+
}),
|
|
143
|
+
"FORBIDDEN",
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
await expectTRPCError(
|
|
147
|
+
user2Caller.worksheet.addQuestion({
|
|
148
|
+
worksheetId: worksheet.id,
|
|
149
|
+
question: "Student-created question",
|
|
150
|
+
answer: "Nope",
|
|
151
|
+
type: "SHORT_ANSWER",
|
|
152
|
+
}),
|
|
153
|
+
"FORBIDDEN",
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("blocks students from grading and feedback actions", async () => {
|
|
158
|
+
await expectTRPCError(
|
|
159
|
+
user2Caller.worksheet.gradeAnswer({
|
|
160
|
+
questionId: question.id,
|
|
161
|
+
responseId: progressId,
|
|
162
|
+
studentWorksheetResponseId: worksheetResponse.id,
|
|
163
|
+
isCorrect: true,
|
|
164
|
+
feedback: "Nice work",
|
|
165
|
+
points: 5,
|
|
166
|
+
}),
|
|
167
|
+
"FORBIDDEN",
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
await expectTRPCError(
|
|
171
|
+
user2Caller.worksheet.addComment({
|
|
172
|
+
responseId: progressId,
|
|
173
|
+
comment: "Teacher feedback",
|
|
174
|
+
}),
|
|
175
|
+
"FORBIDDEN",
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
await expectTRPCError(
|
|
179
|
+
user2Caller.worksheet.cancelGrading({
|
|
180
|
+
worksheetResponseId: worksheetResponse.id,
|
|
181
|
+
progressId,
|
|
182
|
+
}),
|
|
183
|
+
"FORBIDDEN",
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
await expectTRPCError(
|
|
187
|
+
user2Caller.worksheet.regradeQuestion({
|
|
188
|
+
worksheetResponseId: worksheetResponse.id,
|
|
189
|
+
progressId,
|
|
190
|
+
}),
|
|
191
|
+
"FORBIDDEN",
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
});
|
package/tests/setup.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
|
|
6
6
|
import { logger } from '../src/utils/logger';
|
|
7
7
|
import { appRouter } from '../src/routers/_app';
|
|
8
8
|
import { createTRPCContext } from '../src/trpc';
|
|
9
|
-
import { Session } from '@prisma/client';
|
|
9
|
+
import { Session, SessionType } from '@prisma/client';
|
|
10
10
|
|
|
11
11
|
// Load test environment variables
|
|
12
12
|
config({ path: resolve(process.cwd(), '.env.test') });
|
|
@@ -35,9 +35,26 @@ beforeAll(async () => {
|
|
|
35
35
|
throw new Error('Failed to get login tokens — ensure globalSetup ran');
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
const authSession1 = await prisma.session.findFirst({
|
|
39
|
+
where: { userId: login1.user.id, type: SessionType.AUTH },
|
|
40
|
+
orderBy: { createdAt: 'desc' },
|
|
41
|
+
});
|
|
42
|
+
const authSession2 = await prisma.session.findFirst({
|
|
43
|
+
where: { userId: login2.user.id, type: SessionType.AUTH },
|
|
44
|
+
orderBy: { createdAt: 'desc' },
|
|
45
|
+
});
|
|
46
|
+
const authSession3 = await prisma.session.findFirst({
|
|
47
|
+
where: { userId: login3.user.id, type: SessionType.AUTH },
|
|
48
|
+
orderBy: { createdAt: 'desc' },
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (!authSession1 || !authSession2 || !authSession3) {
|
|
52
|
+
throw new Error('Failed to retrieve AUTH sessions for test users');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
session1 = authSession1;
|
|
56
|
+
session2 = authSession2;
|
|
57
|
+
session3 = authSession3;
|
|
41
58
|
|
|
42
59
|
verification1 = { verified: true };
|
|
43
60
|
verification2 = { verified: true };
|
|
@@ -69,4 +86,4 @@ export let verification2: any;
|
|
|
69
86
|
export let verification3: any;
|
|
70
87
|
export let login1: any;
|
|
71
88
|
export let login2: any;
|
|
72
|
-
export let login3: any;
|
|
89
|
+
export let login3: any;
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
const {
|
|
5
|
+
chatCreateMock,
|
|
6
|
+
responseParseMock,
|
|
7
|
+
modelsListMock,
|
|
8
|
+
prismaMocks,
|
|
9
|
+
logger,
|
|
10
|
+
} = vi.hoisted(() => ({
|
|
11
|
+
chatCreateMock: vi.fn(),
|
|
12
|
+
responseParseMock: vi.fn(),
|
|
13
|
+
modelsListMock: vi.fn(),
|
|
14
|
+
prismaMocks: {
|
|
15
|
+
aiUsageLog: {
|
|
16
|
+
create: vi.fn(),
|
|
17
|
+
},
|
|
18
|
+
message: {
|
|
19
|
+
create: vi.fn(),
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
logger: {
|
|
23
|
+
info: vi.fn(),
|
|
24
|
+
error: vi.fn(),
|
|
25
|
+
},
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
vi.mock('openai', () => ({
|
|
29
|
+
default: class OpenAI {
|
|
30
|
+
chat = {
|
|
31
|
+
completions: {
|
|
32
|
+
create: chatCreateMock,
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
responses = {
|
|
37
|
+
parse: responseParseMock,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
models = {
|
|
41
|
+
list: modelsListMock,
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
vi.mock('openai/helpers/zod', () => ({
|
|
47
|
+
zodTextFormat: vi.fn((schema, name) => ({ schema, name })),
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
vi.mock('../../src/lib/prisma.js', () => ({
|
|
51
|
+
prisma: prismaMocks,
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
vi.mock('../../src/utils/logger.js', () => ({
|
|
55
|
+
logger,
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
vi.mock('../../src/lib/pusher.js', () => ({
|
|
59
|
+
chatChannel: vi.fn(),
|
|
60
|
+
pusher: {
|
|
61
|
+
trigger: vi.fn(),
|
|
62
|
+
},
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
vi.mock('../../src/utils/aiUser.js', () => ({
|
|
66
|
+
ensureAIUserExists: vi.fn(),
|
|
67
|
+
getAIUserId: vi.fn(() => 'AI_ASSISTANT'),
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
vi.mock('../../src/lib/config/env.js', () => ({
|
|
71
|
+
env: {
|
|
72
|
+
INFERENCE_API_KEY: 'test-key',
|
|
73
|
+
INFERENCE_API_BASE_URL: 'http://localhost:11434/v1',
|
|
74
|
+
OPENAI_API_KEY: 'test-openai-key',
|
|
75
|
+
},
|
|
76
|
+
}));
|
|
77
|
+
|
|
78
|
+
import { estimateTokenCount, inference } from '../../src/utils/inference.js';
|
|
79
|
+
|
|
80
|
+
describe('inference', () => {
|
|
81
|
+
beforeEach(() => {
|
|
82
|
+
vi.clearAllMocks();
|
|
83
|
+
prismaMocks.aiUsageLog.create.mockResolvedValue({ id: 'log-1' });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('logs chat completion usage with the provided reason', async () => {
|
|
87
|
+
chatCreateMock.mockResolvedValue({
|
|
88
|
+
choices: [{ message: { content: 'Hello back' } }],
|
|
89
|
+
usage: {
|
|
90
|
+
prompt_tokens: 12,
|
|
91
|
+
completion_tokens: 8,
|
|
92
|
+
total_tokens: 20,
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const result = await inference<string>('Hello there', undefined, {
|
|
97
|
+
reason: 'student_progress_chat',
|
|
98
|
+
metadata: { classId: 'class-1', studentId: 'student-1' },
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
expect(result).toBe('Hello back');
|
|
102
|
+
expect(prismaMocks.aiUsageLog.create).toHaveBeenCalledWith({
|
|
103
|
+
data: expect.objectContaining({
|
|
104
|
+
reason: 'student_progress_chat',
|
|
105
|
+
operation: 'chat.completions',
|
|
106
|
+
success: true,
|
|
107
|
+
promptTokens: 12,
|
|
108
|
+
completionTokens: 8,
|
|
109
|
+
totalTokens: 20,
|
|
110
|
+
estimatedInputTokens: estimateTokenCount('Hello there'),
|
|
111
|
+
metadata: { classId: 'class-1', studentId: 'student-1' },
|
|
112
|
+
}),
|
|
113
|
+
});
|
|
114
|
+
expect(logger.info).toHaveBeenCalledWith(
|
|
115
|
+
'AI usage logged',
|
|
116
|
+
expect.objectContaining({
|
|
117
|
+
reason: 'student_progress_chat',
|
|
118
|
+
totalTokens: 20,
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
121
|
+
expect(chatCreateMock).toHaveBeenCalledWith({
|
|
122
|
+
model: 'gpt-5-nano',
|
|
123
|
+
messages: [{ role: 'user', content: 'Hello there' }],
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('passes optional chat completion settings through the helper', async () => {
|
|
128
|
+
chatCreateMock.mockResolvedValue({
|
|
129
|
+
choices: [{ message: { content: 'Configured response' } }],
|
|
130
|
+
usage: {
|
|
131
|
+
prompt_tokens: 10,
|
|
132
|
+
completion_tokens: 5,
|
|
133
|
+
total_tokens: 15,
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const result = await inference<string>('Introduce yourself', undefined, {
|
|
138
|
+
reason: 'newton_chat_introduction',
|
|
139
|
+
metadata: { conversationId: 'conversation-1' },
|
|
140
|
+
model: 'command-a-03-2025',
|
|
141
|
+
maxTokens: 300,
|
|
142
|
+
temperature: 0.8,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
expect(result).toBe('Configured response');
|
|
146
|
+
expect(chatCreateMock).toHaveBeenCalledWith({
|
|
147
|
+
model: 'command-a-03-2025',
|
|
148
|
+
messages: [{ role: 'user', content: 'Introduce yourself' }],
|
|
149
|
+
max_tokens: 300,
|
|
150
|
+
temperature: 0.8,
|
|
151
|
+
});
|
|
152
|
+
expect(prismaMocks.aiUsageLog.create).toHaveBeenCalledWith({
|
|
153
|
+
data: expect.objectContaining({
|
|
154
|
+
reason: 'newton_chat_introduction',
|
|
155
|
+
model: 'command-a-03-2025',
|
|
156
|
+
metadata: { conversationId: 'conversation-1' },
|
|
157
|
+
}),
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('logs structured response usage for parsed responses', async () => {
|
|
162
|
+
responseParseMock.mockResolvedValue({
|
|
163
|
+
output_parsed: { score: 4 },
|
|
164
|
+
usage: {
|
|
165
|
+
input_tokens: 30,
|
|
166
|
+
output_tokens: 10,
|
|
167
|
+
total_tokens: 40,
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const schema = z.object({ score: z.number() });
|
|
172
|
+
const result = await inference<{ score: number }>('Grade this', schema, {
|
|
173
|
+
reason: 'worksheet_autograde',
|
|
174
|
+
metadata: { worksheetResponseId: 'response-1' },
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
expect(result).toEqual({ score: 4 });
|
|
178
|
+
expect(prismaMocks.aiUsageLog.create).toHaveBeenCalledWith({
|
|
179
|
+
data: expect.objectContaining({
|
|
180
|
+
reason: 'worksheet_autograde',
|
|
181
|
+
operation: 'responses.parse',
|
|
182
|
+
success: true,
|
|
183
|
+
promptTokens: 30,
|
|
184
|
+
completionTokens: 10,
|
|
185
|
+
totalTokens: 40,
|
|
186
|
+
metadata: { worksheetResponseId: 'response-1' },
|
|
187
|
+
}),
|
|
188
|
+
});
|
|
189
|
+
expect(responseParseMock).toHaveBeenCalledWith({
|
|
190
|
+
model: 'gpt-5-nano',
|
|
191
|
+
input: [{
|
|
192
|
+
role: 'user',
|
|
193
|
+
content: [{ type: 'input_text', text: 'Grade this' }],
|
|
194
|
+
}],
|
|
195
|
+
text: {
|
|
196
|
+
format: expect.any(Object),
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('rejects ResponseInput content with input parts when format is missing', async () => {
|
|
202
|
+
await expect(
|
|
203
|
+
inference<string>([
|
|
204
|
+
{
|
|
205
|
+
role: 'user',
|
|
206
|
+
content: [
|
|
207
|
+
{ type: 'input_text', text: 'Read this PDF' },
|
|
208
|
+
{ type: 'input_file', file_url: 'https://storage.googleapis.com/bucket/file.pdf' },
|
|
209
|
+
],
|
|
210
|
+
},
|
|
211
|
+
] as any, undefined, {
|
|
212
|
+
reason: 'pdf_without_format',
|
|
213
|
+
}),
|
|
214
|
+
).rejects.toThrow(
|
|
215
|
+
'normalizeInferenceInput cannot handle ResponseInput content with input_* parts when format is undefined; pass a format to use responses.parse.',
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
expect(chatCreateMock).not.toHaveBeenCalled();
|
|
219
|
+
expect(prismaMocks.aiUsageLog.create).toHaveBeenCalledWith({
|
|
220
|
+
data: expect.objectContaining({
|
|
221
|
+
reason: 'pdf_without_format',
|
|
222
|
+
operation: 'chat.completions',
|
|
223
|
+
success: false,
|
|
224
|
+
}),
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('logs failed attempts before rethrowing', async () => {
|
|
229
|
+
chatCreateMock.mockRejectedValue(new Error('OpenAI unavailable'));
|
|
230
|
+
|
|
231
|
+
await expect(
|
|
232
|
+
inference<string>('Hello there', undefined, {
|
|
233
|
+
reason: 'newton_chat_response',
|
|
234
|
+
metadata: { conversationId: 'conversation-1' },
|
|
235
|
+
}),
|
|
236
|
+
).rejects.toThrow('OpenAI unavailable');
|
|
237
|
+
|
|
238
|
+
expect(prismaMocks.aiUsageLog.create).toHaveBeenCalledWith({
|
|
239
|
+
data: expect.objectContaining({
|
|
240
|
+
reason: 'newton_chat_response',
|
|
241
|
+
operation: 'chat.completions',
|
|
242
|
+
success: false,
|
|
243
|
+
estimatedInputTokens: estimateTokenCount('Hello there'),
|
|
244
|
+
error: 'OpenAI unavailable',
|
|
245
|
+
metadata: { conversationId: 'conversation-1' },
|
|
246
|
+
}),
|
|
247
|
+
});
|
|
248
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
249
|
+
'Failed to generate inference response',
|
|
250
|
+
expect.objectContaining({
|
|
251
|
+
reason: 'newton_chat_response',
|
|
252
|
+
}),
|
|
253
|
+
);
|
|
254
|
+
});
|
|
255
|
+
});
|