@goscribe/server 1.2.0 → 1.3.1

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.
Files changed (126) hide show
  1. package/check-difficulty.cjs +14 -0
  2. package/check-questions.cjs +14 -0
  3. package/db-summary.cjs +22 -0
  4. package/dist/context.d.ts +5 -1
  5. package/dist/lib/activity_human_description.d.ts +13 -0
  6. package/dist/lib/activity_human_description.js +221 -0
  7. package/dist/lib/activity_human_description.test.d.ts +1 -0
  8. package/dist/lib/activity_human_description.test.js +16 -0
  9. package/dist/lib/activity_log_service.d.ts +87 -0
  10. package/dist/lib/activity_log_service.js +276 -0
  11. package/dist/lib/activity_log_service.test.d.ts +1 -0
  12. package/dist/lib/activity_log_service.test.js +27 -0
  13. package/dist/lib/ai-session.d.ts +15 -2
  14. package/dist/lib/ai-session.js +147 -85
  15. package/dist/lib/constants.d.ts +13 -0
  16. package/dist/lib/constants.js +12 -0
  17. package/dist/lib/email.d.ts +11 -0
  18. package/dist/lib/email.js +193 -0
  19. package/dist/lib/env.d.ts +13 -0
  20. package/dist/lib/env.js +16 -0
  21. package/dist/lib/inference.d.ts +4 -1
  22. package/dist/lib/inference.js +3 -3
  23. package/dist/lib/logger.d.ts +4 -4
  24. package/dist/lib/logger.js +30 -8
  25. package/dist/lib/notification-service.d.ts +152 -0
  26. package/dist/lib/notification-service.js +473 -0
  27. package/dist/lib/notification-service.test.d.ts +1 -0
  28. package/dist/lib/notification-service.test.js +87 -0
  29. package/dist/lib/prisma.d.ts +2 -1
  30. package/dist/lib/prisma.js +5 -1
  31. package/dist/lib/pusher.d.ts +23 -0
  32. package/dist/lib/pusher.js +69 -5
  33. package/dist/lib/retry.d.ts +15 -0
  34. package/dist/lib/retry.js +37 -0
  35. package/dist/lib/storage.js +2 -2
  36. package/dist/lib/stripe.d.ts +9 -0
  37. package/dist/lib/stripe.js +36 -0
  38. package/dist/lib/subscription_service.d.ts +37 -0
  39. package/dist/lib/subscription_service.js +654 -0
  40. package/dist/lib/usage_service.d.ts +26 -0
  41. package/dist/lib/usage_service.js +59 -0
  42. package/dist/lib/worksheet-generation.d.ts +91 -0
  43. package/dist/lib/worksheet-generation.js +95 -0
  44. package/dist/lib/worksheet-generation.test.d.ts +1 -0
  45. package/dist/lib/worksheet-generation.test.js +20 -0
  46. package/dist/lib/workspace-access.d.ts +18 -0
  47. package/dist/lib/workspace-access.js +13 -0
  48. package/dist/routers/_app.d.ts +1349 -253
  49. package/dist/routers/_app.js +10 -0
  50. package/dist/routers/admin.d.ts +361 -0
  51. package/dist/routers/admin.js +633 -0
  52. package/dist/routers/annotations.d.ts +219 -0
  53. package/dist/routers/annotations.js +187 -0
  54. package/dist/routers/auth.d.ts +88 -7
  55. package/dist/routers/auth.js +339 -19
  56. package/dist/routers/chat.d.ts +6 -12
  57. package/dist/routers/copilot.d.ts +199 -0
  58. package/dist/routers/copilot.js +571 -0
  59. package/dist/routers/flashcards.d.ts +47 -81
  60. package/dist/routers/flashcards.js +143 -27
  61. package/dist/routers/members.d.ts +36 -7
  62. package/dist/routers/members.js +200 -19
  63. package/dist/routers/notifications.d.ts +99 -0
  64. package/dist/routers/notifications.js +127 -0
  65. package/dist/routers/payment.d.ts +89 -0
  66. package/dist/routers/payment.js +403 -0
  67. package/dist/routers/podcast.d.ts +8 -13
  68. package/dist/routers/podcast.js +54 -31
  69. package/dist/routers/studyguide.d.ts +1 -29
  70. package/dist/routers/studyguide.js +80 -71
  71. package/dist/routers/worksheets.d.ts +105 -38
  72. package/dist/routers/worksheets.js +258 -68
  73. package/dist/routers/workspace.d.ts +139 -60
  74. package/dist/routers/workspace.js +455 -315
  75. package/dist/scripts/purge-deleted-users.d.ts +1 -0
  76. package/dist/scripts/purge-deleted-users.js +149 -0
  77. package/dist/server.js +130 -10
  78. package/dist/services/flashcard-progress.service.d.ts +18 -66
  79. package/dist/services/flashcard-progress.service.js +51 -42
  80. package/dist/trpc.d.ts +20 -21
  81. package/dist/trpc.js +150 -1
  82. package/mcq-test.cjs +36 -0
  83. package/package.json +9 -2
  84. package/prisma/migrations/20260413143206_init/migration.sql +873 -0
  85. package/prisma/schema.prisma +471 -324
  86. package/src/context.ts +4 -1
  87. package/src/lib/activity_human_description.test.ts +28 -0
  88. package/src/lib/activity_human_description.ts +239 -0
  89. package/src/lib/activity_log_service.test.ts +37 -0
  90. package/src/lib/activity_log_service.ts +353 -0
  91. package/src/lib/ai-session.ts +79 -51
  92. package/src/lib/email.ts +213 -29
  93. package/src/lib/env.ts +23 -6
  94. package/src/lib/inference.ts +2 -2
  95. package/src/lib/notification-service.test.ts +106 -0
  96. package/src/lib/notification-service.ts +677 -0
  97. package/src/lib/prisma.ts +6 -1
  98. package/src/lib/pusher.ts +86 -2
  99. package/src/lib/stripe.ts +39 -0
  100. package/src/lib/subscription_service.ts +722 -0
  101. package/src/lib/usage_service.ts +74 -0
  102. package/src/lib/worksheet-generation.test.ts +31 -0
  103. package/src/lib/worksheet-generation.ts +139 -0
  104. package/src/routers/_app.ts +9 -0
  105. package/src/routers/admin.ts +710 -0
  106. package/src/routers/annotations.ts +41 -0
  107. package/src/routers/auth.ts +338 -28
  108. package/src/routers/copilot.ts +719 -0
  109. package/src/routers/flashcards.ts +201 -68
  110. package/src/routers/members.ts +280 -80
  111. package/src/routers/notifications.ts +142 -0
  112. package/src/routers/payment.ts +448 -0
  113. package/src/routers/podcast.ts +112 -83
  114. package/src/routers/studyguide.ts +12 -0
  115. package/src/routers/worksheets.ts +289 -66
  116. package/src/routers/workspace.ts +329 -122
  117. package/src/scripts/purge-deleted-users.ts +167 -0
  118. package/src/server.ts +137 -11
  119. package/src/services/flashcard-progress.service.ts +49 -37
  120. package/src/trpc.ts +184 -5
  121. package/test-generate.js +30 -0
  122. package/test-ratio.cjs +9 -0
  123. package/zod-test.cjs +22 -0
  124. package/prisma/migrations/20250826124819_add_worksheet_difficulty_and_estimated_time/migration.sql +0 -213
  125. package/prisma/migrations/20250826133236_add_worksheet_question_progress/migration.sql +0 -31
  126. package/prisma/seed.mjs +0 -135
