@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,232 @@
|
|
|
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 './types';
|
|
9
|
+
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// USER TAG PREFERENCE FILTER
|
|
12
|
+
// ============================================================================
|
|
13
|
+
//
|
|
14
|
+
// Allows users to personalize their learning experience by specifying:
|
|
15
|
+
// - Tags to boost/penalize (score multiplied by boost factor)
|
|
16
|
+
//
|
|
17
|
+
// User preferences are stored in STRATEGY_STATE documents in the user's
|
|
18
|
+
// database, enabling persistence across sessions and sync across devices.
|
|
19
|
+
//
|
|
20
|
+
// Use cases:
|
|
21
|
+
// - Goal-based learning: "I want to learn piano by ear, skip sight-reading"
|
|
22
|
+
// - Selective focus: "I only want to practice chess endgames"
|
|
23
|
+
// - Accessibility: "Skip text-heavy cards, prefer visual content"
|
|
24
|
+
// - Difficulty customization: "Skip beginner content I already know"
|
|
25
|
+
//
|
|
26
|
+
// ============================================================================
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* User's tag preference state, stored in STRATEGY_STATE document.
|
|
30
|
+
*
|
|
31
|
+
* This interface defines what gets persisted to the user's database.
|
|
32
|
+
* UI components write to this structure, and the filter reads from it.
|
|
33
|
+
*
|
|
34
|
+
* ## Preferences vs Goals
|
|
35
|
+
*
|
|
36
|
+
* Preferences are **path constraints** — they affect HOW the user learns,
|
|
37
|
+
* not WHAT they're trying to learn. Examples:
|
|
38
|
+
* - "Skip text-heavy cards" (accessibility)
|
|
39
|
+
* - "Prefer visual content"
|
|
40
|
+
*
|
|
41
|
+
* For **goal-based** filtering (defining WHAT to learn), see the separate
|
|
42
|
+
* UserGoalNavigator (stub). Goals affect progress tracking and completion
|
|
43
|
+
* criteria; preferences only affect card selection.
|
|
44
|
+
*
|
|
45
|
+
* ## Slider Semantics
|
|
46
|
+
*
|
|
47
|
+
* Each tag maps to a multiplier value in the `boost` record:
|
|
48
|
+
* - `0` = banish/exclude (card score = 0)
|
|
49
|
+
* - `0.5` = penalize by 50%
|
|
50
|
+
* - `1.0` = neutral/no effect (default when tag added)
|
|
51
|
+
* - `2.0` = 2x preference boost
|
|
52
|
+
* - Higher values = stronger preference
|
|
53
|
+
*
|
|
54
|
+
* If multiple tags on a card have preferences, the maximum multiplier wins.
|
|
55
|
+
*/
|
|
56
|
+
export interface UserTagPreferenceState {
|
|
57
|
+
/**
|
|
58
|
+
* Tag-specific multipliers.
|
|
59
|
+
* Maps tag name to score multiplier (0 = exclude, 1 = neutral, >1 = boost).
|
|
60
|
+
*/
|
|
61
|
+
boost: Record<string, number>;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* ISO timestamp of last update.
|
|
65
|
+
* Use `moment.utc(updatedAt)` to parse into a Moment object.
|
|
66
|
+
*/
|
|
67
|
+
updatedAt: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A filter that applies user-configured tag preferences.
|
|
72
|
+
*
|
|
73
|
+
* Reads preferences from STRATEGY_STATE document in user's database.
|
|
74
|
+
* If no preferences exist, passes through unchanged (no-op).
|
|
75
|
+
*
|
|
76
|
+
* Implements CardFilter for use in Pipeline architecture.
|
|
77
|
+
* Also extends ContentNavigator for compatibility with dynamic loading.
|
|
78
|
+
*/
|
|
79
|
+
export default class UserTagPreferenceFilter extends ContentNavigator implements CardFilter {
|
|
80
|
+
private _strategyData: ContentNavigationStrategyData;
|
|
81
|
+
|
|
82
|
+
/** Human-readable name for CardFilter interface */
|
|
83
|
+
name: string;
|
|
84
|
+
|
|
85
|
+
constructor(
|
|
86
|
+
user: UserDBInterface,
|
|
87
|
+
course: CourseDBInterface,
|
|
88
|
+
strategyData: ContentNavigationStrategyData
|
|
89
|
+
) {
|
|
90
|
+
super(user, course, strategyData);
|
|
91
|
+
this._strategyData = strategyData;
|
|
92
|
+
this.name = strategyData.name || 'User Tag Preferences';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Compute multiplier for a card based on its tags and user preferences.
|
|
97
|
+
* Returns the maximum multiplier among all matching tags, or 1.0 if no matches.
|
|
98
|
+
*/
|
|
99
|
+
private computeMultiplier(cardTags: string[], boostMap: Record<string, number>): number {
|
|
100
|
+
const multipliers = cardTags
|
|
101
|
+
.map((tag) => boostMap[tag])
|
|
102
|
+
.filter((val) => val !== undefined);
|
|
103
|
+
|
|
104
|
+
if (multipliers.length === 0) {
|
|
105
|
+
return 1.0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Use max multiplier among matching tags
|
|
109
|
+
return Math.max(...multipliers);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Build human-readable reason for the filter's decision.
|
|
114
|
+
*/
|
|
115
|
+
private buildReason(
|
|
116
|
+
cardTags: string[],
|
|
117
|
+
boostMap: Record<string, number>,
|
|
118
|
+
multiplier: number
|
|
119
|
+
): string {
|
|
120
|
+
// Find which tag(s) contributed to the multiplier
|
|
121
|
+
const matchingTags = cardTags.filter((tag) => boostMap[tag] === multiplier);
|
|
122
|
+
|
|
123
|
+
if (multiplier === 0) {
|
|
124
|
+
return `Excluded by user preference: ${matchingTags.join(', ')} (${multiplier}x)`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (multiplier < 1.0) {
|
|
128
|
+
return `Penalized by user preference: ${matchingTags.join(', ')} (${multiplier.toFixed(2)}x)`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (multiplier > 1.0) {
|
|
132
|
+
return `Boosted by user preference: ${matchingTags.join(', ')} (${multiplier.toFixed(2)}x)`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return 'No matching user preferences';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* CardFilter.transform implementation.
|
|
140
|
+
*
|
|
141
|
+
* Apply user tag preferences:
|
|
142
|
+
* 1. Read preferences from strategy state
|
|
143
|
+
* 2. If no preferences, pass through unchanged
|
|
144
|
+
* 3. For each card:
|
|
145
|
+
* - Look up tag in boost record
|
|
146
|
+
* - If tag found: apply multiplier (0 = exclude, 1 = neutral, >1 = boost)
|
|
147
|
+
* - If multiple tags match: use max multiplier
|
|
148
|
+
* - Append provenance with clear reason
|
|
149
|
+
*/
|
|
150
|
+
async transform(cards: WeightedCard[], _context: FilterContext): Promise<WeightedCard[]> {
|
|
151
|
+
// Read user preferences from strategy state
|
|
152
|
+
const prefs = await this.getStrategyState<UserTagPreferenceState>();
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
// No preferences configured → pass through unchanged
|
|
156
|
+
if (!prefs || Object.keys(prefs.boost).length === 0) {
|
|
157
|
+
return cards.map((card) => ({
|
|
158
|
+
...card,
|
|
159
|
+
provenance: [
|
|
160
|
+
...card.provenance,
|
|
161
|
+
{
|
|
162
|
+
strategy: 'userTagPreference',
|
|
163
|
+
strategyName: this.strategyName || this.name,
|
|
164
|
+
strategyId: this.strategyId || this._strategyData._id,
|
|
165
|
+
action: 'passed' as const,
|
|
166
|
+
score: card.score,
|
|
167
|
+
reason: 'No user tag preferences configured',
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
}));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Process each card
|
|
174
|
+
const adjusted: WeightedCard[] = await Promise.all(
|
|
175
|
+
cards.map(async (card) => {
|
|
176
|
+
const cardTags = card.tags ?? [];
|
|
177
|
+
|
|
178
|
+
// Compute multiplier based on card tags and user preferences
|
|
179
|
+
const multiplier = this.computeMultiplier(cardTags, prefs.boost);
|
|
180
|
+
const finalScore = Math.min(1, card.score * multiplier);
|
|
181
|
+
|
|
182
|
+
// Determine action for provenance
|
|
183
|
+
let action: 'passed' | 'boosted' | 'penalized';
|
|
184
|
+
if (multiplier === 0 || multiplier < 1.0) {
|
|
185
|
+
action = 'penalized';
|
|
186
|
+
} else if (multiplier > 1.0) {
|
|
187
|
+
action = 'boosted';
|
|
188
|
+
} else {
|
|
189
|
+
action = 'passed';
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
...card,
|
|
194
|
+
score: finalScore,
|
|
195
|
+
provenance: [
|
|
196
|
+
...card.provenance,
|
|
197
|
+
{
|
|
198
|
+
strategy: 'userTagPreference',
|
|
199
|
+
strategyName: this.strategyName || this.name,
|
|
200
|
+
strategyId: this.strategyId || this._strategyData._id,
|
|
201
|
+
action,
|
|
202
|
+
score: finalScore,
|
|
203
|
+
reason: this.buildReason(cardTags, prefs.boost, multiplier),
|
|
204
|
+
},
|
|
205
|
+
],
|
|
206
|
+
};
|
|
207
|
+
})
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
return adjusted;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Legacy getWeightedCards - throws as filters should not be used as generators.
|
|
215
|
+
*/
|
|
216
|
+
async getWeightedCards(_limit: number): Promise<WeightedCard[]> {
|
|
217
|
+
throw new Error(
|
|
218
|
+
'UserTagPreferenceFilter is a filter and should not be used as a generator. ' +
|
|
219
|
+
'Use Pipeline with a generator and this filter via transform().'
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Legacy methods - stub implementations since filters don't generate cards
|
|
224
|
+
|
|
225
|
+
async getNewCards(_n?: number): Promise<StudySessionNewItem[]> {
|
|
226
|
+
return [];
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async getPendingReviews(): Promise<(StudySessionReviewItem & ScheduledCard)[]> {
|
|
230
|
+
return [];
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { WeightedCard } from '../index';
|
|
2
|
+
import type { CourseDBInterface } from '../../interfaces/courseDB';
|
|
3
|
+
import type { UserDBInterface } from '../../interfaces/userDB';
|
|
4
|
+
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// CARD GENERATOR INTERFACE
|
|
7
|
+
// ============================================================================
|
|
8
|
+
//
|
|
9
|
+
// Generators produce candidate cards with initial scores.
|
|
10
|
+
// They are the "source" stage of a navigation pipeline.
|
|
11
|
+
//
|
|
12
|
+
// Examples: ELO (skill proximity), SRS (review scheduling), HardcodedOrder
|
|
13
|
+
//
|
|
14
|
+
// Generators differ from filters:
|
|
15
|
+
// - Generators: produce candidates from DB queries, assign initial scores
|
|
16
|
+
// - Filters: transform existing candidates, adjust scores with multipliers
|
|
17
|
+
//
|
|
18
|
+
// The Pipeline class orchestrates: Generator → Filter₁ → Filter₂ → ... → Results
|
|
19
|
+
//
|
|
20
|
+
// ============================================================================
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Context available to generators when producing candidates.
|
|
24
|
+
*
|
|
25
|
+
* Built once per getWeightedCards() call by the Pipeline.
|
|
26
|
+
*/
|
|
27
|
+
export interface GeneratorContext {
|
|
28
|
+
/** User database interface */
|
|
29
|
+
user: UserDBInterface;
|
|
30
|
+
|
|
31
|
+
/** Course database interface */
|
|
32
|
+
course: CourseDBInterface;
|
|
33
|
+
|
|
34
|
+
/** User's global ELO score for this course */
|
|
35
|
+
userElo: number;
|
|
36
|
+
|
|
37
|
+
// Future extensions:
|
|
38
|
+
// - user's tag-level ELO data
|
|
39
|
+
// - course config
|
|
40
|
+
// - session state (cards already seen this session)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A generator that produces candidate cards with initial scores.
|
|
45
|
+
*
|
|
46
|
+
* Generators are the "source" stage of a navigation pipeline.
|
|
47
|
+
* They query the database for eligible cards and assign initial
|
|
48
|
+
* suitability scores based on their strategy (ELO proximity,
|
|
49
|
+
* review urgency, fixed order, etc.).
|
|
50
|
+
*
|
|
51
|
+
* ## Implementation Guidelines
|
|
52
|
+
*
|
|
53
|
+
* 1. **Create provenance**: Each card should have a provenance entry
|
|
54
|
+
* with action='generated' documenting why it was selected.
|
|
55
|
+
*
|
|
56
|
+
* 2. **Score semantics**: Higher scores = more suitable for presentation.
|
|
57
|
+
* Scores should be in [0, 1] range for composability.
|
|
58
|
+
*
|
|
59
|
+
* 3. **Limit handling**: Respect the limit parameter, but may over-fetch
|
|
60
|
+
* internally if needed for scoring accuracy.
|
|
61
|
+
*
|
|
62
|
+
* 4. **Sort before returning**: Return cards sorted by score descending.
|
|
63
|
+
*
|
|
64
|
+
* ## Example Implementation
|
|
65
|
+
*
|
|
66
|
+
* ```typescript
|
|
67
|
+
* const myGenerator: CardGenerator = {
|
|
68
|
+
* name: 'My Generator',
|
|
69
|
+
* async getWeightedCards(limit, context) {
|
|
70
|
+
* const candidates = await fetchCandidates(context.course, limit);
|
|
71
|
+
* return candidates.map(c => ({
|
|
72
|
+
* cardId: c.id,
|
|
73
|
+
* courseId: context.course.getCourseID(),
|
|
74
|
+
* score: computeScore(c, context),
|
|
75
|
+
* provenance: [{
|
|
76
|
+
* strategy: 'myGenerator',
|
|
77
|
+
* strategyName: 'My Generator',
|
|
78
|
+
* strategyId: 'MY_GENERATOR',
|
|
79
|
+
* action: 'generated',
|
|
80
|
+
* score: computeScore(c, context),
|
|
81
|
+
* reason: 'Explanation of selection'
|
|
82
|
+
* }]
|
|
83
|
+
* }));
|
|
84
|
+
* }
|
|
85
|
+
* };
|
|
86
|
+
* ```
|
|
87
|
+
*/
|
|
88
|
+
export interface CardGenerator {
|
|
89
|
+
/** Human-readable name for this generator */
|
|
90
|
+
name: string;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Produce candidate cards with initial scores.
|
|
94
|
+
*
|
|
95
|
+
* @param limit - Maximum number of cards to return
|
|
96
|
+
* @param context - Shared context (user, course, userElo, etc.)
|
|
97
|
+
* @returns Cards sorted by score descending, with provenance
|
|
98
|
+
*/
|
|
99
|
+
getWeightedCards(limit: number, context: GeneratorContext): Promise<WeightedCard[]>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Factory function type for creating generators from configuration.
|
|
104
|
+
*
|
|
105
|
+
* Used by PipelineAssembler to instantiate generators from strategy documents.
|
|
106
|
+
*/
|
|
107
|
+
export type CardGeneratorFactory<TConfig = unknown> = (config: TConfig) => CardGenerator;
|
|
@@ -1,22 +1,55 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import type {
|
|
2
|
+
CourseDBInterface,
|
|
3
|
+
QualifiedCardID,
|
|
4
|
+
StudySessionNewItem,
|
|
5
|
+
StudySessionReviewItem,
|
|
6
|
+
UserDBInterface,
|
|
7
|
+
} from '..';
|
|
8
|
+
import type { ContentNavigationStrategyData } from '../types/contentNavigationStrategy';
|
|
9
|
+
import type { ScheduledCard } from '../types/user';
|
|
4
10
|
import { ContentNavigator } from './index';
|
|
11
|
+
import type { WeightedCard } from './index';
|
|
12
|
+
import type { CardGenerator, GeneratorContext } from './generators/types';
|
|
5
13
|
import { logger } from '../../util/logger';
|
|
6
14
|
|
|
7
|
-
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// HARDCODED ORDER NAVIGATOR
|
|
17
|
+
// ============================================================================
|
|
18
|
+
//
|
|
19
|
+
// A generator strategy that presents cards in a fixed, author-defined order.
|
|
20
|
+
//
|
|
21
|
+
// Use case: When course authors want explicit control over content sequencing,
|
|
22
|
+
// e.g., teaching letters in a specific pedagogical order.
|
|
23
|
+
//
|
|
24
|
+
// The order is defined in serializedData as a JSON array of card IDs.
|
|
25
|
+
// Earlier positions in the array get higher scores.
|
|
26
|
+
//
|
|
27
|
+
// ============================================================================
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A navigation strategy that presents cards in a fixed order.
|
|
31
|
+
*
|
|
32
|
+
* Implements CardGenerator for use in Pipeline architecture.
|
|
33
|
+
* Also extends ContentNavigator for backward compatibility with legacy code.
|
|
34
|
+
*
|
|
35
|
+
* Scoring:
|
|
36
|
+
* - Earlier cards in the sequence get higher scores
|
|
37
|
+
* - Reviews get score 1.0 (highest priority)
|
|
38
|
+
* - New cards scored by position: 1.0 - (position / total) * 0.5
|
|
39
|
+
*/
|
|
40
|
+
export default class HardcodedOrderNavigator extends ContentNavigator implements CardGenerator {
|
|
41
|
+
/** Human-readable name for CardGenerator interface */
|
|
42
|
+
name: string;
|
|
43
|
+
|
|
8
44
|
private orderedCardIds: string[] = [];
|
|
9
|
-
private user: UserDBInterface;
|
|
10
|
-
private course: CourseDBInterface;
|
|
11
45
|
|
|
12
46
|
constructor(
|
|
13
47
|
user: UserDBInterface,
|
|
14
48
|
course: CourseDBInterface,
|
|
15
49
|
strategyData: ContentNavigationStrategyData
|
|
16
50
|
) {
|
|
17
|
-
super();
|
|
18
|
-
this.
|
|
19
|
-
this.course = course;
|
|
51
|
+
super(user, course, strategyData);
|
|
52
|
+
this.name = strategyData.name || 'Hardcoded Order';
|
|
20
53
|
|
|
21
54
|
if (strategyData.serializedData) {
|
|
22
55
|
try {
|
|
@@ -45,9 +78,7 @@ export default class HardcodedOrderNavigator extends ContentNavigator {
|
|
|
45
78
|
async getNewCards(limit: number = 99): Promise<StudySessionNewItem[]> {
|
|
46
79
|
const activeCardIds = (await this.user.getActiveCards()).map((c: QualifiedCardID) => c.cardID);
|
|
47
80
|
|
|
48
|
-
const newCardIds = this.orderedCardIds.filter(
|
|
49
|
-
(cardId) => !activeCardIds.includes(cardId)
|
|
50
|
-
);
|
|
81
|
+
const newCardIds = this.orderedCardIds.filter((cardId) => !activeCardIds.includes(cardId));
|
|
51
82
|
|
|
52
83
|
const cardsToReturn = newCardIds.slice(0, limit);
|
|
53
84
|
|
|
@@ -61,4 +92,72 @@ export default class HardcodedOrderNavigator extends ContentNavigator {
|
|
|
61
92
|
};
|
|
62
93
|
});
|
|
63
94
|
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Get cards in hardcoded order with scores based on position.
|
|
98
|
+
*
|
|
99
|
+
* Earlier cards in the sequence get higher scores.
|
|
100
|
+
* Score formula: 1.0 - (position / totalCards) * 0.5
|
|
101
|
+
* This ensures scores range from 1.0 (first card) to 0.5+ (last card).
|
|
102
|
+
*
|
|
103
|
+
* This method supports both the legacy signature (limit only) and the
|
|
104
|
+
* CardGenerator interface signature (limit, context).
|
|
105
|
+
*
|
|
106
|
+
* @param limit - Maximum number of cards to return
|
|
107
|
+
* @param _context - Optional GeneratorContext (currently unused, but required for interface)
|
|
108
|
+
*/
|
|
109
|
+
async getWeightedCards(limit: number, _context?: GeneratorContext): Promise<WeightedCard[]> {
|
|
110
|
+
const activeCardIds = (await this.user.getActiveCards()).map((c: QualifiedCardID) => c.cardID);
|
|
111
|
+
const reviews = await this.getPendingReviews();
|
|
112
|
+
|
|
113
|
+
// Filter out already-active cards
|
|
114
|
+
const newCardIds = this.orderedCardIds.filter((cardId) => !activeCardIds.includes(cardId));
|
|
115
|
+
|
|
116
|
+
const totalCards = newCardIds.length;
|
|
117
|
+
|
|
118
|
+
// Score new cards by position in sequence
|
|
119
|
+
const scoredNew: WeightedCard[] = newCardIds.slice(0, limit).map((cardId, index) => {
|
|
120
|
+
const position = index + 1;
|
|
121
|
+
const score = Math.max(0.5, 1.0 - (index / totalCards) * 0.5);
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
cardId,
|
|
125
|
+
courseId: this.course.getCourseID(),
|
|
126
|
+
score,
|
|
127
|
+
provenance: [
|
|
128
|
+
{
|
|
129
|
+
strategy: 'hardcodedOrder',
|
|
130
|
+
strategyName: this.strategyName || this.name,
|
|
131
|
+
strategyId: this.strategyId || 'NAVIGATION_STRATEGY-hardcoded',
|
|
132
|
+
action: 'generated',
|
|
133
|
+
score,
|
|
134
|
+
reason: `Position ${position} of ${totalCards} in fixed sequence, new card`,
|
|
135
|
+
},
|
|
136
|
+
],
|
|
137
|
+
};
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Score reviews at 1.0 (highest priority)
|
|
141
|
+
const scoredReviews: WeightedCard[] = reviews.map((r) => ({
|
|
142
|
+
cardId: r.cardID,
|
|
143
|
+
courseId: r.courseID,
|
|
144
|
+
score: 1.0,
|
|
145
|
+
provenance: [
|
|
146
|
+
{
|
|
147
|
+
strategy: 'hardcodedOrder',
|
|
148
|
+
strategyName: this.strategyName || this.name,
|
|
149
|
+
strategyId: this.strategyId || 'NAVIGATION_STRATEGY-hardcoded',
|
|
150
|
+
action: 'generated',
|
|
151
|
+
score: 1.0,
|
|
152
|
+
reason: 'Scheduled review, highest priority',
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
}));
|
|
156
|
+
|
|
157
|
+
// Combine (reviews already sorted at top due to score=1.0)
|
|
158
|
+
const all = [...scoredReviews, ...scoredNew];
|
|
159
|
+
all.sort((a, b) => b.score - a.score);
|
|
160
|
+
|
|
161
|
+
return all.slice(0, limit);
|
|
162
|
+
}
|
|
64
163
|
}
|