@goscribe/server 1.0.10 → 1.1.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/ANALYSIS_PROGRESS_SPEC.md +463 -0
- package/PROGRESS_QUICK_REFERENCE.md +239 -0
- package/dist/lib/ai-session.d.ts +20 -9
- package/dist/lib/ai-session.js +316 -80
- package/dist/lib/auth.d.ts +35 -2
- package/dist/lib/auth.js +88 -15
- package/dist/lib/env.d.ts +32 -0
- package/dist/lib/env.js +46 -0
- package/dist/lib/errors.d.ts +33 -0
- package/dist/lib/errors.js +78 -0
- package/dist/lib/inference.d.ts +4 -1
- package/dist/lib/inference.js +9 -11
- package/dist/lib/logger.d.ts +62 -0
- package/dist/lib/logger.js +342 -0
- package/dist/lib/podcast-prompts.d.ts +43 -0
- package/dist/lib/podcast-prompts.js +135 -0
- package/dist/lib/pusher.d.ts +1 -0
- package/dist/lib/pusher.js +14 -2
- package/dist/lib/storage.d.ts +3 -3
- package/dist/lib/storage.js +51 -47
- package/dist/lib/validation.d.ts +51 -0
- package/dist/lib/validation.js +64 -0
- package/dist/routers/_app.d.ts +697 -111
- package/dist/routers/_app.js +5 -0
- package/dist/routers/auth.d.ts +11 -1
- package/dist/routers/chat.d.ts +11 -1
- package/dist/routers/flashcards.d.ts +205 -6
- package/dist/routers/flashcards.js +144 -66
- package/dist/routers/members.d.ts +165 -0
- package/dist/routers/members.js +531 -0
- package/dist/routers/podcast.d.ts +78 -63
- package/dist/routers/podcast.js +330 -393
- package/dist/routers/studyguide.d.ts +11 -1
- package/dist/routers/worksheets.d.ts +124 -13
- package/dist/routers/worksheets.js +123 -50
- package/dist/routers/workspace.d.ts +213 -26
- package/dist/routers/workspace.js +303 -181
- package/dist/server.js +12 -4
- package/dist/services/flashcard-progress.service.d.ts +183 -0
- package/dist/services/flashcard-progress.service.js +383 -0
- package/dist/services/flashcard.service.d.ts +183 -0
- package/dist/services/flashcard.service.js +224 -0
- package/dist/services/podcast-segment-reorder.d.ts +0 -0
- package/dist/services/podcast-segment-reorder.js +107 -0
- package/dist/services/podcast.service.d.ts +0 -0
- package/dist/services/podcast.service.js +326 -0
- package/dist/services/worksheet.service.d.ts +0 -0
- package/dist/services/worksheet.service.js +295 -0
- package/dist/trpc.d.ts +13 -2
- package/dist/trpc.js +55 -6
- package/dist/types/index.d.ts +126 -0
- package/dist/types/index.js +1 -0
- package/package.json +3 -2
- package/prisma/schema.prisma +142 -4
- package/src/lib/ai-session.ts +356 -85
- package/src/lib/auth.ts +113 -19
- package/src/lib/env.ts +59 -0
- package/src/lib/errors.ts +92 -0
- package/src/lib/inference.ts +11 -11
- package/src/lib/logger.ts +405 -0
- package/src/lib/pusher.ts +15 -3
- package/src/lib/storage.ts +56 -51
- package/src/lib/validation.ts +75 -0
- package/src/routers/_app.ts +5 -0
- package/src/routers/chat.ts +2 -23
- package/src/routers/flashcards.ts +108 -24
- package/src/routers/members.ts +586 -0
- package/src/routers/podcast.ts +385 -420
- package/src/routers/worksheets.ts +117 -35
- package/src/routers/workspace.ts +328 -195
- package/src/server.ts +13 -4
- package/src/services/flashcard-progress.service.ts +541 -0
- package/src/trpc.ts +59 -6
- package/src/types/index.ts +165 -0
- package/AUTH_FRONTEND_SPEC.md +0 -21
- package/CHAT_FRONTEND_SPEC.md +0 -474
- package/DATABASE_SETUP.md +0 -165
- package/MEETINGSUMMARY_FRONTEND_SPEC.md +0 -28
- package/PODCAST_FRONTEND_SPEC.md +0 -595
- package/STUDYGUIDE_FRONTEND_SPEC.md +0 -18
- package/WORKSHEETS_FRONTEND_SPEC.md +0 -26
- package/WORKSPACE_FRONTEND_SPEC.md +0 -47
- package/test-ai-integration.js +0 -134
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import type { PrismaClient } from '@prisma/client';
|
|
2
|
+
/**
|
|
3
|
+
* SM-2 Spaced Repetition Algorithm
|
|
4
|
+
* https://www.supermemo.com/en/archives1990-2015/english/ol/sm2
|
|
5
|
+
*/
|
|
6
|
+
export interface SM2Result {
|
|
7
|
+
easeFactor: number;
|
|
8
|
+
interval: number;
|
|
9
|
+
repetitions: number;
|
|
10
|
+
nextReviewAt: Date;
|
|
11
|
+
}
|
|
12
|
+
export declare class FlashcardProgressService {
|
|
13
|
+
private db;
|
|
14
|
+
constructor(db: PrismaClient);
|
|
15
|
+
/**
|
|
16
|
+
* Calculate next review using SM-2 algorithm with smart scheduling
|
|
17
|
+
* @param quality - 0-5 rating (0=complete blackout, 5=perfect response)
|
|
18
|
+
* @param easeFactor - Current ease factor (default 2.5)
|
|
19
|
+
* @param interval - Current interval in days (default 0)
|
|
20
|
+
* @param repetitions - Number of consecutive correct responses (default 0)
|
|
21
|
+
* @param consecutiveIncorrect - Number of consecutive failures (for smart scheduling)
|
|
22
|
+
* @param totalIncorrect - Total incorrect count (for context)
|
|
23
|
+
*/
|
|
24
|
+
calculateSM2(quality: number, easeFactor?: number, interval?: number, repetitions?: number, consecutiveIncorrect?: number, totalIncorrect?: number): SM2Result;
|
|
25
|
+
/**
|
|
26
|
+
* Infer confidence level based on consecutive incorrect attempts
|
|
27
|
+
*/
|
|
28
|
+
inferConfidence(isCorrect: boolean, consecutiveIncorrect: number, timesStudied: number): 'easy' | 'medium' | 'hard';
|
|
29
|
+
/**
|
|
30
|
+
* Convert confidence to SM-2 quality rating
|
|
31
|
+
*/
|
|
32
|
+
confidenceToQuality(confidence: 'easy' | 'medium' | 'hard'): number;
|
|
33
|
+
/**
|
|
34
|
+
* Record flashcard study attempt
|
|
35
|
+
*/
|
|
36
|
+
recordStudyAttempt(data: {
|
|
37
|
+
userId: string;
|
|
38
|
+
flashcardId: string;
|
|
39
|
+
isCorrect: boolean;
|
|
40
|
+
confidence?: 'easy' | 'medium' | 'hard';
|
|
41
|
+
timeSpentMs?: number;
|
|
42
|
+
}): Promise<{
|
|
43
|
+
flashcard: {
|
|
44
|
+
id: string;
|
|
45
|
+
createdAt: Date;
|
|
46
|
+
artifactId: string;
|
|
47
|
+
order: number;
|
|
48
|
+
front: string;
|
|
49
|
+
back: string;
|
|
50
|
+
tags: string[];
|
|
51
|
+
};
|
|
52
|
+
} & {
|
|
53
|
+
id: string;
|
|
54
|
+
createdAt: Date;
|
|
55
|
+
updatedAt: Date;
|
|
56
|
+
userId: string;
|
|
57
|
+
flashcardId: string;
|
|
58
|
+
timesStudied: number;
|
|
59
|
+
timesCorrect: number;
|
|
60
|
+
timesIncorrect: number;
|
|
61
|
+
timesIncorrectConsecutive: number;
|
|
62
|
+
easeFactor: number;
|
|
63
|
+
interval: number;
|
|
64
|
+
repetitions: number;
|
|
65
|
+
masteryLevel: number;
|
|
66
|
+
lastStudiedAt: Date | null;
|
|
67
|
+
nextReviewAt: Date | null;
|
|
68
|
+
}>;
|
|
69
|
+
/**
|
|
70
|
+
* Get user's progress on all flashcards in a set
|
|
71
|
+
*/
|
|
72
|
+
getSetProgress(userId: string, artifactId: string): Promise<{
|
|
73
|
+
flashcardId: any;
|
|
74
|
+
front: any;
|
|
75
|
+
back: any;
|
|
76
|
+
progress: {
|
|
77
|
+
id: string;
|
|
78
|
+
createdAt: Date;
|
|
79
|
+
updatedAt: Date;
|
|
80
|
+
userId: string;
|
|
81
|
+
flashcardId: string;
|
|
82
|
+
timesStudied: number;
|
|
83
|
+
timesCorrect: number;
|
|
84
|
+
timesIncorrect: number;
|
|
85
|
+
timesIncorrectConsecutive: number;
|
|
86
|
+
easeFactor: number;
|
|
87
|
+
interval: number;
|
|
88
|
+
repetitions: number;
|
|
89
|
+
masteryLevel: number;
|
|
90
|
+
lastStudiedAt: Date | null;
|
|
91
|
+
nextReviewAt: Date | null;
|
|
92
|
+
} | null;
|
|
93
|
+
}[]>;
|
|
94
|
+
/**
|
|
95
|
+
* Get flashcards due for review, non-studied flashcards, and flashcards with low mastery
|
|
96
|
+
*/
|
|
97
|
+
getDueFlashcards(userId: string, workspaceId: string): Promise<({
|
|
98
|
+
artifact: {
|
|
99
|
+
id: string;
|
|
100
|
+
createdAt: Date;
|
|
101
|
+
updatedAt: Date;
|
|
102
|
+
title: string;
|
|
103
|
+
description: string | null;
|
|
104
|
+
workspaceId: string;
|
|
105
|
+
type: import("@prisma/client").$Enums.ArtifactType;
|
|
106
|
+
isArchived: boolean;
|
|
107
|
+
generating: boolean;
|
|
108
|
+
generatingMetadata: import("@prisma/client/runtime/library").JsonValue | null;
|
|
109
|
+
difficulty: import("@prisma/client").$Enums.Difficulty | null;
|
|
110
|
+
estimatedTime: string | null;
|
|
111
|
+
imageObjectKey: string | null;
|
|
112
|
+
createdById: string | null;
|
|
113
|
+
};
|
|
114
|
+
} & {
|
|
115
|
+
id: string;
|
|
116
|
+
createdAt: Date;
|
|
117
|
+
artifactId: string;
|
|
118
|
+
order: number;
|
|
119
|
+
front: string;
|
|
120
|
+
back: string;
|
|
121
|
+
tags: string[];
|
|
122
|
+
})[]>;
|
|
123
|
+
/**
|
|
124
|
+
* Get user statistics for a flashcard set
|
|
125
|
+
*/
|
|
126
|
+
getSetStatistics(userId: string, artifactId: string): Promise<{
|
|
127
|
+
totalCards: number;
|
|
128
|
+
studiedCards: number;
|
|
129
|
+
unstudiedCards: number;
|
|
130
|
+
masteredCards: number;
|
|
131
|
+
dueForReview: number;
|
|
132
|
+
averageMastery: number;
|
|
133
|
+
successRate: number;
|
|
134
|
+
totalAttempts: number;
|
|
135
|
+
totalCorrect: number;
|
|
136
|
+
}>;
|
|
137
|
+
/**
|
|
138
|
+
* Reset progress for a flashcard
|
|
139
|
+
*/
|
|
140
|
+
resetProgress(userId: string, flashcardId: string): Promise<import("@prisma/client").Prisma.BatchPayload>;
|
|
141
|
+
/**
|
|
142
|
+
* Bulk record study session
|
|
143
|
+
*/
|
|
144
|
+
recordStudySession(data: {
|
|
145
|
+
userId: string;
|
|
146
|
+
attempts: Array<{
|
|
147
|
+
flashcardId: string;
|
|
148
|
+
isCorrect: boolean;
|
|
149
|
+
confidence?: 'easy' | 'medium' | 'hard';
|
|
150
|
+
timeSpentMs?: number;
|
|
151
|
+
}>;
|
|
152
|
+
}): Promise<({
|
|
153
|
+
flashcard: {
|
|
154
|
+
id: string;
|
|
155
|
+
createdAt: Date;
|
|
156
|
+
artifactId: string;
|
|
157
|
+
order: number;
|
|
158
|
+
front: string;
|
|
159
|
+
back: string;
|
|
160
|
+
tags: string[];
|
|
161
|
+
};
|
|
162
|
+
} & {
|
|
163
|
+
id: string;
|
|
164
|
+
createdAt: Date;
|
|
165
|
+
updatedAt: Date;
|
|
166
|
+
userId: string;
|
|
167
|
+
flashcardId: string;
|
|
168
|
+
timesStudied: number;
|
|
169
|
+
timesCorrect: number;
|
|
170
|
+
timesIncorrect: number;
|
|
171
|
+
timesIncorrectConsecutive: number;
|
|
172
|
+
easeFactor: number;
|
|
173
|
+
interval: number;
|
|
174
|
+
repetitions: number;
|
|
175
|
+
masteryLevel: number;
|
|
176
|
+
lastStudiedAt: Date | null;
|
|
177
|
+
nextReviewAt: Date | null;
|
|
178
|
+
})[]>;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Factory function
|
|
182
|
+
*/
|
|
183
|
+
export declare function createFlashcardProgressService(db: PrismaClient): FlashcardProgressService;
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { NotFoundError } from '../lib/errors.js';
|
|
2
|
+
export class FlashcardProgressService {
|
|
3
|
+
constructor(db) {
|
|
4
|
+
this.db = db;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Calculate next review using SM-2 algorithm with smart scheduling
|
|
8
|
+
* @param quality - 0-5 rating (0=complete blackout, 5=perfect response)
|
|
9
|
+
* @param easeFactor - Current ease factor (default 2.5)
|
|
10
|
+
* @param interval - Current interval in days (default 0)
|
|
11
|
+
* @param repetitions - Number of consecutive correct responses (default 0)
|
|
12
|
+
* @param consecutiveIncorrect - Number of consecutive failures (for smart scheduling)
|
|
13
|
+
* @param totalIncorrect - Total incorrect count (for context)
|
|
14
|
+
*/
|
|
15
|
+
calculateSM2(quality, easeFactor = 2.5, interval = 0, repetitions = 0, consecutiveIncorrect = 0, totalIncorrect = 0) {
|
|
16
|
+
// If quality < 3, determine if immediate review or short delay
|
|
17
|
+
if (quality < 3) {
|
|
18
|
+
// If no consecutive failures but has some overall failures, give short delay
|
|
19
|
+
const shouldDelayReview = consecutiveIncorrect === 0 && totalIncorrect > 0;
|
|
20
|
+
const nextReviewAt = new Date();
|
|
21
|
+
if (shouldDelayReview) {
|
|
22
|
+
// Give them a few hours to let it sink in
|
|
23
|
+
nextReviewAt.setHours(nextReviewAt.getHours() + 4);
|
|
24
|
+
}
|
|
25
|
+
// Otherwise immediate review (consecutiveIncorrect > 0 or first failure)
|
|
26
|
+
return {
|
|
27
|
+
easeFactor: Math.max(1.3, easeFactor - 0.2),
|
|
28
|
+
interval: 0,
|
|
29
|
+
repetitions: 0,
|
|
30
|
+
nextReviewAt,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
// Calculate new ease factor
|
|
34
|
+
const newEaseFactor = Math.max(1.3, easeFactor + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02)));
|
|
35
|
+
// Calculate new interval based on performance history
|
|
36
|
+
let newInterval;
|
|
37
|
+
if (repetitions === 0) {
|
|
38
|
+
// First correct answer
|
|
39
|
+
if (consecutiveIncorrect >= 2 || totalIncorrect >= 5) {
|
|
40
|
+
// If they struggled a lot, start conservative
|
|
41
|
+
newInterval = 1; // 1 day
|
|
42
|
+
}
|
|
43
|
+
else if (totalIncorrect === 0) {
|
|
44
|
+
// Perfect card, never failed
|
|
45
|
+
newInterval = 3; // 3 days (skip ahead)
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
// Normal case
|
|
49
|
+
newInterval = 1; // 1 day
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
else if (repetitions === 1) {
|
|
53
|
+
newInterval = 6; // 6 days
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
newInterval = Math.ceil(interval * newEaseFactor);
|
|
57
|
+
}
|
|
58
|
+
// Calculate next review date
|
|
59
|
+
const nextReviewAt = new Date();
|
|
60
|
+
nextReviewAt.setDate(nextReviewAt.getDate() + newInterval);
|
|
61
|
+
return {
|
|
62
|
+
easeFactor: newEaseFactor,
|
|
63
|
+
interval: newInterval,
|
|
64
|
+
repetitions: repetitions + 1,
|
|
65
|
+
nextReviewAt,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Infer confidence level based on consecutive incorrect attempts
|
|
70
|
+
*/
|
|
71
|
+
inferConfidence(isCorrect, consecutiveIncorrect, timesStudied) {
|
|
72
|
+
if (!isCorrect) {
|
|
73
|
+
// If they got it wrong, it's obviously hard
|
|
74
|
+
return 'hard';
|
|
75
|
+
}
|
|
76
|
+
// If they got it right but have high consecutive failures, it's still hard
|
|
77
|
+
if (consecutiveIncorrect >= 3) {
|
|
78
|
+
return 'hard';
|
|
79
|
+
}
|
|
80
|
+
if (consecutiveIncorrect >= 1) {
|
|
81
|
+
return 'medium';
|
|
82
|
+
}
|
|
83
|
+
// If first time or low failure history, check overall performance
|
|
84
|
+
if (timesStudied === 0 || timesStudied === 1) {
|
|
85
|
+
return 'medium'; // Default for first attempts
|
|
86
|
+
}
|
|
87
|
+
// If they've studied it multiple times with no recent failures, it's easy
|
|
88
|
+
return 'easy';
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Convert confidence to SM-2 quality rating
|
|
92
|
+
*/
|
|
93
|
+
confidenceToQuality(confidence) {
|
|
94
|
+
switch (confidence) {
|
|
95
|
+
case 'easy':
|
|
96
|
+
return 5; // Perfect response
|
|
97
|
+
case 'medium':
|
|
98
|
+
return 4; // Correct after hesitation
|
|
99
|
+
case 'hard':
|
|
100
|
+
return 3; // Correct with difficulty
|
|
101
|
+
default:
|
|
102
|
+
return 4;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Record flashcard study attempt
|
|
107
|
+
*/
|
|
108
|
+
async recordStudyAttempt(data) {
|
|
109
|
+
const { userId, flashcardId, isCorrect, timeSpentMs } = data;
|
|
110
|
+
// Verify flashcard exists and user has access
|
|
111
|
+
const flashcard = await this.db.flashcard.findFirst({
|
|
112
|
+
where: {
|
|
113
|
+
id: flashcardId,
|
|
114
|
+
artifact: {
|
|
115
|
+
workspace: {
|
|
116
|
+
OR: [
|
|
117
|
+
{ ownerId: userId },
|
|
118
|
+
{ members: { some: { userId } } },
|
|
119
|
+
],
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
if (!flashcard) {
|
|
125
|
+
throw new NotFoundError('Flashcard');
|
|
126
|
+
}
|
|
127
|
+
// Get existing progress
|
|
128
|
+
const existingProgress = await this.db.flashcardProgress.findUnique({
|
|
129
|
+
where: {
|
|
130
|
+
userId_flashcardId: {
|
|
131
|
+
userId,
|
|
132
|
+
flashcardId,
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
// Calculate new consecutive incorrect count
|
|
137
|
+
const newConsecutiveIncorrect = isCorrect
|
|
138
|
+
? 0
|
|
139
|
+
: (existingProgress?.timesIncorrectConsecutive || 0) + 1;
|
|
140
|
+
// Auto-infer confidence based on performance
|
|
141
|
+
const inferredConfidence = this.inferConfidence(isCorrect, newConsecutiveIncorrect, existingProgress?.timesStudied || 0);
|
|
142
|
+
// Use provided confidence or inferred
|
|
143
|
+
const finalConfidence = data.confidence || inferredConfidence;
|
|
144
|
+
const quality = this.confidenceToQuality(finalConfidence);
|
|
145
|
+
// Calculate total incorrect after this attempt
|
|
146
|
+
const totalIncorrect = (existingProgress?.timesIncorrect || 0) + (isCorrect ? 0 : 1);
|
|
147
|
+
const sm2Result = this.calculateSM2(quality, existingProgress?.easeFactor, existingProgress?.interval, existingProgress?.repetitions, newConsecutiveIncorrect, totalIncorrect);
|
|
148
|
+
// Calculate mastery level (0-100)
|
|
149
|
+
const totalAttempts = (existingProgress?.timesStudied || 0) + 1;
|
|
150
|
+
const totalCorrect = (existingProgress?.timesCorrect || 0) + (isCorrect ? 1 : 0);
|
|
151
|
+
const successRate = totalCorrect / totalAttempts;
|
|
152
|
+
// Mastery considers success rate, repetitions, and consecutive failures
|
|
153
|
+
const consecutivePenalty = Math.min(newConsecutiveIncorrect * 10, 30); // Max 30% penalty
|
|
154
|
+
const masteryLevel = Math.min(100, Math.max(0, Math.round((successRate * 70) + // 70% weight on success rate
|
|
155
|
+
(Math.min(sm2Result.repetitions, 10) / 10) * 30 - // 30% weight on repetitions
|
|
156
|
+
consecutivePenalty // Penalty for consecutive failures
|
|
157
|
+
)));
|
|
158
|
+
// Upsert progress
|
|
159
|
+
return this.db.flashcardProgress.upsert({
|
|
160
|
+
where: {
|
|
161
|
+
userId_flashcardId: {
|
|
162
|
+
userId,
|
|
163
|
+
flashcardId,
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
update: {
|
|
167
|
+
timesStudied: { increment: 1 },
|
|
168
|
+
timesCorrect: isCorrect ? { increment: 1 } : undefined,
|
|
169
|
+
timesIncorrect: !isCorrect ? { increment: 1 } : undefined,
|
|
170
|
+
timesIncorrectConsecutive: newConsecutiveIncorrect,
|
|
171
|
+
easeFactor: sm2Result.easeFactor,
|
|
172
|
+
interval: sm2Result.interval,
|
|
173
|
+
repetitions: sm2Result.repetitions,
|
|
174
|
+
masteryLevel,
|
|
175
|
+
lastStudiedAt: new Date(),
|
|
176
|
+
nextReviewAt: sm2Result.nextReviewAt,
|
|
177
|
+
},
|
|
178
|
+
create: {
|
|
179
|
+
userId,
|
|
180
|
+
flashcardId,
|
|
181
|
+
timesStudied: 1,
|
|
182
|
+
timesCorrect: isCorrect ? 1 : 0,
|
|
183
|
+
timesIncorrect: isCorrect ? 0 : 1,
|
|
184
|
+
timesIncorrectConsecutive: newConsecutiveIncorrect,
|
|
185
|
+
easeFactor: sm2Result.easeFactor,
|
|
186
|
+
interval: sm2Result.interval,
|
|
187
|
+
repetitions: sm2Result.repetitions,
|
|
188
|
+
masteryLevel,
|
|
189
|
+
lastStudiedAt: new Date(),
|
|
190
|
+
nextReviewAt: sm2Result.nextReviewAt,
|
|
191
|
+
},
|
|
192
|
+
include: {
|
|
193
|
+
flashcard: true,
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Get user's progress on all flashcards in a set
|
|
199
|
+
*/
|
|
200
|
+
async getSetProgress(userId, artifactId) {
|
|
201
|
+
const flashcards = await this.db.flashcard.findMany({
|
|
202
|
+
where: { artifactId },
|
|
203
|
+
});
|
|
204
|
+
// Manually fetch progress for each flashcard
|
|
205
|
+
const flashcardsWithProgress = await Promise.all(flashcards.map(async (card) => {
|
|
206
|
+
const progress = await this.db.flashcardProgress.findUnique({
|
|
207
|
+
where: {
|
|
208
|
+
userId_flashcardId: {
|
|
209
|
+
userId,
|
|
210
|
+
flashcardId: card.id,
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
return {
|
|
215
|
+
flashcardId: card.id,
|
|
216
|
+
front: card.front,
|
|
217
|
+
back: card.back,
|
|
218
|
+
progress: progress || null,
|
|
219
|
+
};
|
|
220
|
+
}));
|
|
221
|
+
return flashcardsWithProgress;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Get flashcards due for review, non-studied flashcards, and flashcards with low mastery
|
|
225
|
+
*/
|
|
226
|
+
async getDueFlashcards(userId, workspaceId) {
|
|
227
|
+
const now = new Date();
|
|
228
|
+
const LOW_MASTERY_THRESHOLD = 50; // Consider mastery < 50 as low
|
|
229
|
+
// Get all flashcards in the workspace
|
|
230
|
+
const allFlashcards = await this.db.flashcard.findMany({
|
|
231
|
+
where: {
|
|
232
|
+
artifact: {
|
|
233
|
+
workspaceId,
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
include: {
|
|
237
|
+
artifact: true,
|
|
238
|
+
progress: {
|
|
239
|
+
where: {
|
|
240
|
+
userId,
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
const TAKE_NUMBER = (allFlashcards.length > 10) ? 10 : allFlashcards.length;
|
|
246
|
+
// Get progress records for flashcards that are due or have low mastery
|
|
247
|
+
const progressRecords = await this.db.flashcardProgress.findMany({
|
|
248
|
+
where: {
|
|
249
|
+
userId,
|
|
250
|
+
OR: [
|
|
251
|
+
{
|
|
252
|
+
nextReviewAt: {
|
|
253
|
+
lte: now,
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
masteryLevel: {
|
|
258
|
+
lt: LOW_MASTERY_THRESHOLD,
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
timesStudied: {
|
|
263
|
+
lt: 3,
|
|
264
|
+
},
|
|
265
|
+
}
|
|
266
|
+
],
|
|
267
|
+
flashcard: {
|
|
268
|
+
artifact: {
|
|
269
|
+
workspaceId,
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
include: {
|
|
274
|
+
flashcard: {
|
|
275
|
+
include: {
|
|
276
|
+
artifact: true,
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
take: TAKE_NUMBER,
|
|
281
|
+
});
|
|
282
|
+
// Build result array: include progress records and non-studied flashcards
|
|
283
|
+
const results = [];
|
|
284
|
+
// Add flashcards with progress (due or low mastery)
|
|
285
|
+
for (const progress of progressRecords) {
|
|
286
|
+
results.push(progress);
|
|
287
|
+
}
|
|
288
|
+
// Sort by priority: due first (by nextReviewAt), then low mastery, then non-studied
|
|
289
|
+
results.sort((a, b) => {
|
|
290
|
+
// Due flashcards first (nextReviewAt <= now)
|
|
291
|
+
const aIsDue = a.nextReviewAt && a.nextReviewAt <= now;
|
|
292
|
+
const bIsDue = b.nextReviewAt && b.nextReviewAt <= now;
|
|
293
|
+
if (aIsDue && !bIsDue)
|
|
294
|
+
return -1;
|
|
295
|
+
if (!aIsDue && bIsDue)
|
|
296
|
+
return 1;
|
|
297
|
+
// Among due flashcards, sort by nextReviewAt
|
|
298
|
+
if (aIsDue && bIsDue && a.nextReviewAt && b.nextReviewAt) {
|
|
299
|
+
return a.nextReviewAt.getTime() - b.nextReviewAt.getTime();
|
|
300
|
+
}
|
|
301
|
+
// Then low mastery (lower mastery first)
|
|
302
|
+
if (a.masteryLevel !== b.masteryLevel) {
|
|
303
|
+
return a.masteryLevel - b.masteryLevel;
|
|
304
|
+
}
|
|
305
|
+
// Finally, non-studied (timesStudied === 0)
|
|
306
|
+
if (a.timesStudied === 0 && b.timesStudied !== 0)
|
|
307
|
+
return -1;
|
|
308
|
+
if (a.timesStudied !== 0 && b.timesStudied === 0)
|
|
309
|
+
return 1;
|
|
310
|
+
return 0;
|
|
311
|
+
});
|
|
312
|
+
return results.map((progress) => progress.flashcard);
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Get user statistics for a flashcard set
|
|
316
|
+
*/
|
|
317
|
+
async getSetStatistics(userId, artifactId) {
|
|
318
|
+
const progress = await this.db.flashcardProgress.findMany({
|
|
319
|
+
where: {
|
|
320
|
+
userId,
|
|
321
|
+
flashcard: {
|
|
322
|
+
artifactId,
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
const totalCards = await this.db.flashcard.count({
|
|
327
|
+
where: { artifactId },
|
|
328
|
+
});
|
|
329
|
+
const studiedCards = progress.length;
|
|
330
|
+
const masteredCards = progress.filter((p) => p.masteryLevel >= 80).length;
|
|
331
|
+
const dueForReview = progress.filter((p) => p.nextReviewAt && p.nextReviewAt <= new Date()).length;
|
|
332
|
+
const averageMastery = progress.length > 0
|
|
333
|
+
? progress.reduce((sum, p) => sum + p.masteryLevel, 0) / progress.length
|
|
334
|
+
: 0;
|
|
335
|
+
const totalCorrect = progress.reduce((sum, p) => sum + p.timesCorrect, 0);
|
|
336
|
+
const totalAttempts = progress.reduce((sum, p) => sum + p.timesStudied, 0);
|
|
337
|
+
const successRate = totalAttempts > 0 ? (totalCorrect / totalAttempts) * 100 : 0;
|
|
338
|
+
return {
|
|
339
|
+
totalCards,
|
|
340
|
+
studiedCards,
|
|
341
|
+
unstudiedCards: totalCards - studiedCards,
|
|
342
|
+
masteredCards,
|
|
343
|
+
dueForReview,
|
|
344
|
+
averageMastery: Math.round(averageMastery),
|
|
345
|
+
successRate: Math.round(successRate),
|
|
346
|
+
totalAttempts,
|
|
347
|
+
totalCorrect,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Reset progress for a flashcard
|
|
352
|
+
*/
|
|
353
|
+
async resetProgress(userId, flashcardId) {
|
|
354
|
+
return this.db.flashcardProgress.deleteMany({
|
|
355
|
+
where: {
|
|
356
|
+
userId,
|
|
357
|
+
flashcardId,
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Bulk record study session
|
|
363
|
+
*/
|
|
364
|
+
async recordStudySession(data) {
|
|
365
|
+
const { userId, attempts } = data;
|
|
366
|
+
// Process attempts sequentially
|
|
367
|
+
const results = [];
|
|
368
|
+
for (const attempt of attempts) {
|
|
369
|
+
const result = await this.recordStudyAttempt({
|
|
370
|
+
userId,
|
|
371
|
+
...attempt,
|
|
372
|
+
});
|
|
373
|
+
results.push(result);
|
|
374
|
+
}
|
|
375
|
+
return results;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Factory function
|
|
380
|
+
*/
|
|
381
|
+
export function createFlashcardProgressService(db) {
|
|
382
|
+
return new FlashcardProgressService(db);
|
|
383
|
+
}
|