@@ -1,12 +1,46 @@
1
1
  import { z } from 'zod';
2
2
  import { TRPCError } from '@trpc/server';
3
- import { router, authedProcedure } from '../trpc.js';
3
+ import { router, authedProcedure, verifiedProcedure,limitedProcedure } from '../trpc.js';
4
4
  import createInferenceService from '../lib/inference.js';
5
5
  import { aiSessionService } from '../lib/ai-session.js';
6
6
  import PusherService from '../lib/pusher.js';
7
+ import { notifyArtifactFailed, notifyArtifactReady } from '../lib/notification-service.js';
7
8
  import { createFlashcardProgressService } from '../services/flashcard-progress.service.js';
8
9
  import { ArtifactType } from '../lib/constants.js';
9
10
  import { workspaceAccessFilter } from '../lib/workspace-access.js';
11
+ import inference from '../lib/inference.js';
12
+
13
+ const typedAnswerGradeSchema = z.object({
14
+ isCorrect: z.boolean(),
15
+ confidence: z.number().min(0).max(1),
16
+ reason: z.string().min(1),
17
+ matchedAnswer: z.string().nullable(),
18
+ });
19
+
20
+ function normalizeAcceptedAnswers(answers?: string[]): string[] {
21
+ if (!answers || answers.length === 0) return [];
22
+
23
+ const seen = new Set<string>();
24
+ const normalized: string[] = [];
25
+
26
+ for (const answer of answers) {
27
+ const trimmed = answer.trim();
28
+ if (!trimmed) continue;
29
+ const key = trimmed.toLowerCase();
30
+ if (seen.has(key)) continue;
31
+ seen.add(key);
32
+ normalized.push(trimmed);
33
+ }
34
+
35
+ return normalized;
36
+ }
37
+
38
+ function extractFirstJsonObject(text: string): string | null {
39
+ const start = text.indexOf('{');
40
+ const end = text.lastIndexOf('}');
41
+ if (start === -1 || end === -1 || end <= start) return null;
42
+ return text.slice(start, end + 1);
43
+ }
10
44
 
