@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.
- package/dist/{userDB-BqwxtJ_7.d.mts → classroomDB-CZdMBiTU.d.ts} +427 -104
- package/dist/{userDB-DNa0XPtn.d.ts → classroomDB-PxDZTky3.d.cts} +427 -104
- package/dist/core/index.d.cts +304 -0
- package/dist/core/index.d.ts +237 -25
- package/dist/core/index.js +2246 -118
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +2235 -114
- package/dist/core/index.mjs.map +1 -1
- package/dist/{dataLayerProvider-VlngD19_.d.mts → dataLayerProvider-D0MoZMjH.d.cts} +1 -1
- package/dist/{dataLayerProvider-BV5iZqt_.d.ts → dataLayerProvider-D8o6ZnKW.d.ts} +1 -1
- package/dist/impl/couch/{index.d.mts → index.d.cts} +47 -5
- package/dist/impl/couch/index.d.ts +46 -4
- package/dist/impl/couch/index.js +2250 -134
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +2212 -97
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/{index.d.mts → index.d.cts} +6 -6
- package/dist/impl/static/index.d.ts +5 -5
- package/dist/impl/static/index.js +1950 -143
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +1922 -117
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/{index-Bmll7Xse.d.mts → index-B_j6u5E4.d.cts} +1 -1
- package/dist/{index-CD8BZz2k.d.ts → index-Dj0SEgk3.d.ts} +1 -1
- package/dist/{index.d.mts → index.d.cts} +97 -13
- package/dist/index.d.ts +96 -12
- package/dist/index.js +2439 -180
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2386 -135
- package/dist/index.mjs.map +1 -1
- package/dist/pouch/index.js +3 -3
- package/dist/{types-Dbp5DaRR.d.mts → types-Bn0itutr.d.cts} +1 -1
- package/dist/{types-CewsN87z.d.ts → types-DQaXnuoc.d.ts} +1 -1
- package/dist/{types-legacy-6ettoclI.d.ts → types-legacy-DDY4N-Uq.d.cts} +3 -1
- package/dist/{types-legacy-6ettoclI.d.mts → types-legacy-DDY4N-Uq.d.ts} +3 -1
- package/dist/util/packer/{index.d.mts → index.d.cts} +3 -3
- package/dist/util/packer/index.d.ts +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 +370 -0
- package/docs/todo-evolutionary-orchestration.md +310 -0
- package/docs/todo-nominal-tag-types.md +121 -0
- package/docs/todo-strategy-authoring.md +401 -0
- package/eslint.config.mjs +1 -1
- package/package.json +9 -4
- package/src/core/index.ts +1 -0
- package/src/core/interfaces/contentSource.ts +88 -4
- package/src/core/interfaces/courseDB.ts +13 -0
- package/src/core/interfaces/navigationStrategyManager.ts +0 -5
- package/src/core/interfaces/userDB.ts +32 -0
- package/src/core/navigators/CompositeGenerator.ts +268 -0
- package/src/core/navigators/Pipeline.ts +318 -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 +9 -0
- package/src/core/navigators/filters/types.ts +115 -0
- package/src/core/navigators/filters/userTagPreference.ts +232 -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 +404 -3
- package/src/core/navigators/inferredPreference.ts +107 -0
- package/src/core/navigators/interferenceMitigator.ts +355 -0
- package/src/core/navigators/relativePriority.ts +255 -0
- package/src/core/navigators/srs.ts +195 -0
- package/src/core/navigators/userGoal.ts +136 -0
- package/src/core/types/strategyState.ts +84 -0
- package/src/core/types/types-legacy.ts +2 -0
- package/src/impl/common/BaseUserDB.ts +74 -7
- package/src/impl/couch/adminDB.ts +1 -2
- package/src/impl/couch/classroomDB.ts +51 -0
- package/src/impl/couch/courseDB.ts +147 -49
- package/src/impl/static/courseDB.ts +11 -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 +406 -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
|
@@ -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(
|
|
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))
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
Array.from(bytes.slice(
|
|
1132
|
-
|
|
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);
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
}
|
|
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
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
-
|
|
550
|
-
|
|
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: '',
|
|
607
|
+
serializedData: '',
|
|
558
608
|
};
|
|
559
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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)));
|