@vue-skuilder/db 0.1.17 → 0.1.20

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 (92) hide show
  1. package/dist/{userDB-BqwxtJ_7.d.mts → classroomDB-CZdMBiTU.d.ts} +427 -104
  2. package/dist/{userDB-DNa0XPtn.d.ts → classroomDB-PxDZTky3.d.cts} +427 -104
  3. package/dist/core/index.d.cts +304 -0
  4. package/dist/core/index.d.ts +237 -25
  5. package/dist/core/index.js +2246 -118
  6. package/dist/core/index.js.map +1 -1
  7. package/dist/core/index.mjs +2235 -114
  8. package/dist/core/index.mjs.map +1 -1
  9. package/dist/{dataLayerProvider-VlngD19_.d.mts → dataLayerProvider-D0MoZMjH.d.cts} +1 -1
  10. package/dist/{dataLayerProvider-BV5iZqt_.d.ts → dataLayerProvider-D8o6ZnKW.d.ts} +1 -1
  11. package/dist/impl/couch/{index.d.mts → index.d.cts} +47 -5
  12. package/dist/impl/couch/index.d.ts +46 -4
  13. package/dist/impl/couch/index.js +2250 -134
  14. package/dist/impl/couch/index.js.map +1 -1
  15. package/dist/impl/couch/index.mjs +2212 -97
  16. package/dist/impl/couch/index.mjs.map +1 -1
  17. package/dist/impl/static/{index.d.mts → index.d.cts} +6 -6
  18. package/dist/impl/static/index.d.ts +5 -5
  19. package/dist/impl/static/index.js +1950 -143
  20. package/dist/impl/static/index.js.map +1 -1
  21. package/dist/impl/static/index.mjs +1922 -117
  22. package/dist/impl/static/index.mjs.map +1 -1
  23. package/dist/{index-Bmll7Xse.d.mts → index-B_j6u5E4.d.cts} +1 -1
  24. package/dist/{index-CD8BZz2k.d.ts → index-Dj0SEgk3.d.ts} +1 -1
  25. package/dist/{index.d.mts → index.d.cts} +97 -13
  26. package/dist/index.d.ts +96 -12
  27. package/dist/index.js +2439 -180
  28. package/dist/index.js.map +1 -1
  29. package/dist/index.mjs +2386 -135
  30. package/dist/index.mjs.map +1 -1
  31. package/dist/pouch/index.js +3 -3
  32. package/dist/{types-Dbp5DaRR.d.mts → types-Bn0itutr.d.cts} +1 -1
  33. package/dist/{types-CewsN87z.d.ts → types-DQaXnuoc.d.ts} +1 -1
  34. package/dist/{types-legacy-6ettoclI.d.ts → types-legacy-DDY4N-Uq.d.cts} +3 -1
  35. package/dist/{types-legacy-6ettoclI.d.mts → types-legacy-DDY4N-Uq.d.ts} +3 -1
  36. package/dist/util/packer/{index.d.mts → index.d.cts} +3 -3
  37. package/dist/util/packer/index.d.ts +3 -3
  38. package/dist/util/packer/index.js.map +1 -1
  39. package/dist/util/packer/index.mjs.map +1 -1
  40. package/docs/brainstorm-navigation-paradigm.md +369 -0
  41. package/docs/navigators-architecture.md +370 -0
  42. package/docs/todo-evolutionary-orchestration.md +310 -0
  43. package/docs/todo-nominal-tag-types.md +121 -0
  44. package/docs/todo-strategy-authoring.md +401 -0
  45. package/eslint.config.mjs +1 -1
  46. package/package.json +9 -4
  47. package/src/core/index.ts +1 -0
  48. package/src/core/interfaces/contentSource.ts +88 -4
  49. package/src/core/interfaces/courseDB.ts +13 -0
  50. package/src/core/interfaces/navigationStrategyManager.ts +0 -5
  51. package/src/core/interfaces/userDB.ts +32 -0
  52. package/src/core/navigators/CompositeGenerator.ts +268 -0
  53. package/src/core/navigators/Pipeline.ts +318 -0
  54. package/src/core/navigators/PipelineAssembler.ts +194 -0
  55. package/src/core/navigators/elo.ts +104 -15
  56. package/src/core/navigators/filters/eloDistance.ts +132 -0
  57. package/src/core/navigators/filters/index.ts +9 -0
  58. package/src/core/navigators/filters/types.ts +115 -0
  59. package/src/core/navigators/filters/userTagPreference.ts +232 -0
  60. package/src/core/navigators/generators/index.ts +2 -0
  61. package/src/core/navigators/generators/types.ts +107 -0
  62. package/src/core/navigators/hardcodedOrder.ts +111 -12
  63. package/src/core/navigators/hierarchyDefinition.ts +266 -0
  64. package/src/core/navigators/index.ts +404 -3
  65. package/src/core/navigators/inferredPreference.ts +107 -0
  66. package/src/core/navigators/interferenceMitigator.ts +355 -0
  67. package/src/core/navigators/relativePriority.ts +255 -0
  68. package/src/core/navigators/srs.ts +195 -0
  69. package/src/core/navigators/userGoal.ts +136 -0
  70. package/src/core/types/strategyState.ts +84 -0
  71. package/src/core/types/types-legacy.ts +2 -0
  72. package/src/impl/common/BaseUserDB.ts +74 -7
  73. package/src/impl/couch/adminDB.ts +1 -2
  74. package/src/impl/couch/classroomDB.ts +51 -0
  75. package/src/impl/couch/courseDB.ts +147 -49
  76. package/src/impl/static/courseDB.ts +11 -4
  77. package/src/study/SessionController.ts +149 -1
  78. package/src/study/TagFilteredContentSource.ts +255 -0
  79. package/src/study/index.ts +1 -0
  80. package/src/util/dataDirectory.test.ts +51 -22
  81. package/src/util/logger.ts +0 -1
  82. package/tests/core/navigators/CompositeGenerator.test.ts +455 -0
  83. package/tests/core/navigators/Pipeline.test.ts +406 -0
  84. package/tests/core/navigators/PipelineAssembler.test.ts +351 -0
  85. package/tests/core/navigators/SRSNavigator.test.ts +344 -0
  86. package/tests/core/navigators/eloDistanceFilter.test.ts +192 -0
  87. package/tests/core/navigators/navigators.test.ts +710 -0
  88. package/tsconfig.json +1 -1
  89. package/vitest.config.ts +29 -0
  90. package/dist/core/index.d.mts +0 -92
  91. /package/dist/{SyncStrategy-CyATpyLQ.d.mts → SyncStrategy-CyATpyLQ.d.cts} +0 -0
  92. /package/dist/pouch/{index.d.mts → index.d.cts} +0 -0
