@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
package/src/services/labChat.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
findLabChatByMember,
|
|
12
12
|
findLabChatByTeacher,
|
|
13
13
|
findLabChatById,
|
|
14
|
+
findClassContextForStarterPrompts,
|
|
14
15
|
findClassMembership,
|
|
15
16
|
findLabChatsByClass,
|
|
16
17
|
findLabChatForPost,
|
|
@@ -203,6 +204,70 @@ export async function getLabChat(userId: string, labChatId: string) {
|
|
|
203
204
|
};
|
|
204
205
|
}
|
|
205
206
|
|
|
207
|
+
const GENERIC_STARTER_PROMPTS = [
|
|
208
|
+
"Create a quiz on this week's topic",
|
|
209
|
+
"Draft a worksheet with 10 practice questions",
|
|
210
|
+
"Plan a lesson with objectives and activities",
|
|
211
|
+
"Suggest an assignment with a mark scheme",
|
|
212
|
+
];
|
|
213
|
+
|
|
214
|
+
const MAX_STARTER_PROMPTS = 5;
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Suggest starter prompts for a class's lab chat, derived from recent
|
|
218
|
+
* class context (upcoming due work, recent assignments, units, subject).
|
|
219
|
+
* Pure heuristics — no LLM call — so it stays cheap and fast. Falls back
|
|
220
|
+
* to generic defaults when the class has no usable context.
|
|
221
|
+
*/
|
|
222
|
+
export async function getStarterPrompts(userId: string, classId: string) {
|
|
223
|
+
const classContext = await findClassContextForStarterPrompts(
|
|
224
|
+
classId,
|
|
225
|
+
userId
|
|
226
|
+
);
|
|
227
|
+
if (!classContext) {
|
|
228
|
+
throw new TRPCError({
|
|
229
|
+
code: "FORBIDDEN",
|
|
230
|
+
message: "Not a teacher in this class",
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const prompts: string[] = [];
|
|
235
|
+
const now = new Date();
|
|
236
|
+
|
|
237
|
+
const published = classContext.assignments.filter((a) => !a.inProgress);
|
|
238
|
+
const upcoming = published
|
|
239
|
+
.filter((a) => a.dueDate > now)
|
|
240
|
+
.sort((a, b) => a.dueDate.getTime() - b.dueDate.getTime())[0];
|
|
241
|
+
if (upcoming) {
|
|
242
|
+
prompts.push(
|
|
243
|
+
`Create a worksheet to help students prepare for "${upcoming.title}"`
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const recent = published.find((a) => a.title !== upcoming?.title);
|
|
248
|
+
if (recent) {
|
|
249
|
+
prompts.push(`Draft a quiz reviewing "${recent.title}"`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const unit = classContext.sections[0];
|
|
253
|
+
if (unit) {
|
|
254
|
+
prompts.push(`Plan a lesson for the unit "${unit.name}"`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (classContext.subject) {
|
|
258
|
+
prompts.push(
|
|
259
|
+
`Suggest a ${classContext.subject} assignment with a mark scheme`
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
for (const generic of GENERIC_STARTER_PROMPTS) {
|
|
264
|
+
if (prompts.length >= 3) break;
|
|
265
|
+
if (!prompts.includes(generic)) prompts.push(generic);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return { prompts: prompts.slice(0, MAX_STARTER_PROMPTS) };
|
|
269
|
+
}
|
|
270
|
+
|
|
206
271
|
export async function listLabChats(userId: string, classId: string) {
|
|
207
272
|
const classMembership = await findClassMembership(classId, userId);
|
|
208
273
|
if (!classMembership) {
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
incrementMarketplaceReportCount,
|
|
22
22
|
searchMarketplaceListings,
|
|
23
23
|
setMarketplaceListingHidden,
|
|
24
|
+
updateMarketplaceListing,
|
|
24
25
|
} from "../models/marketplace.js";
|
|
25
26
|
import {
|
|
26
27
|
findActiveSchoolAdminMembership,
|
|
@@ -659,6 +660,44 @@ export async function reportMarketplaceListing(
|
|
|
659
660
|
return incrementMarketplaceReportCount(listingId);
|
|
660
661
|
}
|
|
661
662
|
|
|
663
|
+
export async function updateMarketplaceListingDetails(
|
|
664
|
+
userId: string,
|
|
665
|
+
input: {
|
|
666
|
+
listingId: string;
|
|
667
|
+
title?: string;
|
|
668
|
+
description?: string | null;
|
|
669
|
+
visibility?: "SCHOOL" | "PUBLIC";
|
|
670
|
+
tags?: string[];
|
|
671
|
+
}
|
|
672
|
+
) {
|
|
673
|
+
const listing = await findMarketplaceListingById(input.listingId);
|
|
674
|
+
if (!listing) {
|
|
675
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Listing not found" });
|
|
676
|
+
}
|
|
677
|
+
const isAuthor = listing.authorId === userId;
|
|
678
|
+
const isSchoolAdmin =
|
|
679
|
+
listing.schoolId != null &&
|
|
680
|
+
Boolean(await findActiveSchoolAdminMembership(listing.schoolId, userId));
|
|
681
|
+
if (!isAuthor && !isSchoolAdmin) {
|
|
682
|
+
throw new TRPCError({
|
|
683
|
+
code: "FORBIDDEN",
|
|
684
|
+
message: "Only the author or a school admin can edit this listing",
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
if (input.visibility === "SCHOOL" && listing.schoolId == null) {
|
|
688
|
+
throw new TRPCError({
|
|
689
|
+
code: "BAD_REQUEST",
|
|
690
|
+
message: "Listing has no school; it cannot use school visibility",
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
return updateMarketplaceListing(input.listingId, {
|
|
694
|
+
title: input.title,
|
|
695
|
+
description: input.description,
|
|
696
|
+
visibility: input.visibility,
|
|
697
|
+
tags: input.tags ? normalizeTags(input.tags) : undefined,
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
|
|
662
701
|
export async function hideMarketplaceListing(
|
|
663
702
|
userId: string,
|
|
664
703
|
listingId: string,
|
package/src/services/message.ts
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import type { AppTransactionClient } from "../lib/prisma.js";
|
|
6
6
|
import { TRPCError } from "@trpc/server";
|
|
7
|
+
import { JobStatus, type AiMessageFeedback } from "@prisma/client";
|
|
8
|
+
import { isAIUser } from "../utils/aiUser.js";
|
|
9
|
+
import { enqueueRunJob } from "../workers/queues.js";
|
|
7
10
|
import { prisma } from "../lib/prisma.js";
|
|
8
11
|
import { chatChannel, pusher } from "../lib/pusher.js";
|
|
9
12
|
import {
|
|
@@ -181,6 +184,7 @@ export async function listMessages(
|
|
|
181
184
|
createdAt: message.createdAt,
|
|
182
185
|
sender: message.sender,
|
|
183
186
|
status: message.status,
|
|
187
|
+
aiFeedback: message.aiFeedback,
|
|
184
188
|
attachments: message.attachments,
|
|
185
189
|
meta: message.meta as Record<string, unknown>,
|
|
186
190
|
mentions: message.mentions.map((m) => ({ user: m.user })),
|
|
@@ -512,148 +516,6 @@ export async function deleteMessage(userId: string, messageId: string) {
|
|
|
512
516
|
return { success: true, messageId };
|
|
513
517
|
}
|
|
514
518
|
|
|
515
|
-
const CREATED_INDICES_KEY: Record<"assignment" | "worksheet" | "section", string> = {
|
|
516
|
-
assignment: "assignments",
|
|
517
|
-
worksheet: "worksheets",
|
|
518
|
-
section: "sections",
|
|
519
|
-
};
|
|
520
|
-
|
|
521
|
-
const MARK_SUGGESTION_MAX_RETRIES = 5;
|
|
522
|
-
|
|
523
|
-
/** Mark an AI-suggested item as created. Stores in message meta for fast reads. */
|
|
524
|
-
export async function markSuggestionCreated(
|
|
525
|
-
userId: string,
|
|
526
|
-
input: {
|
|
527
|
-
messageId: string;
|
|
528
|
-
type: "assignment" | "worksheet" | "section";
|
|
529
|
-
index: number;
|
|
530
|
-
}
|
|
531
|
-
) {
|
|
532
|
-
const { messageId, type, index } = input;
|
|
533
|
-
|
|
534
|
-
const existingMessage = await findMessageByIdMinimal(messageId);
|
|
535
|
-
if (!existingMessage) {
|
|
536
|
-
throw new TRPCError({
|
|
537
|
-
code: "NOT_FOUND",
|
|
538
|
-
message: "Message not found",
|
|
539
|
-
});
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
const membership = await findConversationMembership(
|
|
543
|
-
existingMessage.conversationId,
|
|
544
|
-
userId
|
|
545
|
-
);
|
|
546
|
-
if (!membership) {
|
|
547
|
-
throw new TRPCError({
|
|
548
|
-
code: "FORBIDDEN",
|
|
549
|
-
message: "Not a member of this conversation",
|
|
550
|
-
});
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
const conversationId = existingMessage.conversationId;
|
|
554
|
-
let lastError: Error | null = null;
|
|
555
|
-
|
|
556
|
-
for (let attempt = 0; attempt < MARK_SUGGESTION_MAX_RETRIES; attempt++) {
|
|
557
|
-
const msg = await prisma.message.findUnique({
|
|
558
|
-
where: { id: messageId },
|
|
559
|
-
select: { meta: true },
|
|
560
|
-
});
|
|
561
|
-
if (!msg) {
|
|
562
|
-
throw new TRPCError({
|
|
563
|
-
code: "NOT_FOUND",
|
|
564
|
-
message: "Message not found",
|
|
565
|
-
});
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
const currentMeta = (msg.meta as Record<string, unknown>) ?? {};
|
|
569
|
-
const createdIndices = (currentMeta.createdIndices as Record<string, number[]>) ?? {};
|
|
570
|
-
const typeIndices = createdIndices[CREATED_INDICES_KEY[type]] ?? [];
|
|
571
|
-
if (typeIndices.includes(index)) return { success: true };
|
|
572
|
-
|
|
573
|
-
const newTypeIndices = [...typeIndices, index].sort((a, b) => a - b);
|
|
574
|
-
const newMeta = {
|
|
575
|
-
...currentMeta,
|
|
576
|
-
createdIndices: {
|
|
577
|
-
...createdIndices,
|
|
578
|
-
[CREATED_INDICES_KEY[type]]: newTypeIndices,
|
|
579
|
-
},
|
|
580
|
-
};
|
|
581
|
-
|
|
582
|
-
const newMetaJson = JSON.stringify(newMeta);
|
|
583
|
-
const currentMetaJson = msg.meta === null ? null : JSON.stringify(msg.meta);
|
|
584
|
-
|
|
585
|
-
const updatedCount =
|
|
586
|
-
currentMetaJson === null
|
|
587
|
-
? await prisma.$executeRaw`
|
|
588
|
-
UPDATE "Message" SET meta = ${newMetaJson}::jsonb
|
|
589
|
-
WHERE id = ${messageId} AND meta IS NULL
|
|
590
|
-
`
|
|
591
|
-
: await prisma.$executeRaw`
|
|
592
|
-
UPDATE "Message" SET meta = ${newMetaJson}::jsonb
|
|
593
|
-
WHERE id = ${messageId} AND meta = ${currentMetaJson}::jsonb
|
|
594
|
-
`;
|
|
595
|
-
|
|
596
|
-
if (Number(updatedCount) > 0) {
|
|
597
|
-
const updated = await prisma.message.findUnique({
|
|
598
|
-
where: { id: messageId },
|
|
599
|
-
include: {
|
|
600
|
-
sender: {
|
|
601
|
-
select: {
|
|
602
|
-
id: true,
|
|
603
|
-
username: true,
|
|
604
|
-
profile: {
|
|
605
|
-
select: { displayName: true, profilePicture: true },
|
|
606
|
-
},
|
|
607
|
-
},
|
|
608
|
-
},
|
|
609
|
-
attachments: { select: messageFileSelect },
|
|
610
|
-
mentions: { select: { userId: true } },
|
|
611
|
-
},
|
|
612
|
-
});
|
|
613
|
-
|
|
614
|
-
if (updated) {
|
|
615
|
-
const mentionedUserIds = updated.mentions.map((m) => m.userId);
|
|
616
|
-
try {
|
|
617
|
-
await pusher.trigger(
|
|
618
|
-
chatChannel(conversationId),
|
|
619
|
-
"message-updated",
|
|
620
|
-
{
|
|
621
|
-
id: updated.id,
|
|
622
|
-
content: updated.content,
|
|
623
|
-
senderId: updated.senderId,
|
|
624
|
-
conversationId: updated.conversationId,
|
|
625
|
-
status: updated.status,
|
|
626
|
-
createdAt: updated.createdAt,
|
|
627
|
-
sender: updated.sender,
|
|
628
|
-
attachments: updated.attachments ?? [],
|
|
629
|
-
meta: newMeta,
|
|
630
|
-
mentionedUserIds,
|
|
631
|
-
}
|
|
632
|
-
);
|
|
633
|
-
} catch (error) {
|
|
634
|
-
logger.error("Failed to broadcast suggestion status via Pusher", {
|
|
635
|
-
error,
|
|
636
|
-
messageId,
|
|
637
|
-
});
|
|
638
|
-
}
|
|
639
|
-
}
|
|
640
|
-
return { success: true };
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
lastError = new Error("Concurrent update conflict");
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
logger.error("markSuggestionCreated failed after retries", {
|
|
647
|
-
messageId,
|
|
648
|
-
type,
|
|
649
|
-
index,
|
|
650
|
-
error: lastError,
|
|
651
|
-
});
|
|
652
|
-
throw new TRPCError({
|
|
653
|
-
code: "CONFLICT",
|
|
654
|
-
message: "Failed to update suggestion status after retries due to concurrent updates",
|
|
655
|
-
});
|
|
656
|
-
}
|
|
657
519
|
|
|
658
520
|
export async function markAsRead(userId: string, conversationId: string) {
|
|
659
521
|
const membership = await findConversationMembership(conversationId, userId);
|
|
@@ -757,3 +619,204 @@ export async function getUnreadCount(
|
|
|
757
619
|
|
|
758
620
|
return { unreadCount, unreadMentionCount };
|
|
759
621
|
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Regenerate an AI response in a lab or Newton chat.
|
|
625
|
+
*
|
|
626
|
+
* The existing AI bubble is reused (not deleted): the row flips back to
|
|
627
|
+
* PENDING with cleared content/meta/feedback so the message id stays stable
|
|
628
|
+
* for the client, then the matching pipeline refills it. Only the latest
|
|
629
|
+
* message in the conversation can be regenerated — the pipelines build
|
|
630
|
+
* their context from the messages preceding the placeholder.
|
|
631
|
+
*/
|
|
632
|
+
export async function regenerateAIResponse(userId: string, messageId: string) {
|
|
633
|
+
const message = await prisma.message.findUnique({
|
|
634
|
+
where: { id: messageId },
|
|
635
|
+
select: {
|
|
636
|
+
id: true,
|
|
637
|
+
senderId: true,
|
|
638
|
+
conversationId: true,
|
|
639
|
+
status: true,
|
|
640
|
+
conversation: {
|
|
641
|
+
select: {
|
|
642
|
+
labChat: { select: { id: true, classId: true } },
|
|
643
|
+
newtonChat: { select: { id: true } },
|
|
644
|
+
},
|
|
645
|
+
},
|
|
646
|
+
},
|
|
647
|
+
});
|
|
648
|
+
if (!message) {
|
|
649
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Message not found" });
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const membership = await findConversationMembership(
|
|
653
|
+
message.conversationId,
|
|
654
|
+
userId
|
|
655
|
+
);
|
|
656
|
+
if (!membership) {
|
|
657
|
+
throw new TRPCError({
|
|
658
|
+
code: "FORBIDDEN",
|
|
659
|
+
message: "Not a member of this conversation",
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if (!isAIUser(message.senderId)) {
|
|
664
|
+
throw new TRPCError({
|
|
665
|
+
code: "BAD_REQUEST",
|
|
666
|
+
message: "Only AI messages can be regenerated",
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const { labChat, newtonChat } = message.conversation;
|
|
671
|
+
if (!labChat && !newtonChat) {
|
|
672
|
+
throw new TRPCError({
|
|
673
|
+
code: "BAD_REQUEST",
|
|
674
|
+
message: "This conversation has no AI pipeline to regenerate with",
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
if (message.status === JobStatus.PENDING) {
|
|
679
|
+
throw new TRPCError({
|
|
680
|
+
code: "BAD_REQUEST",
|
|
681
|
+
message: "This response is still being generated",
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const latestMessage = await prisma.message.findFirst({
|
|
686
|
+
where: { conversationId: message.conversationId },
|
|
687
|
+
orderBy: { createdAt: "desc" },
|
|
688
|
+
select: { id: true },
|
|
689
|
+
});
|
|
690
|
+
if (latestMessage?.id !== message.id) {
|
|
691
|
+
throw new TRPCError({
|
|
692
|
+
code: "BAD_REQUEST",
|
|
693
|
+
message: "Only the latest AI response can be regenerated",
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const reusedAiMessage = await prisma.message.update({
|
|
698
|
+
where: { id: message.id },
|
|
699
|
+
data: {
|
|
700
|
+
content: "",
|
|
701
|
+
status: JobStatus.PENDING,
|
|
702
|
+
meta: {},
|
|
703
|
+
aiFeedback: null,
|
|
704
|
+
},
|
|
705
|
+
select: {
|
|
706
|
+
id: true,
|
|
707
|
+
content: true,
|
|
708
|
+
senderId: true,
|
|
709
|
+
conversationId: true,
|
|
710
|
+
status: true,
|
|
711
|
+
createdAt: true,
|
|
712
|
+
},
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
// Tell the chat UI the existing AI bubble is regenerating — content
|
|
716
|
+
// cleared, status flipped back to PENDING, same id.
|
|
717
|
+
try {
|
|
718
|
+
await pusher.trigger(
|
|
719
|
+
chatChannel(message.conversationId),
|
|
720
|
+
"message-updated",
|
|
721
|
+
{
|
|
722
|
+
id: reusedAiMessage.id,
|
|
723
|
+
content: reusedAiMessage.content,
|
|
724
|
+
senderId: reusedAiMessage.senderId,
|
|
725
|
+
conversationId: reusedAiMessage.conversationId,
|
|
726
|
+
status: reusedAiMessage.status,
|
|
727
|
+
createdAt: reusedAiMessage.createdAt,
|
|
728
|
+
sender: {
|
|
729
|
+
id: reusedAiMessage.senderId,
|
|
730
|
+
username: "Newton_AI",
|
|
731
|
+
profile: { displayName: "Newton AI", profilePicture: null },
|
|
732
|
+
},
|
|
733
|
+
mentionedUserIds: [],
|
|
734
|
+
meta: {},
|
|
735
|
+
attachments: [],
|
|
736
|
+
}
|
|
737
|
+
);
|
|
738
|
+
} catch (error) {
|
|
739
|
+
logger.error("Failed to broadcast AI regenerate reset", {
|
|
740
|
+
error,
|
|
741
|
+
messageId,
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
try {
|
|
746
|
+
if (labChat) {
|
|
747
|
+
await enqueueRunJob(
|
|
748
|
+
"AI_LAB_CHAT",
|
|
749
|
+
{ conversationId: message.conversationId, aiMessageId: reusedAiMessage.id },
|
|
750
|
+
{ classId: labChat.classId, userId }
|
|
751
|
+
);
|
|
752
|
+
} else {
|
|
753
|
+
await enqueueRunJob(
|
|
754
|
+
"AI_NEWTON_CHAT",
|
|
755
|
+
{ conversationId: message.conversationId, aiMessageId: reusedAiMessage.id },
|
|
756
|
+
{ userId }
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
} catch (error) {
|
|
760
|
+
logger.error("Failed to enqueue AI regeneration", {
|
|
761
|
+
error,
|
|
762
|
+
messageId,
|
|
763
|
+
conversationId: message.conversationId,
|
|
764
|
+
});
|
|
765
|
+
throw new TRPCError({
|
|
766
|
+
code: "INTERNAL_SERVER_ERROR",
|
|
767
|
+
message: "Failed to start regeneration",
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
return { success: true, messageId: reusedAiMessage.id };
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/** Record (or clear) thumbs up/down feedback on an AI message. */
|
|
775
|
+
export async function setAIMessageFeedback(
|
|
776
|
+
userId: string,
|
|
777
|
+
input: {
|
|
778
|
+
messageId: string;
|
|
779
|
+
feedback: AiMessageFeedback | null;
|
|
780
|
+
}
|
|
781
|
+
) {
|
|
782
|
+
const message = await prisma.message.findUnique({
|
|
783
|
+
where: { id: input.messageId },
|
|
784
|
+
select: { id: true, senderId: true, conversationId: true, status: true },
|
|
785
|
+
});
|
|
786
|
+
if (!message) {
|
|
787
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Message not found" });
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const membership = await findConversationMembership(
|
|
791
|
+
message.conversationId,
|
|
792
|
+
userId
|
|
793
|
+
);
|
|
794
|
+
if (!membership) {
|
|
795
|
+
throw new TRPCError({
|
|
796
|
+
code: "FORBIDDEN",
|
|
797
|
+
message: "Not a member of this conversation",
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
if (!isAIUser(message.senderId)) {
|
|
802
|
+
throw new TRPCError({
|
|
803
|
+
code: "BAD_REQUEST",
|
|
804
|
+
message: "Feedback can only be left on AI messages",
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
if (message.status !== JobStatus.COMPLETED) {
|
|
809
|
+
throw new TRPCError({
|
|
810
|
+
code: "BAD_REQUEST",
|
|
811
|
+
message: "Feedback can only be left on completed AI responses",
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
const updated = await prisma.message.update({
|
|
816
|
+
where: { id: message.id },
|
|
817
|
+
data: { aiFeedback: input.feedback },
|
|
818
|
+
select: { id: true, aiFeedback: true },
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
return { success: true, messageId: updated.id, feedback: updated.aiFeedback };
|
|
822
|
+
}
|