@studious-lms/server 1.12.0 → 1.14.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/.dockerignore +28 -0
- package/.env.example +5 -0
- package/.github/workflows/deploy.yml +123 -0
- package/Dockerfile +54 -0
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/config/cors.d.ts.map +1 -1
- package/dist/lib/config/cors.js +31 -11
- package/dist/lib/config/cors.js.map +1 -1
- package/dist/lib/config/env.d.ts +17 -0
- package/dist/lib/config/env.d.ts.map +1 -1
- package/dist/lib/config/env.js +8 -2
- package/dist/lib/config/env.js.map +1 -1
- package/dist/lib/googleCloudStorage.d.ts +10 -0
- package/dist/lib/googleCloudStorage.d.ts.map +1 -1
- package/dist/lib/googleCloudStorage.js +22 -2
- package/dist/lib/googleCloudStorage.js.map +1 -1
- package/dist/lib/prisma.d.ts +104 -5
- package/dist/lib/prisma.d.ts.map +1 -1
- package/dist/lib/prisma.js +33 -2
- package/dist/lib/prisma.js.map +1 -1
- package/dist/models/agenda.d.ts +20 -20
- package/dist/models/announcement.d.ts +177 -69
- package/dist/models/announcement.d.ts.map +1 -1
- package/dist/models/assignment.d.ts +551 -353
- package/dist/models/assignment.d.ts.map +1 -1
- package/dist/models/attendance.d.ts +197 -71
- package/dist/models/attendance.d.ts.map +1 -1
- package/dist/models/auth.d.ts +348 -114
- package/dist/models/auth.d.ts.map +1 -1
- package/dist/models/class.d.ts +324 -188
- package/dist/models/class.d.ts.map +1 -1
- package/dist/models/class.js +9 -3
- package/dist/models/class.js.map +1 -1
- package/dist/models/cloudStorage.d.ts +146 -56
- package/dist/models/cloudStorage.d.ts.map +1 -1
- package/dist/models/comment.d.ts +269 -110
- package/dist/models/comment.d.ts.map +1 -1
- package/dist/models/comment.js +3 -2
- package/dist/models/comment.js.map +1 -1
- package/dist/models/conversation.d.ts +206 -63
- package/dist/models/conversation.d.ts.map +1 -1
- package/dist/models/conversation.js +13 -2
- package/dist/models/conversation.js.map +1 -1
- package/dist/models/department.d.ts +196 -70
- package/dist/models/department.d.ts.map +1 -1
- package/dist/models/event.d.ts +287 -125
- package/dist/models/event.d.ts.map +1 -1
- package/dist/models/file.d.ts +389 -263
- package/dist/models/file.d.ts.map +1 -1
- package/dist/models/folder.d.ts +433 -181
- package/dist/models/folder.d.ts.map +1 -1
- package/dist/models/labChat.d.ts +209 -83
- package/dist/models/labChat.d.ts.map +1 -1
- package/dist/models/marketing.d.ts +80 -26
- package/dist/models/marketing.d.ts.map +1 -1
- package/dist/models/message.d.ts +95 -41
- package/dist/models/message.d.ts.map +1 -1
- package/dist/models/newtonChat.d.ts +83 -29
- package/dist/models/newtonChat.d.ts.map +1 -1
- package/dist/models/notification.d.ts +61 -25
- package/dist/models/notification.d.ts.map +1 -1
- package/dist/models/school.d.ts +349 -133
- package/dist/models/school.d.ts.map +1 -1
- package/dist/models/section.d.ts +216 -72
- package/dist/models/section.d.ts.map +1 -1
- package/dist/models/tag.d.ts +156 -48
- package/dist/models/tag.d.ts.map +1 -1
- package/dist/models/user.d.ts +82 -28
- package/dist/models/user.d.ts.map +1 -1
- package/dist/models/worksheet.d.ts +1023 -410
- package/dist/models/worksheet.d.ts.map +1 -1
- package/dist/models/worksheet.js +19 -7
- package/dist/models/worksheet.js.map +1 -1
- package/dist/pipelines/aiLabChat.d.ts +0 -8
- package/dist/pipelines/aiLabChat.d.ts.map +1 -1
- package/dist/pipelines/aiLabChat.js +50 -67
- package/dist/pipelines/aiLabChat.js.map +1 -1
- package/dist/pipelines/aiLabChatContract.d.ts +393 -388
- package/dist/pipelines/aiLabChatContract.d.ts.map +1 -1
- package/dist/pipelines/aiLabChatContract.js +63 -69
- package/dist/pipelines/aiLabChatContract.js.map +1 -1
- package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
- package/dist/pipelines/aiNewtonChat.js +41 -41
- package/dist/pipelines/aiNewtonChat.js.map +1 -1
- package/dist/pipelines/gradeWorksheet.d.ts +8 -8
- package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
- package/dist/pipelines/gradeWorksheet.js +59 -14
- package/dist/pipelines/gradeWorksheet.js.map +1 -1
- package/dist/pipelines/labChatPrompt.d.ts +10 -8
- package/dist/pipelines/labChatPrompt.d.ts.map +1 -1
- package/dist/pipelines/labChatPrompt.js +47 -67
- package/dist/pipelines/labChatPrompt.js.map +1 -1
- package/dist/pipelines/pdfToWorksheet.d.ts +2 -2
- package/dist/routers/_app.d.ts +2268 -1898
- 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 +13 -13
- package/dist/routers/agenda.d.ts +18 -18
- package/dist/routers/announcement.d.ts +32 -28
- package/dist/routers/announcement.d.ts.map +1 -1
- package/dist/routers/announcement.js +6 -5
- package/dist/routers/announcement.js.map +1 -1
- package/dist/routers/assignment.d.ts +335 -327
- package/dist/routers/assignment.d.ts.map +1 -1
- package/dist/routers/assignment.js +4 -4
- package/dist/routers/assignment.js.map +1 -1
- package/dist/routers/attendance.d.ts +10 -10
- package/dist/routers/auth.d.ts +2 -2
- package/dist/routers/class.d.ts +170 -147
- package/dist/routers/class.d.ts.map +1 -1
- package/dist/routers/comment.d.ts +40 -16
- package/dist/routers/comment.d.ts.map +1 -1
- package/dist/routers/comment.js +6 -3
- package/dist/routers/comment.js.map +1 -1
- package/dist/routers/conversation.d.ts +17 -17
- package/dist/routers/department.d.ts +34 -34
- package/dist/routers/discussion.d.ts +65 -53
- package/dist/routers/discussion.d.ts.map +1 -1
- package/dist/routers/discussion.js +6 -2
- package/dist/routers/discussion.js.map +1 -1
- package/dist/routers/embedding.d.ts +69 -0
- package/dist/routers/embedding.d.ts.map +1 -0
- package/dist/routers/embedding.js +143 -0
- package/dist/routers/embedding.js.map +1 -0
- package/dist/routers/event.d.ts +39 -39
- package/dist/routers/file.d.ts +12 -12
- package/dist/routers/folder.d.ts +38 -38
- package/dist/routers/labChat.d.ts +19 -19
- package/dist/routers/marketing.d.ts +2 -2
- package/dist/routers/message.d.ts +13 -13
- package/dist/routers/newtonChat.d.ts +4 -4
- package/dist/routers/notifications.d.ts +9 -9
- package/dist/routers/permissions.d.ts +14 -14
- package/dist/routers/school.d.ts +55 -55
- package/dist/routers/search.d.ts +22 -1
- package/dist/routers/search.d.ts.map +1 -1
- package/dist/routers/search.js +9 -3
- package/dist/routers/search.js.map +1 -1
- package/dist/routers/section.d.ts +4 -4
- package/dist/routers/studentProgress.d.ts +1 -1
- package/dist/routers/tag.d.ts +7 -7
- package/dist/routers/user.d.ts +10 -10
- package/dist/routers/worksheet.d.ts +97 -56
- package/dist/routers/worksheet.d.ts.map +1 -1
- package/dist/routers/worksheet.js +28 -2
- package/dist/routers/worksheet.js.map +1 -1
- package/dist/routes/aiStream.d.ts +2 -0
- package/dist/routes/aiStream.d.ts.map +1 -0
- package/dist/routes/aiStream.js +696 -0
- package/dist/routes/aiStream.js.map +1 -0
- package/dist/seedDatabase.d.ts +2 -2
- package/dist/seedDatabase.js +6 -6
- package/dist/seedDatabase.js.map +1 -1
- package/dist/services/adminInstitution.d.ts +8 -8
- package/dist/services/adminRunJob.d.ts +4 -4
- package/dist/services/agenda.d.ts +18 -18
- package/dist/services/aiChatStream.d.ts +68 -0
- package/dist/services/aiChatStream.d.ts.map +1 -0
- package/dist/services/aiChatStream.js +157 -0
- package/dist/services/aiChatStream.js.map +1 -0
- package/dist/services/aiModifierTools.d.ts +32 -0
- package/dist/services/aiModifierTools.d.ts.map +1 -0
- package/dist/services/aiModifierTools.js +165 -0
- package/dist/services/aiModifierTools.js.map +1 -0
- package/dist/services/aiProposalTools.d.ts +32 -0
- package/dist/services/aiProposalTools.d.ts.map +1 -0
- package/dist/services/aiProposalTools.js +207 -0
- package/dist/services/aiProposalTools.js.map +1 -0
- package/dist/services/aiStream.d.ts +22 -0
- package/dist/services/aiStream.d.ts.map +1 -0
- package/dist/services/aiStream.js +142 -0
- package/dist/services/aiStream.js.map +1 -0
- package/dist/services/aiTools.d.ts +16 -0
- package/dist/services/aiTools.d.ts.map +1 -0
- package/dist/services/aiTools.js +271 -0
- package/dist/services/aiTools.js.map +1 -0
- package/dist/services/announcement.d.ts +12 -12
- package/dist/services/announcement.d.ts.map +1 -1
- package/dist/services/announcement.js +7 -2
- package/dist/services/announcement.js.map +1 -1
- package/dist/services/assignment.d.ts +293 -293
- package/dist/services/assignment.d.ts.map +1 -1
- package/dist/services/assignment.js +36 -15
- package/dist/services/assignment.js.map +1 -1
- package/dist/services/attendance.d.ts +10 -10
- package/dist/services/auth.d.ts +2 -2
- package/dist/services/class.d.ts +161 -138
- package/dist/services/class.d.ts.map +1 -1
- package/dist/services/class.js +25 -4
- package/dist/services/class.js.map +1 -1
- package/dist/services/comment.d.ts +34 -16
- package/dist/services/comment.d.ts.map +1 -1
- package/dist/services/comment.js +23 -2
- package/dist/services/comment.js.map +1 -1
- package/dist/services/conversation.d.ts +17 -17
- package/dist/services/conversation.d.ts.map +1 -1
- package/dist/services/conversation.js +9 -7
- package/dist/services/conversation.js.map +1 -1
- package/dist/services/department.d.ts +33 -33
- package/dist/services/discussion.d.ts +58 -58
- package/dist/services/discussion.d.ts.map +1 -1
- package/dist/services/discussion.js +8 -8
- package/dist/services/discussion.js.map +1 -1
- package/dist/services/discussionSummary.d.ts +21 -0
- package/dist/services/discussionSummary.d.ts.map +1 -0
- package/dist/services/discussionSummary.js +179 -0
- package/dist/services/discussionSummary.js.map +1 -0
- package/dist/services/embedding.d.ts +67 -0
- package/dist/services/embedding.d.ts.map +1 -0
- package/dist/services/embedding.js +226 -0
- package/dist/services/embedding.js.map +1 -0
- package/dist/services/event.d.ts +37 -37
- package/dist/services/file.d.ts +24 -24
- package/dist/services/folder.d.ts +36 -36
- package/dist/services/labChat.d.ts +19 -19
- package/dist/services/marketing.d.ts +2 -2
- package/dist/services/message.d.ts +11 -11
- package/dist/services/message.d.ts.map +1 -1
- package/dist/services/message.js +2 -2
- package/dist/services/message.js.map +1 -1
- package/dist/services/newtonChat.d.ts +4 -4
- package/dist/services/notification.d.ts +9 -9
- package/dist/services/notification.d.ts.map +1 -1
- package/dist/services/permissions.d.ts +14 -14
- package/dist/services/school.d.ts +50 -50
- package/dist/services/search.d.ts +12 -0
- package/dist/services/search.d.ts.map +1 -1
- package/dist/services/search.js +120 -2
- package/dist/services/search.js.map +1 -1
- package/dist/services/section.d.ts +3 -3
- package/dist/services/tag.d.ts +4 -4
- package/dist/services/user.d.ts +4 -4
- package/dist/services/user.d.ts.map +1 -1
- package/dist/services/user.js +10 -15
- package/dist/services/user.js.map +1 -1
- package/dist/services/worksheet.d.ts +62 -47
- package/dist/services/worksheet.d.ts.map +1 -1
- package/dist/services/worksheet.js +11 -4
- package/dist/services/worksheet.js.map +1 -1
- package/dist/utils/streamBroadcast.d.ts +11 -0
- package/dist/utils/streamBroadcast.d.ts.map +1 -0
- package/dist/utils/streamBroadcast.js +32 -0
- package/dist/utils/streamBroadcast.js.map +1 -0
- package/dist/workers/connection.d.ts +15 -0
- package/dist/workers/connection.d.ts.map +1 -1
- package/dist/workers/connection.js +20 -2
- package/dist/workers/connection.js.map +1 -1
- package/dist/workers/index.js +4 -2
- package/dist/workers/index.js.map +1 -1
- package/dist/workers/processors/createRunJobWorker.d.ts.map +1 -1
- package/dist/workers/processors/createRunJobWorker.js +31 -8
- package/dist/workers/processors/createRunJobWorker.js.map +1 -1
- package/dist/workers/processors/embedContent.d.ts +8 -0
- package/dist/workers/processors/embedContent.d.ts.map +1 -0
- package/dist/workers/processors/embedContent.js +76 -0
- package/dist/workers/processors/embedContent.js.map +1 -0
- package/dist/workers/queues.d.ts +6 -0
- package/dist/workers/queues.d.ts.map +1 -1
- package/dist/workers/queues.js +5 -3
- package/dist/workers/queues.js.map +1 -1
- package/package.json +5 -1
- package/prisma/migrations/20260609215135_init/migration.sql +1412 -0
- package/prisma/migrations/20260610130000_add_worksheet_tags/migration.sql +13 -0
- package/prisma/schema.prisma +39 -7
- package/src/index.ts +4 -0
- package/src/lib/config/cors.ts +32 -9
- package/src/lib/config/env.ts +6 -0
- package/src/lib/googleCloudStorage.ts +21 -0
- package/src/lib/prisma.ts +40 -1
- package/src/models/class.ts +10 -3
- package/src/models/comment.ts +1 -0
- package/src/models/conversation.ts +12 -0
- package/src/models/worksheet.ts +16 -7
- package/src/pipelines/aiLabChat.ts +54 -79
- package/src/pipelines/aiLabChatContract.ts +69 -69
- package/src/pipelines/aiNewtonChat.ts +46 -43
- package/src/pipelines/gradeWorksheet.ts +66 -15
- package/src/pipelines/labChatPrompt.ts +63 -88
- package/src/routers/_app.ts +2 -0
- package/src/routers/announcement.ts +4 -3
- package/src/routers/assignment.ts +2 -2
- package/src/routers/comment.ts +5 -0
- package/src/routers/discussion.ts +7 -0
- package/src/routers/embedding.ts +159 -0
- package/src/routers/search.ts +10 -1
- package/src/routers/worksheet.ts +29 -0
- package/src/routes/aiStream.ts +789 -0
- package/src/seedDatabase.ts +4 -4
- package/src/services/aiChatStream.ts +219 -0
- package/src/services/aiModifierTools.ts +188 -0
- package/src/services/aiProposalTools.ts +272 -0
- package/src/services/aiStream.ts +175 -0
- package/src/services/aiTools.ts +290 -0
- package/src/services/announcement.ts +7 -0
- package/src/services/assignment.ts +36 -14
- package/src/services/class.ts +30 -2
- package/src/services/comment.ts +22 -0
- package/src/services/conversation.ts +9 -4
- package/src/services/discussion.ts +9 -8
- package/src/services/discussionSummary.ts +226 -0
- package/src/services/embedding.ts +285 -0
- package/src/services/message.ts +2 -2
- package/src/services/search.ts +138 -0
- package/src/services/user.ts +9 -17
- package/src/services/worksheet.ts +10 -2
- package/src/utils/streamBroadcast.ts +48 -0
- package/src/workers/connection.ts +19 -0
- package/src/workers/index.ts +2 -0
- package/src/workers/processors/createRunJobWorker.ts +32 -6
- package/src/workers/processors/embedContent.ts +90 -0
- package/src/workers/queues.ts +5 -1
- package/tests/integration/routers/conversation.test.ts +9 -7
- package/tests/unit/pipelines/aiLabChatContract.test.ts +38 -21
- package/tests/unit/pipelines/labChatPrompt.test.ts +23 -36
- package/prisma/migrations/20250807062924_init/migration.sql +0 -436
- package/prisma/migrations/20250919063704_init2/migration.sql +0 -24
- package/prisma/migrations/20250920143543_add_profile_fields/migration.sql +0 -15
- package/prisma/migrations/20250922165147_add_chat_models/migration.sql +0 -68
- package/prisma/migrations/20250922165314_add_chat_roles/migration.sql +0 -5
- package/prisma/migrations/20250922171242_refactor_notification_system/migration.sql +0 -20
- package/prisma/migrations/20250922172208_add_mentions_system/migration.sql +0 -21
- package/prisma/migrations/20250925072732_add_lab_chat_system/migration.sql +0 -25
- package/prisma/migrations/20251020151505_add_upload_tracking_fields/migration.sql +0 -57
- package/prisma/migrations/20251109122857_annuoncements_comments/migration.sql +0 -30
- package/prisma/migrations/20251109135555_reactions_announcements_comments/migration.sql +0 -35
- package/prisma/migrations/20260410124000_add_submission_recommendation_state/migration.sql +0 -14
- package/prisma/migrations/20260415103500_add_session_type/migration.sql +0 -33
- package/prisma/migrations/20260415133000_add_ai_usage_logs/migration.sql +0 -19
- package/prisma/migrations/20260417083000_add_conversation_member_hidden_at/migration.sql +0 -2
- package/prisma/migrations/20260502230500_assignment_submission_comments/migration.sql +0 -60
- package/prisma/migrations/20260504071500_school_memberships/migration.sql +0 -81
- package/prisma/migrations/20260506162500_consolidate_run_job_and_unify_job_status/migration.sql +0 -166
- package/prisma/migrations/20260509220000_institution_overhaul/migration.sql +0 -184
- package/prisma/migrations/20260510170000_add_folder_permissions/migration.sql +0 -93
- package/prisma/migrations/20260510183000_add_discussions/migration.sql +0 -151
- /package/.github/workflows/{npm.yml → npm-publish.yml} +0 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI-powered discussion summary service with in-memory + Redis caching.
|
|
3
|
+
*/
|
|
4
|
+
import { prisma } from "../lib/prisma.js";
|
|
5
|
+
import { getRedis } from "../lib/redis.js";
|
|
6
|
+
import { inference } from "../utils/inference.js";
|
|
7
|
+
import { logger } from "../utils/logger.js";
|
|
8
|
+
import { TRPCError } from "@trpc/server";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { classMemberOrSchoolAdminWhere } from "../models/access.js";
|
|
11
|
+
|
|
12
|
+
const discussionSummarySchema = z.object({
|
|
13
|
+
summary: z
|
|
14
|
+
.string()
|
|
15
|
+
.describe("A concise 2-4 sentence TL;DR of the entire discussion thread."),
|
|
16
|
+
keyQuestions: z
|
|
17
|
+
.array(z.string())
|
|
18
|
+
.describe("Up to 5 important student questions raised in the thread."),
|
|
19
|
+
unresolvedIssues: z
|
|
20
|
+
.array(z.string())
|
|
21
|
+
.describe(
|
|
22
|
+
"Up to 3 questions or topics that remain unanswered or unresolved.",
|
|
23
|
+
),
|
|
24
|
+
participantCount: z
|
|
25
|
+
.number()
|
|
26
|
+
.describe("Number of unique participants in the thread."),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export type DiscussionSummary = z.infer<typeof discussionSummarySchema>;
|
|
30
|
+
|
|
31
|
+
const REDIS_PREFIX = "discussionSummary:";
|
|
32
|
+
const REDIS_TTL_SECONDS = 60 * 30; // 30 min
|
|
33
|
+
|
|
34
|
+
const memoryCache = new Map<
|
|
35
|
+
string,
|
|
36
|
+
{ value: DiscussionSummary; replyCount: number; cachedAt: number }
|
|
37
|
+
>();
|
|
38
|
+
const MEMORY_TTL_MS = 10 * 60 * 1000; // 10 min
|
|
39
|
+
|
|
40
|
+
function memoryCacheKey(discussionId: string) {
|
|
41
|
+
return discussionId;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function getMemoryCacheHit(
|
|
45
|
+
discussionId: string,
|
|
46
|
+
currentReplyCount: number,
|
|
47
|
+
): DiscussionSummary | undefined {
|
|
48
|
+
const entry = memoryCache.get(memoryCacheKey(discussionId));
|
|
49
|
+
if (!entry) return undefined;
|
|
50
|
+
if (Date.now() - entry.cachedAt > MEMORY_TTL_MS) {
|
|
51
|
+
memoryCache.delete(memoryCacheKey(discussionId));
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
if (entry.replyCount !== currentReplyCount) {
|
|
55
|
+
memoryCache.delete(memoryCacheKey(discussionId));
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
return entry.value;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function setMemoryCache(
|
|
62
|
+
discussionId: string,
|
|
63
|
+
value: DiscussionSummary,
|
|
64
|
+
replyCount: number,
|
|
65
|
+
) {
|
|
66
|
+
memoryCache.set(memoryCacheKey(discussionId), {
|
|
67
|
+
value,
|
|
68
|
+
replyCount,
|
|
69
|
+
cachedAt: Date.now(),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function getRedisCacheHit(
|
|
74
|
+
discussionId: string,
|
|
75
|
+
currentReplyCount: number,
|
|
76
|
+
): Promise<DiscussionSummary | undefined> {
|
|
77
|
+
const redis = getRedis();
|
|
78
|
+
if (!redis) return undefined;
|
|
79
|
+
try {
|
|
80
|
+
const raw = await redis.get(`${REDIS_PREFIX}${discussionId}`);
|
|
81
|
+
if (!raw) return undefined;
|
|
82
|
+
const parsed = JSON.parse(raw) as {
|
|
83
|
+
value: DiscussionSummary;
|
|
84
|
+
replyCount: number;
|
|
85
|
+
};
|
|
86
|
+
if (parsed.replyCount !== currentReplyCount) return undefined;
|
|
87
|
+
return parsed.value;
|
|
88
|
+
} catch {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function setRedisCache(
|
|
94
|
+
discussionId: string,
|
|
95
|
+
value: DiscussionSummary,
|
|
96
|
+
replyCount: number,
|
|
97
|
+
) {
|
|
98
|
+
const redis = getRedis();
|
|
99
|
+
if (!redis) return;
|
|
100
|
+
try {
|
|
101
|
+
await redis.set(
|
|
102
|
+
`${REDIS_PREFIX}${discussionId}`,
|
|
103
|
+
JSON.stringify({ value, replyCount }),
|
|
104
|
+
"EX",
|
|
105
|
+
REDIS_TTL_SECONDS,
|
|
106
|
+
);
|
|
107
|
+
} catch (err) {
|
|
108
|
+
logger.error("Failed to cache discussion summary in Redis", { err });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function getDiscussionSummary(
|
|
113
|
+
userId: string,
|
|
114
|
+
discussionId: string,
|
|
115
|
+
): Promise<DiscussionSummary> {
|
|
116
|
+
const discussion = await prisma.discussion.findUnique({
|
|
117
|
+
where: { id: discussionId },
|
|
118
|
+
select: {
|
|
119
|
+
id: true,
|
|
120
|
+
classId: true,
|
|
121
|
+
title: true,
|
|
122
|
+
body: true,
|
|
123
|
+
type: true,
|
|
124
|
+
replies: {
|
|
125
|
+
where: { deletedAt: null },
|
|
126
|
+
select: {
|
|
127
|
+
body: true,
|
|
128
|
+
author: {
|
|
129
|
+
select: {
|
|
130
|
+
username: true,
|
|
131
|
+
profile: { select: { displayName: true } },
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
createdAt: true,
|
|
135
|
+
},
|
|
136
|
+
orderBy: { createdAt: "asc" },
|
|
137
|
+
},
|
|
138
|
+
author: {
|
|
139
|
+
select: {
|
|
140
|
+
username: true,
|
|
141
|
+
profile: { select: { displayName: true } },
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
if (!discussion) {
|
|
148
|
+
throw new TRPCError({
|
|
149
|
+
code: "NOT_FOUND",
|
|
150
|
+
message: "Discussion not found",
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Verify user is a member of the class
|
|
155
|
+
const classMembership = await prisma.class.findFirst({
|
|
156
|
+
where: { id: discussion.classId, ...classMemberOrSchoolAdminWhere(userId) },
|
|
157
|
+
select: { id: true },
|
|
158
|
+
});
|
|
159
|
+
if (!classMembership) {
|
|
160
|
+
throw new TRPCError({
|
|
161
|
+
code: "UNAUTHORIZED",
|
|
162
|
+
message: "You are not a member of this class",
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const replyCount = discussion.replies.length;
|
|
167
|
+
|
|
168
|
+
// Check memory cache
|
|
169
|
+
const memoryCached = getMemoryCacheHit(discussionId, replyCount);
|
|
170
|
+
if (memoryCached) return memoryCached;
|
|
171
|
+
|
|
172
|
+
// Check Redis cache
|
|
173
|
+
const redisCached = await getRedisCacheHit(discussionId, replyCount);
|
|
174
|
+
if (redisCached) {
|
|
175
|
+
setMemoryCache(discussionId, redisCached, replyCount);
|
|
176
|
+
return redisCached;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Build context for inference
|
|
180
|
+
const authorName =
|
|
181
|
+
discussion.author.profile?.displayName ?? discussion.author.username;
|
|
182
|
+
const threadText = [
|
|
183
|
+
`Title: ${discussion.title}`,
|
|
184
|
+
`Type: ${discussion.type}`,
|
|
185
|
+
`Author: ${authorName}`,
|
|
186
|
+
`Original post:\n${discussion.body}`,
|
|
187
|
+
"",
|
|
188
|
+
...discussion.replies.map((reply, i) => {
|
|
189
|
+
const name =
|
|
190
|
+
reply.author.profile?.displayName ?? reply.author.username;
|
|
191
|
+
return `Reply ${i + 1} by ${name}:\n${reply.body}`;
|
|
192
|
+
}),
|
|
193
|
+
].join("\n\n");
|
|
194
|
+
|
|
195
|
+
const messages = [
|
|
196
|
+
{
|
|
197
|
+
role: "system" as const,
|
|
198
|
+
content:
|
|
199
|
+
"You are a helpful assistant summarizing a class discussion thread for a teacher. Be concise, specific, and grounded in the actual content. Do not fabricate information.",
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
role: "user" as const,
|
|
203
|
+
content: threadText,
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
role: "user" as const,
|
|
207
|
+
content:
|
|
208
|
+
"Generate a structured summary of this discussion thread. Include a TL;DR, key student questions, and any unresolved issues.",
|
|
209
|
+
},
|
|
210
|
+
];
|
|
211
|
+
|
|
212
|
+
const result = await inference<DiscussionSummary>(
|
|
213
|
+
messages,
|
|
214
|
+
discussionSummarySchema,
|
|
215
|
+
{
|
|
216
|
+
reason: "discussion_summary",
|
|
217
|
+
metadata: { discussionId, classId: discussion.classId },
|
|
218
|
+
},
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
// Cache result
|
|
222
|
+
setMemoryCache(discussionId, result, replyCount);
|
|
223
|
+
await setRedisCache(discussionId, result, replyCount);
|
|
224
|
+
|
|
225
|
+
return result;
|
|
226
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embedding service – pgvector-backed RAG for class materials.
|
|
3
|
+
* Handles text chunking, embedding generation, storage, and semantic search.
|
|
4
|
+
*/
|
|
5
|
+
import { prisma } from '../lib/prisma.js';
|
|
6
|
+
import { openAIClient } from '../utils/inference.js';
|
|
7
|
+
import { logger } from '../utils/logger.js';
|
|
8
|
+
import { enqueueRunJob } from '../workers/queues.js';
|
|
9
|
+
import type { EmbeddingSourceType } from '@prisma/client';
|
|
10
|
+
|
|
11
|
+
const EMBEDDING_MODEL = 'text-embedding-3-small';
|
|
12
|
+
const EMBEDDING_DIMENSIONS = 1536;
|
|
13
|
+
const CHUNK_SIZE = 512; // tokens (~2048 chars)
|
|
14
|
+
const CHUNK_OVERLAP = 100; // tokens (~400 chars)
|
|
15
|
+
const CHARS_PER_TOKEN = 4;
|
|
16
|
+
|
|
17
|
+
export interface EmbeddingSearchResult {
|
|
18
|
+
id: string;
|
|
19
|
+
sourceType: EmbeddingSourceType;
|
|
20
|
+
sourceId: string;
|
|
21
|
+
content: string;
|
|
22
|
+
metadata: Record<string, unknown>;
|
|
23
|
+
similarity: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Split text into overlapping chunks for embedding.
|
|
28
|
+
*/
|
|
29
|
+
export function chunkText(text: string, chunkSize = CHUNK_SIZE, overlap = CHUNK_OVERLAP): string[] {
|
|
30
|
+
const maxChars = chunkSize * CHARS_PER_TOKEN;
|
|
31
|
+
const overlapChars = overlap * CHARS_PER_TOKEN;
|
|
32
|
+
|
|
33
|
+
if (text.length <= maxChars) {
|
|
34
|
+
return [text];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const chunks: string[] = [];
|
|
38
|
+
let start = 0;
|
|
39
|
+
|
|
40
|
+
while (start < text.length) {
|
|
41
|
+
const end = Math.min(start + maxChars, text.length);
|
|
42
|
+
chunks.push(text.slice(start, end));
|
|
43
|
+
|
|
44
|
+
if (end >= text.length) break;
|
|
45
|
+
start = end - overlapChars;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return chunks;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Generate embedding vector for a text string.
|
|
53
|
+
*/
|
|
54
|
+
export async function generateEmbedding(text: string): Promise<number[]> {
|
|
55
|
+
const response = await openAIClient.embeddings.create({
|
|
56
|
+
model: EMBEDDING_MODEL,
|
|
57
|
+
input: text,
|
|
58
|
+
dimensions: EMBEDDING_DIMENSIONS,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return response.data[0].embedding;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Generate embeddings for multiple texts in a single batch.
|
|
66
|
+
*/
|
|
67
|
+
export async function generateEmbeddings(texts: string[]): Promise<number[][]> {
|
|
68
|
+
if (texts.length === 0) return [];
|
|
69
|
+
|
|
70
|
+
const response = await openAIClient.embeddings.create({
|
|
71
|
+
model: EMBEDDING_MODEL,
|
|
72
|
+
input: texts,
|
|
73
|
+
dimensions: EMBEDDING_DIMENSIONS,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return response.data
|
|
77
|
+
.sort((a, b) => a.index - b.index)
|
|
78
|
+
.map((item) => item.embedding);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Store embeddings for a source document. Replaces any existing embeddings for
|
|
83
|
+
* the same sourceType + sourceId combination.
|
|
84
|
+
*/
|
|
85
|
+
export async function indexContent(params: {
|
|
86
|
+
classId: string;
|
|
87
|
+
sourceType: EmbeddingSourceType;
|
|
88
|
+
sourceId: string;
|
|
89
|
+
text: string;
|
|
90
|
+
metadata?: Record<string, unknown>;
|
|
91
|
+
}): Promise<number> {
|
|
92
|
+
const { classId, sourceType, sourceId, text, metadata = {} } = params;
|
|
93
|
+
|
|
94
|
+
// Remove existing embeddings for this source
|
|
95
|
+
await prisma.embedding.deleteMany({
|
|
96
|
+
where: { classId, sourceType, sourceId },
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const chunks = chunkText(text);
|
|
100
|
+
if (chunks.length === 0) return 0;
|
|
101
|
+
|
|
102
|
+
const embeddings = await generateEmbeddings(chunks);
|
|
103
|
+
|
|
104
|
+
// Insert using raw SQL for pgvector support
|
|
105
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
106
|
+
const vectorStr = `[${embeddings[i].join(',')}]`;
|
|
107
|
+
const chunkMetadata = { ...metadata, chunkIndex: i, totalChunks: chunks.length };
|
|
108
|
+
|
|
109
|
+
await prisma.$executeRaw`
|
|
110
|
+
INSERT INTO "Embedding" (id, "classId", "sourceType", "sourceId", content, vector, metadata, "createdAt")
|
|
111
|
+
VALUES (
|
|
112
|
+
gen_random_uuid(),
|
|
113
|
+
${classId},
|
|
114
|
+
${sourceType}::"EmbeddingSourceType",
|
|
115
|
+
${sourceId},
|
|
116
|
+
${chunks[i]},
|
|
117
|
+
${vectorStr}::vector,
|
|
118
|
+
${JSON.stringify(chunkMetadata)}::jsonb,
|
|
119
|
+
NOW()
|
|
120
|
+
)
|
|
121
|
+
`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
logger.info('Content indexed', { classId, sourceType, sourceId, chunks: chunks.length });
|
|
125
|
+
return chunks.length;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Semantic search over class embeddings using cosine similarity.
|
|
130
|
+
*/
|
|
131
|
+
export async function searchEmbeddings(params: {
|
|
132
|
+
classId: string;
|
|
133
|
+
query: string;
|
|
134
|
+
limit?: number;
|
|
135
|
+
sourceTypes?: EmbeddingSourceType[];
|
|
136
|
+
}): Promise<EmbeddingSearchResult[]> {
|
|
137
|
+
const { classId, query, limit = 5, sourceTypes } = params;
|
|
138
|
+
|
|
139
|
+
const queryEmbedding = await generateEmbedding(query);
|
|
140
|
+
const vectorStr = `[${queryEmbedding.join(',')}]`;
|
|
141
|
+
|
|
142
|
+
let results: EmbeddingSearchResult[];
|
|
143
|
+
|
|
144
|
+
if (sourceTypes && sourceTypes.length > 0) {
|
|
145
|
+
results = await prisma.$queryRaw<EmbeddingSearchResult[]>`
|
|
146
|
+
SELECT
|
|
147
|
+
id,
|
|
148
|
+
"sourceType",
|
|
149
|
+
"sourceId",
|
|
150
|
+
content,
|
|
151
|
+
metadata,
|
|
152
|
+
1 - (vector <=> ${vectorStr}::vector) as similarity
|
|
153
|
+
FROM "Embedding"
|
|
154
|
+
WHERE "classId" = ${classId}
|
|
155
|
+
AND "sourceType" = ANY(${sourceTypes}::"EmbeddingSourceType"[])
|
|
156
|
+
ORDER BY vector <=> ${vectorStr}::vector
|
|
157
|
+
LIMIT ${limit}
|
|
158
|
+
`;
|
|
159
|
+
} else {
|
|
160
|
+
results = await prisma.$queryRaw<EmbeddingSearchResult[]>`
|
|
161
|
+
SELECT
|
|
162
|
+
id,
|
|
163
|
+
"sourceType",
|
|
164
|
+
"sourceId",
|
|
165
|
+
content,
|
|
166
|
+
metadata,
|
|
167
|
+
1 - (vector <=> ${vectorStr}::vector) as similarity
|
|
168
|
+
FROM "Embedding"
|
|
169
|
+
WHERE "classId" = ${classId}
|
|
170
|
+
ORDER BY vector <=> ${vectorStr}::vector
|
|
171
|
+
LIMIT ${limit}
|
|
172
|
+
`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return results;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Remove all embeddings for a given source.
|
|
180
|
+
*/
|
|
181
|
+
export async function removeEmbeddings(params: {
|
|
182
|
+
classId: string;
|
|
183
|
+
sourceType: EmbeddingSourceType;
|
|
184
|
+
sourceId: string;
|
|
185
|
+
}): Promise<void> {
|
|
186
|
+
await prisma.embedding.deleteMany({
|
|
187
|
+
where: {
|
|
188
|
+
classId: params.classId,
|
|
189
|
+
sourceType: params.sourceType,
|
|
190
|
+
sourceId: params.sourceId,
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Index an assignment (title + instructions) into the vector store.
|
|
197
|
+
*/
|
|
198
|
+
export async function indexAssignment(classId: string, assignmentId: string): Promise<void> {
|
|
199
|
+
const assignment = await prisma.assignment.findUnique({
|
|
200
|
+
where: { id: assignmentId },
|
|
201
|
+
select: { title: true, instructions: true, type: true },
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
if (!assignment) return;
|
|
205
|
+
|
|
206
|
+
const text = [
|
|
207
|
+
`Title: ${assignment.title}`,
|
|
208
|
+
`Type: ${assignment.type}`,
|
|
209
|
+
assignment.instructions ? `Instructions: ${assignment.instructions}` : '',
|
|
210
|
+
].filter(Boolean).join('\n');
|
|
211
|
+
|
|
212
|
+
await indexContent({
|
|
213
|
+
classId,
|
|
214
|
+
sourceType: 'ASSIGNMENT',
|
|
215
|
+
sourceId: assignmentId,
|
|
216
|
+
text,
|
|
217
|
+
metadata: { title: assignment.title, type: assignment.type },
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Index a worksheet (questions + answers) into the vector store.
|
|
223
|
+
*/
|
|
224
|
+
export async function indexWorksheet(classId: string, worksheetId: string): Promise<void> {
|
|
225
|
+
const worksheet = await prisma.worksheet.findUnique({
|
|
226
|
+
where: { id: worksheetId },
|
|
227
|
+
include: { questions: true },
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
if (!worksheet) return;
|
|
231
|
+
|
|
232
|
+
const questionTexts = worksheet.questions.map((q, i) =>
|
|
233
|
+
`Question ${i + 1} (${q.type}): ${q.question}\nAnswer: ${q.answer}`
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const text = [
|
|
237
|
+
`Worksheet: ${worksheet.name}`,
|
|
238
|
+
...questionTexts,
|
|
239
|
+
].join('\n\n');
|
|
240
|
+
|
|
241
|
+
await indexContent({
|
|
242
|
+
classId,
|
|
243
|
+
sourceType: 'WORKSHEET',
|
|
244
|
+
sourceId: worksheetId,
|
|
245
|
+
text,
|
|
246
|
+
metadata: { name: worksheet.name, questionCount: worksheet.questions.length },
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Index an announcement into the vector store.
|
|
252
|
+
*/
|
|
253
|
+
export async function indexAnnouncement(classId: string, announcementId: string): Promise<void> {
|
|
254
|
+
const announcement = await prisma.announcement.findUnique({
|
|
255
|
+
where: { id: announcementId },
|
|
256
|
+
select: { remarks: true, teacherId: true },
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
if (!announcement || !announcement.remarks) return;
|
|
260
|
+
|
|
261
|
+
const text = `Announcement: ${announcement.remarks}`;
|
|
262
|
+
|
|
263
|
+
await indexContent({
|
|
264
|
+
classId,
|
|
265
|
+
sourceType: 'ANNOUNCEMENT',
|
|
266
|
+
sourceId: announcementId,
|
|
267
|
+
text,
|
|
268
|
+
metadata: { teacherId: announcement.teacherId },
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Fire-and-forget: enqueue a background job to embed content.
|
|
274
|
+
* Safe to call from service functions after a create/update.
|
|
275
|
+
*/
|
|
276
|
+
export function enqueueEmbedding(
|
|
277
|
+
classId: string,
|
|
278
|
+
sourceType: EmbeddingSourceType,
|
|
279
|
+
sourceId: string,
|
|
280
|
+
): void {
|
|
281
|
+
enqueueRunJob('EMBED_CONTENT', { classId, sourceType: sourceType as string, sourceId }, { classId })
|
|
282
|
+
.catch((err) => {
|
|
283
|
+
logger.error('Failed to enqueue embedding job', { err, classId, sourceType, sourceId });
|
|
284
|
+
});
|
|
285
|
+
}
|
package/src/services/message.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Message service – send, update, delete messages; mark as read; track mentions.
|
|
3
3
|
* Broadcasts real-time updates via Pusher.
|
|
4
4
|
*/
|
|
5
|
-
import type {
|
|
5
|
+
import type { AppTransactionClient } from "../lib/prisma.js";
|
|
6
6
|
import { TRPCError } from "@trpc/server";
|
|
7
7
|
import { prisma } from "../lib/prisma.js";
|
|
8
8
|
import { chatChannel, pusher } from "../lib/pusher.js";
|
|
@@ -35,7 +35,7 @@ export function ensureMessageHasContentOrAttachments(
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
export async function claimMessageAttachments(
|
|
38
|
-
tx:
|
|
38
|
+
tx: AppTransactionClient,
|
|
39
39
|
userId: string,
|
|
40
40
|
conversationId: string,
|
|
41
41
|
messageId: string,
|
package/src/services/search.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { prisma } from "../lib/prisma.js";
|
|
2
|
+
import { generateEmbedding } from "./embedding.js";
|
|
3
|
+
import { logger } from "../utils/logger.js";
|
|
4
|
+
import type { EmbeddingSourceType } from "@prisma/client";
|
|
2
5
|
|
|
3
6
|
type SearchScope =
|
|
4
7
|
| "class"
|
|
@@ -395,3 +398,138 @@ export async function searchGlobalClasses(
|
|
|
395
398
|
|
|
396
399
|
return { query, results: trimResults(results, limit) };
|
|
397
400
|
}
|
|
401
|
+
|
|
402
|
+
const SOURCE_TYPE_TO_SCOPE: Record<EmbeddingSourceType, SearchScope> = {
|
|
403
|
+
ASSIGNMENT: "assignment",
|
|
404
|
+
WORKSHEET: "worksheet",
|
|
405
|
+
ANNOUNCEMENT: "announcement",
|
|
406
|
+
FILE: "file",
|
|
407
|
+
SECTION: "section",
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
const MIN_SIMILARITY = 0.25;
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Cross-class semantic search via pgvector.
|
|
414
|
+
* Generates an embedding for `query`, searches across all classes the user
|
|
415
|
+
* belongs to, and maps hits back to SearchResult shape.
|
|
416
|
+
*/
|
|
417
|
+
export async function searchSemanticGlobal(
|
|
418
|
+
userId: string,
|
|
419
|
+
input: { query: string; limit?: number },
|
|
420
|
+
) {
|
|
421
|
+
const query = input.query.trim().slice(0, 200);
|
|
422
|
+
if (!query) {
|
|
423
|
+
return { query, results: [] as SearchResult[] };
|
|
424
|
+
}
|
|
425
|
+
const limit = Math.min(Math.max(input.limit ?? SEARCH_LIMIT, 1), 30);
|
|
426
|
+
|
|
427
|
+
const memberships = await prisma.class.findMany({
|
|
428
|
+
where: {
|
|
429
|
+
OR: [
|
|
430
|
+
{ teachers: { some: { id: userId } } },
|
|
431
|
+
{ students: { some: { id: userId } } },
|
|
432
|
+
],
|
|
433
|
+
},
|
|
434
|
+
select: { id: true, name: true },
|
|
435
|
+
});
|
|
436
|
+
const allClassIds = memberships.map((cls) => cls.id);
|
|
437
|
+
if (allClassIds.length === 0) {
|
|
438
|
+
return { query, results: [] as SearchResult[] };
|
|
439
|
+
}
|
|
440
|
+
const classById = new Map(memberships.map((cls) => [cls.id, cls]));
|
|
441
|
+
|
|
442
|
+
let queryVector: number[];
|
|
443
|
+
try {
|
|
444
|
+
queryVector = await generateEmbedding(query);
|
|
445
|
+
} catch (err) {
|
|
446
|
+
logger.error("Semantic search embedding failed", { err });
|
|
447
|
+
return { query, results: [] as SearchResult[] };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const vectorStr = `[${queryVector.join(",")}]`;
|
|
451
|
+
|
|
452
|
+
type SemanticRow = {
|
|
453
|
+
sourceType: EmbeddingSourceType;
|
|
454
|
+
sourceId: string;
|
|
455
|
+
classId: string;
|
|
456
|
+
content: string;
|
|
457
|
+
similarity: number;
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
const hits = await prisma.$queryRaw<SemanticRow[]>`
|
|
461
|
+
SELECT DISTINCT ON ("sourceType", "sourceId")
|
|
462
|
+
"sourceType",
|
|
463
|
+
"sourceId",
|
|
464
|
+
"classId",
|
|
465
|
+
content,
|
|
466
|
+
1 - (vector <=> ${vectorStr}::vector) as similarity
|
|
467
|
+
FROM "Embedding"
|
|
468
|
+
WHERE "classId" = ANY(${allClassIds}::text[])
|
|
469
|
+
ORDER BY "sourceType", "sourceId", vector <=> ${vectorStr}::vector
|
|
470
|
+
LIMIT ${limit * 3}
|
|
471
|
+
`;
|
|
472
|
+
|
|
473
|
+
const filtered = hits
|
|
474
|
+
.filter((h) => h.similarity >= MIN_SIMILARITY)
|
|
475
|
+
.sort((a, b) => b.similarity - a.similarity)
|
|
476
|
+
.slice(0, limit);
|
|
477
|
+
|
|
478
|
+
const results: SearchResult[] = [];
|
|
479
|
+
for (const hit of filtered) {
|
|
480
|
+
const cls = classById.get(hit.classId);
|
|
481
|
+
const className = cls?.name ?? "Class";
|
|
482
|
+
const scope = SOURCE_TYPE_TO_SCOPE[hit.sourceType];
|
|
483
|
+
if (!scope) continue;
|
|
484
|
+
|
|
485
|
+
const snippet = hit.content.replace(/\s+/g, " ").trim().slice(0, 140);
|
|
486
|
+
|
|
487
|
+
let title = snippet.slice(0, 60);
|
|
488
|
+
let href = `/class/${hit.classId}`;
|
|
489
|
+
|
|
490
|
+
if (scope === "assignment") {
|
|
491
|
+
const assignment = await prisma.assignment.findUnique({
|
|
492
|
+
where: { id: hit.sourceId },
|
|
493
|
+
select: { title: true },
|
|
494
|
+
});
|
|
495
|
+
title = assignment?.title ?? title;
|
|
496
|
+
href = `/class/${hit.classId}/assignment/${hit.sourceId}`;
|
|
497
|
+
} else if (scope === "worksheet") {
|
|
498
|
+
const worksheet = await prisma.worksheet.findUnique({
|
|
499
|
+
where: { id: hit.sourceId },
|
|
500
|
+
select: { name: true },
|
|
501
|
+
});
|
|
502
|
+
title = worksheet?.name ?? title;
|
|
503
|
+
href = `/class/${hit.classId}/worksheets`;
|
|
504
|
+
} else if (scope === "announcement") {
|
|
505
|
+
href = `/class/${hit.classId}`;
|
|
506
|
+
} else if (scope === "file") {
|
|
507
|
+
const file = await prisma.file.findUnique({
|
|
508
|
+
where: { id: hit.sourceId },
|
|
509
|
+
select: { name: true },
|
|
510
|
+
});
|
|
511
|
+
title = file?.name ?? title;
|
|
512
|
+
href = `/class/${hit.classId}/files`;
|
|
513
|
+
} else if (scope === "section") {
|
|
514
|
+
const section = await prisma.section.findUnique({
|
|
515
|
+
where: { id: hit.sourceId },
|
|
516
|
+
select: { name: true },
|
|
517
|
+
});
|
|
518
|
+
title = section?.name ?? title;
|
|
519
|
+
href = `/class/${hit.classId}/assignments`;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
results.push({
|
|
523
|
+
id: `semantic:${hit.sourceId}`,
|
|
524
|
+
type: scope,
|
|
525
|
+
title,
|
|
526
|
+
subtitle: `${className} · ${scope.charAt(0).toUpperCase() + scope.slice(1)}`,
|
|
527
|
+
classId: hit.classId,
|
|
528
|
+
className,
|
|
529
|
+
href,
|
|
530
|
+
matchedText: snippet,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
return { query, results };
|
|
535
|
+
}
|