@@ -1,4 +1,4 @@
1
- import { DocType, DocTypePrefixes } from '@db/core';
1
+ import { DocType, DocTypePrefixes, StrategyStateDoc, buildStrategyStateId } from '@db/core';
2
2
  import { getCardHistoryID } from '@db/core/util';
3
3
  import { CourseElo, Status } from '@vue-skuilder/common';
4
4
  import moment, { Moment } from 'moment';
@@ -1046,6 +1046,61 @@ Currently logged-in as ${this._username}.`
1046
1046
  public async updateUserElo(courseId: string, elo: CourseElo): Promise<PouchDB.Core.Response> {
1047
1047
  return updateUserElo(this._username, courseId, elo);
1048
1048
  }
1049
+
1050
+ public async getStrategyState<T>(courseId: string, strategyKey: string): Promise<T | null> {
1051
+ const docId = buildStrategyStateId(courseId, strategyKey);
1052
+ try {
1053
+ const doc = await this.localDB.get<StrategyStateDoc<T>>(docId);
1054
+ return doc.data;
1055
+ } catch (e) {
1056
+ const err = e as PouchError;
1057
+ if (err.status === 404) {
1058
+ return null;
1059
+ }
1060
+ throw e;
1061
+ }
1062
+ }
1063
+
1064
+ public async putStrategyState<T>(courseId: string, strategyKey: string, data: T): Promise<void> {
1065
+ const docId = buildStrategyStateId(courseId, strategyKey);
1066
+ let existingRev: string | undefined;
1067
+
1068
+ try {
1069
+ const existing = await this.localDB.get<StrategyStateDoc<T>>(docId);
1070
+ existingRev = existing._rev;
1071
+ } catch (e) {
1072
+ const err = e as PouchError;
1073
+ if (err.status !== 404) {
1074
+ throw e;
1075
+ }
1076
+ }
1077
+
1078
+ const doc: StrategyStateDoc<T> = {
1079
+ _id: docId,
1080
+ _rev: existingRev,
1081
+ docType: DocType.STRATEGY_STATE,
1082
+ courseId,
1083
+ strategyKey,
1084
+ data,
1085
+ updatedAt: new Date().toISOString(),
1086
+ };
1087
+
1088
+ await this.localDB.put(doc);
1089
+ }
1090
+
1091
+ public async deleteStrategyState(courseId: string, strategyKey: string): Promise<void> {
1092
+ const docId = buildStrategyStateId(courseId, strategyKey);
1093
+ try {
1094
+ const doc = await this.localDB.get(docId);
1095
+ await this.localDB.remove(doc);
1096
+ } catch (e) {
1097
+ const err = e as PouchError;
1098
+ if (err.status === 404) {
1099
+ return;
1100
+ }
1101
+ throw e;
1102
+ }
1103
+ }
1049
1104
  }
1050
1105
 
1051
1106
  export function accomodateGuest(): {
@@ -1056,7 +1111,9 @@ export function accomodateGuest(): {
1056
1111
 
1057
1112
  // Check if localStorage is available (browser environment)
1058
1113
  if (typeof localStorage === 'undefined') {
1059
- logger.log('[funnel] localStorage not available (Node.js environment), returning default guest');
1114
+ logger.log(
1115
+ '[funnel] localStorage not available (Node.js environment), returning default guest'
1116
+ );
1060
1117
  return {
1061
1118
  username: GuestUsername + 'nodejs-test',
1062
1119
  firstVisit: true,
@@ -1125,11 +1182,21 @@ export function accomodateGuest(): {
1125
1182
  bytes[8] = (bytes[8] & 0x3f) | 0x80; // Variant 10
1126
1183
 
1127
1184
  const uuid = [
1128
- Array.from(bytes.slice(0, 4)).map(b => b.toString(16).padStart(2, '0')).join(''),
1129
- Array.from(bytes.slice(4, 6)).map(b => b.toString(16).padStart(2, '0')).join(''),
1130
- Array.from(bytes.slice(6, 8)).map(b => b.toString(16).padStart(2, '0')).join(''),
1131
- Array.from(bytes.slice(8, 10)).map(b => b.toString(16).padStart(2, '0')).join(''),
1132
- Array.from(bytes.slice(10, 16)).map(b => b.toString(16).padStart(2, '0')).join(''),
1185
+ Array.from(bytes.slice(0, 4))
1186
+ .map((b) => b.toString(16).padStart(2, '0'))
1187
+ .join(''),
1188
+ Array.from(bytes.slice(4, 6))
1189
+ .map((b) => b.toString(16).padStart(2, '0'))
1190
+ .join(''),
1191
+ Array.from(bytes.slice(6, 8))
1192
+ .map((b) => b.toString(16).padStart(2, '0'))
1193
+ .join(''),
1194
+ Array.from(bytes.slice(8, 10))
1195
+ .map((b) => b.toString(16).padStart(2, '0'))
1196
+ .join(''),
1197
+ Array.from(bytes.slice(10, 16))
1198
+ .map((b) => b.toString(16).padStart(2, '0'))
1199
+ .join(''),
1133
1200
  ].join('-');
1134
1201
 
1135
1202
  logger.log('[funnel] Generated UUID using crypto.getRandomValues():', uuid);
@@ -80,8 +80,7 @@ export class AdminDB implements AdminDBInterface {
80
80
  }
81
81
  }
82
82
 
83
- const dbs = await Promise.all(promisedCRDbs);
84
- return dbs.map((db) => {
83
+ return promisedCRDbs.map((db) => {
85
84
  return {
86
85
  ...db.getConfig(),
87
86
  _id: db._id,
@@ -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
 
@@ -264,16 +270,6 @@ export class CourseDB implements StudyContentSource, CourseDBInterface {
264
270
  }
265
271
  });
266
272
 
267
- await Promise.all(
268
- cards.rows.map((r) => {
269
- return async () => {
270
- if (isSuccessRow(r)) {
271
- ret[r.id] = r.doc!.id_displayable_data;
272
- }
273
- };
274
- })
275
- );
276
-
277
273
  return ret;
278
274
  }
279
275
 
@@ -373,6 +369,36 @@ above:\n${above.rows.map((r) => `\t${r.id}-${r.key}\n`)}`;
373
369
  }
