@goscribe/server 1.1.7 → 1.3.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 +43 -0
- package/check-difficulty.cjs +14 -0
- package/check-questions.cjs +14 -0
- package/db-summary.cjs +22 -0
- package/dist/routers/auth.js +1 -1
- package/mcq-test.cjs +36 -0
- package/package.json +10 -2
- package/prisma/migrations/20260413143206_init/migration.sql +873 -0
- package/prisma/schema.prisma +485 -292
- package/src/context.ts +4 -1
- package/src/lib/activity_human_description.test.ts +28 -0
- package/src/lib/activity_human_description.ts +239 -0
- package/src/lib/activity_log_service.test.ts +37 -0
- package/src/lib/activity_log_service.ts +353 -0
- package/src/lib/ai-session.ts +194 -112
- package/src/lib/constants.ts +14 -0
- package/src/lib/email.ts +230 -0
- package/src/lib/env.ts +23 -6
- package/src/lib/inference.ts +3 -3
- package/src/lib/logger.ts +26 -9
- package/src/lib/notification-service.test.ts +106 -0
- package/src/lib/notification-service.ts +677 -0
- package/src/lib/prisma.ts +6 -1
- package/src/lib/pusher.ts +90 -6
- package/src/lib/retry.ts +61 -0
- package/src/lib/storage.ts +2 -2
- package/src/lib/stripe.ts +39 -0
- package/src/lib/subscription_service.ts +722 -0
- package/src/lib/usage_service.ts +74 -0
- package/src/lib/worksheet-generation.test.ts +31 -0
- package/src/lib/worksheet-generation.ts +139 -0
- package/src/lib/workspace-access.ts +13 -0
- package/src/routers/_app.ts +11 -0
- package/src/routers/admin.ts +710 -0
- package/src/routers/annotations.ts +227 -0
- package/src/routers/auth.ts +432 -33
- package/src/routers/copilot.ts +719 -0
- package/src/routers/flashcards.ts +207 -80
- package/src/routers/members.ts +280 -80
- package/src/routers/notifications.ts +142 -0
- package/src/routers/payment.ts +448 -0
- package/src/routers/podcast.ts +133 -108
- package/src/routers/studyguide.ts +80 -74
- package/src/routers/worksheets.ts +300 -80
- package/src/routers/workspace.ts +538 -328
- package/src/scripts/purge-deleted-users.ts +167 -0
- package/src/server.ts +140 -12
- package/src/services/flashcard-progress.service.ts +52 -43
- package/src/trpc.ts +184 -5
- package/test-generate.js +30 -0
- package/test-ratio.cjs +9 -0
- package/zod-test.cjs +22 -0
- package/prisma/migrations/20250826124819_add_worksheet_difficulty_and_estimated_time/migration.sql +0 -213
- package/prisma/migrations/20250826133236_add_worksheet_question_progress/migration.sql +0 -31
- package/prisma/seed.mjs +0 -135
- package/src/routers/meetingsummary.ts +0 -416
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
import { TRPCError } from "@trpc/server";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { router, verifiedProcedure } from "../trpc.js";
|
|
4
|
+
import inference from "../lib/inference.js";
|
|
5
|
+
import { sanitizeString } from "../lib/validation.js";
|
|
6
|
+
import { workspaceAccessFilter } from "../lib/workspace-access.js";
|
|
7
|
+
import PusherService from "../lib/pusher.js";
|
|
8
|
+
import { ArtifactType } from "../lib/constants.js";
|
|
9
|
+
import type { Context } from "../context.js";
|
|
10
|
+
|
|
11
|
+
const copilotArtifactType = z.enum([
|
|
12
|
+
"study-guide",
|
|
13
|
+
"worksheet",
|
|
14
|
+
"flashcards",
|
|
15
|
+
"notes",
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
const copilotContextSchema = z.object({
|
|
19
|
+
workspaceId: z.string().min(1),
|
|
20
|
+
artifactId: z.string().min(1),
|
|
21
|
+
artifactType: copilotArtifactType,
|
|
22
|
+
documentContent: z.string().min(1),
|
|
23
|
+
selectedText: z.string().optional(),
|
|
24
|
+
viewportText: z.string().optional(),
|
|
25
|
+
cursorPosition: z
|
|
26
|
+
.object({
|
|
27
|
+
start: z.number().int().min(0),
|
|
28
|
+
end: z.number().int().min(0),
|
|
29
|
+
})
|
|
30
|
+
.optional(),
|
|
31
|
+
metadata: z
|
|
32
|
+
.object({
|
|
33
|
+
flashcardId: z.string().optional(),
|
|
34
|
+
questionId: z.string().optional(),
|
|
35
|
+
documentId: z.string().optional(),
|
|
36
|
+
})
|
|
37
|
+
.optional(),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const highlightInstructionSchema = z.object({
|
|
41
|
+
start: z.number().int().min(0),
|
|
42
|
+
end: z.number().int().min(0),
|
|
43
|
+
label: z.string().optional(),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const flashcardSchema = z.object({
|
|
47
|
+
front: z.string().min(1),
|
|
48
|
+
back: z.string().min(1),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
type CopilotContextInput = z.infer<typeof copilotContextSchema>;
|
|
52
|
+
|
|
53
|
+
const RATE_LIMIT_WINDOW_MS = 60_000;
|
|
54
|
+
const RATE_LIMIT_MAX_REQUESTS = 20;
|
|
55
|
+
const requestBuckets = new Map<string, number[]>();
|
|
56
|
+
|
|
57
|
+
function enforceRateLimit(userId: string, workspaceId: string) {
|
|
58
|
+
const now = Date.now();
|
|
59
|
+
const key = `${userId}:${workspaceId}`;
|
|
60
|
+
const existing = requestBuckets.get(key) ?? [];
|
|
61
|
+
const recent = existing.filter((timestamp) => now - timestamp < RATE_LIMIT_WINDOW_MS);
|
|
62
|
+
|
|
63
|
+
if (recent.length >= RATE_LIMIT_MAX_REQUESTS) {
|
|
64
|
+
throw new TRPCError({
|
|
65
|
+
code: "TOO_MANY_REQUESTS",
|
|
66
|
+
message: "Too many copilot requests. Please wait a moment.",
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
recent.push(now);
|
|
71
|
+
requestBuckets.set(key, recent);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function contextWindow(context: CopilotContextInput): string {
|
|
75
|
+
// Pass the full document so the copilot has full context and does not make mistakes
|
|
76
|
+
return sanitizeString(context.documentContent, 5_000_000);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function extractJson(content: string): Record<string, unknown> | null {
|
|
80
|
+
const fencedMatch = content.match(/```json\s*([\s\S]*?)```/i);
|
|
81
|
+
if (fencedMatch?.[1]) {
|
|
82
|
+
try {
|
|
83
|
+
return JSON.parse(fencedMatch[1]);
|
|
84
|
+
} catch {
|
|
85
|
+
// fall through
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const objectMatch = content.match(/\{[\s\S]*\}/);
|
|
90
|
+
if (objectMatch?.[0]) {
|
|
91
|
+
try {
|
|
92
|
+
return JSON.parse(objectMatch[0]);
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function getAnswerFromParsedJSON(parsed: Record<string, any> | null): string | null {
|
|
102
|
+
if (!parsed) return null;
|
|
103
|
+
|
|
104
|
+
const potentialKeys = [
|
|
105
|
+
"answer",
|
|
106
|
+
"summary",
|
|
107
|
+
"explanation",
|
|
108
|
+
"content",
|
|
109
|
+
"explanationText",
|
|
110
|
+
"response",
|
|
111
|
+
"result",
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
for (const key of potentialKeys) {
|
|
115
|
+
if (typeof parsed[key] === "string" && parsed[key].length > 0) {
|
|
116
|
+
return parsed[key];
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function assertWorkspaceAccess(
|
|
124
|
+
ctx: Context & { session: { user: { id: string } } },
|
|
125
|
+
workspaceId: string
|
|
126
|
+
) {
|
|
127
|
+
const workspace = await ctx.db.workspace.findFirst({
|
|
128
|
+
where: {
|
|
129
|
+
id: workspaceId,
|
|
130
|
+
...workspaceAccessFilter(ctx.session.user.id),
|
|
131
|
+
},
|
|
132
|
+
select: { id: true },
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
if (!workspace) {
|
|
136
|
+
throw new TRPCError({
|
|
137
|
+
code: "FORBIDDEN",
|
|
138
|
+
message: "You do not have access to this workspace",
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function assertConversationAccess(params: {
|
|
144
|
+
ctx: Context & { session: { user: { id: string } } };
|
|
145
|
+
workspaceId: string;
|
|
146
|
+
conversationId: string;
|
|
147
|
+
}) {
|
|
148
|
+
const conversation = await params.ctx.db.copilotConversation.findFirst({
|
|
149
|
+
where: {
|
|
150
|
+
id: params.conversationId,
|
|
151
|
+
workspaceId: params.workspaceId,
|
|
152
|
+
userId: params.ctx.session.user.id,
|
|
153
|
+
},
|
|
154
|
+
select: {
|
|
155
|
+
id: true,
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
if (!conversation) {
|
|
160
|
+
throw new TRPCError({
|
|
161
|
+
code: "NOT_FOUND",
|
|
162
|
+
message: "Conversation not found",
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function persistConversationExchange(params: {
|
|
168
|
+
ctx: Context & { session: { user: { id: string } } };
|
|
169
|
+
workspaceId: string;
|
|
170
|
+
conversationId?: string;
|
|
171
|
+
userMessage: string;
|
|
172
|
+
assistantMessage: string;
|
|
173
|
+
}) {
|
|
174
|
+
if (!params.conversationId) return;
|
|
175
|
+
|
|
176
|
+
await assertConversationAccess({
|
|
177
|
+
ctx: params.ctx,
|
|
178
|
+
workspaceId: params.workspaceId,
|
|
179
|
+
conversationId: params.conversationId,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const generatedTitle = sanitizeString(
|
|
183
|
+
params.userMessage.replace(/\s+/g, " ").trim(),
|
|
184
|
+
80
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const conversation = await params.ctx.db.copilotConversation.findUnique({
|
|
188
|
+
where: { id: params.conversationId },
|
|
189
|
+
select: { title: true },
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const shouldUpdateTitle =
|
|
193
|
+
!!conversation &&
|
|
194
|
+
conversation.title.trim().toLowerCase() === "new chat" &&
|
|
195
|
+
generatedTitle.length > 0;
|
|
196
|
+
|
|
197
|
+
await params.ctx.db.copilotMessage.createMany({
|
|
198
|
+
data: [
|
|
199
|
+
{
|
|
200
|
+
conversationId: params.conversationId,
|
|
201
|
+
role: "USER",
|
|
202
|
+
content: sanitizeString(params.userMessage, 8_000),
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
conversationId: params.conversationId,
|
|
206
|
+
role: "ASSISTANT",
|
|
207
|
+
content: sanitizeString(params.assistantMessage, 20_000),
|
|
208
|
+
},
|
|
209
|
+
],
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
await params.ctx.db.copilotConversation.update({
|
|
213
|
+
where: { id: params.conversationId },
|
|
214
|
+
data: {
|
|
215
|
+
updatedAt: new Date(),
|
|
216
|
+
...(shouldUpdateTitle ? { title: generatedTitle } : {}),
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function callCopilotModel(params: {
|
|
222
|
+
mode: "ask" | "explainSelection" | "suggestHighlights" | "generateFlashcards";
|
|
223
|
+
context: CopilotContextInput;
|
|
224
|
+
message: string;
|
|
225
|
+
history?: { role: "user" | "assistant"; content: string }[];
|
|
226
|
+
}) {
|
|
227
|
+
const documentSnippet = contextWindow(params.context);
|
|
228
|
+
const selectedText = params.context.selectedText
|
|
229
|
+
? sanitizeString(params.context.selectedText, 2_000)
|
|
230
|
+
: "";
|
|
231
|
+
const viewportText = params.context.viewportText
|
|
232
|
+
? sanitizeString(params.context.viewportText, 4_000)
|
|
233
|
+
: "";
|
|
234
|
+
const message = sanitizeString(params.message, 4_000);
|
|
235
|
+
|
|
236
|
+
const systemPrompt = [
|
|
237
|
+
"You are an AI study assistant for the Scribe learning platform.",
|
|
238
|
+
"Be concise, correct, and safe. Do not claim edits were applied unless asked and confirmed by the user.",
|
|
239
|
+
"Return valid JSON only.",
|
|
240
|
+
"",
|
|
241
|
+
"OUTPUT FORMAT:",
|
|
242
|
+
"{",
|
|
243
|
+
' "answer": "markdown response for the user",',
|
|
244
|
+
' "highlights": [{ "start": 0, "end": 10, "label": "optional" }],',
|
|
245
|
+
' "flashcards": [{ "front": "question", "back": "answer" }]',
|
|
246
|
+
"}",
|
|
247
|
+
"Include only relevant keys for the task.",
|
|
248
|
+
"",
|
|
249
|
+
`MODE: ${params.mode}`,
|
|
250
|
+
`ARTIFACT_TYPE: ${params.context.artifactType}`,
|
|
251
|
+
].join("\n");
|
|
252
|
+
|
|
253
|
+
const contextPrompt = [
|
|
254
|
+
"DOCUMENT:",
|
|
255
|
+
documentSnippet,
|
|
256
|
+
"",
|
|
257
|
+
"SELECTION:",
|
|
258
|
+
selectedText || "None",
|
|
259
|
+
"",
|
|
260
|
+
"VIEWPORT_TEXT:",
|
|
261
|
+
viewportText || "None",
|
|
262
|
+
].join("\n");
|
|
263
|
+
|
|
264
|
+
const messages: { role: "system" | "user" | "assistant"; content: string }[] = [
|
|
265
|
+
{ role: "system", content: systemPrompt },
|
|
266
|
+
{ role: "user", content: contextPrompt },
|
|
267
|
+
];
|
|
268
|
+
|
|
269
|
+
if (params.history && params.history.length > 0) {
|
|
270
|
+
params.history.forEach((msg) => {
|
|
271
|
+
messages.push({
|
|
272
|
+
role: msg.role === "user" ? "user" : "assistant",
|
|
273
|
+
content: msg.content,
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
messages.push({ role: "user", content: message });
|
|
279
|
+
|
|
280
|
+
const response = await inference(messages);
|
|
281
|
+
const content = response.choices?.[0]?.message?.content ?? "";
|
|
282
|
+
const parsed = extractJson(content);
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
rawContent: content,
|
|
286
|
+
parsed,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export const copilot = router({
|
|
291
|
+
listConversations: verifiedProcedure
|
|
292
|
+
.input(
|
|
293
|
+
z.object({
|
|
294
|
+
workspaceId: z.string().min(1),
|
|
295
|
+
})
|
|
296
|
+
)
|
|
297
|
+
.query(async ({ ctx, input }) => {
|
|
298
|
+
await assertWorkspaceAccess(ctx, input.workspaceId);
|
|
299
|
+
|
|
300
|
+
const conversations = await ctx.db.copilotConversation.findMany({
|
|
301
|
+
where: {
|
|
302
|
+
workspaceId: input.workspaceId,
|
|
303
|
+
userId: ctx.session.user.id,
|
|
304
|
+
},
|
|
305
|
+
include: {
|
|
306
|
+
messages: {
|
|
307
|
+
orderBy: { createdAt: "desc" },
|
|
308
|
+
take: 1,
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
orderBy: { updatedAt: "desc" },
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
return conversations.map((conversation) => ({
|
|
315
|
+
id: conversation.id,
|
|
316
|
+
title: conversation.title,
|
|
317
|
+
updatedAt: conversation.updatedAt,
|
|
318
|
+
preview: conversation.messages[0]?.content ?? "",
|
|
319
|
+
}));
|
|
320
|
+
}),
|
|
321
|
+
|
|
322
|
+
getConversation: verifiedProcedure
|
|
323
|
+
.input(
|
|
324
|
+
z.object({
|
|
325
|
+
workspaceId: z.string().min(1),
|
|
326
|
+
conversationId: z.string().min(1),
|
|
327
|
+
})
|
|
328
|
+
)
|
|
329
|
+
.query(async ({ ctx, input }) => {
|
|
330
|
+
await assertWorkspaceAccess(ctx, input.workspaceId);
|
|
331
|
+
await assertConversationAccess({
|
|
332
|
+
ctx,
|
|
333
|
+
workspaceId: input.workspaceId,
|
|
334
|
+
conversationId: input.conversationId,
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
const conversation = await ctx.db.copilotConversation.findUnique({
|
|
338
|
+
where: { id: input.conversationId },
|
|
339
|
+
include: {
|
|
340
|
+
messages: {
|
|
341
|
+
orderBy: { createdAt: "asc" },
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
if (!conversation) {
|
|
347
|
+
throw new TRPCError({
|
|
348
|
+
code: "NOT_FOUND",
|
|
349
|
+
message: "Conversation not found",
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return {
|
|
354
|
+
id: conversation.id,
|
|
355
|
+
title: conversation.title,
|
|
356
|
+
messages: conversation.messages.map((message) => ({
|
|
357
|
+
id: message.id,
|
|
358
|
+
role: message.role === "USER" ? "user" : "assistant",
|
|
359
|
+
content: message.content,
|
|
360
|
+
createdAt: message.createdAt,
|
|
361
|
+
})),
|
|
362
|
+
};
|
|
363
|
+
}),
|
|
364
|
+
|
|
365
|
+
createConversation: verifiedProcedure
|
|
366
|
+
.input(
|
|
367
|
+
z.object({
|
|
368
|
+
workspaceId: z.string().min(1),
|
|
369
|
+
title: z.string().optional(),
|
|
370
|
+
})
|
|
371
|
+
)
|
|
372
|
+
.mutation(async ({ ctx, input }) => {
|
|
373
|
+
await assertWorkspaceAccess(ctx, input.workspaceId);
|
|
374
|
+
|
|
375
|
+
const conversation = await ctx.db.copilotConversation.create({
|
|
376
|
+
data: {
|
|
377
|
+
workspaceId: input.workspaceId,
|
|
378
|
+
userId: ctx.session.user.id,
|
|
379
|
+
title: sanitizeString(input.title || "New Chat", 120),
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
return {
|
|
384
|
+
id: conversation.id,
|
|
385
|
+
title: conversation.title,
|
|
386
|
+
updatedAt: conversation.updatedAt,
|
|
387
|
+
};
|
|
388
|
+
}),
|
|
389
|
+
|
|
390
|
+
deleteConversation: verifiedProcedure
|
|
391
|
+
.input(
|
|
392
|
+
z.object({
|
|
393
|
+
workspaceId: z.string().min(1),
|
|
394
|
+
conversationId: z.string().min(1),
|
|
395
|
+
})
|
|
396
|
+
)
|
|
397
|
+
.mutation(async ({ ctx, input }) => {
|
|
398
|
+
await assertWorkspaceAccess(ctx, input.workspaceId);
|
|
399
|
+
await assertConversationAccess({
|
|
400
|
+
ctx,
|
|
401
|
+
workspaceId: input.workspaceId,
|
|
402
|
+
conversationId: input.conversationId,
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
await ctx.db.copilotConversation.delete({
|
|
406
|
+
where: { id: input.conversationId },
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
return { success: true };
|
|
410
|
+
}),
|
|
411
|
+
|
|
412
|
+
ask: verifiedProcedure
|
|
413
|
+
.input(
|
|
414
|
+
z.object({
|
|
415
|
+
context: copilotContextSchema,
|
|
416
|
+
message: z.string().min(1),
|
|
417
|
+
conversationId: z.string().optional(),
|
|
418
|
+
})
|
|
419
|
+
)
|
|
420
|
+
.mutation(async ({ ctx, input }) => {
|
|
421
|
+
enforceRateLimit(ctx.session.user.id, input.context.workspaceId);
|
|
422
|
+
await assertWorkspaceAccess(ctx, input.context.workspaceId);
|
|
423
|
+
|
|
424
|
+
await PusherService.emitTaskComplete(input.context.workspaceId, "copilot:thinking", {
|
|
425
|
+
status: "started",
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
let history: { role: "user" | "assistant"; content: string }[] = [];
|
|
429
|
+
if (input.conversationId) {
|
|
430
|
+
const messages = await ctx.db.copilotMessage.findMany({
|
|
431
|
+
where: { conversationId: input.conversationId },
|
|
432
|
+
orderBy: { createdAt: "asc" },
|
|
433
|
+
take: 50,
|
|
434
|
+
});
|
|
435
|
+
history = messages.map((m) => ({
|
|
436
|
+
role: m.role.toLowerCase() as "user" | "assistant",
|
|
437
|
+
content: m.content,
|
|
438
|
+
}));
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const model = await callCopilotModel({
|
|
442
|
+
mode: "ask",
|
|
443
|
+
context: input.context,
|
|
444
|
+
message: input.message,
|
|
445
|
+
history,
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
const answer =
|
|
449
|
+
getAnswerFromParsedJSON(model.parsed) ||
|
|
450
|
+
model.rawContent ||
|
|
451
|
+
"I could not generate a response.";
|
|
452
|
+
|
|
453
|
+
const highlights = z.array(highlightInstructionSchema).safeParse(model.parsed?.highlights);
|
|
454
|
+
const safeHighlights = highlights.success
|
|
455
|
+
? highlights.data.filter(
|
|
456
|
+
(item) =>
|
|
457
|
+
item.start < item.end &&
|
|
458
|
+
item.end <= Math.max(input.context.documentContent.length, 0)
|
|
459
|
+
)
|
|
460
|
+
: [];
|
|
461
|
+
|
|
462
|
+
await PusherService.emitTaskComplete(input.context.workspaceId, "copilot:response", {
|
|
463
|
+
status: "completed",
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
await persistConversationExchange({
|
|
467
|
+
ctx,
|
|
468
|
+
workspaceId: input.context.workspaceId,
|
|
469
|
+
conversationId: input.conversationId,
|
|
470
|
+
userMessage: input.message,
|
|
471
|
+
assistantMessage: answer,
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
return {
|
|
475
|
+
answer,
|
|
476
|
+
highlights: safeHighlights,
|
|
477
|
+
};
|
|
478
|
+
}),
|
|
479
|
+
|
|
480
|
+
explainSelection: verifiedProcedure
|
|
481
|
+
.input(
|
|
482
|
+
z.object({
|
|
483
|
+
context: copilotContextSchema,
|
|
484
|
+
message: z.string().optional(),
|
|
485
|
+
conversationId: z.string().optional(),
|
|
486
|
+
})
|
|
487
|
+
)
|
|
488
|
+
.mutation(async ({ ctx, input }) => {
|
|
489
|
+
enforceRateLimit(ctx.session.user.id, input.context.workspaceId);
|
|
490
|
+
await assertWorkspaceAccess(ctx, input.context.workspaceId);
|
|
491
|
+
|
|
492
|
+
await PusherService.emitTaskComplete(input.context.workspaceId, "copilot:thinking", {
|
|
493
|
+
status: "started",
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
const question =
|
|
497
|
+
input.message && input.message.trim().length > 0
|
|
498
|
+
? input.message
|
|
499
|
+
: "Explain the selected text in simple study-friendly terms and include one practical example.";
|
|
500
|
+
|
|
501
|
+
let history: { role: "user" | "assistant"; content: string }[] = [];
|
|
502
|
+
if (input.conversationId) {
|
|
503
|
+
const messages = await ctx.db.copilotMessage.findMany({
|
|
504
|
+
where: { conversationId: input.conversationId },
|
|
505
|
+
orderBy: { createdAt: "asc" },
|
|
506
|
+
take: 50,
|
|
507
|
+
});
|
|
508
|
+
history = messages.map((m) => ({
|
|
509
|
+
role: m.role.toLowerCase() as "user" | "assistant",
|
|
510
|
+
content: m.content,
|
|
511
|
+
}));
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const model = await callCopilotModel({
|
|
515
|
+
mode: "explainSelection",
|
|
516
|
+
context: input.context,
|
|
517
|
+
message: question,
|
|
518
|
+
history,
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
const answer =
|
|
522
|
+
getAnswerFromParsedJSON(model.parsed) ||
|
|
523
|
+
model.rawContent ||
|
|
524
|
+
"I could not explain this selection.";
|
|
525
|
+
|
|
526
|
+
await PusherService.emitTaskComplete(input.context.workspaceId, "copilot:response", {
|
|
527
|
+
status: "completed",
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
await persistConversationExchange({
|
|
531
|
+
ctx,
|
|
532
|
+
workspaceId: input.context.workspaceId,
|
|
533
|
+
conversationId: input.conversationId,
|
|
534
|
+
userMessage: question,
|
|
535
|
+
assistantMessage: answer,
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
return { answer };
|
|
539
|
+
}),
|
|
540
|
+
|
|
541
|
+
suggestHighlights: verifiedProcedure
|
|
542
|
+
.input(
|
|
543
|
+
z.object({
|
|
544
|
+
context: copilotContextSchema,
|
|
545
|
+
message: z.string().optional(),
|
|
546
|
+
conversationId: z.string().optional(),
|
|
547
|
+
})
|
|
548
|
+
)
|
|
549
|
+
.mutation(async ({ ctx, input }) => {
|
|
550
|
+
enforceRateLimit(ctx.session.user.id, input.context.workspaceId);
|
|
551
|
+
await assertWorkspaceAccess(ctx, input.context.workspaceId);
|
|
552
|
+
|
|
553
|
+
await PusherService.emitTaskComplete(input.context.workspaceId, "copilot:thinking", {
|
|
554
|
+
status: "started",
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
const instruction =
|
|
558
|
+
input.message && input.message.trim().length > 0
|
|
559
|
+
? input.message
|
|
560
|
+
: "Suggest key highlights for the provided section.";
|
|
561
|
+
|
|
562
|
+
let history: { role: "user" | "assistant"; content: string }[] = [];
|
|
563
|
+
if (input.conversationId) {
|
|
564
|
+
const messages = await ctx.db.copilotMessage.findMany({
|
|
565
|
+
where: { conversationId: input.conversationId },
|
|
566
|
+
orderBy: { createdAt: "asc" },
|
|
567
|
+
take: 50,
|
|
568
|
+
});
|
|
569
|
+
history = messages.map((m) => ({
|
|
570
|
+
role: m.role.toLowerCase() as "user" | "assistant",
|
|
571
|
+
content: m.content,
|
|
572
|
+
}));
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const model = await callCopilotModel({
|
|
576
|
+
mode: "suggestHighlights",
|
|
577
|
+
context: input.context,
|
|
578
|
+
message: instruction,
|
|
579
|
+
history,
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
const parsedHighlights = z.array(highlightInstructionSchema).safeParse(
|
|
583
|
+
model.parsed?.highlights
|
|
584
|
+
);
|
|
585
|
+
|
|
586
|
+
const safeHighlights = parsedHighlights.success
|
|
587
|
+
? parsedHighlights.data.filter(
|
|
588
|
+
(item) =>
|
|
589
|
+
item.start < item.end &&
|
|
590
|
+
item.end <= Math.max(input.context.documentContent.length, 0)
|
|
591
|
+
)
|
|
592
|
+
: [];
|
|
593
|
+
|
|
594
|
+
await PusherService.emitTaskComplete(
|
|
595
|
+
input.context.workspaceId,
|
|
596
|
+
"copilot:highlight_suggestions",
|
|
597
|
+
{
|
|
598
|
+
highlights: safeHighlights,
|
|
599
|
+
}
|
|
600
|
+
);
|
|
601
|
+
|
|
602
|
+
const answer =
|
|
603
|
+
getAnswerFromParsedJSON(model.parsed) ||
|
|
604
|
+
"Here are suggested highlights.";
|
|
605
|
+
|
|
606
|
+
await persistConversationExchange({
|
|
607
|
+
ctx,
|
|
608
|
+
workspaceId: input.context.workspaceId,
|
|
609
|
+
conversationId: input.conversationId,
|
|
610
|
+
userMessage: instruction,
|
|
611
|
+
assistantMessage: answer,
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
return {
|
|
615
|
+
answer,
|
|
616
|
+
highlights: safeHighlights,
|
|
617
|
+
};
|
|
618
|
+
}),
|
|
619
|
+
|
|
620
|
+
generateFlashcards: verifiedProcedure
|
|
621
|
+
.input(
|
|
622
|
+
z.object({
|
|
623
|
+
context: copilotContextSchema,
|
|
624
|
+
message: z.string().optional(),
|
|
625
|
+
numCards: z.number().int().min(1).max(30).default(10),
|
|
626
|
+
conversationId: z.string().optional(),
|
|
627
|
+
})
|
|
628
|
+
)
|
|
629
|
+
.mutation(async ({ ctx, input }) => {
|
|
630
|
+
enforceRateLimit(ctx.session.user.id, input.context.workspaceId);
|
|
631
|
+
await assertWorkspaceAccess(ctx, input.context.workspaceId);
|
|
632
|
+
|
|
633
|
+
await PusherService.emitTaskComplete(input.context.workspaceId, "copilot:thinking", {
|
|
634
|
+
status: "started",
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
const instruction =
|
|
638
|
+
input.message && input.message.trim().length > 0
|
|
639
|
+
? input.message
|
|
640
|
+
: `Generate ${input.numCards} concise study flashcards from this context.`;
|
|
641
|
+
|
|
642
|
+
let history: { role: "user" | "assistant"; content: string }[] = [];
|
|
643
|
+
if (input.conversationId) {
|
|
644
|
+
const messages = await ctx.db.copilotMessage.findMany({
|
|
645
|
+
where: { conversationId: input.conversationId },
|
|
646
|
+
orderBy: { createdAt: "asc" },
|
|
647
|
+
take: 50,
|
|
648
|
+
});
|
|
649
|
+
history = messages.map((m) => ({
|
|
650
|
+
role: m.role.toLowerCase() as "user" | "assistant",
|
|
651
|
+
content: m.content,
|
|
652
|
+
}));
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const model = await callCopilotModel({
|
|
656
|
+
mode: "generateFlashcards",
|
|
657
|
+
context: input.context,
|
|
658
|
+
message: instruction,
|
|
659
|
+
history,
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
const parsedFlashcards = z.array(flashcardSchema).safeParse(model.parsed?.flashcards);
|
|
663
|
+
const cards = (parsedFlashcards.success ? parsedFlashcards.data : []).slice(
|
|
664
|
+
0,
|
|
665
|
+
input.numCards
|
|
666
|
+
);
|
|
667
|
+
|
|
668
|
+
if (cards.length === 0) {
|
|
669
|
+
throw new TRPCError({
|
|
670
|
+
code: "BAD_REQUEST",
|
|
671
|
+
message: "Copilot did not return valid flashcards.",
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const artifact = await ctx.db.artifact.create({
|
|
676
|
+
data: {
|
|
677
|
+
workspaceId: input.context.workspaceId,
|
|
678
|
+
type: ArtifactType.FLASHCARD_SET,
|
|
679
|
+
title: `Copilot Flashcards - ${new Date().toLocaleString()}`,
|
|
680
|
+
createdById: ctx.session.user.id,
|
|
681
|
+
generating: false,
|
|
682
|
+
flashcards: {
|
|
683
|
+
create: cards.map((card, index) => ({
|
|
684
|
+
front: sanitizeString(card.front, 200),
|
|
685
|
+
back: sanitizeString(card.back, 500),
|
|
686
|
+
order: index,
|
|
687
|
+
tags: ["copilot-generated"],
|
|
688
|
+
})),
|
|
689
|
+
},
|
|
690
|
+
},
|
|
691
|
+
include: {
|
|
692
|
+
flashcards: true,
|
|
693
|
+
},
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
await PusherService.emitTaskComplete(input.context.workspaceId, "copilot:response", {
|
|
697
|
+
status: "completed",
|
|
698
|
+
flashcardSetId: artifact.id,
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
const answer =
|
|
702
|
+
getAnswerFromParsedJSON(model.parsed) ||
|
|
703
|
+
`Generated ${artifact.flashcards.length} flashcards.`;
|
|
704
|
+
|
|
705
|
+
await persistConversationExchange({
|
|
706
|
+
ctx,
|
|
707
|
+
workspaceId: input.context.workspaceId,
|
|
708
|
+
conversationId: input.conversationId,
|
|
709
|
+
userMessage: instruction,
|
|
710
|
+
assistantMessage: answer,
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
return {
|
|
714
|
+
answer,
|
|
715
|
+
artifactId: artifact.id,
|
|
716
|
+
flashcards: artifact.flashcards,
|
|
717
|
+
};
|
|
718
|
+
}),
|
|
719
|
+
});
|