@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.
Files changed (80) hide show
  1. package/dist/{userDB-DNa0XPtn.d.ts → classroomDB-BgfrVb8d.d.ts} +357 -103
  2. package/dist/{userDB-BqwxtJ_7.d.mts → classroomDB-CTOenngH.d.cts} +358 -104
  3. package/dist/core/index.d.cts +230 -0
  4. package/dist/core/index.d.ts +161 -23
  5. package/dist/core/index.js +1964 -154
  6. package/dist/core/index.js.map +1 -1
  7. package/dist/core/index.mjs +1925 -121
  8. package/dist/core/index.mjs.map +1 -1
  9. package/dist/{dataLayerProvider-BV5iZqt_.d.ts → dataLayerProvider-CZxC9GtB.d.ts} +1 -1
  10. package/dist/{dataLayerProvider-VlngD19_.d.mts → dataLayerProvider-D6PoCwS6.d.cts} +1 -1
  11. package/dist/impl/couch/{index.d.mts → index.d.cts} +46 -5
  12. package/dist/impl/couch/index.d.ts +44 -3
  13. package/dist/impl/couch/index.js +1971 -171
  14. package/dist/impl/couch/index.js.map +1 -1
  15. package/dist/impl/couch/index.mjs +1933 -134
  16. package/dist/impl/couch/index.mjs.map +1 -1
  17. package/dist/impl/static/{index.d.mts → index.d.cts} +5 -6
  18. package/dist/impl/static/index.d.ts +2 -3
  19. package/dist/impl/static/index.js +1614 -119
  20. package/dist/impl/static/index.js.map +1 -1
  21. package/dist/impl/static/index.mjs +1585 -92
  22. package/dist/impl/static/index.mjs.map +1 -1
  23. package/dist/{index-Bmll7Xse.d.mts → index-D-Fa4Smt.d.cts} +1 -1
  24. package/dist/{index.d.mts → index.d.cts} +97 -13
  25. package/dist/index.d.ts +90 -6
  26. package/dist/index.js +2085 -153
  27. package/dist/index.js.map +1 -1
  28. package/dist/index.mjs +2031 -106
  29. package/dist/index.mjs.map +1 -1
  30. package/dist/pouch/index.js +3 -3
  31. package/dist/{types-Dbp5DaRR.d.mts → types-CzPDLAK6.d.cts} +1 -1
  32. package/dist/util/packer/{index.d.mts → index.d.cts} +3 -3
  33. package/dist/util/packer/index.js.map +1 -1
  34. package/dist/util/packer/index.mjs.map +1 -1
  35. package/docs/brainstorm-navigation-paradigm.md +369 -0
  36. package/docs/navigators-architecture.md +265 -0
  37. package/docs/todo-evolutionary-orchestration.md +310 -0
  38. package/docs/todo-nominal-tag-types.md +121 -0
  39. package/docs/todo-pipeline-optimization.md +117 -0
  40. package/docs/todo-strategy-authoring.md +401 -0
  41. package/docs/todo-strategy-state-storage.md +278 -0
  42. package/eslint.config.mjs +1 -1
  43. package/package.json +9 -4
  44. package/src/core/interfaces/contentSource.ts +88 -4
  45. package/src/core/interfaces/navigationStrategyManager.ts +0 -5
  46. package/src/core/navigators/CompositeGenerator.ts +268 -0
  47. package/src/core/navigators/Pipeline.ts +205 -0
  48. package/src/core/navigators/PipelineAssembler.ts +194 -0
  49. package/src/core/navigators/elo.ts +104 -15
  50. package/src/core/navigators/filters/eloDistance.ts +132 -0
  51. package/src/core/navigators/filters/index.ts +6 -0
  52. package/src/core/navigators/filters/types.ts +115 -0
  53. package/src/core/navigators/generators/index.ts +2 -0
  54. package/src/core/navigators/generators/types.ts +107 -0
  55. package/src/core/navigators/hardcodedOrder.ts +111 -12
  56. package/src/core/navigators/hierarchyDefinition.ts +266 -0
  57. package/src/core/navigators/index.ts +345 -3
  58. package/src/core/navigators/interferenceMitigator.ts +367 -0
  59. package/src/core/navigators/relativePriority.ts +267 -0
  60. package/src/core/navigators/srs.ts +195 -0
  61. package/src/impl/couch/classroomDB.ts +51 -0
  62. package/src/impl/couch/courseDB.ts +117 -39
  63. package/src/impl/static/courseDB.ts +0 -4
  64. package/src/study/SessionController.ts +149 -1
  65. package/src/study/TagFilteredContentSource.ts +255 -0
  66. package/src/study/index.ts +1 -0
  67. package/src/util/dataDirectory.test.ts +51 -22
  68. package/src/util/logger.ts +0 -1
  69. package/tests/core/navigators/CompositeGenerator.test.ts +455 -0
  70. package/tests/core/navigators/Pipeline.test.ts +405 -0
  71. package/tests/core/navigators/PipelineAssembler.test.ts +351 -0
  72. package/tests/core/navigators/SRSNavigator.test.ts +344 -0
  73. package/tests/core/navigators/eloDistanceFilter.test.ts +192 -0
  74. package/tests/core/navigators/navigators.test.ts +710 -0
  75. package/tsconfig.json +1 -1
  76. package/vitest.config.ts +29 -0
  77. package/dist/core/index.d.mts +0 -92
  78. /package/dist/{SyncStrategy-CyATpyLQ.d.mts → SyncStrategy-CyATpyLQ.d.cts} +0 -0
  79. /package/dist/pouch/{index.d.mts → index.d.cts} +0 -0
  80. /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
- async surfaceNavigationStrategy(): Promise<ContentNavigationStrategyData> {
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 config = await this.getCourseConfig();
525
- // @ts-expect-error tmp: defaultNavigationStrategyId property does not yet exist
526
- if (config.defaultNavigationStrategyId) {
527
- try {
528
- // @ts-expect-error tmp: defaultNavigationStrategyId property does not yet exist
529
- const strategy = await this.getNavigationStrategy(config.defaultNavigationStrategyId);
530
- if (strategy) {
531
- logger.debug(`Surfacing strategy ${strategy.name} from course config`);
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
- } catch (e) {
543
- logger.warn(
544
- 'Could not retrieve course config to determine navigation strategy. Falling back to ELO.',
545
- e
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
- logger.warn(`Returning hard-coded default ELO navigator`);
550
- const ret: ContentNavigationStrategyData = {
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: '', // serde is a noop for ELO navigator.
587
+ serializedData: '',
558
588
  };
559
- return Promise.resolve(ret);
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 strategy = await this.surfaceNavigationStrategy();
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 surfacing a NavigationStrategy: ${e}`);
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 strategy = await this.surfaceNavigationStrategy();
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 surfacing a NavigationStrategy: ${e}`);
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
- await Promise.all([this.getScheduledReviews(), this.getNewCards()]);
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)));