374
370
  }
375
371
 
372
+ async getAppliedTagsBatch(cardIds: string[]): Promise<Map<string, string[]>> {
373
+ if (cardIds.length === 0) {
374
+ return new Map();
375
+ }
376
+
377
+ const db = getCourseDB(this.id);
378
+ const result = await db.query<TagStub>('getTags', {
379
+ keys: cardIds,
380
+ include_docs: false,
381
+ });
382
+
383
+ const tagsByCard = new Map<string, string[]>();
384
+
385
+ // Initialize all requested cards with empty arrays
386
+ for (const cardId of cardIds) {
387
+ tagsByCard.set(cardId, []);
388
+ }
389
+
390
+ // Populate from query results
391
+ for (const row of result.rows) {
392
+ const cardId = row.key as string;
393
+ const tagName = row.value?.name;
394
+ if (tagName && tagsByCard.has(cardId)) {
395
+ tagsByCard.get(cardId)!.push(tagName);
396
+ }
397
+ }
398
+
399
+ return tagsByCard;
400
+ }
401
+
376
402
  async addTagToCard(
377
403
  cardId: string,
378
404
  tagId: string,
@@ -519,44 +545,97 @@ above:\n${above.rows.map((r) => `\t${r.id}-${r.key}\n`)}`;
519
545
  return Promise.resolve();
520
546
  }
521
547
 
522
- async surfaceNavigationStrategy(): Promise<ContentNavigationStrategyData> {
548
+ /**
549
+ * Creates an instantiated navigator for this course.
550
+ *
551
+ * Handles multiple generators by wrapping them in CompositeGenerator.
552
+ * This is the preferred method for getting a ready-to-use navigator.
553
+ *
554
+ * @param user - User database interface
555
+ * @returns Instantiated ContentNavigator ready for use
556
+ */
557
+ async createNavigator(user: UserDBInterface): Promise<ContentNavigator> {
523
558
  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
- }
559
+ const allStrategies = await this.getAllNavigationStrategies();
560
+
561
+ if (allStrategies.length === 0) {
562
+ // No strategies configured: use default Pipeline(Composite(ELO, SRS), [eloDistanceFilter])
563
+ logger.debug(
564
+ '[courseDB] No strategy documents found, using default Pipeline(Composite(ELO, SRS), [eloDistanceFilter])'
565
+ );
566
+ return this.createDefaultPipeline(user);
541
567
  }
542
- } catch (e) {
543
- logger.warn(
544
- 'Could not retrieve course config to determine navigation strategy. Falling back to ELO.',
545
- e
568
+
569
+ // Use PipelineAssembler to build a Pipeline from strategy documents
570
+ const assembler = new PipelineAssembler();
571
+ const { pipeline, generatorStrategies, filterStrategies, warnings } =
572
+ await assembler.assemble({
573
+ strategies: allStrategies,
574
+ user,
575
+ course: this,
576
+ });
577
+
578
+ // Log any warnings from assembly
579
+ for (const warning of warnings) {
580
+ logger.warn(`[PipelineAssembler] ${warning}`);
581
+ }
582
+
583
+ if (!pipeline) {
584
+ // Assembly failed - fall back to default
585
+ logger.debug('[courseDB] Pipeline assembly failed, using default pipeline');
586
+ return this.createDefaultPipeline(user);
587
+ }
588
+
589
+ logger.debug(
590
+ `[courseDB] Using assembled pipeline with ${generatorStrategies.length} generator(s) and ${filterStrategies.length} filter(s)`
546
591
  );
592
+ return pipeline;
593
+ } catch (e) {
594
+ logger.error(`[courseDB] Error creating navigator: ${e}`);
595
+ throw e;
547
596
  }
597
+ }
548
598
 
549
- logger.warn(`Returning hard-coded default ELO navigator`);
550
- const ret: ContentNavigationStrategyData = {
551
- _id: 'NAVIGATION_STRATEGY-ELO',
599
+ private makeDefaultEloStrategy(): ContentNavigationStrategyData {
600
+ return {
601
+ _id: 'NAVIGATION_STRATEGY-ELO-default',
552
602
  docType: DocType.NAVIGATION_STRATEGY,
553
- name: 'ELO',
554
- description: 'ELO-based navigation strategy',
603
+ name: 'ELO (default)',
604
+ description: 'Default ELO-based navigation strategy for new cards',
555
605
  implementingClass: Navigators.ELO,
556
606
  course: this.id,
557
- serializedData: '', // serde is a noop for ELO navigator.
607
+ serializedData: '',
558
608
  };
559
- return Promise.resolve(ret);
609
+ }
610
+
611
+ private makeDefaultSrsStrategy(): ContentNavigationStrategyData {
612
+ return {
613
+ _id: 'NAVIGATION_STRATEGY-SRS-default',
614
+ docType: DocType.NAVIGATION_STRATEGY,
615
+ name: 'SRS (default)',
616
+ description: 'Default SRS-based navigation strategy for reviews',
617
+ implementingClass: Navigators.SRS,
618
+ course: this.id,
619
+ serializedData: '',
620
+ };
621
+ }
622
+
623
+ /**
624
+ * Creates the default navigation pipeline for courses with no configured strategies.
625
+ *
626
+ * Default: Pipeline(Composite(ELO, SRS), [eloDistanceFilter])
627
+ * - ELO generator: scores new cards by skill proximity
628
+ * - SRS generator: scores reviews by overdueness and interval recency
629
+ * - ELO distance filter: penalizes cards far from user's current level
630
+ */
631
+ private createDefaultPipeline(user: UserDBInterface): Pipeline {
632
+ const eloNavigator = new ELONavigator(user, this, this.makeDefaultEloStrategy());
633
+ const srsNavigator = new SRSNavigator(user, this, this.makeDefaultSrsStrategy());
634
+
635
+ const compositeGenerator = new CompositeGenerator([eloNavigator, srsNavigator]);
636
+ const eloDistanceFilter = createEloDistanceFilter();
637
+
638
+ return new Pipeline(compositeGenerator, [eloDistanceFilter], user, this);
560
639
  }
561
640
 
562
641
  ////////////////////////////////////
@@ -571,11 +650,10 @@ above:\n${above.rows.map((r) => `\t${r.id}-${r.key}\n`)}`;
571
650
  const u = await this._getCurrentUser();
572
651
 
573
652
  try {
574
- const strategy = await this.surfaceNavigationStrategy();
575
- const navigator = await ContentNavigator.create(u, this, strategy);
653
+ const navigator = await this.createNavigator(u);
576
654
  return navigator.getNewCards(limit);
577
655
  } catch (e) {
578
- logger.error(`[courseDB] Error surfacing a NavigationStrategy: ${e}`);
656
+ logger.error(`[courseDB] Error in getNewCards: ${e}`);
579
657
  throw e;
580
658
  }
581
659
  }
