@vue-skuilder/db 0.1.31-a → 0.1.31
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/{contentSource-BmnmvH8C.d.ts → contentSource-Bdwkvqa8.d.ts} +35 -4
- package/dist/{contentSource-DfBbaLA-.d.cts → contentSource-DF1nUbPQ.d.cts} +35 -4
- package/dist/core/index.d.cts +48 -3
- package/dist/core/index.d.ts +48 -3
- package/dist/core/index.js +587 -56
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +586 -56
- package/dist/core/index.mjs.map +1 -1
- package/dist/{dataLayerProvider-BeRXVMs5.d.cts → dataLayerProvider-BKmVoyJR.d.ts} +20 -1
- package/dist/{dataLayerProvider-CG9GfaAY.d.ts → dataLayerProvider-BQdfJuBN.d.cts} +20 -1
- package/dist/impl/couch/index.d.cts +156 -4
- package/dist/impl/couch/index.d.ts +156 -4
- package/dist/impl/couch/index.js +805 -47
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +804 -47
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.d.cts +3 -2
- package/dist/impl/static/index.d.ts +3 -2
- package/dist/impl/static/index.js +542 -37
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +542 -37
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/index.d.cts +64 -3
- package/dist/index.d.ts +64 -3
- package/dist/index.js +1040 -90
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1030 -81
- package/dist/index.mjs.map +1 -1
- package/docs/navigators-architecture.md +64 -5
- package/package.json +3 -3
- package/src/core/interfaces/contentSource.ts +6 -0
- package/src/core/interfaces/courseDB.ts +6 -0
- package/src/core/interfaces/dataLayerProvider.ts +20 -0
- package/src/core/navigators/Pipeline.ts +414 -9
- package/src/core/navigators/PipelineAssembler.ts +23 -18
- package/src/core/navigators/PipelineDebugger.ts +115 -1
- package/src/core/navigators/filters/hierarchyDefinition.ts +78 -8
- package/src/core/navigators/generators/prescribed.ts +95 -0
- package/src/core/navigators/index.ts +55 -10
- package/src/impl/common/BaseUserDB.ts +4 -1
- package/src/impl/couch/CourseSyncService.ts +356 -0
- package/src/impl/couch/PouchDataLayerProvider.ts +21 -1
- package/src/impl/couch/courseDB.ts +60 -13
- package/src/impl/couch/index.ts +1 -0
- package/src/impl/static/courseDB.ts +5 -0
- package/src/study/ItemQueue.ts +42 -0
- package/src/study/SessionController.ts +195 -22
- package/src/study/SpacedRepetition.ts +7 -2
- package/tests/core/navigators/Pipeline.test.ts +1 -1
- package/tests/core/navigators/PipelineAssembler.test.ts +15 -14
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
import type { WeightedCard, StrategyContribution } from './index';
|
|
2
|
+
import {
|
|
3
|
+
getRegisteredNavigatorNames,
|
|
4
|
+
getRegisteredNavigatorRole,
|
|
5
|
+
NavigatorRoles,
|
|
6
|
+
type Navigators,
|
|
7
|
+
isGenerator,
|
|
8
|
+
isFilter,
|
|
9
|
+
} from './index';
|
|
2
10
|
import { logger } from '../../util/logger';
|
|
11
|
+
import type { Pipeline, CardSpaceDiagnosis } from './Pipeline';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Captured reference to the most recently created Pipeline instance.
|
|
15
|
+
* Used by the debug API to run diagnostics against the live pipeline.
|
|
16
|
+
*/
|
|
17
|
+
let _activePipeline: Pipeline | null = null;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Register a pipeline instance for diagnostic access.
|
|
21
|
+
* Called by Pipeline constructor.
|
|
22
|
+
*/
|
|
23
|
+
export function registerPipelineForDebug(pipeline: Pipeline): void {
|
|
24
|
+
_activePipeline = pipeline;
|
|
25
|
+
}
|
|
3
26
|
|
|
4
27
|
// ============================================================================
|
|
5
28
|
// PIPELINE DEBUGGER
|
|
@@ -68,6 +91,7 @@ export interface PipelineRunReport {
|
|
|
68
91
|
origin: 'new' | 'review' | 'unknown';
|
|
69
92
|
finalScore: number;
|
|
70
93
|
provenance: StrategyContribution[];
|
|
94
|
+
tags?: string[];
|
|
71
95
|
selected: boolean;
|
|
72
96
|
}>;
|
|
73
97
|
}
|
|
@@ -127,6 +151,7 @@ export function buildRunReport(
|
|
|
127
151
|
origin: getOrigin(card),
|
|
128
152
|
finalScore: card.score,
|
|
129
153
|
provenance: card.provenance,
|
|
154
|
+
tags: card.tags,
|
|
130
155
|
selected: selectedIds.has(card.cardId),
|
|
131
156
|
}));
|
|
132
157
|
|
|
@@ -381,6 +406,92 @@ export const pipelineDebugAPI = {
|
|
|
381
406
|
logger.info('[Pipeline Debug] Run history cleared.');
|
|
382
407
|
},
|
|
383
408
|
|
|
409
|
+
/**
|
|
410
|
+
* Show the navigator registry: all registered classes and their roles.
|
|
411
|
+
*
|
|
412
|
+
* Useful for verifying that consumer-defined navigators were registered
|
|
413
|
+
* before pipeline assembly.
|
|
414
|
+
*/
|
|
415
|
+
showRegistry(): void {
|
|
416
|
+
const names = getRegisteredNavigatorNames();
|
|
417
|
+
if (names.length === 0) {
|
|
418
|
+
logger.info('[Pipeline Debug] Navigator registry is empty.');
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// eslint-disable-next-line no-console
|
|
423
|
+
console.group('📦 Navigator Registry');
|
|
424
|
+
// eslint-disable-next-line no-console
|
|
425
|
+
console.table(
|
|
426
|
+
names.map((name) => {
|
|
427
|
+
const registryRole = getRegisteredNavigatorRole(name);
|
|
428
|
+
const builtinRole = NavigatorRoles[name as Navigators];
|
|
429
|
+
const effectiveRole = builtinRole || registryRole || '⚠️ NONE';
|
|
430
|
+
const source = builtinRole ? 'built-in' : registryRole ? 'consumer' : 'unclassified';
|
|
431
|
+
return {
|
|
432
|
+
name,
|
|
433
|
+
role: effectiveRole,
|
|
434
|
+
source,
|
|
435
|
+
isGenerator: isGenerator(name),
|
|
436
|
+
isFilter: isFilter(name),
|
|
437
|
+
};
|
|
438
|
+
})
|
|
439
|
+
);
|
|
440
|
+
// eslint-disable-next-line no-console
|
|
441
|
+
console.groupEnd();
|
|
442
|
+
},
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Show strategy documents from the last pipeline run and how they mapped
|
|
446
|
+
* to the registry.
|
|
447
|
+
*
|
|
448
|
+
* If no runs are captured yet, falls back to showing just the registry.
|
|
449
|
+
*/
|
|
450
|
+
showStrategies(): void {
|
|
451
|
+
this.showRegistry();
|
|
452
|
+
|
|
453
|
+
if (runHistory.length === 0) {
|
|
454
|
+
logger.info('[Pipeline Debug] No pipeline runs captured yet — cannot show strategy doc mapping.');
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const run = runHistory[0];
|
|
459
|
+
// eslint-disable-next-line no-console
|
|
460
|
+
console.group('🔌 Pipeline Strategy Mapping (last run)');
|
|
461
|
+
logger.info(`Generator: ${run.generatorName}`);
|
|
462
|
+
if (run.generators && run.generators.length > 0) {
|
|
463
|
+
for (const g of run.generators) {
|
|
464
|
+
logger.info(` 📥 ${g.name}: ${g.cardCount} cards (${g.newCount} new, ${g.reviewCount} reviews)`);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
if (run.filters.length > 0) {
|
|
468
|
+
logger.info('Filters:');
|
|
469
|
+
for (const f of run.filters) {
|
|
470
|
+
logger.info(` 🔸 ${f.name}: ↑${f.boosted} ↓${f.penalized} =${f.passed} ✕${f.removed}`);
|
|
471
|
+
}
|
|
472
|
+
} else {
|
|
473
|
+
logger.info('Filters: (none)');
|
|
474
|
+
}
|
|
475
|
+
// eslint-disable-next-line no-console
|
|
476
|
+
console.groupEnd();
|
|
477
|
+
},
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Scan the full card space through the filter chain for the current user.
|
|
481
|
+
*
|
|
482
|
+
* Reports how many cards are well-indicated and how many are new.
|
|
483
|
+
* Use this to understand how the search space grows during onboarding.
|
|
484
|
+
*
|
|
485
|
+
* @param threshold - Score threshold for "well indicated" (default 0.10)
|
|
486
|
+
*/
|
|
487
|
+
async diagnoseCardSpace(threshold?: number): Promise<CardSpaceDiagnosis | null> {
|
|
488
|
+
if (!_activePipeline) {
|
|
489
|
+
logger.info('[Pipeline Debug] No active pipeline. Run a session first.');
|
|
490
|
+
return null;
|
|
491
|
+
}
|
|
492
|
+
return _activePipeline.diagnoseCardSpace({ threshold });
|
|
493
|
+
},
|
|
494
|
+
|
|
384
495
|
/**
|
|
385
496
|
* Show help.
|
|
386
497
|
*/
|
|
@@ -393,6 +504,9 @@ Commands:
|
|
|
393
504
|
.showRun(id|index) Show summary of a specific run (by index or ID suffix)
|
|
394
505
|
.showCard(cardId) Show provenance trail for a specific card
|
|
395
506
|
.explainReviews() Analyze why reviews were/weren't selected
|
|
507
|
+
.diagnoseCardSpace() Scan full card space through filters (async)
|
|
508
|
+
.showRegistry() Show navigator registry (classes + roles)
|
|
509
|
+
.showStrategies() Show registry + strategy mapping from last run
|
|
396
510
|
.listRuns() List all captured runs in table format
|
|
397
511
|
.export() Export run history as JSON for bug reports
|
|
398
512
|
.clear() Clear run history
|
|
@@ -402,7 +516,7 @@ Commands:
|
|
|
402
516
|
Example:
|
|
403
517
|
window.skuilder.pipeline.showLastRun()
|
|
404
518
|
window.skuilder.pipeline.showRun(1)
|
|
405
|
-
window.skuilder.pipeline.
|
|
519
|
+
await window.skuilder.pipeline.diagnoseCardSpace()
|
|
406
520
|
`);
|
|
407
521
|
},
|
|
408
522
|
};
|
|
@@ -20,6 +20,15 @@ interface TagPrerequisite {
|
|
|
20
20
|
/** Minimum interaction count (default: 3) */
|
|
21
21
|
minCount?: number;
|
|
22
22
|
};
|
|
23
|
+
/**
|
|
24
|
+
* Score multiplier applied to cards that carry this prereq tag while
|
|
25
|
+
* the gate is still closed. Steers the pipeline toward cards that help
|
|
26
|
+
* unlock the gated content. Falls away once the prereq is met.
|
|
27
|
+
*
|
|
28
|
+
* Example: `preReqBoost: 1.3` gives a 30% score increase to cards
|
|
29
|
+
* tagged `gpc:expose:t-T` while `gpc:intro:t-T` is still locked.
|
|
30
|
+
*/
|
|
31
|
+
preReqBoost?: number;
|
|
23
32
|
}
|
|
24
33
|
|
|
25
34
|
/**
|
|
@@ -38,7 +47,7 @@ const DEFAULT_MIN_COUNT = 3;
|
|
|
38
47
|
* A filter strategy that gates cards based on prerequisite mastery.
|
|
39
48
|
*
|
|
40
49
|
* Cards are locked until the user masters all prerequisite tags.
|
|
41
|
-
* Locked cards receive score * 0.
|
|
50
|
+
* Locked cards receive score * 0.05 (strong penalty, not hard filter).
|
|
42
51
|
*
|
|
43
52
|
* Mastery is determined by:
|
|
44
53
|
* - User's ELO for the tag exceeds threshold (or avgElo if not specified)
|
|
@@ -94,8 +103,13 @@ export default class HierarchyDefinitionNavigator extends ContentNavigator imple
|
|
|
94
103
|
|
|
95
104
|
if (prereq.masteryThreshold?.minElo !== undefined) {
|
|
96
105
|
return userTagElo.score >= prereq.masteryThreshold.minElo;
|
|
106
|
+
} else if (prereq.masteryThreshold?.minCount !== undefined) {
|
|
107
|
+
// Explicit minCount without minElo: count alone is sufficient.
|
|
108
|
+
// The config author specified a concrete interaction threshold —
|
|
109
|
+
// don't additionally require above-average ELO.
|
|
110
|
+
return true;
|
|
97
111
|
} else {
|
|
98
|
-
//
|
|
112
|
+
// No thresholds specified at all: fall back to above-average ELO
|
|
99
113
|
return userTagElo.score >= userGlobalElo;
|
|
100
114
|
}
|
|
101
115
|
}
|
|
@@ -195,17 +209,51 @@ export default class HierarchyDefinitionNavigator extends ContentNavigator imple
|
|
|
195
209
|
}
|
|
196
210
|
}
|
|
197
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Build a map of prereq tag → max configured boost for all *closed* gates.
|
|
214
|
+
*
|
|
215
|
+
* When a gate is closed (prereqs unmet), cards carrying that gate's prereq
|
|
216
|
+
* tags get boosted — steering the pipeline toward content that helps unlock
|
|
217
|
+
* the gated material. Once the gate opens, the boost disappears.
|
|
218
|
+
*/
|
|
219
|
+
private getPreReqBoosts(
|
|
220
|
+
unlockedTags: Set<string>,
|
|
221
|
+
masteredTags: Set<string>
|
|
222
|
+
): Map<string, number> {
|
|
223
|
+
const boosts = new Map<string, number>();
|
|
224
|
+
|
|
225
|
+
for (const [tagId, prereqs] of Object.entries(this.config.prerequisites)) {
|
|
226
|
+
// Only boost prereqs of closed gates
|
|
227
|
+
if (unlockedTags.has(tagId)) continue;
|
|
228
|
+
|
|
229
|
+
for (const prereq of prereqs) {
|
|
230
|
+
if (!prereq.preReqBoost || prereq.preReqBoost <= 1.0) continue;
|
|
231
|
+
// Only boost prereqs that aren't already met
|
|
232
|
+
if (masteredTags.has(prereq.tag)) continue;
|
|
233
|
+
|
|
234
|
+
const existing = boosts.get(prereq.tag) ?? 1.0;
|
|
235
|
+
boosts.set(prereq.tag, Math.max(existing, prereq.preReqBoost));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return boosts;
|
|
240
|
+
}
|
|
241
|
+
|
|
198
242
|
/**
|
|
199
243
|
* CardFilter.transform implementation.
|
|
200
244
|
*
|
|
201
|
-
*
|
|
245
|
+
* Two effects:
|
|
246
|
+
* 1. Cards with locked tags receive score * 0.05 (gating penalty)
|
|
247
|
+
* 2. Cards carrying prereq tags of closed gates receive a configured
|
|
248
|
+
* boost (preReqBoost), steering toward content that unlocks gates
|
|
202
249
|
*/
|
|
203
250
|
async transform(cards: WeightedCard[], context: FilterContext): Promise<WeightedCard[]> {
|
|
204
251
|
// Get mastery state
|
|
205
252
|
const masteredTags = await this.getMasteredTags(context);
|
|
206
253
|
const unlockedTags = this.getUnlockedTags(masteredTags);
|
|
254
|
+
const preReqBoosts = this.getPreReqBoosts(unlockedTags, masteredTags);
|
|
207
255
|
|
|
208
|
-
// Apply prerequisite gating
|
|
256
|
+
// Apply prerequisite gating + prereq boosting
|
|
209
257
|
const gated: WeightedCard[] = [];
|
|
210
258
|
|
|
211
259
|
for (const card of cards) {
|
|
@@ -215,9 +263,31 @@ export default class HierarchyDefinitionNavigator extends ContentNavigator imple
|
|
|
215
263
|
unlockedTags,
|
|
216
264
|
masteredTags
|
|
217
265
|
);
|
|
218
|
-
const LOCKED_PENALTY = 0.
|
|
219
|
-
|
|
220
|
-
|
|
266
|
+
const LOCKED_PENALTY = 0.02;
|
|
267
|
+
let finalScore = isUnlocked ? card.score : card.score * LOCKED_PENALTY;
|
|
268
|
+
let action: 'passed' | 'penalized' | 'boosted' = isUnlocked ? 'passed' : 'penalized';
|
|
269
|
+
let finalReason = reason;
|
|
270
|
+
|
|
271
|
+
// Apply prereq boost to cards that passed gating (don't boost locked cards)
|
|
272
|
+
if (isUnlocked && preReqBoosts.size > 0) {
|
|
273
|
+
const cardTags = card.tags ?? [];
|
|
274
|
+
let maxBoost = 1.0;
|
|
275
|
+
const boostedPrereqs: string[] = [];
|
|
276
|
+
|
|
277
|
+
for (const tag of cardTags) {
|
|
278
|
+
const boost = preReqBoosts.get(tag);
|
|
279
|
+
if (boost && boost > maxBoost) {
|
|
280
|
+
maxBoost = boost;
|
|
281
|
+
boostedPrereqs.push(tag);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (maxBoost > 1.0) {
|
|
286
|
+
finalScore *= maxBoost;
|
|
287
|
+
action = 'boosted';
|
|
288
|
+
finalReason = `${reason} | preReqBoost ×${maxBoost.toFixed(2)} for ${boostedPrereqs.join(', ')}`;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
221
291
|
|
|
222
292
|
gated.push({
|
|
223
293
|
...card,
|
|
@@ -230,7 +300,7 @@ export default class HierarchyDefinitionNavigator extends ContentNavigator imple
|
|
|
230
300
|
strategyId: this.strategyId || 'NAVIGATION_STRATEGY-hierarchy',
|
|
231
301
|
action,
|
|
232
302
|
score: finalScore,
|
|
233
|
-
reason,
|
|
303
|
+
reason: finalReason,
|
|
234
304
|
},
|
|
235
305
|
],
|
|
236
306
|
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { CourseDBInterface } from '../../interfaces/courseDB';
|
|
2
|
+
import type { UserDBInterface } from '../../interfaces/userDB';
|
|
3
|
+
import { ContentNavigator } from '../index';
|
|
4
|
+
import type { WeightedCard } from '../index';
|
|
5
|
+
import type { ContentNavigationStrategyData } from '../../types/contentNavigationStrategy';
|
|
6
|
+
import type { CardGenerator, GeneratorContext } from './types';
|
|
7
|
+
import { logger } from '@db/util/logger';
|
|
8
|
+
|
|
9
|
+
// ============================================================================
|
|
10
|
+
// PRESCRIBED CARDS GENERATOR
|
|
11
|
+
// ============================================================================
|
|
12
|
+
//
|
|
13
|
+
// A generator that always emits a configured list of card IDs at score 1.0.
|
|
14
|
+
//
|
|
15
|
+
// Use case: Cold-start curriculum bootstrapping. Ensures critical cards
|
|
16
|
+
// (e.g., intro-s, early WS exercises) are always in the candidate set
|
|
17
|
+
// regardless of ELO proximity sampling. Filters (hierarchy, priority)
|
|
18
|
+
// still run — cards whose utility has expired get penalized normally
|
|
19
|
+
// and drop out of the top-N selection.
|
|
20
|
+
//
|
|
21
|
+
// Config format:
|
|
22
|
+
// { "cardIds": ["c-intro-s-S", "c-ws-sit-abc123", ...] }
|
|
23
|
+
//
|
|
24
|
+
// ============================================================================
|
|
25
|
+
|
|
26
|
+
interface PrescribedConfig {
|
|
27
|
+
cardIds: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export default class PrescribedCardsGenerator extends ContentNavigator implements CardGenerator {
|
|
31
|
+
name: string;
|
|
32
|
+
private config: PrescribedConfig;
|
|
33
|
+
|
|
34
|
+
constructor(
|
|
35
|
+
user: UserDBInterface,
|
|
36
|
+
course: CourseDBInterface,
|
|
37
|
+
strategyData: ContentNavigationStrategyData
|
|
38
|
+
) {
|
|
39
|
+
super(user, course, strategyData);
|
|
40
|
+
this.name = strategyData.name || 'Prescribed Cards';
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const parsed = JSON.parse(strategyData.serializedData);
|
|
44
|
+
this.config = { cardIds: parsed.cardIds || [] };
|
|
45
|
+
} catch {
|
|
46
|
+
this.config = { cardIds: [] };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
logger.debug(
|
|
50
|
+
`[Prescribed] Initialized with ${this.config.cardIds.length} prescribed cards`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async getWeightedCards(limit: number, _context?: GeneratorContext): Promise<WeightedCard[]> {
|
|
55
|
+
if (this.config.cardIds.length === 0) {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const courseId = this.course.getCourseID();
|
|
60
|
+
|
|
61
|
+
// Filter out cards the user has already interacted with
|
|
62
|
+
const activeCards = await this.user.getActiveCards();
|
|
63
|
+
const activeIds = new Set(activeCards.map((ac) => ac.cardID));
|
|
64
|
+
const eligibleIds = this.config.cardIds.filter((id) => !activeIds.has(id));
|
|
65
|
+
|
|
66
|
+
if (eligibleIds.length === 0) {
|
|
67
|
+
logger.debug('[Prescribed] All prescribed cards already active, returning empty');
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Emit at score 1.0 — CompositeGenerator deduplicates, and if ELO
|
|
72
|
+
// also surfaces the same card, the composite picks the higher score.
|
|
73
|
+
const cards: WeightedCard[] = eligibleIds.slice(0, limit).map((cardId) => ({
|
|
74
|
+
cardId,
|
|
75
|
+
courseId,
|
|
76
|
+
score: 1.0,
|
|
77
|
+
provenance: [
|
|
78
|
+
{
|
|
79
|
+
strategy: 'prescribed',
|
|
80
|
+
strategyName: this.strategyName || this.name,
|
|
81
|
+
strategyId: this.strategyId || 'NAVIGATION_STRATEGY-prescribed',
|
|
82
|
+
action: 'generated' as const,
|
|
83
|
+
score: 1.0,
|
|
84
|
+
reason: `Prescribed card (${eligibleIds.length} eligible of ${this.config.cardIds.length} configured)`,
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
}));
|
|
88
|
+
|
|
89
|
+
logger.info(
|
|
90
|
+
`[Prescribed] Emitting ${cards.length} cards (${eligibleIds.length} eligible, ${activeIds.size} already active)`
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
return cards;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -46,10 +46,20 @@ export type NavigatorConstructor = new (
|
|
|
46
46
|
) => ContentNavigator;
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
|
-
*
|
|
49
|
+
* Entry in the navigator registry, storing the constructor and an optional
|
|
50
|
+
* pipeline role. The role is used by PipelineAssembler to classify
|
|
51
|
+
* consumer-registered navigators that aren't in the built-in Navigators enum.
|
|
52
|
+
*/
|
|
53
|
+
interface NavigatorRegistryEntry {
|
|
54
|
+
constructor: NavigatorConstructor;
|
|
55
|
+
role?: NavigatorRole;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Registry mapping implementingClass names to navigator entries.
|
|
50
60
|
* Populated by registerNavigator() and used by ContentNavigator.create().
|
|
51
61
|
*/
|
|
52
|
-
const navigatorRegistry = new Map<string,
|
|
62
|
+
const navigatorRegistry = new Map<string, NavigatorRegistryEntry>();
|
|
53
63
|
|
|
54
64
|
/**
|
|
55
65
|
* Register a navigator implementation.
|
|
@@ -57,15 +67,21 @@ const navigatorRegistry = new Map<string, NavigatorConstructor>();
|
|
|
57
67
|
* Call this to make a navigator available for instantiation by
|
|
58
68
|
* ContentNavigator.create() without relying on dynamic imports.
|
|
59
69
|
*
|
|
60
|
-
*
|
|
70
|
+
* Passing a `role` is optional for built-in navigators (whose roles are in
|
|
71
|
+
* the hardcoded `NavigatorRoles` record), but **required** for consumer-
|
|
72
|
+
* defined navigators that need to participate in pipeline assembly.
|
|
73
|
+
*
|
|
74
|
+
* @param implementingClass - The class name (e.g., 'elo', 'letterGatingFilter')
|
|
61
75
|
* @param constructor - The navigator class constructor
|
|
76
|
+
* @param role - Optional pipeline role (GENERATOR or FILTER)
|
|
62
77
|
*/
|
|
63
78
|
export function registerNavigator(
|
|
64
79
|
implementingClass: string,
|
|
65
|
-
constructor: NavigatorConstructor
|
|
80
|
+
constructor: NavigatorConstructor,
|
|
81
|
+
role?: NavigatorRole
|
|
66
82
|
): void {
|
|
67
|
-
navigatorRegistry.set(implementingClass, constructor);
|
|
68
|
-
logger.debug(`[NavigatorRegistry] Registered: ${implementingClass}`);
|
|
83
|
+
navigatorRegistry.set(implementingClass, { constructor, role });
|
|
84
|
+
logger.debug(`[NavigatorRegistry] Registered: ${implementingClass}${role ? ` (${role})` : ''}`);
|
|
69
85
|
}
|
|
70
86
|
|
|
71
87
|
/**
|
|
@@ -75,7 +91,7 @@ export function registerNavigator(
|
|
|
75
91
|
* @returns The constructor, or undefined if not registered
|
|
76
92
|
*/
|
|
77
93
|
export function getRegisteredNavigator(implementingClass: string): NavigatorConstructor | undefined {
|
|
78
|
-
return navigatorRegistry.get(implementingClass);
|
|
94
|
+
return navigatorRegistry.get(implementingClass)?.constructor;
|
|
79
95
|
}
|
|
80
96
|
|
|
81
97
|
/**
|
|
@@ -88,6 +104,16 @@ export function hasRegisteredNavigator(implementingClass: string): boolean {
|
|
|
88
104
|
return navigatorRegistry.has(implementingClass);
|
|
89
105
|
}
|
|
90
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Get the registered role for a navigator, if one was provided at registration.
|
|
109
|
+
*
|
|
110
|
+
* @param implementingClass - The class name to look up
|
|
111
|
+
* @returns The role, or undefined if not registered or no role was specified
|
|
112
|
+
*/
|
|
113
|
+
export function getRegisteredNavigatorRole(implementingClass: string): NavigatorRole | undefined {
|
|
114
|
+
return navigatorRegistry.get(implementingClass)?.role;
|
|
115
|
+
}
|
|
116
|
+
|
|
91
117
|
/**
|
|
92
118
|
* Get all registered navigator names.
|
|
93
119
|
* Useful for debugging and testing.
|
|
@@ -114,8 +140,10 @@ export async function initializeNavigatorRegistry(): Promise<void> {
|
|
|
114
140
|
import('./generators/elo'),
|
|
115
141
|
import('./generators/srs'),
|
|
116
142
|
]);
|
|
143
|
+
const prescribedModule = await import('./generators/prescribed');
|
|
117
144
|
registerNavigator('elo', eloModule.default);
|
|
118
145
|
registerNavigator('srs', srsModule.default);
|
|
146
|
+
registerNavigator('prescribed', prescribedModule.default);
|
|
119
147
|
|
|
120
148
|
// Import and register filters
|
|
121
149
|
const [
|
|
@@ -320,6 +348,7 @@ export function getCardOrigin(card: WeightedCard): 'new' | 'review' | 'failed' {
|
|
|
320
348
|
export enum Navigators {
|
|
321
349
|
ELO = 'elo',
|
|
322
350
|
SRS = 'srs',
|
|
351
|
+
PRESCRIBED = 'prescribed',
|
|
323
352
|
HIERARCHY = 'hierarchyDefinition',
|
|
324
353
|
INTERFERENCE = 'interferenceMitigator',
|
|
325
354
|
RELATIVE_PRIORITY = 'relativePriority',
|
|
@@ -358,6 +387,7 @@ export enum NavigatorRole {
|
|
|
358
387
|
export const NavigatorRoles: Record<Navigators, NavigatorRole> = {
|
|
359
388
|
[Navigators.ELO]: NavigatorRole.GENERATOR,
|
|
360
389
|
[Navigators.SRS]: NavigatorRole.GENERATOR,
|
|
390
|
+
[Navigators.PRESCRIBED]: NavigatorRole.GENERATOR,
|
|
361
391
|
[Navigators.HIERARCHY]: NavigatorRole.FILTER,
|
|
362
392
|
[Navigators.INTERFERENCE]: NavigatorRole.FILTER,
|
|
363
393
|
[Navigators.RELATIVE_PRIORITY]: NavigatorRole.FILTER,
|
|
@@ -371,17 +401,24 @@ export const NavigatorRoles: Record<Navigators, NavigatorRole> = {
|
|
|
371
401
|
* @returns true if the navigator is a generator, false otherwise
|
|
372
402
|
*/
|
|
373
403
|
export function isGenerator(impl: string): boolean {
|
|
374
|
-
|
|
404
|
+
if (NavigatorRoles[impl as Navigators] === NavigatorRole.GENERATOR) return true;
|
|
405
|
+
// Fallback: check the registry for consumer-registered navigators
|
|
406
|
+
return getRegisteredNavigatorRole(impl) === NavigatorRole.GENERATOR;
|
|
375
407
|
}
|
|
376
408
|
|
|
377
409
|
/**
|
|
378
410
|
* Check if a navigator implementation is a filter.
|
|
379
411
|
*
|
|
380
|
-
*
|
|
412
|
+
* Checks the built-in NavigatorRoles enum first, then falls back to the
|
|
413
|
+
* navigator registry for consumer-registered navigators.
|
|
414
|
+
*
|
|
415
|
+
* @param impl - Navigator implementation name (e.g., 'elo', 'letterGatingFilter')
|
|
381
416
|
* @returns true if the navigator is a filter, false otherwise
|
|
382
417
|
*/
|
|
383
418
|
export function isFilter(impl: string): boolean {
|
|
384
|
-
|
|
419
|
+
if (NavigatorRoles[impl as Navigators] === NavigatorRole.FILTER) return true;
|
|
420
|
+
// Fallback: check the registry for consumer-registered navigators
|
|
421
|
+
return getRegisteredNavigatorRole(impl) === NavigatorRole.FILTER;
|
|
385
422
|
}
|
|
386
423
|
|
|
387
424
|
/**
|
|
@@ -591,4 +628,12 @@ export abstract class ContentNavigator implements StudyContentSource {
|
|
|
591
628
|
async getWeightedCards(_limit: number): Promise<WeightedCard[]> {
|
|
592
629
|
throw new Error(`${this.constructor.name} must implement getWeightedCards(). `);
|
|
593
630
|
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Set ephemeral hints for the next pipeline run.
|
|
634
|
+
* No-op for non-Pipeline navigators. Pipeline overrides this.
|
|
635
|
+
*/
|
|
636
|
+
setEphemeralHints(_hints: Record<string, unknown>): void {
|
|
637
|
+
// no-op — only Pipeline implements this
|
|
638
|
+
}
|
|
594
639
|
}
|
|
@@ -174,10 +174,13 @@ Currently logged-in as ${this._username}.`
|
|
|
174
174
|
const docsToDelete = allDocs.rows
|
|
175
175
|
.filter((row) => {
|
|
176
176
|
const id = row.id;
|
|
177
|
-
// Delete user progress data but preserve
|
|
177
|
+
// Delete user progress data but preserve authentication and user identity
|
|
178
178
|
return (
|
|
179
179
|
id.startsWith(DocTypePrefixes[DocType.CARDRECORD]) || // Card interaction history
|
|
180
180
|
id.startsWith(DocTypePrefixes[DocType.SCHEDULED_CARD]) || // Scheduled reviews
|
|
181
|
+
id.startsWith(DocTypePrefixes[DocType.STRATEGY_STATE]) || // Strategy state (user prefs, progression)
|
|
182
|
+
id.startsWith(DocTypePrefixes[DocType.USER_OUTCOME]) || // Evolutionary orchestration outcomes
|
|
183
|
+
id.startsWith(DocTypePrefixes[DocType.STRATEGY_LEARNING_STATE]) || // Strategy learning state
|
|
181
184
|
id === BaseUser.DOC_IDS.COURSE_REGISTRATIONS || // Course registrations
|
|
182
185
|
id === BaseUser.DOC_IDS.CLASSROOM_REGISTRATIONS || // Classroom registrations
|
|
183
186
|
id === BaseUser.DOC_IDS.CONFIG // User config
|