@studious-lms/server 1.4.1 → 1.4.2
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 +6 -0
- package/.env.test.example +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +36 -50
- package/dist/index.js.map +1 -1
- package/dist/lib/config/cors.d.ts +16 -0
- package/dist/lib/config/cors.d.ts.map +1 -0
- package/dist/lib/config/cors.js +75 -0
- package/dist/lib/config/cors.js.map +1 -0
- package/dist/lib/config/env.d.ts +14 -0
- package/dist/lib/config/env.d.ts.map +1 -1
- package/dist/lib/config/env.js +9 -2
- package/dist/lib/config/env.js.map +1 -1
- package/dist/lib/prisma.d.ts +14 -2
- package/dist/lib/prisma.d.ts.map +1 -1
- package/dist/lib/prisma.js +27 -8
- package/dist/lib/prisma.js.map +1 -1
- package/dist/middleware/security.d.ts.map +1 -1
- package/dist/middleware/security.js +3 -3
- package/dist/middleware/security.js.map +1 -1
- package/dist/models/agenda.d.ts +16 -16
- package/dist/models/announcement.d.ts +59 -23
- package/dist/models/announcement.d.ts.map +1 -1
- package/dist/models/assignment.d.ts +363 -276
- package/dist/models/assignment.d.ts.map +1 -1
- package/dist/models/attendance.d.ts +63 -21
- package/dist/models/attendance.d.ts.map +1 -1
- package/dist/models/auth.d.ts +102 -18
- package/dist/models/auth.d.ts.map +1 -1
- package/dist/models/class.d.ts +112 -64
- package/dist/models/class.d.ts.map +1 -1
- package/dist/models/comment.d.ts +52 -16
- package/dist/models/comment.d.ts.map +1 -1
- package/dist/models/conversation.d.ts +46 -16
- package/dist/models/conversation.d.ts.map +1 -1
- package/dist/models/event.d.ts +107 -53
- package/dist/models/event.d.ts.map +1 -1
- package/dist/models/file.d.ts +213 -165
- package/dist/models/file.d.ts.map +1 -1
- package/dist/models/folder.d.ts +161 -77
- package/dist/models/folder.d.ts.map +1 -1
- package/dist/models/labChat.d.ts +73 -31
- package/dist/models/labChat.d.ts.map +1 -1
- package/dist/models/marketing.d.ts +25 -7
- package/dist/models/marketing.d.ts.map +1 -1
- package/dist/models/message.d.ts +31 -13
- package/dist/models/message.d.ts.map +1 -1
- package/dist/models/newtonChat.d.ts +34 -10
- package/dist/models/newtonChat.d.ts.map +1 -1
- package/dist/models/notification.d.ts +25 -7
- package/dist/models/notification.d.ts.map +1 -1
- package/dist/models/section.d.ts +71 -23
- package/dist/models/section.d.ts.map +1 -1
- package/dist/models/user.d.ts +27 -9
- package/dist/models/user.d.ts.map +1 -1
- package/dist/models/worksheet.d.ts +237 -108
- package/dist/models/worksheet.d.ts.map +1 -1
- package/dist/pipelines/aiLabChat.d.ts +22 -2
- package/dist/pipelines/aiLabChat.d.ts.map +1 -1
- package/dist/pipelines/aiLabChat.js +125 -95
- package/dist/pipelines/aiLabChat.js.map +1 -1
- package/dist/pipelines/aiLabChatContract.d.ts +22 -22
- package/dist/pipelines/gradeWorksheet.d.ts +8 -8
- package/dist/pipelines/gradeWorksheet.js +4 -4
- package/dist/pipelines/gradeWorksheet.js.map +1 -1
- package/dist/pipelines/labChatPrompt.d.ts +27 -0
- package/dist/pipelines/labChatPrompt.d.ts.map +1 -1
- package/dist/pipelines/labChatPrompt.js +143 -69
- package/dist/pipelines/labChatPrompt.js.map +1 -1
- package/dist/routers/_app.d.ts +1439 -1223
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/agenda.d.ts +16 -16
- package/dist/routers/announcement.d.ts +19 -19
- package/dist/routers/assignment.d.ts +307 -291
- package/dist/routers/assignment.d.ts.map +1 -1
- package/dist/routers/assignment.js +3 -2
- package/dist/routers/assignment.js.map +1 -1
- package/dist/routers/attendance.d.ts +7 -7
- package/dist/routers/auth.d.ts +1 -1
- package/dist/routers/class.d.ts +77 -71
- package/dist/routers/class.d.ts.map +1 -1
- package/dist/routers/comment.d.ts +6 -6
- package/dist/routers/conversation.d.ts +11 -11
- package/dist/routers/event.d.ts +35 -35
- package/dist/routers/file.d.ts +12 -12
- package/dist/routers/folder.d.ts +54 -54
- package/dist/routers/labChat.d.ts +12 -12
- package/dist/routers/marketing.d.ts +2 -2
- package/dist/routers/message.d.ts +2 -2
- package/dist/routers/newtonChat.d.ts +1 -1
- package/dist/routers/notifications.d.ts +4 -4
- package/dist/routers/section.d.ts +7 -7
- package/dist/routers/studentProgress.d.ts +86 -0
- package/dist/routers/studentProgress.d.ts.map +1 -1
- package/dist/routers/studentProgress.js +14 -4
- package/dist/routers/studentProgress.js.map +1 -1
- package/dist/routers/user.d.ts +1 -1
- package/dist/routers/worksheet.d.ts +58 -58
- package/dist/seedDatabase.d.ts +1 -1
- package/dist/services/agenda.d.ts +16 -16
- package/dist/services/announcement.d.ts +8 -8
- package/dist/services/assignment.d.ts +299 -283
- package/dist/services/assignment.d.ts.map +1 -1
- package/dist/services/assignment.js +24 -5
- package/dist/services/assignment.js.map +1 -1
- package/dist/services/attendance.d.ts +7 -7
- package/dist/services/auth.d.ts +1 -1
- package/dist/services/class.d.ts +73 -67
- package/dist/services/class.d.ts.map +1 -1
- package/dist/services/comment.d.ts +6 -6
- package/dist/services/conversation.d.ts +11 -11
- package/dist/services/event.d.ts +31 -31
- package/dist/services/file.d.ts +12 -12
- package/dist/services/folder.d.ts +52 -52
- package/dist/services/labChat.d.ts +12 -12
- package/dist/services/marketing.d.ts +2 -2
- package/dist/services/notification.d.ts +4 -4
- package/dist/services/section.d.ts +6 -6
- package/dist/services/studentProgress.d.ts +75 -0
- package/dist/services/studentProgress.d.ts.map +1 -1
- package/dist/services/studentProgress.js +296 -106
- package/dist/services/studentProgress.js.map +1 -1
- package/dist/services/worksheet.d.ts +49 -49
- package/dist/utils/inference.d.ts +0 -11
- package/dist/utils/inference.d.ts.map +1 -1
- package/dist/utils/inference.js +2 -50
- package/dist/utils/inference.js.map +1 -1
- package/package.json +1 -1
- package/prisma/migrations/20260410124000_add_submission_recommendation_state/migration.sql +14 -0
- package/prisma/schema.prisma +14 -0
- package/src/index.ts +39 -51
- package/src/lib/config/cors.ts +96 -0
- package/src/lib/config/env.ts +12 -1
- package/src/lib/prisma.ts +25 -6
- package/src/middleware/security.ts +1 -1
- package/src/pipelines/aiLabChat.ts +175 -104
- package/src/pipelines/gradeWorksheet.ts +2 -2
- package/src/pipelines/labChatPrompt.ts +196 -68
- package/src/routers/assignment.ts +1 -0
- package/src/routers/studentProgress.ts +25 -1
- package/src/services/assignment.ts +30 -2
- package/src/services/studentProgress.ts +421 -120
- package/src/utils/inference.ts +0 -61
- package/tests/lib/cors.test.ts +103 -0
- package/tests/pipelines/aiLabChat.test.ts +64 -84
- package/tests/routers/studentProgress.test.ts +2 -31
- package/tests/utils/aiLabChatPrompt.test.ts +114 -6
- package/tests/utils/studentProgress.test.ts +361 -0
- package/vitest.unit.config.ts +1 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
createCorsOriginMatcher,
|
|
4
|
+
isValidCorsOriginPatternList,
|
|
5
|
+
parseCorsOriginPatterns,
|
|
6
|
+
parseCorsOrigins,
|
|
7
|
+
} from '../../src/lib/config/cors.js';
|
|
8
|
+
|
|
9
|
+
describe('CORS configuration', () => {
|
|
10
|
+
test('parses comma-separated origins while trimming empty entries', () => {
|
|
11
|
+
expect(parseCorsOrigins(' https://one.example, ,https://two.example ')).toEqual([
|
|
12
|
+
'https://one.example',
|
|
13
|
+
'https://two.example',
|
|
14
|
+
]);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('normalizes configured origin URLs before matching', () => {
|
|
18
|
+
const { allowedOrigins, isAllowedOrigin } = createCorsOriginMatcher({
|
|
19
|
+
NODE_ENV: 'production',
|
|
20
|
+
NEXT_PUBLIC_APP_URL: 'https://app.example/dashboard/',
|
|
21
|
+
CORS_ALLOWED_ORIGINS: 'https://preview.example/some/path/, https://branch.example/',
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
expect(allowedOrigins).toContain('https://app.example');
|
|
25
|
+
expect(allowedOrigins).toContain('https://preview.example');
|
|
26
|
+
expect(allowedOrigins).toContain('https://branch.example');
|
|
27
|
+
expect(isAllowedOrigin('https://preview.example/')).toBe(true);
|
|
28
|
+
expect(isAllowedOrigin('https://branch.example/path')).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('allows exact configured origins', () => {
|
|
32
|
+
const { isAllowedOrigin } = createCorsOriginMatcher({
|
|
33
|
+
NODE_ENV: 'production',
|
|
34
|
+
NEXT_PUBLIC_APP_URL: 'https://app.example',
|
|
35
|
+
CORS_ALLOWED_ORIGINS: 'https://preview.example,https://branch.example',
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
expect(isAllowedOrigin('https://www.studious.sh')).toBe(true);
|
|
39
|
+
expect(isAllowedOrigin('https://app.example')).toBe(true);
|
|
40
|
+
expect(isAllowedOrigin('https://preview.example')).toBe(true);
|
|
41
|
+
expect(isAllowedOrigin('https://branch.example')).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('does not allow dev subdomains in production unless configured', () => {
|
|
45
|
+
const defaultProductionMatcher = createCorsOriginMatcher({
|
|
46
|
+
NODE_ENV: 'production',
|
|
47
|
+
NEXT_PUBLIC_APP_URL: 'https://app.example',
|
|
48
|
+
});
|
|
49
|
+
const configuredProductionMatcher = createCorsOriginMatcher({
|
|
50
|
+
NODE_ENV: 'production',
|
|
51
|
+
NEXT_PUBLIC_APP_URL: 'https://app.example',
|
|
52
|
+
CORS_ALLOWED_ORIGINS: 'https://dev.studious.sh',
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
expect(defaultProductionMatcher.isAllowedOrigin('https://dev.studious.sh')).toBe(false);
|
|
56
|
+
expect(defaultProductionMatcher.isAllowedOrigin('https://www.dev.studious.sh')).toBe(false);
|
|
57
|
+
expect(configuredProductionMatcher.isAllowedOrigin('https://dev.studious.sh')).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('allows narrow configured origin patterns', () => {
|
|
61
|
+
const { isAllowedOrigin } = createCorsOriginMatcher({
|
|
62
|
+
NODE_ENV: 'production',
|
|
63
|
+
CORS_ALLOWED_ORIGIN_PATTERNS: '^https://studious-git-[a-z0-9-]+-studious-lms\\.vercel\\.app$',
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
expect(isAllowedOrigin('https://studious-git-configure-vercel-cors-studious-lms.vercel.app')).toBe(true);
|
|
67
|
+
expect(isAllowedOrigin('https://malicious-studious-git-configure-vercel-cors-studious-lms.vercel.app')).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('rejects missing and unconfigured origins instead of using a fallback', () => {
|
|
71
|
+
const { isAllowedOrigin } = createCorsOriginMatcher({
|
|
72
|
+
NODE_ENV: 'production',
|
|
73
|
+
NEXT_PUBLIC_APP_URL: 'https://app.example',
|
|
74
|
+
CORS_ALLOWED_ORIGINS: 'https://preview.example',
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(isAllowedOrigin()).toBe(false);
|
|
78
|
+
expect(isAllowedOrigin('https://evil.example')).toBe(false);
|
|
79
|
+
expect(isAllowedOrigin('http://localhost:3000')).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('compiles configured regex patterns', () => {
|
|
83
|
+
expect(parseCorsOriginPatterns('^https://one\\.example$, ^https://two\\.example$').map((pattern) => pattern.source)).toEqual([
|
|
84
|
+
'^https:\\/\\/one\\.example$',
|
|
85
|
+
'^https:\\/\\/two\\.example$',
|
|
86
|
+
]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('validates origin pattern syntax for env parsing', () => {
|
|
90
|
+
expect(isValidCorsOriginPatternList()).toBe(true);
|
|
91
|
+
expect(isValidCorsOriginPatternList('')).toBe(true);
|
|
92
|
+
expect(isValidCorsOriginPatternList('^https://one\\.example$')).toBe(true);
|
|
93
|
+
expect(isValidCorsOriginPatternList('(')).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('rejects overly long origin patterns', () => {
|
|
97
|
+
expect(isValidCorsOriginPatternList(`^https://${'a'.repeat(250)}\\.example$`)).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('rejects origin patterns with nested quantifiers', () => {
|
|
101
|
+
expect(isValidCorsOriginPatternList('^(a+)+$')).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -1,95 +1,75 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
labChat: {
|
|
5
|
-
findUnique: vi.fn(),
|
|
6
|
-
},
|
|
7
|
-
message: {
|
|
8
|
-
update: vi.fn(),
|
|
9
|
-
},
|
|
10
|
-
};
|
|
3
|
+
import { sliceMessagesThroughAnchor } from "../../src/pipelines/aiLabChat.js";
|
|
11
4
|
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
5
|
+
describe("sliceMessagesThroughAnchor", () => {
|
|
6
|
+
const messages = [
|
|
7
|
+
{
|
|
8
|
+
id: "latest-teacher",
|
|
9
|
+
content: "Actually make it harder",
|
|
10
|
+
senderId: "teacher-1",
|
|
11
|
+
createdAt: new Date("2026-04-08T12:04:00Z"),
|
|
12
|
+
sender: null,
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
id: "anchor-first",
|
|
16
|
+
content: "Create a quiz",
|
|
17
|
+
senderId: "teacher-1",
|
|
18
|
+
createdAt: new Date("2026-04-08T12:03:00Z"),
|
|
19
|
+
sender: null,
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
id: "middle-ai",
|
|
23
|
+
content: "What topic should I cover?",
|
|
24
|
+
senderId: "ai-user",
|
|
25
|
+
createdAt: new Date("2026-04-08T12:02:00Z"),
|
|
26
|
+
sender: null,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: "anchor-last",
|
|
30
|
+
content: "Earlier context",
|
|
31
|
+
senderId: "teacher-1",
|
|
32
|
+
createdAt: new Date("2026-04-08T12:01:00Z"),
|
|
33
|
+
sender: null,
|
|
34
|
+
},
|
|
35
|
+
];
|
|
16
36
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
},
|
|
25
|
-
teacherChannel: teacherChannelMock,
|
|
26
|
-
}));
|
|
27
|
-
|
|
28
|
-
vi.mock('../../src/utils/logger.js', () => ({
|
|
29
|
-
logger: {
|
|
30
|
-
error: loggerErrorMock,
|
|
31
|
-
info: vi.fn(),
|
|
32
|
-
},
|
|
33
|
-
}));
|
|
34
|
-
|
|
35
|
-
vi.mock('../../src/utils/aiUser.js', () => ({
|
|
36
|
-
getAIUserId: vi.fn(),
|
|
37
|
-
isAIUser: vi.fn(() => false),
|
|
38
|
-
}));
|
|
39
|
-
|
|
40
|
-
vi.mock('../../src/utils/inference.js', () => ({
|
|
41
|
-
inference: vi.fn(),
|
|
42
|
-
inferenceClient: {},
|
|
43
|
-
sendAIMessage: vi.fn(),
|
|
44
|
-
}));
|
|
45
|
-
|
|
46
|
-
vi.mock('../../src/lib/jsonConversion.js', () => ({
|
|
47
|
-
createPdf: vi.fn(),
|
|
48
|
-
}));
|
|
49
|
-
|
|
50
|
-
vi.mock('../../src/lib/googleCloudStorage.js', () => ({
|
|
51
|
-
bucket: {
|
|
52
|
-
file: vi.fn(),
|
|
53
|
-
},
|
|
54
|
-
}));
|
|
55
|
-
|
|
56
|
-
const { generateAndSendLabResponse } = await import('../../src/pipelines/aiLabChat.js');
|
|
37
|
+
test("drops messages newer than the anchor message", () => {
|
|
38
|
+
expect(sliceMessagesThroughAnchor(messages, "anchor-first")).toEqual([
|
|
39
|
+
messages[1],
|
|
40
|
+
messages[2],
|
|
41
|
+
messages[3],
|
|
42
|
+
]);
|
|
43
|
+
});
|
|
57
44
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
45
|
+
test("returns the first limit messages when no anchor is provided", () => {
|
|
46
|
+
expect(sliceMessagesThroughAnchor(messages, undefined, 2)).toEqual([
|
|
47
|
+
messages[0],
|
|
48
|
+
messages[1],
|
|
49
|
+
]);
|
|
62
50
|
});
|
|
63
51
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
52
|
+
test("returns the first limit messages when the anchor is not found", () => {
|
|
53
|
+
expect(sliceMessagesThroughAnchor(messages, "missing-anchor", 2)).toEqual([
|
|
54
|
+
messages[0],
|
|
55
|
+
messages[1],
|
|
56
|
+
]);
|
|
57
|
+
});
|
|
67
58
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
59
|
+
test("returns an empty array when given no messages", () => {
|
|
60
|
+
expect(sliceMessagesThroughAnchor([], "missing-anchor")).toEqual([]);
|
|
61
|
+
});
|
|
71
62
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
63
|
+
test("returns the anchored slice when the anchor is the first eligible message", () => {
|
|
64
|
+
expect(sliceMessagesThroughAnchor(messages, "anchor-first", 2)).toEqual([
|
|
65
|
+
messages[1],
|
|
66
|
+
messages[2],
|
|
67
|
+
]);
|
|
68
|
+
});
|
|
78
69
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
expect(teacherChannelMock).toHaveBeenCalledWith('class-1');
|
|
84
|
-
expect(pusherTriggerMock).toHaveBeenCalledWith('teacher-class-1', 'lab-response-failed', {
|
|
85
|
-
labChatId: 'lab-chat-1',
|
|
86
|
-
messageId: 'message-1',
|
|
87
|
-
error: 'AI response generation failed',
|
|
88
|
-
});
|
|
89
|
-
expect(loggerErrorMock).toHaveBeenCalledWith('Failed to set message status FAILED:', {
|
|
90
|
-
error: statusError,
|
|
91
|
-
labChatId: 'lab-chat-1',
|
|
92
|
-
messageId: 'message-1',
|
|
93
|
-
});
|
|
70
|
+
test("returns only the last message when the anchor is already the oldest", () => {
|
|
71
|
+
expect(sliceMessagesThroughAnchor(messages, "anchor-last", 3)).toEqual([
|
|
72
|
+
messages[3],
|
|
73
|
+
]);
|
|
94
74
|
});
|
|
95
75
|
});
|
|
@@ -53,16 +53,6 @@ describe("Student Progress Router", () => {
|
|
|
53
53
|
const futureDueDate = new Date();
|
|
54
54
|
futureDueDate.setDate(futureDueDate.getDate() + 2);
|
|
55
55
|
|
|
56
|
-
const futureAssignment = await user1Caller.assignment.create({
|
|
57
|
-
classId: testClass.id,
|
|
58
|
-
title: "Upcoming Quiz",
|
|
59
|
-
instructions: "Prepare for the upcoming quiz.",
|
|
60
|
-
dueDate: futureDueDate.toISOString(),
|
|
61
|
-
maxGrade: 100,
|
|
62
|
-
graded: true,
|
|
63
|
-
type: "QUIZ",
|
|
64
|
-
});
|
|
65
|
-
|
|
66
56
|
const futureCompletedAssignment = await user1Caller.assignment.create({
|
|
67
57
|
classId: testClass.id,
|
|
68
58
|
title: "Completed Future Quiz",
|
|
@@ -110,17 +100,6 @@ describe("Student Progress Router", () => {
|
|
|
110
100
|
expect(result.recommendations[0].assignmentId).toBe(assignment.id);
|
|
111
101
|
expect(result.recommendations[0].reasons).toContain("Scored 50%");
|
|
112
102
|
|
|
113
|
-
const futureRecommendation = result.recommendations.find(
|
|
114
|
-
(recommendation) =>
|
|
115
|
-
recommendation.assignmentId === futureAssignment.id,
|
|
116
|
-
);
|
|
117
|
-
expect(futureRecommendation?.reasons).toContain(
|
|
118
|
-
"Upcoming graded assignment",
|
|
119
|
-
);
|
|
120
|
-
expect(futureRecommendation?.reasons).not.toContain(
|
|
121
|
-
"Awaiting grade or feedback",
|
|
122
|
-
);
|
|
123
|
-
|
|
124
103
|
const completedFutureRecommendation = result.recommendations.find(
|
|
125
104
|
(recommendation) =>
|
|
126
105
|
recommendation.assignmentId === futureCompletedAssignment.id,
|
|
@@ -189,11 +168,7 @@ describe("Student Progress Router", () => {
|
|
|
189
168
|
});
|
|
190
169
|
|
|
191
170
|
expect(result.isFallback).toBe(true);
|
|
192
|
-
expect(result.message).toContain("current overall
|
|
193
|
-
expect(result.message).toContain(
|
|
194
|
-
"not enough graded work to determine a recent trend",
|
|
195
|
-
);
|
|
196
|
-
expect(result.message).not.toContain("missing or low-scoring");
|
|
171
|
+
expect(result.message).toContain("current overall grade");
|
|
197
172
|
});
|
|
198
173
|
|
|
199
174
|
test("returns a flagged fallback chat response for empty model output", async () => {
|
|
@@ -208,11 +183,7 @@ describe("Student Progress Router", () => {
|
|
|
208
183
|
});
|
|
209
184
|
|
|
210
185
|
expect(result.isFallback).toBe(true);
|
|
211
|
-
expect(result.message).toContain("current overall
|
|
212
|
-
expect(result.message).toContain(
|
|
213
|
-
"not enough graded work to determine a recent trend",
|
|
214
|
-
);
|
|
215
|
-
expect(result.message).not.toContain("missing or low-scoring");
|
|
186
|
+
expect(result.message).toContain("current overall grade");
|
|
216
187
|
});
|
|
217
188
|
|
|
218
189
|
test("prevents a student from chatting about another student's progress", async () => {
|
|
@@ -1,18 +1,126 @@
|
|
|
1
1
|
import { describe, expect, test } from "vitest";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
buildLabChatResponseMessages,
|
|
5
|
+
buildLabChatSystemPrompt,
|
|
6
|
+
} from "../../src/pipelines/labChatPrompt";
|
|
7
|
+
import { labChatArrayFieldInstructions, labChatResponseFormat } from "../../src/pipelines/aiLabChatContract";
|
|
4
8
|
|
|
5
9
|
describe("buildLabChatSystemPrompt", () => {
|
|
6
10
|
test("requires arrays for worksheet and section structured output fields", () => {
|
|
7
11
|
const prompt = buildLabChatSystemPrompt("Context");
|
|
8
12
|
|
|
9
|
-
expect(prompt).toContain(
|
|
10
|
-
|
|
11
|
-
);
|
|
12
|
-
expect(prompt).toContain('"worksheetsToCreate" must always be an array. Use [] when there are no worksheets to create.');
|
|
13
|
-
expect(prompt).toContain('"sectionsToCreate" must always be an array. Use [] when there are no sections to create.');
|
|
13
|
+
expect(prompt).toContain(labChatResponseFormat);
|
|
14
|
+
expect(prompt).toContain(labChatArrayFieldInstructions);
|
|
14
15
|
expect(prompt).toContain('Do not return null for "worksheetsToCreate" or "sectionsToCreate".');
|
|
15
16
|
expect(prompt).not.toContain('"worksheetsToCreate": null | array');
|
|
16
17
|
expect(prompt).not.toContain('"sectionsToCreate": null | array');
|
|
17
18
|
});
|
|
18
19
|
});
|
|
20
|
+
|
|
21
|
+
describe("buildLabChatResponseMessages", () => {
|
|
22
|
+
test("builds response messages from history without replaying the latest teacher turn", () => {
|
|
23
|
+
const messages = buildLabChatResponseMessages({
|
|
24
|
+
context: "Topic context",
|
|
25
|
+
classContext: "CLASS: Biology",
|
|
26
|
+
recentMessages: [
|
|
27
|
+
{
|
|
28
|
+
senderId: "teacher-1",
|
|
29
|
+
content: "Create a quiz on osmosis",
|
|
30
|
+
sender: {
|
|
31
|
+
username: "teacher",
|
|
32
|
+
profile: { displayName: "Ms Patel" },
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
senderId: "ai-user",
|
|
37
|
+
content: "I can do that.",
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
isAIUser: (senderId) => senderId === "ai-user",
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const teacherTurns = messages.filter(
|
|
44
|
+
(message) =>
|
|
45
|
+
message.role === "user" &&
|
|
46
|
+
typeof message.content === "string" &&
|
|
47
|
+
message.content.includes("Create a quiz on osmosis"),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
expect(teacherTurns).toHaveLength(1);
|
|
51
|
+
expect(messages.at(-2)).toMatchObject({
|
|
52
|
+
role: "developer",
|
|
53
|
+
content: expect.stringContaining("CLASS CONTEXT"),
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("sorts timestamped history into chronological order before prompt assembly", () => {
|
|
58
|
+
const messages = buildLabChatResponseMessages({
|
|
59
|
+
context: "Topic context",
|
|
60
|
+
classContext: "CLASS: Biology",
|
|
61
|
+
recentMessages: [
|
|
62
|
+
{
|
|
63
|
+
id: "assistant-later",
|
|
64
|
+
senderId: "ai-user",
|
|
65
|
+
content: "Second response",
|
|
66
|
+
createdAt: new Date("2026-04-08T12:02:00Z"),
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: "teacher-earlier",
|
|
70
|
+
senderId: "teacher-1",
|
|
71
|
+
content: "First request",
|
|
72
|
+
createdAt: new Date("2026-04-08T12:01:00Z"),
|
|
73
|
+
sender: {
|
|
74
|
+
username: "teacher",
|
|
75
|
+
profile: { displayName: "Ms Patel" },
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
isAIUser: (senderId) => senderId === "ai-user",
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
expect(messages[1]).toMatchObject({
|
|
83
|
+
role: "user",
|
|
84
|
+
content: "Ms Patel: First request",
|
|
85
|
+
});
|
|
86
|
+
expect(messages[2]).toMatchObject({
|
|
87
|
+
role: "assistant",
|
|
88
|
+
content: "Second response",
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("keeps original order among messages without timestamps", () => {
|
|
93
|
+
const messages = buildLabChatResponseMessages({
|
|
94
|
+
context: "Topic context",
|
|
95
|
+
classContext: "CLASS: Biology",
|
|
96
|
+
recentMessages: [
|
|
97
|
+
{
|
|
98
|
+
senderId: "teacher-1",
|
|
99
|
+
content: "First untimed request",
|
|
100
|
+
sender: {
|
|
101
|
+
username: "teacher",
|
|
102
|
+
profile: { displayName: "Ms Patel" },
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
senderId: "teacher-1",
|
|
107
|
+
content: "Second untimed request",
|
|
108
|
+
sender: {
|
|
109
|
+
username: "teacher",
|
|
110
|
+
profile: { displayName: "Ms Patel" },
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
isAIUser: () => false,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
expect(messages[1]).toMatchObject({
|
|
118
|
+
role: "user",
|
|
119
|
+
content: "Ms Patel: First untimed request",
|
|
120
|
+
});
|
|
121
|
+
expect(messages[2]).toMatchObject({
|
|
122
|
+
role: "user",
|
|
123
|
+
content: "Ms Patel: Second untimed request",
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
});
|