@@ -584,11 +662,31 @@ above:\n${above.rows.map((r) => `\t${r.id}-${r.key}\n`)}`;
584
662
  const u = await this._getCurrentUser();
585
663
 
586
664
  try {
587
- const strategy = await this.surfaceNavigationStrategy();
588
- const navigator = await ContentNavigator.create(u, this, strategy);
665
+ const navigator = await this.createNavigator(u);
589
666
  return navigator.getPendingReviews();
590
667
  } catch (e) {
591
- logger.error(`[courseDB] Error surfacing a NavigationStrategy: ${e}`);
668
+ logger.error(`[courseDB] Error in getPendingReviews: ${e}`);
669
+ throw e;
670
+ }
671
+ }
672
+
673
+ /**
674
+ * Get cards with suitability scores for presentation.
675
+ *
676
+ * This is the PRIMARY API for content sources going forward. Delegates to the
677
+ * course's configured NavigationStrategy to get scored candidates.
678
+ *
679
+ * @param limit - Maximum number of cards to return
680
+ * @returns Cards sorted by score descending
681
+ */
682
+ public async getWeightedCards(limit: number): Promise<WeightedCard[]> {
683
+ const u = await this._getCurrentUser();
684
+
685
+ try {
686
+ const navigator = await this.createNavigator(u);
687
+ return navigator.getWeightedCards(limit);
688
+ } catch (e) {
689
+ logger.error(`[courseDB] Error getting weighted cards: ${e}`);
592
690
  throw e;
593
691
  }
594
692
  }
@@ -249,6 +249,17 @@ export class StaticCourseDB implements CourseDBInterface {
249
249
  }
250
250
  }
251
251
 
252
+ async getAppliedTagsBatch(cardIds: string[]): Promise<Map<string, string[]>> {
253
+ const tagsIndex = await this.unpacker.getTagsIndex();
254
+ const tagsByCard = new Map<string, string[]>();
255
+
256
+ for (const cardId of cardIds) {
257
+ tagsByCard.set(cardId, tagsIndex.byCard[cardId] || []);
258
+ }
259
+
260
+ return tagsByCard;
261
+ }
262
+
252
263
  async addTagToCard(_cardId: string, _tagId: string): Promise<PouchDB.Core.Response> {
253
264
  throw new Error('Cannot modify tags in static mode');
254
265
  }
@@ -391,10 +402,6 @@ export class StaticCourseDB implements CourseDBInterface {
391
402
  throw new Error('Cannot update navigation strategies in static mode');
392
403
  }
393
404
 
394
- async surfaceNavigationStrategy(): Promise<ContentNavigationStrategyData> {
395
- return this.getNavigationStrategy('ELO');
396
- }
397
-
398
405
  // Study Content Source implementation
399
406
  async getPendingReviews(): Promise<(StudySessionReviewItem & ScheduledCard)[]> {
400
407
  // 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)));