@vue-skuilder/db 0.1.17 → 0.1.18
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/dist/{userDB-DNa0XPtn.d.ts → classroomDB-BgfrVb8d.d.ts} +357 -103
- package/dist/{userDB-BqwxtJ_7.d.mts → classroomDB-CTOenngH.d.cts} +358 -104
- package/dist/core/index.d.cts +230 -0
- package/dist/core/index.d.ts +161 -23
- package/dist/core/index.js +1964 -154
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +1925 -121
- package/dist/core/index.mjs.map +1 -1
- package/dist/{dataLayerProvider-BV5iZqt_.d.ts → dataLayerProvider-CZxC9GtB.d.ts} +1 -1
- package/dist/{dataLayerProvider-VlngD19_.d.mts → dataLayerProvider-D6PoCwS6.d.cts} +1 -1
- package/dist/impl/couch/{index.d.mts → index.d.cts} +46 -5
- package/dist/impl/couch/index.d.ts +44 -3
- package/dist/impl/couch/index.js +1971 -171
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +1933 -134
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/{index.d.mts → index.d.cts} +5 -6
- package/dist/impl/static/index.d.ts +2 -3
- package/dist/impl/static/index.js +1614 -119
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +1585 -92
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/{index-Bmll7Xse.d.mts → index-D-Fa4Smt.d.cts} +1 -1
- package/dist/{index.d.mts → index.d.cts} +97 -13
- package/dist/index.d.ts +90 -6
- package/dist/index.js +2085 -153
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2031 -106
- package/dist/index.mjs.map +1 -1
- package/dist/pouch/index.js +3 -3
- package/dist/{types-Dbp5DaRR.d.mts → types-CzPDLAK6.d.cts} +1 -1
- package/dist/util/packer/{index.d.mts → index.d.cts} +3 -3
- package/dist/util/packer/index.js.map +1 -1
- package/dist/util/packer/index.mjs.map +1 -1
- package/docs/brainstorm-navigation-paradigm.md +369 -0
- package/docs/navigators-architecture.md +265 -0
- package/docs/todo-evolutionary-orchestration.md +310 -0
- package/docs/todo-nominal-tag-types.md +121 -0
- package/docs/todo-pipeline-optimization.md +117 -0
- package/docs/todo-strategy-authoring.md +401 -0
- package/docs/todo-strategy-state-storage.md +278 -0
- package/eslint.config.mjs +1 -1
- package/package.json +9 -4
- package/src/core/interfaces/contentSource.ts +88 -4
- package/src/core/interfaces/navigationStrategyManager.ts +0 -5
- package/src/core/navigators/CompositeGenerator.ts +268 -0
- package/src/core/navigators/Pipeline.ts +205 -0
- package/src/core/navigators/PipelineAssembler.ts +194 -0
- package/src/core/navigators/elo.ts +104 -15
- package/src/core/navigators/filters/eloDistance.ts +132 -0
- package/src/core/navigators/filters/index.ts +6 -0
- package/src/core/navigators/filters/types.ts +115 -0
- package/src/core/navigators/generators/index.ts +2 -0
- package/src/core/navigators/generators/types.ts +107 -0
- package/src/core/navigators/hardcodedOrder.ts +111 -12
- package/src/core/navigators/hierarchyDefinition.ts +266 -0
- package/src/core/navigators/index.ts +345 -3
- package/src/core/navigators/interferenceMitigator.ts +367 -0
- package/src/core/navigators/relativePriority.ts +267 -0
- package/src/core/navigators/srs.ts +195 -0
- package/src/impl/couch/classroomDB.ts +51 -0
- package/src/impl/couch/courseDB.ts +117 -39
- package/src/impl/static/courseDB.ts +0 -4
- package/src/study/SessionController.ts +149 -1
- package/src/study/TagFilteredContentSource.ts +255 -0
- package/src/study/index.ts +1 -0
- package/src/util/dataDirectory.test.ts +51 -22
- package/src/util/logger.ts +0 -1
- package/tests/core/navigators/CompositeGenerator.test.ts +455 -0
- package/tests/core/navigators/Pipeline.test.ts +405 -0
- package/tests/core/navigators/PipelineAssembler.test.ts +351 -0
- package/tests/core/navigators/SRSNavigator.test.ts +344 -0
- package/tests/core/navigators/eloDistanceFilter.test.ts +192 -0
- package/tests/core/navigators/navigators.test.ts +710 -0
- package/tsconfig.json +1 -1
- package/vitest.config.ts +29 -0
- package/dist/core/index.d.mts +0 -92
- /package/dist/{SyncStrategy-CyATpyLQ.d.mts → SyncStrategy-CyATpyLQ.d.cts} +0 -0
- /package/dist/pouch/{index.d.mts → index.d.cts} +0 -0
- /package/dist/{types-legacy-6ettoclI.d.mts → types-legacy-6ettoclI.d.cts} +0 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import moment from 'moment';
|
|
2
|
+
import type { ScheduledCard } from '../types/user';
|
|
3
|
+
import type { CourseDBInterface } from '../interfaces/courseDB';
|
|
4
|
+
import type { UserDBInterface } from '../interfaces/userDB';
|
|
5
|
+
import { ContentNavigator } from './index';
|
|
6
|
+
import type { WeightedCard } from './index';
|
|
7
|
+
import type { ContentNavigationStrategyData } from '../types/contentNavigationStrategy';
|
|
8
|
+
import type { StudySessionReviewItem, StudySessionNewItem } from '../interfaces/contentSource';
|
|
9
|
+
import type { CardGenerator, GeneratorContext } from './generators/types';
|
|
10
|
+
|
|
11
|
+
// ============================================================================
|
|
12
|
+
// SRS NAVIGATOR
|
|
13
|
+
// ============================================================================
|
|
14
|
+
//
|
|
15
|
+
// A generator strategy that scores review cards by urgency.
|
|
16
|
+
//
|
|
17
|
+
// Urgency is determined by two factors:
|
|
18
|
+
// 1. Overdueness - how far past the scheduled review time
|
|
19
|
+
// 2. Interval recency - shorter scheduled intervals indicate "novel content in progress"
|
|
20
|
+
//
|
|
21
|
+
// A card with a 3-day interval that's 2 days overdue is more urgent than a card
|
|
22
|
+
// with a 6-month interval that's 2 days overdue. The shorter interval represents
|
|
23
|
+
// active learning at higher resolution.
|
|
24
|
+
//
|
|
25
|
+
// This navigator only handles reviews - it does not generate new cards.
|
|
26
|
+
// For new cards, use ELONavigator or another generator via CompositeGenerator.
|
|
27
|
+
//
|
|
28
|
+
// ============================================================================
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Configuration for the SRS strategy.
|
|
32
|
+
* Currently minimal - the algorithm is not parameterized.
|
|
33
|
+
*/
|
|
34
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
35
|
+
export interface SRSConfig {
|
|
36
|
+
// Future: configurable urgency curves, thresholds, etc.
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A navigation strategy that scores review cards by urgency.
|
|
41
|
+
*
|
|
42
|
+
* Implements CardGenerator for use in Pipeline architecture.
|
|
43
|
+
* Also extends ContentNavigator for backward compatibility with legacy code.
|
|
44
|
+
*
|
|
45
|
+
* Higher scores indicate more urgent reviews:
|
|
46
|
+
* - Cards that are more overdue (relative to their interval) score higher
|
|
47
|
+
* - Cards with shorter intervals (recent learning) score higher
|
|
48
|
+
*
|
|
49
|
+
* Only returns cards that are actually due (reviewTime has passed).
|
|
50
|
+
* Does not generate new cards - use with CompositeGenerator for mixed content.
|
|
51
|
+
*/
|
|
52
|
+
export default class SRSNavigator extends ContentNavigator implements CardGenerator {
|
|
53
|
+
/** Human-readable name for CardGenerator interface */
|
|
54
|
+
name: string;
|
|
55
|
+
|
|
56
|
+
constructor(
|
|
57
|
+
user: UserDBInterface,
|
|
58
|
+
course: CourseDBInterface,
|
|
59
|
+
strategyData?: ContentNavigationStrategyData
|
|
60
|
+
) {
|
|
61
|
+
super(user, course, strategyData as ContentNavigationStrategyData);
|
|
62
|
+
this.name = strategyData?.name || 'SRS';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Get review cards scored by urgency.
|
|
67
|
+
*
|
|
68
|
+
* Score formula combines:
|
|
69
|
+
* - Relative overdueness: hoursOverdue / intervalHours
|
|
70
|
+
* - Interval recency: exponential decay favoring shorter intervals
|
|
71
|
+
*
|
|
72
|
+
* Cards not yet due are excluded (not scored as 0).
|
|
73
|
+
*
|
|
74
|
+
* This method supports both the legacy signature (limit only) and the
|
|
75
|
+
* CardGenerator interface signature (limit, context).
|
|
76
|
+
*
|
|
77
|
+
* @param limit - Maximum number of cards to return
|
|
78
|
+
* @param _context - Optional GeneratorContext (currently unused, but required for interface)
|
|
79
|
+
*/
|
|
80
|
+
async getWeightedCards(limit: number, _context?: GeneratorContext): Promise<WeightedCard[]> {
|
|
81
|
+
if (!this.user || !this.course) {
|
|
82
|
+
throw new Error('SRSNavigator requires user and course to be set');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const reviews = await this.user.getPendingReviews(this.course.getCourseID());
|
|
86
|
+
const now = moment.utc();
|
|
87
|
+
|
|
88
|
+
// Filter to only cards that are actually due
|
|
89
|
+
const dueReviews = reviews.filter((r) => now.isAfter(moment.utc(r.reviewTime)));
|
|
90
|
+
|
|
91
|
+
const scored = dueReviews.map((review) => {
|
|
92
|
+
const { score, reason } = this.computeUrgencyScore(review, now);
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
cardId: review.cardId,
|
|
96
|
+
courseId: review.courseId,
|
|
97
|
+
score,
|
|
98
|
+
provenance: [
|
|
99
|
+
{
|
|
100
|
+
strategy: 'srs',
|
|
101
|
+
strategyName: this.strategyName || this.name,
|
|
102
|
+
strategyId: this.strategyId || 'NAVIGATION_STRATEGY-SRS-default',
|
|
103
|
+
action: 'generated' as const,
|
|
104
|
+
score,
|
|
105
|
+
reason,
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
};
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Sort by score descending and limit
|
|
112
|
+
return scored.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Compute urgency score for a review card.
|
|
117
|
+
*
|
|
118
|
+
* Two factors:
|
|
119
|
+
* 1. Relative overdueness = hoursOverdue / intervalHours
|
|
120
|
+
* - 2 days overdue on 3-day interval = 0.67 (urgent)
|
|
121
|
+
* - 2 days overdue on 180-day interval = 0.01 (not urgent)
|
|
122
|
+
*
|
|
123
|
+
* 2. Interval recency factor = 0.3 + 0.7 * exp(-intervalHours / 720)
|
|
124
|
+
* - 24h interval → ~1.0 (very recent learning)
|
|
125
|
+
* - 30 days (720h) → ~0.56
|
|
126
|
+
* - 180 days → ~0.30
|
|
127
|
+
*
|
|
128
|
+
* Combined: base 0.5 + weighted average of factors * 0.45
|
|
129
|
+
* Result range: approximately 0.5 to 0.95
|
|
130
|
+
*/
|
|
131
|
+
private computeUrgencyScore(
|
|
132
|
+
review: ScheduledCard,
|
|
133
|
+
now: moment.Moment
|
|
134
|
+
): { score: number; reason: string } {
|
|
135
|
+
const scheduledAt = moment.utc(review.scheduledAt);
|
|
136
|
+
const due = moment.utc(review.reviewTime);
|
|
137
|
+
|
|
138
|
+
// Interval = time between scheduling and due date (minimum 1 hour to avoid division issues)
|
|
139
|
+
const intervalHours = Math.max(1, due.diff(scheduledAt, 'hours'));
|
|
140
|
+
const hoursOverdue = now.diff(due, 'hours');
|
|
141
|
+
|
|
142
|
+
// Relative overdueness: how late relative to the interval
|
|
143
|
+
const relativeOverdue = hoursOverdue / intervalHours;
|
|
144
|
+
|
|
145
|
+
// Interval recency factor: shorter intervals = more urgent
|
|
146
|
+
// Exponential decay with 720h (30 days) as the characteristic time
|
|
147
|
+
const recencyFactor = 0.3 + 0.7 * Math.exp(-intervalHours / 720);
|
|
148
|
+
|
|
149
|
+
// Combined urgency: weighted average of relative overdue and recency
|
|
150
|
+
// Clamp relative overdue contribution to [0, 1] to avoid runaway scores
|
|
151
|
+
const overdueContribution = Math.min(1.0, Math.max(0, relativeOverdue));
|
|
152
|
+
const urgency = overdueContribution * 0.5 + recencyFactor * 0.5;
|
|
153
|
+
|
|
154
|
+
// Final score: base 0.5 + urgency contribution, capped at 0.95
|
|
155
|
+
const score = Math.min(0.95, 0.5 + urgency * 0.45);
|
|
156
|
+
|
|
157
|
+
const reason =
|
|
158
|
+
`${Math.round(hoursOverdue)}h overdue (interval: ${Math.round(intervalHours)}h, ` +
|
|
159
|
+
`relative: ${relativeOverdue.toFixed(2)}), recency: ${recencyFactor.toFixed(2)}, review`;
|
|
160
|
+
|
|
161
|
+
return { score, reason };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Get pending reviews in legacy format.
|
|
166
|
+
*
|
|
167
|
+
* Returns all pending reviews for the course, enriched with session item fields.
|
|
168
|
+
*/
|
|
169
|
+
async getPendingReviews(): Promise<(StudySessionReviewItem & ScheduledCard)[]> {
|
|
170
|
+
if (!this.user || !this.course) {
|
|
171
|
+
throw new Error('SRSNavigator requires user and course to be set');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const reviews = await this.user.getPendingReviews(this.course.getCourseID());
|
|
175
|
+
|
|
176
|
+
return reviews.map((r) => ({
|
|
177
|
+
...r,
|
|
178
|
+
contentSourceType: 'course' as const,
|
|
179
|
+
contentSourceID: this.course!.getCourseID(),
|
|
180
|
+
cardID: r.cardId,
|
|
181
|
+
courseID: r.courseId,
|
|
182
|
+
qualifiedID: `${r.courseId}-${r.cardId}`,
|
|
183
|
+
reviewID: r._id,
|
|
184
|
+
status: 'review' as const,
|
|
185
|
+
}));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* SRS does not generate new cards.
|
|
190
|
+
* Use ELONavigator or another generator for new cards.
|
|
191
|
+
*/
|
|
192
|
+
async getNewCards(_n?: number): Promise<StudySessionNewItem[]> {
|
|
193
|
+
return [];
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
StudySessionNewItem,
|
|
4
4
|
StudySessionReviewItem,
|
|
5
5
|
} from '@db/core/interfaces/contentSource';
|
|
6
|
+
import { WeightedCard } from '@db/core/navigators';
|
|
6
7
|
import { ClassroomConfig } from '@vue-skuilder/common';
|
|
7
8
|
import { ENV } from '@db/factory';
|
|
8
9
|
import { logger } from '@db/util/logger';
|
|
@@ -189,6 +190,56 @@ export class StudentClassroomDB
|
|
|
189
190
|
}
|
|
190
191
|
});
|
|
191
192
|
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Get cards with suitability scores for presentation.
|
|
196
|
+
*
|
|
197
|
+
* This implementation wraps the legacy getNewCards/getPendingReviews methods,
|
|
198
|
+
* assigning score=1.0 to all cards. StudentClassroomDB does not currently
|
|
199
|
+
* support pluggable navigation strategies.
|
|
200
|
+
*
|
|
201
|
+
* @param limit - Maximum number of cards to return
|
|
202
|
+
* @returns Cards sorted by score descending (all scores = 1.0)
|
|
203
|
+
*/
|
|
204
|
+
public async getWeightedCards(limit: number): Promise<WeightedCard[]> {
|
|
205
|
+
const [newCards, reviews] = await Promise.all([this.getNewCards(), this.getPendingReviews()]);
|
|
206
|
+
|
|
207
|
+
const weighted: WeightedCard[] = [
|
|
208
|
+
...newCards.map((c) => ({
|
|
209
|
+
cardId: c.cardID,
|
|
210
|
+
courseId: c.courseID,
|
|
211
|
+
score: 1.0,
|
|
212
|
+
provenance: [
|
|
213
|
+
{
|
|
214
|
+
strategy: 'classroom',
|
|
215
|
+
strategyName: 'Classroom',
|
|
216
|
+
strategyId: 'CLASSROOM',
|
|
217
|
+
action: 'generated' as const,
|
|
218
|
+
score: 1.0,
|
|
219
|
+
reason: 'Classroom legacy getNewCards(), new card',
|
|
220
|
+
},
|
|
221
|
+
],
|
|
222
|
+
})),
|
|
223
|
+
...reviews.map((r) => ({
|
|
224
|
+
cardId: r.cardID,
|
|
225
|
+
courseId: r.courseID,
|
|
226
|
+
score: 1.0,
|
|
227
|
+
provenance: [
|
|
228
|
+
{
|
|
229
|
+
strategy: 'classroom',
|
|
230
|
+
strategyName: 'Classroom',
|
|
231
|
+
strategyId: 'CLASSROOM',
|
|
232
|
+
action: 'generated' as const,
|
|
233
|
+
score: 1.0,
|
|
234
|
+
reason: 'Classroom legacy getPendingReviews(), review',
|
|
235
|
+
},
|
|
236
|
+
],
|
|
237
|
+
})),
|
|
238
|
+
];
|
|
239
|
+
|
|
240
|
+
// Sort by score descending (all 1.0 in this case) and limit
|
|
241
|
+
return weighted.slice(0, limit);
|
|
242
|
+
}
|
|
192
243
|
}
|
|
193
244
|
|
|
194
245
|
/**
|
|
@@ -34,7 +34,13 @@ import { DataLayerResult } from '@db/core/types/db';
|
|
|
34
34
|
import { PouchError } from './types';
|
|
35
35
|
import CourseLookup from './courseLookupDB';
|
|
36
36
|
import { ContentNavigationStrategyData } from '@db/core/types/contentNavigationStrategy';
|
|
37
|
-
import { ContentNavigator, Navigators } from '@db/core/navigators';
|
|
37
|
+
import { ContentNavigator, Navigators, WeightedCard } from '@db/core/navigators';
|
|
38
|
+
import { Pipeline } from '@db/core/navigators/Pipeline';
|
|
39
|
+
import { PipelineAssembler } from '@db/core/navigators/PipelineAssembler';
|
|
40
|
+
import CompositeGenerator from '@db/core/navigators/CompositeGenerator';
|
|
41
|
+
import ELONavigator from '@db/core/navigators/elo';
|
|
42
|
+
import SRSNavigator from '@db/core/navigators/srs';
|
|
43
|
+
import { createEloDistanceFilter } from '@db/core/navigators/filters/eloDistance';
|
|
38
44
|
|
|
39
45
|
export class CoursesDB implements CoursesDBInterface {
|
|
40
46
|
_courseIDs: string[] | undefined;
|
|
@@ -225,7 +231,7 @@ export class CourseDB implements StudyContentSource, CourseDBInterface {
|
|
|
225
231
|
if (!doc.docType || !(doc.docType === DocType.CARD)) {
|
|
226
232
|
throw new Error(`failed to remove ${id} from course ${this.id}. id does not point to a card`);
|
|
227
233
|
}
|
|
228
|
-
|
|
234
|
+
|
|
229
235
|
// Remove card from all associated tags before deleting the card
|
|
230
236
|
try {
|
|
231
237
|
const appliedTags = await this.getAppliedTags(id);
|
|
@@ -235,7 +241,7 @@ export class CourseDB implements StudyContentSource, CourseDBInterface {
|
|
|
235
241
|
await this.removeTagFromCard(id, tagId);
|
|
236
242
|
})
|
|
237
243
|
);
|
|
238
|
-
|
|
244
|
+
|
|
239
245
|
// Log any individual tag cleanup failures
|
|
240
246
|
results.forEach((result, index) => {
|
|
241
247
|
if (result.status === 'rejected') {
|
|
@@ -247,7 +253,7 @@ export class CourseDB implements StudyContentSource, CourseDBInterface {
|
|
|
247
253
|
logger.error(`Error removing card ${id} from tags: ${error}`);
|
|
248
254
|
// Continue with card deletion even if tag cleanup fails
|
|
249
255
|
}
|
|
250
|
-
|
|
256
|
+
|
|
251
257
|
return this.db.remove(doc);
|
|
252
258
|
}
|
|
253
259
|
|
|
@@ -519,44 +525,97 @@ above:\n${above.rows.map((r) => `\t${r.id}-${r.key}\n`)}`;
|
|
|
519
525
|
return Promise.resolve();
|
|
520
526
|
}
|
|
521
527
|
|
|
522
|
-
|
|
528
|
+
/**
|
|
529
|
+
* Creates an instantiated navigator for this course.
|
|
530
|
+
*
|
|
531
|
+
* Handles multiple generators by wrapping them in CompositeGenerator.
|
|
532
|
+
* This is the preferred method for getting a ready-to-use navigator.
|
|
533
|
+
*
|
|
534
|
+
* @param user - User database interface
|
|
535
|
+
* @returns Instantiated ContentNavigator ready for use
|
|
536
|
+
*/
|
|
537
|
+
async createNavigator(user: UserDBInterface): Promise<ContentNavigator> {
|
|
523
538
|
try {
|
|
524
|
-
const
|
|
525
|
-
|
|
526
|
-
if (
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
return strategy;
|
|
533
|
-
}
|
|
534
|
-
} catch (e) {
|
|
535
|
-
logger.warn(
|
|
536
|
-
// @ts-expect-error tmp: defaultNavigationStrategyId property does not yet exist
|
|
537
|
-
`Failed to load strategy '${config.defaultNavigationStrategyId}' specified in course config. Falling back to ELO.`,
|
|
538
|
-
e
|
|
539
|
-
);
|
|
540
|
-
}
|
|
539
|
+
const allStrategies = await this.getAllNavigationStrategies();
|
|
540
|
+
|
|
541
|
+
if (allStrategies.length === 0) {
|
|
542
|
+
// No strategies configured: use default Pipeline(Composite(ELO, SRS), [eloDistanceFilter])
|
|
543
|
+
logger.debug(
|
|
544
|
+
'[courseDB] No strategy documents found, using default Pipeline(Composite(ELO, SRS), [eloDistanceFilter])'
|
|
545
|
+
);
|
|
546
|
+
return this.createDefaultPipeline(user);
|
|
541
547
|
}
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
548
|
+
|
|
549
|
+
// Use PipelineAssembler to build a Pipeline from strategy documents
|
|
550
|
+
const assembler = new PipelineAssembler();
|
|
551
|
+
const { pipeline, generatorStrategies, filterStrategies, warnings } =
|
|
552
|
+
await assembler.assemble({
|
|
553
|
+
strategies: allStrategies,
|
|
554
|
+
user,
|
|
555
|
+
course: this,
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
// Log any warnings from assembly
|
|
559
|
+
for (const warning of warnings) {
|
|
560
|
+
logger.warn(`[PipelineAssembler] ${warning}`);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (!pipeline) {
|
|
564
|
+
// Assembly failed - fall back to default
|
|
565
|
+
logger.debug('[courseDB] Pipeline assembly failed, using default pipeline');
|
|
566
|
+
return this.createDefaultPipeline(user);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
logger.debug(
|
|
570
|
+
`[courseDB] Using assembled pipeline with ${generatorStrategies.length} generator(s) and ${filterStrategies.length} filter(s)`
|
|
546
571
|
);
|
|
572
|
+
return pipeline;
|
|
573
|
+
} catch (e) {
|
|
574
|
+
logger.error(`[courseDB] Error creating navigator: ${e}`);
|
|
575
|
+
throw e;
|
|
547
576
|
}
|
|
577
|
+
}
|
|
548
578
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
_id: 'NAVIGATION_STRATEGY-ELO',
|
|
579
|
+
private makeDefaultEloStrategy(): ContentNavigationStrategyData {
|
|
580
|
+
return {
|
|
581
|
+
_id: 'NAVIGATION_STRATEGY-ELO-default',
|
|
552
582
|
docType: DocType.NAVIGATION_STRATEGY,
|
|
553
|
-
name: 'ELO',
|
|
554
|
-
description: 'ELO-based navigation strategy',
|
|
583
|
+
name: 'ELO (default)',
|
|
584
|
+
description: 'Default ELO-based navigation strategy for new cards',
|
|
555
585
|
implementingClass: Navigators.ELO,
|
|
556
586
|
course: this.id,
|
|
557
|
-
serializedData: '',
|
|
587
|
+
serializedData: '',
|
|
558
588
|
};
|
|
559
|
-
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
private makeDefaultSrsStrategy(): ContentNavigationStrategyData {
|
|
592
|
+
return {
|
|
593
|
+
_id: 'NAVIGATION_STRATEGY-SRS-default',
|
|
594
|
+
docType: DocType.NAVIGATION_STRATEGY,
|
|
595
|
+
name: 'SRS (default)',
|
|
596
|
+
description: 'Default SRS-based navigation strategy for reviews',
|
|
597
|
+
implementingClass: Navigators.SRS,
|
|
598
|
+
course: this.id,
|
|
599
|
+
serializedData: '',
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Creates the default navigation pipeline for courses with no configured strategies.
|
|
605
|
+
*
|
|
606
|
+
* Default: Pipeline(Composite(ELO, SRS), [eloDistanceFilter])
|
|
607
|
+
* - ELO generator: scores new cards by skill proximity
|
|
608
|
+
* - SRS generator: scores reviews by overdueness and interval recency
|
|
609
|
+
* - ELO distance filter: penalizes cards far from user's current level
|
|
610
|
+
*/
|
|
611
|
+
private createDefaultPipeline(user: UserDBInterface): Pipeline {
|
|
612
|
+
const eloNavigator = new ELONavigator(user, this, this.makeDefaultEloStrategy());
|
|
613
|
+
const srsNavigator = new SRSNavigator(user, this, this.makeDefaultSrsStrategy());
|
|
614
|
+
|
|
615
|
+
const compositeGenerator = new CompositeGenerator([eloNavigator, srsNavigator]);
|
|
616
|
+
const eloDistanceFilter = createEloDistanceFilter();
|
|
617
|
+
|
|
618
|
+
return new Pipeline(compositeGenerator, [eloDistanceFilter], user, this);
|
|
560
619
|
}
|
|
561
620
|
|
|
562
621
|
////////////////////////////////////
|
|
@@ -571,11 +630,10 @@ above:\n${above.rows.map((r) => `\t${r.id}-${r.key}\n`)}`;
|
|
|
571
630
|
const u = await this._getCurrentUser();
|
|
572
631
|
|
|
573
632
|
try {
|
|
574
|
-
const
|
|
575
|
-
const navigator = await ContentNavigator.create(u, this, strategy);
|
|
633
|
+
const navigator = await this.createNavigator(u);
|
|
576
634
|
return navigator.getNewCards(limit);
|
|
577
635
|
} catch (e) {
|
|
578
|
-
logger.error(`[courseDB] Error
|
|
636
|
+
logger.error(`[courseDB] Error in getNewCards: ${e}`);
|
|
579
637
|
throw e;
|
|
580
638
|
}
|
|
581
639
|
}
|
|
@@ -584,11 +642,31 @@ above:\n${above.rows.map((r) => `\t${r.id}-${r.key}\n`)}`;
|
|
|
584
642
|
const u = await this._getCurrentUser();
|
|
585
643
|
|
|
586
644
|
try {
|
|
587
|
-
const
|
|
588
|
-
const navigator = await ContentNavigator.create(u, this, strategy);
|
|
645
|
+
const navigator = await this.createNavigator(u);
|
|
589
646
|
return navigator.getPendingReviews();
|
|
590
647
|
} catch (e) {
|
|
591
|
-
logger.error(`[courseDB] Error
|
|
648
|
+
logger.error(`[courseDB] Error in getPendingReviews: ${e}`);
|
|
649
|
+
throw e;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Get cards with suitability scores for presentation.
|
|
655
|
+
*
|
|
656
|
+
* This is the PRIMARY API for content sources going forward. Delegates to the
|
|
657
|
+
* course's configured NavigationStrategy to get scored candidates.
|
|
658
|
+
*
|
|
659
|
+
* @param limit - Maximum number of cards to return
|
|
660
|
+
* @returns Cards sorted by score descending
|
|
661
|
+
*/
|
|
662
|
+
public async getWeightedCards(limit: number): Promise<WeightedCard[]> {
|
|
663
|
+
const u = await this._getCurrentUser();
|
|
664
|
+
|
|
665
|
+
try {
|
|
666
|
+
const navigator = await this.createNavigator(u);
|
|
667
|
+
return navigator.getWeightedCards(limit);
|
|
668
|
+
} catch (e) {
|
|
669
|
+
logger.error(`[courseDB] Error getting weighted cards: ${e}`);
|
|
592
670
|
throw e;
|
|
593
671
|
}
|
|
594
672
|
}
|
|
@@ -391,10 +391,6 @@ export class StaticCourseDB implements CourseDBInterface {
|
|
|
391
391
|
throw new Error('Cannot update navigation strategies in static mode');
|
|
392
392
|
}
|
|
393
393
|
|
|
394
|
-
async surfaceNavigationStrategy(): Promise<ContentNavigationStrategyData> {
|
|
395
|
-
return this.getNavigationStrategy('ELO');
|
|
396
|
-
}
|
|
397
|
-
|
|
398
394
|
// Study Content Source implementation
|
|
399
395
|
async getPendingReviews(): Promise<(StudySessionReviewItem & ScheduledCard)[]> {
|
|
400
396
|
// In static mode, reviews would be stored locally
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
import { CardRecord, CardHistory, CourseRegistrationDoc } from '@db/core';
|
|
16
16
|
import { Loggable } from '@db/util';
|
|
17
17
|
import { ScheduledCard } from '@db/core/types/user';
|
|
18
|
+
import { WeightedCard, getCardOrigin } from '@db/core/navigators';
|
|
18
19
|
|
|
19
20
|
function randomInt(min: number, max: number): number {
|
|
20
21
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
@@ -181,7 +182,15 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
181
182
|
|
|
182
183
|
public async prepareSession() {
|
|
183
184
|
try {
|
|
184
|
-
|
|
185
|
+
// Use new getWeightedCards API if available, fall back to legacy methods
|
|
186
|
+
const hasWeightedCards = this.sources.some((s) => typeof s.getWeightedCards === 'function');
|
|
187
|
+
|
|
188
|
+
if (hasWeightedCards) {
|
|
189
|
+
await this.getWeightedContent();
|
|
190
|
+
} else {
|
|
191
|
+
// Legacy path: separate calls for reviews and new cards
|
|
192
|
+
await Promise.all([this.getScheduledReviews(), this.getNewCards()]);
|
|
193
|
+
}
|
|
185
194
|
} catch (e) {
|
|
186
195
|
this.error('Error preparing study session:', e);
|
|
187
196
|
}
|
|
@@ -213,6 +222,11 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
213
222
|
* Used by SessionControllerDebug component for runtime inspection.
|
|
214
223
|
*/
|
|
215
224
|
public getDebugInfo() {
|
|
225
|
+
// Check if sources support weighted cards
|
|
226
|
+
const supportsWeightedCards = this.sources.some(
|
|
227
|
+
(s) => typeof s.getWeightedCards === 'function'
|
|
228
|
+
);
|
|
229
|
+
|
|
216
230
|
const extractQueueItems = (queue: ItemQueue<any>, limit: number = 10) => {
|
|
217
231
|
const items = [];
|
|
218
232
|
for (let i = 0; i < Math.min(queue.length, limit); i++) {
|
|
@@ -235,6 +249,12 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
235
249
|
};
|
|
236
250
|
|
|
237
251
|
return {
|
|
252
|
+
api: {
|
|
253
|
+
mode: supportsWeightedCards ? 'weighted' : 'legacy',
|
|
254
|
+
description: supportsWeightedCards
|
|
255
|
+
? 'Using getWeightedCards() API with scored candidates'
|
|
256
|
+
: 'Using legacy getNewCards()/getPendingReviews() API',
|
|
257
|
+
},
|
|
238
258
|
reviewQueue: {
|
|
239
259
|
length: this.reviewQ.length,
|
|
240
260
|
dequeueCount: this.reviewQ.dequeueCount,
|
|
@@ -258,6 +278,130 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
258
278
|
};
|
|
259
279
|
}
|
|
260
280
|
|
|
281
|
+
/**
|
|
282
|
+
* Fetch content using the new getWeightedCards API.
|
|
283
|
+
*
|
|
284
|
+
* This method uses getWeightedCards() to get scored candidates, then uses the
|
|
285
|
+
* scores to determine ordering. For reviews, we still need the full ScheduledCard
|
|
286
|
+
* data from getPendingReviews(), so we fetch both and use scores for ordering.
|
|
287
|
+
*
|
|
288
|
+
* The hybrid approach:
|
|
289
|
+
* 1. Fetch weighted cards to get scoring/ordering information
|
|
290
|
+
* 2. Fetch full review data via legacy getPendingReviews()
|
|
291
|
+
* 3. Order reviews by their weighted scores
|
|
292
|
+
* 4. Add new cards ordered by their weighted scores
|
|
293
|
+
*/
|
|
294
|
+
private async getWeightedContent() {
|
|
295
|
+
const limit = 20; // Initial batch size per source
|
|
296
|
+
|
|
297
|
+
// Collect weighted cards for scoring, and full review data for queue population
|
|
298
|
+
const allWeighted: WeightedCard[] = [];
|
|
299
|
+
const allReviews: (StudySessionReviewItem & ScheduledCard)[] = [];
|
|
300
|
+
const allNewCards: StudySessionNewItem[] = [];
|
|
301
|
+
|
|
302
|
+
for (const source of this.sources) {
|
|
303
|
+
try {
|
|
304
|
+
// Always fetch full review data (we need ScheduledCard fields)
|
|
305
|
+
const reviews = await source.getPendingReviews().catch((error) => {
|
|
306
|
+
this.error(`Failed to get reviews for source:`, error);
|
|
307
|
+
return [];
|
|
308
|
+
});
|
|
309
|
+
allReviews.push(...reviews);
|
|
310
|
+
|
|
311
|
+
// Fetch weighted cards for scoring if available
|
|
312
|
+
if (typeof source.getWeightedCards === 'function') {
|
|
313
|
+
const weighted = await source.getWeightedCards(limit);
|
|
314
|
+
allWeighted.push(...weighted);
|
|
315
|
+
} else {
|
|
316
|
+
// Fallback: fetch new cards directly and assign score=1.0
|
|
317
|
+
const newCards = await source.getNewCards(limit);
|
|
318
|
+
allNewCards.push(...newCards);
|
|
319
|
+
|
|
320
|
+
// Create pseudo-weighted entries for ordering
|
|
321
|
+
allWeighted.push(
|
|
322
|
+
...newCards.map((c) => ({
|
|
323
|
+
cardId: c.cardID,
|
|
324
|
+
courseId: c.courseID,
|
|
325
|
+
score: 1.0,
|
|
326
|
+
provenance: [
|
|
327
|
+
{
|
|
328
|
+
strategy: 'legacy',
|
|
329
|
+
strategyName: 'Legacy Fallback',
|
|
330
|
+
strategyId: 'legacy-fallback',
|
|
331
|
+
action: 'generated' as const,
|
|
332
|
+
score: 1.0,
|
|
333
|
+
reason: 'Fallback to legacy getNewCards(), new card',
|
|
334
|
+
},
|
|
335
|
+
],
|
|
336
|
+
})),
|
|
337
|
+
...reviews.map((r) => ({
|
|
338
|
+
cardId: r.cardID,
|
|
339
|
+
courseId: r.courseID,
|
|
340
|
+
score: 1.0,
|
|
341
|
+
provenance: [
|
|
342
|
+
{
|
|
343
|
+
strategy: 'legacy',
|
|
344
|
+
strategyName: 'Legacy Fallback',
|
|
345
|
+
strategyId: 'legacy-fallback',
|
|
346
|
+
action: 'generated' as const,
|
|
347
|
+
score: 1.0,
|
|
348
|
+
reason: 'Fallback to legacy getPendingReviews(), review',
|
|
349
|
+
},
|
|
350
|
+
],
|
|
351
|
+
}))
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
} catch (error) {
|
|
355
|
+
this.error(`Failed to get content from source:`, error);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Build a score lookup map from weighted cards
|
|
360
|
+
const scoreMap = new Map<string, number>();
|
|
361
|
+
for (const w of allWeighted) {
|
|
362
|
+
const key = `${w.courseId}::${w.cardId}`;
|
|
363
|
+
scoreMap.set(key, w.score);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Sort reviews by score (from weighted cards) descending
|
|
367
|
+
const scoredReviews = allReviews.map((r) => ({
|
|
368
|
+
review: r,
|
|
369
|
+
score: scoreMap.get(`${r.courseID}::${r.cardID}`) ?? 1.0,
|
|
370
|
+
}));
|
|
371
|
+
scoredReviews.sort((a, b) => b.score - a.score);
|
|
372
|
+
|
|
373
|
+
// Add reviews to queue in score order
|
|
374
|
+
let report = 'Weighted content session created with:\n';
|
|
375
|
+
for (const { review, score } of scoredReviews) {
|
|
376
|
+
this.reviewQ.add(review, review.cardID);
|
|
377
|
+
report += `Review: ${review.courseID}::${review.cardID} (score: ${score.toFixed(2)})\n`;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Get new cards from weighted list (filter out reviews)
|
|
381
|
+
const newCardWeighted = allWeighted
|
|
382
|
+
.filter((w) => getCardOrigin(w) === 'new')
|
|
383
|
+
.sort((a, b) => b.score - a.score);
|
|
384
|
+
|
|
385
|
+
// Add new cards to queue in score order
|
|
386
|
+
for (const card of newCardWeighted) {
|
|
387
|
+
const newItem: StudySessionNewItem = {
|
|
388
|
+
cardID: card.cardId,
|
|
389
|
+
courseID: card.courseId,
|
|
390
|
+
contentSourceType: 'course',
|
|
391
|
+
contentSourceID: card.courseId,
|
|
392
|
+
status: 'new',
|
|
393
|
+
};
|
|
394
|
+
this.newQ.add(newItem, card.cardId);
|
|
395
|
+
report += `New: ${card.courseId}::${card.cardId} (score: ${card.score.toFixed(2)})\n`;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
this.log(report);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* @deprecated Use getWeightedContent() instead. This method is kept for backward
|
|
403
|
+
* compatibility with sources that don't support getWeightedCards().
|
|
404
|
+
*/
|
|
261
405
|
private async getScheduledReviews() {
|
|
262
406
|
const reviews = await Promise.all(
|
|
263
407
|
this.sources.map((c) =>
|
|
@@ -289,6 +433,10 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
289
433
|
this.log(report);
|
|
290
434
|
}
|
|
291
435
|
|
|
436
|
+
/**
|
|
437
|
+
* @deprecated Use getWeightedContent() instead. This method is kept for backward
|
|
438
|
+
* compatibility with sources that don't support getWeightedCards().
|
|
439
|
+
*/
|
|
292
440
|
private async getNewCards(n: number = 10) {
|
|
293
441
|
const perCourse = Math.ceil(n / this.sources.length);
|
|
294
442
|
const newContent = await Promise.all(this.sources.map((c) => c.getNewCards(perCourse)));
|