@studious-lms/server 1.26.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/message.d.ts +3 -0
- package/dist/models/message.d.ts.map +1 -1
- 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/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 +220 -10
- package/dist/routers/_app.d.ts.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/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/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/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/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/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/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/20260811130000_add_ai_message_feedback/migration.sql +7 -0
- package/prisma/schema.prisma +8 -0
- package/src/models/cloudStorage.ts +19 -0
- package/src/models/labChat.ts +34 -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/labChatPrompt.ts +11 -12
- package/src/routers/labChat.ts +5 -0
- package/src/routers/message.ts +34 -5
- 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/labChat.ts +65 -0
- package/src/services/message.ts +205 -142
- package/src/services/parentPortal.ts +85 -0
- 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/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
|
+
}
|
|
@@ -85,6 +85,91 @@ export async function linkParentToStudent(
|
|
|
85
85
|
return { link };
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
export async function addParentToStudent(
|
|
89
|
+
userId: string,
|
|
90
|
+
input: { schoolId: string; studentUserId: string; parentIdentifier: string }
|
|
91
|
+
) {
|
|
92
|
+
await requireSchoolAdmin(input.schoolId, userId);
|
|
93
|
+
|
|
94
|
+
const studentMembership = await findSchoolMembership(
|
|
95
|
+
input.schoolId,
|
|
96
|
+
input.studentUserId
|
|
97
|
+
);
|
|
98
|
+
if (
|
|
99
|
+
!studentMembership ||
|
|
100
|
+
studentMembership.status !== "ACTIVE" ||
|
|
101
|
+
studentMembership.role !== "STUDENT"
|
|
102
|
+
) {
|
|
103
|
+
throw new TRPCError({
|
|
104
|
+
code: "BAD_REQUEST",
|
|
105
|
+
message: "studentUserId must be an active STUDENT member",
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const identifier = input.parentIdentifier.trim();
|
|
110
|
+
const parentUser = await prisma.user.findFirst({
|
|
111
|
+
where: {
|
|
112
|
+
OR: [{ email: identifier }, { username: identifier }],
|
|
113
|
+
},
|
|
114
|
+
select: { id: true },
|
|
115
|
+
});
|
|
116
|
+
if (!parentUser) {
|
|
117
|
+
throw new TRPCError({
|
|
118
|
+
code: "NOT_FOUND",
|
|
119
|
+
message: "No account found with that email or username",
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
if (parentUser.id === input.studentUserId) {
|
|
123
|
+
throw new TRPCError({
|
|
124
|
+
code: "BAD_REQUEST",
|
|
125
|
+
message: "A student cannot be their own parent",
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const parentMembership = await findSchoolMembership(
|
|
130
|
+
input.schoolId,
|
|
131
|
+
parentUser.id
|
|
132
|
+
);
|
|
133
|
+
if (!parentMembership) {
|
|
134
|
+
await prisma.schoolMembership.create({
|
|
135
|
+
data: {
|
|
136
|
+
schoolId: input.schoolId,
|
|
137
|
+
userId: parentUser.id,
|
|
138
|
+
role: "PARENT",
|
|
139
|
+
status: "ACTIVE",
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
} else if (parentMembership.role !== "PARENT") {
|
|
143
|
+
throw new TRPCError({
|
|
144
|
+
code: "BAD_REQUEST",
|
|
145
|
+
message: "That account already belongs to this school with another role",
|
|
146
|
+
});
|
|
147
|
+
} else if (parentMembership.status !== "ACTIVE") {
|
|
148
|
+
throw new TRPCError({
|
|
149
|
+
code: "BAD_REQUEST",
|
|
150
|
+
message: "Parent must be an active school member",
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const link = await prisma.parentStudentLink.upsert({
|
|
155
|
+
where: {
|
|
156
|
+
schoolId_parentUserId_studentUserId: {
|
|
157
|
+
schoolId: input.schoolId,
|
|
158
|
+
parentUserId: parentUser.id,
|
|
159
|
+
studentUserId: input.studentUserId,
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
update: {},
|
|
163
|
+
create: {
|
|
164
|
+
schoolId: input.schoolId,
|
|
165
|
+
parentUserId: parentUser.id,
|
|
166
|
+
studentUserId: input.studentUserId,
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
return { link };
|
|
171
|
+
}
|
|
172
|
+
|
|
88
173
|
export async function unlinkParentFromStudent(
|
|
89
174
|
userId: string,
|
|
90
175
|
input: { schoolId: string; parentUserId: string; studentUserId: string }
|
|
@@ -6,6 +6,7 @@ import type { AssignmentType } from "@prisma/client";
|
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import { prisma } from "../lib/prisma.js";
|
|
8
8
|
import { isTeacherInClass } from "../models/class.js";
|
|
9
|
+
import { createAssignmentRecord } from "./assignment.js";
|
|
9
10
|
import { inference } from "../utils/inference.js";
|
|
10
11
|
import { logger } from "../utils/logger.js";
|
|
11
12
|
import {
|
|
@@ -617,6 +618,140 @@ export async function dismissStudentRecommendation(
|
|
|
617
618
|
return { success: true };
|
|
618
619
|
}
|
|
619
620
|
|
|
621
|
+
const followUpAssignmentSchema = z.object({
|
|
622
|
+
title: z.string().min(1).max(200),
|
|
623
|
+
instructions: z.string().min(1),
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
export type FollowUpAssignmentDraft = z.infer<typeof followUpAssignmentSchema>;
|
|
627
|
+
|
|
628
|
+
export async function generateFollowUpAssignment(
|
|
629
|
+
viewerId: string,
|
|
630
|
+
input: {
|
|
631
|
+
classId: string;
|
|
632
|
+
studentId: string;
|
|
633
|
+
submissionId: string;
|
|
634
|
+
prompt: string;
|
|
635
|
+
dueDate?: string;
|
|
636
|
+
},
|
|
637
|
+
) {
|
|
638
|
+
const teacherInClass = await isTeacherInClass(input.classId, viewerId);
|
|
639
|
+
if (!teacherInClass) {
|
|
640
|
+
throw new TRPCError({
|
|
641
|
+
code: "UNAUTHORIZED",
|
|
642
|
+
message: "Only teachers can generate follow-up assignments",
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const submission = await prisma.submission.findFirst({
|
|
647
|
+
where: {
|
|
648
|
+
id: input.submissionId,
|
|
649
|
+
studentId: input.studentId,
|
|
650
|
+
assignment: { classId: input.classId },
|
|
651
|
+
},
|
|
652
|
+
select: {
|
|
653
|
+
id: true,
|
|
654
|
+
gradeReceived: true,
|
|
655
|
+
teacherComments: true,
|
|
656
|
+
student: {
|
|
657
|
+
select: {
|
|
658
|
+
id: true,
|
|
659
|
+
username: true,
|
|
660
|
+
profile: { select: { displayName: true } },
|
|
661
|
+
},
|
|
662
|
+
},
|
|
663
|
+
assignment: {
|
|
664
|
+
select: {
|
|
665
|
+
id: true,
|
|
666
|
+
title: true,
|
|
667
|
+
instructions: true,
|
|
668
|
+
type: true,
|
|
669
|
+
maxGrade: true,
|
|
670
|
+
weight: true,
|
|
671
|
+
section: { select: { id: true, name: true } },
|
|
672
|
+
},
|
|
673
|
+
},
|
|
674
|
+
},
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
if (!submission) {
|
|
678
|
+
throw new TRPCError({
|
|
679
|
+
code: "NOT_FOUND",
|
|
680
|
+
message: "Submission not found for this student in this class",
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const displayName =
|
|
685
|
+
submission.student.profile?.displayName ?? submission.student.username;
|
|
686
|
+
const percentage =
|
|
687
|
+
submission.gradeReceived != null && submission.assignment.maxGrade
|
|
688
|
+
? Math.round(
|
|
689
|
+
(submission.gradeReceived / submission.assignment.maxGrade) * 1000,
|
|
690
|
+
) / 10
|
|
691
|
+
: null;
|
|
692
|
+
|
|
693
|
+
const draft = await inference<FollowUpAssignmentDraft>(
|
|
694
|
+
[
|
|
695
|
+
{
|
|
696
|
+
role: "system" as const,
|
|
697
|
+
content:
|
|
698
|
+
"You are an experienced teacher creating a targeted follow-up assignment for one student. Ground the task in the source assignment and the teacher's request. Do not fabricate material beyond the source assignment's topic. Return a concise title and clear, student-facing instructions.",
|
|
699
|
+
},
|
|
700
|
+
{
|
|
701
|
+
role: "user" as const,
|
|
702
|
+
content: JSON.stringify({
|
|
703
|
+
student: { displayName },
|
|
704
|
+
sourceAssignment: {
|
|
705
|
+
title: submission.assignment.title,
|
|
706
|
+
instructions: submission.assignment.instructions,
|
|
707
|
+
type: submission.assignment.type,
|
|
708
|
+
maxGrade: submission.assignment.maxGrade,
|
|
709
|
+
section: submission.assignment.section?.name ?? null,
|
|
710
|
+
},
|
|
711
|
+
submission: {
|
|
712
|
+
gradeReceived: submission.gradeReceived,
|
|
713
|
+
percentage,
|
|
714
|
+
teacherFeedback: submission.teacherComments,
|
|
715
|
+
},
|
|
716
|
+
}),
|
|
717
|
+
},
|
|
718
|
+
{ role: "user" as const, content: input.prompt },
|
|
719
|
+
],
|
|
720
|
+
followUpAssignmentSchema,
|
|
721
|
+
{
|
|
722
|
+
reason: "student_progress_follow_up_assignment",
|
|
723
|
+
metadata: {
|
|
724
|
+
viewerId,
|
|
725
|
+
classId: input.classId,
|
|
726
|
+
studentId: input.studentId,
|
|
727
|
+
submissionId: input.submissionId,
|
|
728
|
+
},
|
|
729
|
+
attribution: { userId: viewerId, classId: input.classId },
|
|
730
|
+
},
|
|
731
|
+
);
|
|
732
|
+
|
|
733
|
+
const dueDate =
|
|
734
|
+
input.dueDate ??
|
|
735
|
+
new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
736
|
+
|
|
737
|
+
const assignment = await createAssignmentRecord(viewerId, {
|
|
738
|
+
classId: input.classId,
|
|
739
|
+
title: draft.title,
|
|
740
|
+
instructions: draft.instructions,
|
|
741
|
+
dueDate,
|
|
742
|
+
studentIds: [input.studentId],
|
|
743
|
+
maxGrade: submission.assignment.maxGrade ?? 100,
|
|
744
|
+
graded: true,
|
|
745
|
+
weight: submission.assignment.weight || 1,
|
|
746
|
+
sectionId: submission.assignment.section?.id ?? undefined,
|
|
747
|
+
type: normalizeAssignmentType(submission.assignment.type),
|
|
748
|
+
acceptExtendedResponse: true,
|
|
749
|
+
sourceAssignmentId: submission.assignment.id,
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
return { assignment, draft };
|
|
753
|
+
}
|
|
754
|
+
|
|
620
755
|
function buildProgressSummary(submissions: GradeSubmission[]) {
|
|
621
756
|
return submissions.map((submission) => ({
|
|
622
757
|
title: submission.assignment.title,
|
package/src/utils/inference.ts
CHANGED
|
@@ -476,6 +476,77 @@ export async function completePendingAIMessage(
|
|
|
476
476
|
};
|
|
477
477
|
}
|
|
478
478
|
|
|
479
|
+
/**
|
|
480
|
+
* Persist the partial output of a cancelled generation and flip the AI
|
|
481
|
+
* message to CANCELLED. Whatever text/tool parts streamed before the stop
|
|
482
|
+
* are kept on the row (content + meta) so they survive reloads. Broadcasts
|
|
483
|
+
* `message-updated` so clients patch the bubble in place.
|
|
484
|
+
*/
|
|
485
|
+
export async function cancelPendingAIMessage(
|
|
486
|
+
messageId: string,
|
|
487
|
+
content: string,
|
|
488
|
+
options: {
|
|
489
|
+
meta?: Record<string, any>;
|
|
490
|
+
customSender?: AIMessageSenderInfo;
|
|
491
|
+
} = {},
|
|
492
|
+
): Promise<void> {
|
|
493
|
+
try {
|
|
494
|
+
const updated = await prisma.message.update({
|
|
495
|
+
where: { id: messageId },
|
|
496
|
+
data: {
|
|
497
|
+
content,
|
|
498
|
+
status: JobStatus.CANCELLED,
|
|
499
|
+
...(options.meta !== undefined && {
|
|
500
|
+
meta: options.meta as Prisma.InputJsonValue,
|
|
501
|
+
}),
|
|
502
|
+
},
|
|
503
|
+
include: {
|
|
504
|
+
attachments: true,
|
|
505
|
+
},
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
logger.info('AI message cancelled', {
|
|
509
|
+
messageId: updated.id,
|
|
510
|
+
conversationId: updated.conversationId,
|
|
511
|
+
contentLength: content.length,
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
const senderInfo = buildAISenderInfo(options.customSender);
|
|
515
|
+
|
|
516
|
+
try {
|
|
517
|
+
await pusher.trigger(chatChannel(updated.conversationId), 'message-updated', {
|
|
518
|
+
id: updated.id,
|
|
519
|
+
content: updated.content,
|
|
520
|
+
senderId: updated.senderId,
|
|
521
|
+
conversationId: updated.conversationId,
|
|
522
|
+
status: updated.status,
|
|
523
|
+
createdAt: updated.createdAt,
|
|
524
|
+
sender: senderInfo,
|
|
525
|
+
mentionedUserIds: [],
|
|
526
|
+
meta: updated.meta ?? {},
|
|
527
|
+
attachments: updated.attachments.map((attachment) => ({
|
|
528
|
+
id: attachment.id,
|
|
529
|
+
attachmentId: attachment.id,
|
|
530
|
+
name: attachment.name,
|
|
531
|
+
type: attachment.type,
|
|
532
|
+
size: attachment.size,
|
|
533
|
+
path: attachment.path,
|
|
534
|
+
})),
|
|
535
|
+
});
|
|
536
|
+
} catch (broadcastError) {
|
|
537
|
+
logger.error('Failed to broadcast cancelled AI message:', {
|
|
538
|
+
error: broadcastError,
|
|
539
|
+
messageId,
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
} catch (error) {
|
|
543
|
+
logger.error('Failed to mark AI message as cancelled:', {
|
|
544
|
+
error,
|
|
545
|
+
messageId,
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
479
550
|
/**
|
|
480
551
|
* Flip a PENDING AI message to FAILED and broadcast the status change.
|
|
481
552
|
* Does NOT throw — failure paths in the pipelines call this in catch blocks
|
|
@@ -486,6 +557,20 @@ export async function failPendingAIMessage(
|
|
|
486
557
|
options: { reason?: string } = {},
|
|
487
558
|
): Promise<void> {
|
|
488
559
|
try {
|
|
560
|
+
const current = await prisma.message.findUnique({
|
|
561
|
+
where: { id: messageId },
|
|
562
|
+
select: { status: true },
|
|
563
|
+
});
|
|
564
|
+
// A message the user already cancelled must stay CANCELLED — late
|
|
565
|
+
// failures from the aborted pipeline are a consequence of the cancel.
|
|
566
|
+
if (current?.status === JobStatus.CANCELLED) {
|
|
567
|
+
logger.info('Skipping FAILED flip; AI message already cancelled', {
|
|
568
|
+
messageId,
|
|
569
|
+
reason: options.reason,
|
|
570
|
+
});
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
|
|
489
574
|
const updated = await prisma.message.update({
|
|
490
575
|
where: { id: messageId },
|
|
491
576
|
data: { status: JobStatus.FAILED },
|