@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
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import type { ScheduledCard } from '../types/user';
|
|
2
|
+
import type { CourseDBInterface } from '../interfaces/courseDB';
|
|
3
|
+
import type { UserDBInterface } from '../interfaces/userDB';
|
|
4
|
+
import { ContentNavigator } from './index';
|
|
5
|
+
import type { WeightedCard } from './index';
|
|
6
|
+
import type { ContentNavigationStrategyData } from '../types/contentNavigationStrategy';
|
|
7
|
+
import type { StudySessionReviewItem, StudySessionNewItem } from '..';
|
|
8
|
+
import type { CardFilter, FilterContext } from './filters/types';
|
|
9
|
+
import { toCourseElo } from '@vue-skuilder/common';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A single prerequisite requirement for a tag.
|
|
13
|
+
* Each prerequisite refers to one tag with its own mastery threshold.
|
|
14
|
+
*/
|
|
15
|
+
interface TagPrerequisite {
|
|
16
|
+
/** The tag that must be mastered */
|
|
17
|
+
tag: string;
|
|
18
|
+
/** Thresholds for considering this prerequisite tag "mastered" */
|
|
19
|
+
masteryThreshold?: {
|
|
20
|
+
/** Minimum ELO score for mastery. If not set, uses avgElo comparison */
|
|
21
|
+
minElo?: number;
|
|
22
|
+
/** Minimum interaction count (default: 3) */
|
|
23
|
+
minCount?: number;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Configuration for the HierarchyDefinition strategy
|
|
29
|
+
*/
|
|
30
|
+
export interface HierarchyConfig {
|
|
31
|
+
/** Map of tag ID to its list of prerequisites (each with individual thresholds) */
|
|
32
|
+
prerequisites: {
|
|
33
|
+
[tagId: string]: TagPrerequisite[];
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DEFAULT_MIN_COUNT = 3;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A filter strategy that gates cards based on prerequisite mastery.
|
|
41
|
+
*
|
|
42
|
+
* Cards are locked until the user masters all prerequisite tags.
|
|
43
|
+
* Locked cards receive score: 0 (hard filter).
|
|
44
|
+
*
|
|
45
|
+
* Mastery is determined by:
|
|
46
|
+
* - User's ELO for the tag exceeds threshold (or avgElo if not specified)
|
|
47
|
+
* - User has minimum interaction count with the tag
|
|
48
|
+
*
|
|
49
|
+
* Tags with no prerequisites are always unlocked.
|
|
50
|
+
*
|
|
51
|
+
* Implements CardFilter for use in Pipeline architecture.
|
|
52
|
+
* Also extends ContentNavigator for backward compatibility.
|
|
53
|
+
*/
|
|
54
|
+
export default class HierarchyDefinitionNavigator extends ContentNavigator implements CardFilter {
|
|
55
|
+
private config: HierarchyConfig;
|
|
56
|
+
private _strategyData: ContentNavigationStrategyData;
|
|
57
|
+
|
|
58
|
+
/** Human-readable name for CardFilter interface */
|
|
59
|
+
name: string;
|
|
60
|
+
|
|
61
|
+
constructor(
|
|
62
|
+
user: UserDBInterface,
|
|
63
|
+
course: CourseDBInterface,
|
|
64
|
+
_strategyData: ContentNavigationStrategyData
|
|
65
|
+
) {
|
|
66
|
+
super(user, course, _strategyData);
|
|
67
|
+
this._strategyData = _strategyData;
|
|
68
|
+
this.config = this.parseConfig(_strategyData.serializedData);
|
|
69
|
+
this.name = _strategyData.name || 'Hierarchy Definition';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private parseConfig(serializedData: string): HierarchyConfig {
|
|
73
|
+
try {
|
|
74
|
+
const parsed = JSON.parse(serializedData);
|
|
75
|
+
return {
|
|
76
|
+
prerequisites: parsed.prerequisites || {},
|
|
77
|
+
};
|
|
78
|
+
} catch {
|
|
79
|
+
// Return safe defaults if parsing fails
|
|
80
|
+
return {
|
|
81
|
+
prerequisites: {},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Check if a specific prerequisite is satisfied
|
|
88
|
+
*/
|
|
89
|
+
private isPrerequisiteMet(
|
|
90
|
+
prereq: TagPrerequisite,
|
|
91
|
+
userTagElo: { score: number; count: number } | undefined,
|
|
92
|
+
userGlobalElo: number
|
|
93
|
+
): boolean {
|
|
94
|
+
if (!userTagElo) return false;
|
|
95
|
+
|
|
96
|
+
const minCount = prereq.masteryThreshold?.minCount ?? DEFAULT_MIN_COUNT;
|
|
97
|
+
if (userTagElo.count < minCount) return false;
|
|
98
|
+
|
|
99
|
+
if (prereq.masteryThreshold?.minElo !== undefined) {
|
|
100
|
+
return userTagElo.score >= prereq.masteryThreshold.minElo;
|
|
101
|
+
} else {
|
|
102
|
+
// Default: user ELO for tag > global user ELO (proxy for "above average")
|
|
103
|
+
return userTagElo.score >= userGlobalElo;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Get the set of tags the user has mastered.
|
|
109
|
+
* A tag is "mastered" if it appears as a prerequisite somewhere and meets its threshold.
|
|
110
|
+
*/
|
|
111
|
+
private async getMasteredTags(context: FilterContext): Promise<Set<string>> {
|
|
112
|
+
const mastered = new Set<string>();
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const courseReg = await context.user.getCourseRegDoc(context.course.getCourseID());
|
|
116
|
+
const userElo = toCourseElo(courseReg.elo);
|
|
117
|
+
|
|
118
|
+
// Collect all unique prerequisite tags and check mastery for each
|
|
119
|
+
for (const prereqs of Object.values(this.config.prerequisites)) {
|
|
120
|
+
for (const prereq of prereqs) {
|
|
121
|
+
const tagElo = userElo.tags[prereq.tag];
|
|
122
|
+
if (this.isPrerequisiteMet(prereq, tagElo, userElo.global.score)) {
|
|
123
|
+
mastered.add(prereq.tag);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
} catch {
|
|
128
|
+
// If we can't get user data, return empty set (no tags mastered)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return mastered;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Get the set of tags that are unlocked (prerequisites met)
|
|
136
|
+
*/
|
|
137
|
+
private getUnlockedTags(masteredTags: Set<string>): Set<string> {
|
|
138
|
+
const unlocked = new Set<string>();
|
|
139
|
+
|
|
140
|
+
for (const [tagId, prereqs] of Object.entries(this.config.prerequisites)) {
|
|
141
|
+
const allPrereqsMet = prereqs.every((prereq) => masteredTags.has(prereq.tag));
|
|
142
|
+
if (allPrereqsMet) {
|
|
143
|
+
unlocked.add(tagId);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return unlocked;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Check if a tag has prerequisites defined in config
|
|
152
|
+
*/
|
|
153
|
+
private hasPrerequisites(tagId: string): boolean {
|
|
154
|
+
return tagId in this.config.prerequisites;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Check if a card is unlocked and generate reason.
|
|
159
|
+
*/
|
|
160
|
+
private async checkCardUnlock(
|
|
161
|
+
card: WeightedCard,
|
|
162
|
+
course: CourseDBInterface,
|
|
163
|
+
unlockedTags: Set<string>,
|
|
164
|
+
masteredTags: Set<string>
|
|
165
|
+
): Promise<{ isUnlocked: boolean; reason: string }> {
|
|
166
|
+
try {
|
|
167
|
+
// Pipeline hydrates tags before filters run
|
|
168
|
+
const cardTags = card.tags ?? [];
|
|
169
|
+
|
|
170
|
+
// Check each tag's prerequisite status
|
|
171
|
+
const lockedTags = cardTags.filter(
|
|
172
|
+
(tag) => this.hasPrerequisites(tag) && !unlockedTags.has(tag)
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
if (lockedTags.length === 0) {
|
|
176
|
+
const tagList = cardTags.length > 0 ? cardTags.join(', ') : 'none';
|
|
177
|
+
return {
|
|
178
|
+
isUnlocked: true,
|
|
179
|
+
reason: `Prerequisites met, tags: ${tagList}`,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Find missing prerequisites for locked tags
|
|
184
|
+
const missingPrereqs = lockedTags.flatMap((tag) => {
|
|
185
|
+
const prereqs = this.config.prerequisites[tag] || [];
|
|
186
|
+
return prereqs.filter((p) => !masteredTags.has(p.tag)).map((p) => p.tag);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
isUnlocked: false,
|
|
191
|
+
reason: `Blocked: missing prerequisites ${missingPrereqs.join(', ')} for tags ${lockedTags.join(', ')}`,
|
|
192
|
+
};
|
|
193
|
+
} catch {
|
|
194
|
+
// If we can't get tags, assume unlocked (fail open)
|
|
195
|
+
return {
|
|
196
|
+
isUnlocked: true,
|
|
197
|
+
reason: 'Prerequisites check skipped (tag lookup failed)',
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* CardFilter.transform implementation.
|
|
204
|
+
*
|
|
205
|
+
* Apply prerequisite gating to cards. Cards with locked tags receive score: 0.
|
|
206
|
+
*/
|
|
207
|
+
async transform(cards: WeightedCard[], context: FilterContext): Promise<WeightedCard[]> {
|
|
208
|
+
// Get mastery state
|
|
209
|
+
const masteredTags = await this.getMasteredTags(context);
|
|
210
|
+
const unlockedTags = this.getUnlockedTags(masteredTags);
|
|
211
|
+
|
|
212
|
+
// Apply prerequisite gating as score multiplier
|
|
213
|
+
const gated: WeightedCard[] = [];
|
|
214
|
+
|
|
215
|
+
for (const card of cards) {
|
|
216
|
+
const { isUnlocked, reason } = await this.checkCardUnlock(
|
|
217
|
+
card,
|
|
218
|
+
context.course,
|
|
219
|
+
unlockedTags,
|
|
220
|
+
masteredTags
|
|
221
|
+
);
|
|
222
|
+
const finalScore = isUnlocked ? card.score : 0;
|
|
223
|
+
const action = isUnlocked ? 'passed' : 'penalized';
|
|
224
|
+
|
|
225
|
+
gated.push({
|
|
226
|
+
...card,
|
|
227
|
+
score: finalScore,
|
|
228
|
+
provenance: [
|
|
229
|
+
...card.provenance,
|
|
230
|
+
{
|
|
231
|
+
strategy: 'hierarchyDefinition',
|
|
232
|
+
strategyName: this.strategyName || this.name,
|
|
233
|
+
strategyId: this.strategyId || 'NAVIGATION_STRATEGY-hierarchy',
|
|
234
|
+
action,
|
|
235
|
+
score: finalScore,
|
|
236
|
+
reason,
|
|
237
|
+
},
|
|
238
|
+
],
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return gated;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Legacy getWeightedCards - now throws as filters should not be used as generators.
|
|
247
|
+
*
|
|
248
|
+
* Use transform() via Pipeline instead.
|
|
249
|
+
*/
|
|
250
|
+
async getWeightedCards(_limit: number): Promise<WeightedCard[]> {
|
|
251
|
+
throw new Error(
|
|
252
|
+
'HierarchyDefinitionNavigator is a filter and should not be used as a generator. ' +
|
|
253
|
+
'Use Pipeline with a generator and this filter via transform().'
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Legacy methods - stub implementations since filters don't generate cards
|
|
258
|
+
|
|
259
|
+
async getNewCards(_n?: number): Promise<StudySessionNewItem[]> {
|
|
260
|
+
return [];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async getPendingReviews(): Promise<(StudySessionReviewItem & ScheduledCard)[]> {
|
|
264
|
+
return [];
|
|
265
|
+
}
|
|
266
|
+
}
|
|
@@ -5,23 +5,341 @@ import {
|
|
|
5
5
|
StudySessionReviewItem,
|
|
6
6
|
StudySessionNewItem,
|
|
7
7
|
} from '..';
|
|
8
|
+
|
|
9
|
+
// Re-export filter types
|
|
10
|
+
export type { CardFilter, FilterContext, CardFilterFactory } from './filters/types';
|
|
11
|
+
|
|
12
|
+
// Re-export generator types
|
|
13
|
+
export type { CardGenerator, GeneratorContext, CardGeneratorFactory } from './generators/types';
|
|
14
|
+
|
|
8
15
|
import { ContentNavigationStrategyData } from '../types/contentNavigationStrategy';
|
|
9
16
|
import { ScheduledCard } from '../types/user';
|
|
10
17
|
import { logger } from '../../util/logger';
|
|
11
18
|
|
|
19
|
+
// ============================================================================
|
|
20
|
+
// NAVIGATION STRATEGY API
|
|
21
|
+
// ============================================================================
|
|
22
|
+
//
|
|
23
|
+
// This module defines the ContentNavigator base class and the WeightedCard type,
|
|
24
|
+
// which form the foundation of the pluggable navigation strategy system.
|
|
25
|
+
//
|
|
26
|
+
// KEY CONCEPTS:
|
|
27
|
+
//
|
|
28
|
+
// 1. WeightedCard - A card with a suitability score (0-1) and provenance trail.
|
|
29
|
+
// The provenance tracks how each strategy in the pipeline contributed to
|
|
30
|
+
// the card's final score, ensuring transparency and debuggability.
|
|
31
|
+
//
|
|
32
|
+
// 2. ContentNavigator - Abstract base class for backward compatibility.
|
|
33
|
+
// New code should use CardGenerator or CardFilter interfaces directly.
|
|
34
|
+
//
|
|
35
|
+
// 3. CardGenerator vs CardFilter:
|
|
36
|
+
// - Generators (ELO, SRS, HardcodedOrder) produce candidate cards with scores
|
|
37
|
+
// - Filters (Hierarchy, Interference, Priority, EloDistance) transform scores
|
|
38
|
+
//
|
|
39
|
+
// 4. Pipeline architecture:
|
|
40
|
+
// Pipeline(generator, [filter1, filter2, ...]) executes:
|
|
41
|
+
// cards = generator.getWeightedCards()
|
|
42
|
+
// cards = filter1.transform(cards, context)
|
|
43
|
+
// cards = filter2.transform(cards, context)
|
|
44
|
+
// return sorted(cards)
|
|
45
|
+
//
|
|
46
|
+
// 5. Provenance tracking - Each strategy adds an entry explaining its contribution.
|
|
47
|
+
// This makes the system transparent and debuggable.
|
|
48
|
+
//
|
|
49
|
+
// ============================================================================
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Tracks a single strategy's contribution to a card's final score.
|
|
53
|
+
*
|
|
54
|
+
* Each strategy in the pipeline adds a StrategyContribution entry to the
|
|
55
|
+
* card's provenance array, creating an audit trail of scoring decisions.
|
|
56
|
+
*/
|
|
57
|
+
export interface StrategyContribution {
|
|
58
|
+
/**
|
|
59
|
+
* Strategy type (implementing class name).
|
|
60
|
+
* Examples: 'elo', 'hierarchyDefinition', 'interferenceMitigator'
|
|
61
|
+
*/
|
|
62
|
+
strategy: string;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Human-readable name identifying this specific strategy instance.
|
|
66
|
+
* Extracted from ContentNavigationStrategyData.name.
|
|
67
|
+
* Courses may have multiple instances of the same strategy type with
|
|
68
|
+
* different configurations.
|
|
69
|
+
*
|
|
70
|
+
* Examples:
|
|
71
|
+
* - "ELO (default)"
|
|
72
|
+
* - "Interference: b/d/p confusion"
|
|
73
|
+
* - "Interference: phonetic confusables"
|
|
74
|
+
* - "Priority: Common letters first"
|
|
75
|
+
*/
|
|
76
|
+
strategyName: string;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Unique database document ID for this strategy instance.
|
|
80
|
+
* Extracted from ContentNavigationStrategyData._id.
|
|
81
|
+
* Use this to fetch the full strategy configuration document.
|
|
82
|
+
*
|
|
83
|
+
* Examples:
|
|
84
|
+
* - "NAVIGATION_STRATEGY-ELO-default"
|
|
85
|
+
* - "NAVIGATION_STRATEGY-interference-bdp"
|
|
86
|
+
* - "NAVIGATION_STRATEGY-priority-common-letters"
|
|
87
|
+
*/
|
|
88
|
+
strategyId: string;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* What the strategy did:
|
|
92
|
+
* - 'generated': Strategy produced this card (generators only)
|
|
93
|
+
* - 'passed': Strategy evaluated but didn't change score (transparent pass-through)
|
|
94
|
+
* - 'boosted': Strategy increased the score
|
|
95
|
+
* - 'penalized': Strategy decreased the score
|
|
96
|
+
*/
|
|
97
|
+
action: 'generated' | 'passed' | 'boosted' | 'penalized';
|
|
98
|
+
|
|
99
|
+
/** Score after this strategy's processing */
|
|
100
|
+
score: number;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Human-readable explanation of the strategy's decision.
|
|
104
|
+
*
|
|
105
|
+
* Examples:
|
|
106
|
+
* - "ELO distance 75, new card"
|
|
107
|
+
* - "Prerequisites met: letter-sounds"
|
|
108
|
+
* - "Interferes with immature tag 'd' (decay 0.8)"
|
|
109
|
+
* - "High-priority tag 's' (0.95) → boost 1.15x"
|
|
110
|
+
*
|
|
111
|
+
* Required for transparency - silent adjusters are anti-patterns.
|
|
112
|
+
*/
|
|
113
|
+
reason: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A card with a suitability score and provenance trail.
|
|
118
|
+
*
|
|
119
|
+
* Scores range from 0-1:
|
|
120
|
+
* - 1.0 = fully suitable
|
|
121
|
+
* - 0.0 = hard filter (e.g., prerequisite not met)
|
|
122
|
+
* - 0.5 = neutral
|
|
123
|
+
* - Intermediate values = soft preference
|
|
124
|
+
*
|
|
125
|
+
* Provenance tracks the scoring pipeline:
|
|
126
|
+
* - First entry: Generator that produced the card
|
|
127
|
+
* - Subsequent entries: Filters that transformed the score
|
|
128
|
+
* - Each entry includes action and human-readable reason
|
|
129
|
+
*/
|
|
130
|
+
export interface WeightedCard {
|
|
131
|
+
cardId: string;
|
|
132
|
+
courseId: string;
|
|
133
|
+
/** Suitability score from 0-1 */
|
|
134
|
+
score: number;
|
|
135
|
+
/**
|
|
136
|
+
* Audit trail of strategy contributions.
|
|
137
|
+
* First entry is from the generator, subsequent entries from filters.
|
|
138
|
+
*/
|
|
139
|
+
provenance: StrategyContribution[];
|
|
140
|
+
/**
|
|
141
|
+
* Pre-fetched tags. Populated by Pipeline before filters run.
|
|
142
|
+
* Filters should use this instead of querying getAppliedTags() individually.
|
|
143
|
+
*/
|
|
144
|
+
tags?: string[];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Extract card origin from provenance trail.
|
|
149
|
+
*
|
|
150
|
+
* The first provenance entry (from the generator) indicates whether
|
|
151
|
+
* this is a new card, review, or failed card. We parse the reason
|
|
152
|
+
* string to extract this information.
|
|
153
|
+
*
|
|
154
|
+
* @param card - Card with provenance trail
|
|
155
|
+
* @returns Card origin ('new', 'review', or 'failed')
|
|
156
|
+
*/
|
|
157
|
+
export function getCardOrigin(card: WeightedCard): 'new' | 'review' | 'failed' {
|
|
158
|
+
if (card.provenance.length === 0) {
|
|
159
|
+
throw new Error('Card has no provenance - cannot determine origin');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const firstEntry = card.provenance[0];
|
|
163
|
+
const reason = firstEntry.reason.toLowerCase();
|
|
164
|
+
|
|
165
|
+
if (reason.includes('failed')) {
|
|
166
|
+
return 'failed';
|
|
167
|
+
}
|
|
168
|
+
if (reason.includes('review')) {
|
|
169
|
+
return 'review';
|
|
170
|
+
}
|
|
171
|
+
return 'new';
|
|
172
|
+
}
|
|
173
|
+
|
|
12
174
|
export enum Navigators {
|
|
13
175
|
ELO = 'elo',
|
|
176
|
+
SRS = 'srs',
|
|
14
177
|
HARDCODED = 'hardcodedOrder',
|
|
178
|
+
HIERARCHY = 'hierarchyDefinition',
|
|
179
|
+
INTERFERENCE = 'interferenceMitigator',
|
|
180
|
+
RELATIVE_PRIORITY = 'relativePriority',
|
|
181
|
+
USER_TAG_PREFERENCE = 'userTagPreference',
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ============================================================================
|
|
185
|
+
// NAVIGATOR ROLE CLASSIFICATION
|
|
186
|
+
// ============================================================================
|
|
187
|
+
//
|
|
188
|
+
// Navigators are classified as either generators or filters:
|
|
189
|
+
// - Generators: Produce candidate cards (ELO, SRS, HardcodedOrder)
|
|
190
|
+
// - Filters: Transform/score candidates (Hierarchy, Interference, RelativePriority)
|
|
191
|
+
//
|
|
192
|
+
// This classification is used by PipelineAssembler to build pipelines:
|
|
193
|
+
// 1. Instantiate generators (possibly into a CompositeGenerator)
|
|
194
|
+
// 2. Instantiate filters
|
|
195
|
+
// 3. Create Pipeline(generator, filters)
|
|
196
|
+
//
|
|
197
|
+
// ============================================================================
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Role classification for navigation strategies.
|
|
201
|
+
*
|
|
202
|
+
* - GENERATOR: Produces candidate cards with initial scores
|
|
203
|
+
* - FILTER: Transforms cards with score multipliers
|
|
204
|
+
*/
|
|
205
|
+
export enum NavigatorRole {
|
|
206
|
+
GENERATOR = 'generator',
|
|
207
|
+
FILTER = 'filter',
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Registry mapping navigator implementations to their roles.
|
|
212
|
+
*/
|
|
213
|
+
export const NavigatorRoles: Record<Navigators, NavigatorRole> = {
|
|
214
|
+
[Navigators.ELO]: NavigatorRole.GENERATOR,
|
|
215
|
+
[Navigators.SRS]: NavigatorRole.GENERATOR,
|
|
216
|
+
[Navigators.HARDCODED]: NavigatorRole.GENERATOR,
|
|
217
|
+
[Navigators.HIERARCHY]: NavigatorRole.FILTER,
|
|
218
|
+
[Navigators.INTERFERENCE]: NavigatorRole.FILTER,
|
|
219
|
+
[Navigators.RELATIVE_PRIORITY]: NavigatorRole.FILTER,
|
|
220
|
+
[Navigators.USER_TAG_PREFERENCE]: NavigatorRole.FILTER,
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Check if a navigator implementation is a generator.
|
|
225
|
+
*
|
|
226
|
+
* @param impl - Navigator implementation name (e.g., 'elo', 'hierarchyDefinition')
|
|
227
|
+
* @returns true if the navigator is a generator, false otherwise
|
|
228
|
+
*/
|
|
229
|
+
export function isGenerator(impl: string): boolean {
|
|
230
|
+
return NavigatorRoles[impl as Navigators] === NavigatorRole.GENERATOR;
|
|
15
231
|
}
|
|
16
232
|
|
|
17
233
|
/**
|
|
18
|
-
*
|
|
234
|
+
* Check if a navigator implementation is a filter.
|
|
235
|
+
*
|
|
236
|
+
* @param impl - Navigator implementation name (e.g., 'elo', 'hierarchyDefinition')
|
|
237
|
+
* @returns true if the navigator is a filter, false otherwise
|
|
238
|
+
*/
|
|
239
|
+
export function isFilter(impl: string): boolean {
|
|
240
|
+
return NavigatorRoles[impl as Navigators] === NavigatorRole.FILTER;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Abstract base class for navigation strategies.
|
|
245
|
+
*
|
|
246
|
+
* This class exists primarily for backward compatibility with legacy code.
|
|
247
|
+
* New code should use CardGenerator or CardFilter interfaces directly.
|
|
248
|
+
*
|
|
249
|
+
* The class implements StudyContentSource for compatibility with SessionController.
|
|
250
|
+
* Once SessionController migrates to use getWeightedCards() exclusively,
|
|
251
|
+
* the legacy methods can be removed.
|
|
19
252
|
*/
|
|
20
253
|
export abstract class ContentNavigator implements StudyContentSource {
|
|
254
|
+
/** User interface for this navigation session */
|
|
255
|
+
protected user?: UserDBInterface;
|
|
256
|
+
|
|
257
|
+
/** Course interface for this navigation session */
|
|
258
|
+
protected course?: CourseDBInterface;
|
|
259
|
+
|
|
260
|
+
/** Human-readable name for this strategy instance (from ContentNavigationStrategyData.name) */
|
|
261
|
+
protected strategyName?: string;
|
|
262
|
+
|
|
263
|
+
/** Unique document ID for this strategy instance (from ContentNavigationStrategyData._id) */
|
|
264
|
+
protected strategyId?: string;
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Constructor for standard navigators.
|
|
268
|
+
* Call this from subclass constructors to initialize common fields.
|
|
269
|
+
*
|
|
270
|
+
* Note: CompositeGenerator doesn't use this pattern and should call super() without args.
|
|
271
|
+
*/
|
|
272
|
+
constructor(
|
|
273
|
+
user?: UserDBInterface,
|
|
274
|
+
course?: CourseDBInterface,
|
|
275
|
+
strategyData?: ContentNavigationStrategyData
|
|
276
|
+
) {
|
|
277
|
+
if (user && course && strategyData) {
|
|
278
|
+
this.user = user;
|
|
279
|
+
this.course = course;
|
|
280
|
+
this.strategyName = strategyData.name;
|
|
281
|
+
this.strategyId = strategyData._id;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ============================================================================
|
|
286
|
+
// STRATEGY STATE HELPERS
|
|
287
|
+
// ============================================================================
|
|
288
|
+
//
|
|
289
|
+
// These methods allow strategies to persist their own state (user preferences,
|
|
290
|
+
// learned patterns, temporal tracking) in the user database.
|
|
291
|
+
//
|
|
292
|
+
// ============================================================================
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Unique key identifying this strategy for state storage.
|
|
296
|
+
*
|
|
297
|
+
* Defaults to the constructor name (e.g., "UserTagPreferenceFilter").
|
|
298
|
+
* Override in subclasses if multiple instances of the same strategy type
|
|
299
|
+
* need separate state storage.
|
|
300
|
+
*/
|
|
301
|
+
protected get strategyKey(): string {
|
|
302
|
+
return this.constructor.name;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Get this strategy's persisted state for the current course.
|
|
307
|
+
*
|
|
308
|
+
* @returns The strategy's data payload, or null if no state exists
|
|
309
|
+
* @throws Error if user or course is not initialized
|
|
310
|
+
*/
|
|
311
|
+
protected async getStrategyState<T>(): Promise<T | null> {
|
|
312
|
+
if (!this.user || !this.course) {
|
|
313
|
+
throw new Error(
|
|
314
|
+
`Cannot get strategy state: navigator not properly initialized. ` +
|
|
315
|
+
`Ensure user and course are provided to constructor.`
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
return this.user.getStrategyState<T>(this.course.getCourseID(), this.strategyKey);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Persist this strategy's state for the current course.
|
|
323
|
+
*
|
|
324
|
+
* @param data - The strategy's data payload to store
|
|
325
|
+
* @throws Error if user or course is not initialized
|
|
326
|
+
*/
|
|
327
|
+
protected async putStrategyState<T>(data: T): Promise<void> {
|
|
328
|
+
if (!this.user || !this.course) {
|
|
329
|
+
throw new Error(
|
|
330
|
+
`Cannot put strategy state: navigator not properly initialized. ` +
|
|
331
|
+
`Ensure user and course are provided to constructor.`
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
return this.user.putStrategyState<T>(this.course.getCourseID(), this.strategyKey, data);
|
|
335
|
+
}
|
|
336
|
+
|
|
21
337
|
/**
|
|
338
|
+
* Factory method to create navigator instances dynamically.
|
|
22
339
|
*
|
|
23
|
-
* @param user
|
|
24
|
-
* @param
|
|
340
|
+
* @param user - User interface
|
|
341
|
+
* @param course - Course interface
|
|
342
|
+
* @param strategyData - Strategy configuration document
|
|
25
343
|
* @returns the runtime object used to steer a study session.
|
|
26
344
|
*/
|
|
27
345
|
static async create(
|
|
@@ -53,6 +371,89 @@ export abstract class ContentNavigator implements StudyContentSource {
|
|
|
53
371
|
return new NavigatorImpl(user, course, strategyData);
|
|
54
372
|
}
|
|
55
373
|
|
|
374
|
+
/**
|
|
375
|
+
* Get cards scheduled for review.
|
|
376
|
+
*
|
|
377
|
+
* @deprecated This method is part of the legacy StudyContentSource interface.
|
|
378
|
+
* New strategies should focus on implementing CardGenerator.getWeightedCards() instead.
|
|
379
|
+
*/
|
|
56
380
|
abstract getPendingReviews(): Promise<(StudySessionReviewItem & ScheduledCard)[]>;
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Get new cards for introduction.
|
|
384
|
+
*
|
|
385
|
+
* @deprecated This method is part of the legacy StudyContentSource interface.
|
|
386
|
+
* New strategies should focus on implementing CardGenerator.getWeightedCards() instead.
|
|
387
|
+
*
|
|
388
|
+
* @param n - Maximum number of new cards to return
|
|
389
|
+
*/
|
|
57
390
|
abstract getNewCards(n?: number): Promise<StudySessionNewItem[]>;
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Get cards with suitability scores and provenance trails.
|
|
394
|
+
*
|
|
395
|
+
* **This is the PRIMARY API for navigation strategies.**
|
|
396
|
+
*
|
|
397
|
+
* Returns cards ranked by suitability score (0-1). Higher scores indicate
|
|
398
|
+
* better candidates for presentation. Each card includes a provenance trail
|
|
399
|
+
* documenting how strategies contributed to the final score.
|
|
400
|
+
*
|
|
401
|
+
* ## For Generators
|
|
402
|
+
* Override this method to generate candidates and compute scores based on
|
|
403
|
+
* your strategy's logic (e.g., ELO proximity, review urgency). Create the
|
|
404
|
+
* initial provenance entry with action='generated'.
|
|
405
|
+
*
|
|
406
|
+
* ## Default Implementation
|
|
407
|
+
* The base class provides a backward-compatible default that:
|
|
408
|
+
* 1. Calls legacy getNewCards() and getPendingReviews()
|
|
409
|
+
* 2. Assigns score=1.0 to all cards
|
|
410
|
+
* 3. Creates minimal provenance from legacy methods
|
|
411
|
+
* 4. Returns combined results up to limit
|
|
412
|
+
*
|
|
413
|
+
* This allows existing strategies to work without modification while
|
|
414
|
+
* new strategies can override with proper scoring and provenance.
|
|
415
|
+
*
|
|
416
|
+
* @param limit - Maximum cards to return
|
|
417
|
+
* @returns Cards sorted by score descending, with provenance trails
|
|
418
|
+
*/
|
|
419
|
+
async getWeightedCards(limit: number): Promise<WeightedCard[]> {
|
|
420
|
+
// Default implementation: delegate to legacy methods, assign score=1.0
|
|
421
|
+
const newCards = await this.getNewCards(limit);
|
|
422
|
+
const reviews = await this.getPendingReviews();
|
|
423
|
+
|
|
424
|
+
const weighted: WeightedCard[] = [
|
|
425
|
+
...newCards.map((c) => ({
|
|
426
|
+
cardId: c.cardID,
|
|
427
|
+
courseId: c.courseID,
|
|
428
|
+
score: 1.0,
|
|
429
|
+
provenance: [
|
|
430
|
+
{
|
|
431
|
+
strategy: 'legacy',
|
|
432
|
+
strategyName: this.strategyName || 'Legacy API',
|
|
433
|
+
strategyId: this.strategyId || 'legacy-fallback',
|
|
434
|
+
action: 'generated' as const,
|
|
435
|
+
score: 1.0,
|
|
436
|
+
reason: 'Generated via legacy getNewCards(), new card',
|
|
437
|
+
},
|
|
438
|
+
],
|
|
439
|
+
})),
|
|
440
|
+
...reviews.map((r) => ({
|
|
441
|
+
cardId: r.cardID,
|
|
442
|
+
courseId: r.courseID,
|
|
443
|
+
score: 1.0,
|
|
444
|
+
provenance: [
|
|
445
|
+
{
|
|
446
|
+
strategy: 'legacy',
|
|
447
|
+
strategyName: this.strategyName || 'Legacy API',
|
|
448
|
+
strategyId: this.strategyId || 'legacy-fallback',
|
|
449
|
+
action: 'generated' as const,
|
|
450
|
+
score: 1.0,
|
|
451
|
+
reason: 'Generated via legacy getPendingReviews(), review',
|
|
452
|
+
},
|
|
453
|
+
],
|
|
454
|
+
})),
|
|
455
|
+
];
|
|
456
|
+
|
|
457
|
+
return weighted.slice(0, limit);
|
|
458
|
+
}
|
|
58
459
|
}
|