11
45
  export const flashcards = router({
12
46
  listSets: authedProcedure
@@ -44,7 +78,7 @@ export const flashcards = router({
44
78
  },
45
79
 
46
80
  },
47
- orderBy: { updatedAt: 'desc' },
81
+ orderBy: { createdAt: 'desc' },
48
82
  });
49
83
  if (!set) throw new TRPCError({ code: 'NOT_FOUND' });
50
84
  return set.flashcards;
@@ -53,23 +87,27 @@ export const flashcards = router({
53
87
  .input(z.object({ workspaceId: z.string() }))
54
88
  .query(async ({ ctx, input }) => {
55
89
  const artifact = await ctx.db.artifact.findFirst({
56
- where: { workspaceId: input.workspaceId, type: ArtifactType.FLASHCARD_SET, workspace: workspaceAccessFilter(ctx.session.user.id) }, orderBy: { updatedAt: 'desc' },
90
+ where: { workspaceId: input.workspaceId, type: ArtifactType.FLASHCARD_SET, workspace: workspaceAccessFilter(ctx.session.user.id) },
91
+ orderBy: { createdAt: 'desc' },
57
92
  });
58
93
  return artifact?.generating;
59
94
  }),
60
- createCard: authedProcedure
95
+ createCard: limitedProcedure
61
96
  .input(z.object({
62
97
  workspaceId: z.string(),
63
98
  front: z.string().min(1),
64
99
  back: z.string().min(1),
100
+ acceptedAnswers: z.array(z.string()).optional(),
65
101
  tags: z.array(z.string()).optional(),
66
102
  order: z.number().int().optional(),
67
103
  }))
68
104
  .mutation(async ({ ctx, input }) => {
69
105
  const set = await ctx.db.artifact.findFirst({
70
- where: { type: ArtifactType.FLASHCARD_SET, workspace: {
71
- id: input.workspaceId,
72
- } },
106
+ where: {
107
+ type: ArtifactType.FLASHCARD_SET, workspace: {
108
+ id: input.workspaceId,
109
+ }
110
+ },
73
111
  include: {
74
112
  flashcards: true,
75
113
  },
@@ -81,6 +119,7 @@ export const flashcards = router({
81
119
  artifactId: set.id,
82
120
  front: input.front,
83
121
  back: input.back,
122
+ acceptedAnswers: normalizeAcceptedAnswers(input.acceptedAnswers),
84
123
  tags: input.tags ?? [],
85
124
  order: input.order ?? 0,
86
125
  },
@@ -92,6 +131,7 @@ export const flashcards = router({
92
131
  cardId: z.string(),
93
132
  front: z.string().optional(),
94
133
  back: z.string().optional(),
134
+ acceptedAnswers: z.array(z.string()).optional(),
95
135
  tags: z.array(z.string()).optional(),
96
136
  order: z.number().int().optional(),
97
137
  }))
@@ -105,12 +145,83 @@ export const flashcards = router({
105
145
  data: {
106
146
  front: input.front ?? card.front,
107
147
  back: input.back ?? card.back,
148
+ acceptedAnswers: input.acceptedAnswers ? normalizeAcceptedAnswers(input.acceptedAnswers) : card.acceptedAnswers,
108
149
  tags: input.tags ?? card.tags,
109
150
  order: input.order ?? card.order,
110
151
  },
111
152
  });
112
153
  }),
113
154
 
155
+ gradeTypedAnswer: authedProcedure
156
+ .input(z.object({
157
+ flashcardId: z.string().cuid(),
158
+ userAnswer: z.string().min(1),
159
+ }))
160
+ .mutation(async ({ ctx, input }) => {
161
+ const flashcard = await ctx.db.flashcard.findFirst({
162
+ where: {
163
+ id: input.flashcardId,
164
+ artifact: {
165
+ type: ArtifactType.FLASHCARD_SET,
166
+ workspace: workspaceAccessFilter(ctx.session.user.id),
167
+ },
168
+ },
169
+ select: {
170
+ id: true,
171
+ front: true,
172
+ back: true,
173
+ acceptedAnswers: true,
174
+ },
175
+ });
176
+
177
+ if (!flashcard) {
178
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Flashcard not found' });
179
+ }
180
+
181
+ const acceptedAnswers = [
182
+ flashcard.back,
183
+ ...normalizeAcceptedAnswers(flashcard.acceptedAnswers),
184
+ ];
185
+
186
+ const prompt = [
187
+ 'Grade whether the student answer is semantically correct for the flashcard.',
188
+ '',
189
+ 'Return ONLY valid JSON with this exact shape:',
190
+ '{"isCorrect": boolean, "confidence": number, "reason": string, "matchedAnswer": string | null}',
191
+ '',
192
+ 'Rules:',
193
+ '- Accept synonyms, short paraphrases, and equivalent wording.',
194
+ '- Reject answers that are contradictory, unrelated, or materially incomplete.',
195
+ '- confidence must be from 0 to 1.',
196
+ '- reason must be under 160 characters.',
197
+ '- matchedAnswer must be the matched canonical/alias answer, or null if incorrect.',
198
+ '',
199
+ `Question: ${flashcard.front}`,
200
+ `Canonical answer: ${flashcard.back}`,
201
+ `Accepted aliases: ${JSON.stringify(acceptedAnswers)}`,
202
+ `Student answer: ${input.userAnswer}`,
203
+ ].join('\n');
204
+
205
+ try {
206
+ const response = await inference([{ role: 'user', content: prompt }]);
207
+ const content = response.choices?.[0]?.message?.content ?? '';
208
+ const jsonCandidate = extractFirstJsonObject(content);
209
+
210
+ if (!jsonCandidate) {
211
+ throw new Error('No JSON object found in grading response');
212
+ }
213
+
214
+ const parsed = JSON.parse(jsonCandidate);
215
+ return typedAnswerGradeSchema.parse(parsed);
216
+ } catch (error) {
217
+ throw new TRPCError({
218
+ code: 'INTERNAL_SERVER_ERROR',
219
+ message: 'Failed to grade typed answer. Please retry.',
220
+ cause: error,
221
+ });
222
+ }
223
+ }),
224
+
114
225
  deleteCard: authedProcedure
115
226
  .input(z.object({ cardId: z.string() }))
116
227
  .mutation(async ({ ctx, input }) => {
@@ -133,7 +244,7 @@ export const flashcards = router({
133
244
  }),
134
245
 
135
246
  // Generate a flashcard set from a user prompt
136
- generateFromPrompt: authedProcedure
247
+ generateFromPrompt: limitedProcedure
137
248
  .input(z.object({
138
249
  workspaceId: z.string(),
139
250
  prompt: z.string().min(1),
@@ -164,84 +275,106 @@ export const flashcards = router({
164
275
  });
165
276
 
166
277
  try {
167
- await ctx.db.artifact.update({
168
- where: { id: flashcardCurrent?.id },
169
- data: { generating: true, generatingMetadata: { quantity: input.numCards, difficulty: input.difficulty.toLowerCase() } },
170
- });
278
+ await PusherService.emitTaskComplete(input.workspaceId, 'flash_card_info', { status: 'generating', numCards: input.numCards, difficulty: input.difficulty });
171
279
 
172
- await PusherService.emitTaskComplete(input.workspaceId, 'flash_card_info', { status: 'generating', numCards: input.numCards, difficulty: input.difficulty });
173
-
174
- const artifact = await ctx.db.artifact.create({
175
- data: {
176
- workspaceId: input.workspaceId,
177
- type: ArtifactType.FLASHCARD_SET,
178
- title: input.title || `Flashcards - ${new Date().toLocaleString()}`,
179
- createdById: ctx.session.user.id,
180
- flashcards: {
181
- create: flashcardCurrent?.flashcards.map((card) => ({
182
- front: card.front,
183
- back: card.back,
184
- })),
280
+ const artifact = await ctx.db.artifact.create({
281
+ data: {
282
+ workspaceId: input.workspaceId,
283
+ type: ArtifactType.FLASHCARD_SET,
284
+ title: input.title || `Flashcards - ${new Date().toLocaleString()}`,
285
+ createdById: ctx.session.user.id,
286
+ generating: true,
287
+ generatingMetadata: { quantity: input.numCards, difficulty: input.difficulty.toLowerCase() },
288
+ flashcards: {
289
+ create: flashcardCurrent?.flashcards.map((card) => ({
290
+ front: card.front,
291
+ back: card.back,
292
+ })),
293
+ },
185
294
  },
186
- },
187
- });
188
-
189
- const currentCards = flashcardCurrent?.flashcards.length || 0;
190
- const newCards = input.numCards - currentCards;
295
+ });
191
296
 
297
+ const currentCards = flashcardCurrent?.flashcards.length || 0;
298
+ const newCards = input.numCards - currentCards;
192
299
 
193
- // Generate
194
- const content = await aiSessionService.generateFlashcardQuestions(input.workspaceId, ctx.session.user.id, input.numCards, input.difficulty);
195
300
 
196
- let createdCards = 0;
197
- try {
198
- const flashcardData: any = content;
199
- for (let i = 0; i < Math.min(flashcardData.length, input.numCards); i++) {
200
- const card = flashcardData[i];
201
- const front = card.term || card.front || card.question || card.prompt || `Question ${i + 1}`;
202
- const back = card.definition || card.back || card.answer || card.solution || `Answer ${i + 1}`;
203
- await ctx.db.flashcard.create({
204
- data: {
205
- artifactId: artifact.id,
206
- front,
207
- back,
208
- order: i,
209
- tags: input.tags ?? ['ai-generated', input.difficulty],
210
- },
211
- });
212
- createdCards++;
213
- }
214
- } catch {
215
- // Fallback to text parsing if JSON fails
216
- const lines = content.split('\n').filter(line => line.trim());
217
- for (let i = 0; i < Math.min(lines.length, input.numCards); i++) {
218
- const line = lines[i];
219
- if (line.includes(' - ')) {
220
- const [front, back] = line.split(' - ');
301
+ // Generate
302
+ const content = await aiSessionService.generateFlashcardQuestions(input.workspaceId, ctx.session.user.id, input.numCards, input.difficulty, input.prompt);
303
+
304
+ let createdCards = 0;
305
+ try {
306
+ const parsed = typeof content === 'string' ? JSON.parse(content) : content;
307
+ const flashcardData = Array.isArray(parsed) ? parsed : (parsed.flashcards || []);
308
+
309
+ for (let i = 0; i < Math.min(flashcardData.length, input.numCards); i++) {
310
+ const card = flashcardData[i];
311
+ const front = card.term || card.front || card.question || card.prompt || `Question ${i + 1}`;
312
+ const back = card.definition || card.back || card.answer || card.solution || `Answer ${i + 1}`;
221
313
  await ctx.db.flashcard.create({
222
314
  data: {
223
315
  artifactId: artifact.id,
224
- front: front.trim(),
225
- back: back.trim(),
316
+ front,
317
+ back,
226
318
  order: i,
227
319
  tags: input.tags ?? ['ai-generated', input.difficulty],
228
320
  },
229
321
  });
230
322
  createdCards++;
231
323
  }
324
+ } catch (error) {
325
+ console.error("Failed to parse flashcard JSON or create cards:", error);
326
+ // Fallback to text parsing if JSON fails
327
+ const lines = content.split('\n').filter(line => line.trim());
328
+ for (let i = 0; i < Math.min(lines.length, input.numCards); i++) {
329
+ const line = lines[i];
330
+ if (line.includes(' - ')) {
331
+ const [front, back] = line.split(' - ');
332
+ await ctx.db.flashcard.create({
333
+ data: {
334
+ artifactId: artifact.id,
335
+ front: front.trim(),
336
+ back: back.trim(),
337
+ order: i,
338
+ tags: input.tags ?? ['ai-generated', input.difficulty],
339
+ },
340
+ });
341
+ createdCards++;
342
+ }
343
+ }
232
344
  }
233
- }
234
345
 
235
- // Pusher complete
236
- await PusherService.emitFlashcardComplete(input.workspaceId, artifact);
346
+ // Pusher complete
347
+ await PusherService.emitFlashcardComplete(input.workspaceId, artifact);
348
+
349
+ // Set generating to false on the artifact
350
+ await ctx.db.artifact.update({ where: { id: artifact.id }, data: { generating: false } });
351
+
352
+ await notifyArtifactReady(ctx.db, {
353
+ userId: ctx.session.user.id,
354
+ workspaceId: input.workspaceId,
355
+ artifactId: artifact.id,
356
+ artifactType: ArtifactType.FLASHCARD_SET,
357
+ title: artifact.title,
358
+ }).catch(() => {});
237
359
 
238
- return { artifact, createdCards };
360
+ return { artifact, createdCards };
239
361
 
240
- } catch (error) {
241
- await ctx.db.artifact.update({ where: { id: flashcardCurrent?.id }, data: { generating: false } });
242
- await PusherService.emitError(input.workspaceId, `Failed to generate flashcards: ${error}`, 'flash_card_generation');
243
- throw error;
244
- }
362
+ } catch (error) {
363
+ if (flashcardCurrent?.id) {
364
+ await ctx.db.artifact.update({ where: { id: flashcardCurrent.id }, data: { generating: false } });
365
+ }
366
+ await PusherService.emitError(input.workspaceId, `Failed to generate flashcards: ${error}`, 'flash_card_generation');
367
+ await notifyArtifactFailed(ctx.db, {
368
+ userId: ctx.session.user.id,
369
+ workspaceId: input.workspaceId,
370
+ artifactType: ArtifactType.FLASHCARD_SET,
371
+ message:
372
+ error instanceof Error
373
+ ? error.message
374
+ : 'Flashcard generation failed.',
375
+ }).catch(() => {});
376
+ throw error;
377
+ }
245
378
  }),
246
379
 
247
380
  // Record study attempt