@vue-skuilder/db 0.1.18 → 0.1.21
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/CLAUDE.md +2 -2
- package/dist/{classroomDB-BgfrVb8d.d.ts → contentSource-BP9hznNV.d.ts} +220 -197
- package/dist/{classroomDB-CTOenngH.d.cts → contentSource-DsJadoBU.d.cts} +220 -197
- package/dist/core/index.d.cts +80 -6
- package/dist/core/index.d.ts +80 -6
- package/dist/core/index.js +735 -1560
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +708 -1539
- package/dist/core/index.mjs.map +1 -1
- package/dist/{dataLayerProvider-D6PoCwS6.d.cts → dataLayerProvider-CHYrQ5pB.d.cts} +1 -1
- package/dist/{dataLayerProvider-CZxC9GtB.d.ts → dataLayerProvider-MDTxXq2l.d.ts} +1 -1
- package/dist/impl/couch/index.d.cts +8 -23
- package/dist/impl/couch/index.d.ts +8 -23
- package/dist/impl/couch/index.js +723 -1578
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +692 -1552
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.d.cts +25 -8
- package/dist/impl/static/index.d.ts +25 -8
- package/dist/impl/static/index.js +700 -1400
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +688 -1393
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/{index-D-Fa4Smt.d.cts → index-B_j6u5E4.d.cts} +1 -1
- package/dist/{index-CD8BZz2k.d.ts → index-Dj0SEgk3.d.ts} +1 -1
- package/dist/index.d.cts +71 -63
- package/dist/index.d.ts +71 -63
- package/dist/index.js +1162 -1996
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1124 -1955
- package/dist/index.mjs.map +1 -1
- package/dist/pouch/index.js +3 -0
- package/dist/pouch/index.js.map +1 -1
- package/dist/pouch/index.mjs +3 -0
- package/dist/pouch/index.mjs.map +1 -1
- package/dist/{types-CzPDLAK6.d.cts → types-Bn0itutr.d.cts} +1 -1
- package/dist/{types-CewsN87z.d.ts → types-DQaXnuoc.d.ts} +1 -1
- package/dist/{types-legacy-6ettoclI.d.cts → types-legacy-DDY4N-Uq.d.cts} +3 -1
- package/dist/{types-legacy-6ettoclI.d.ts → types-legacy-DDY4N-Uq.d.ts} +3 -1
- package/dist/util/packer/index.d.cts +3 -3
- package/dist/util/packer/index.d.ts +3 -3
- package/docs/navigators-architecture.md +115 -17
- package/package.json +4 -4
- package/src/core/index.ts +1 -0
- package/src/core/interfaces/classroomDB.ts +5 -13
- package/src/core/interfaces/contentSource.ts +6 -66
- package/src/core/interfaces/courseDB.ts +15 -7
- package/src/core/interfaces/userDB.ts +32 -0
- package/src/core/navigators/Pipeline.ts +136 -52
- package/src/core/navigators/PipelineAssembler.ts +1 -1
- package/src/core/navigators/defaults.ts +84 -0
- package/src/core/navigators/{hierarchyDefinition.ts → filters/hierarchyDefinition.ts} +15 -29
- package/src/core/navigators/filters/index.ts +3 -0
- package/src/core/navigators/filters/inferredPreferenceStub.ts +107 -0
- package/src/core/navigators/{interferenceMitigator.ts → filters/interferenceMitigator.ts} +11 -37
- package/src/core/navigators/{relativePriority.ts → filters/relativePriority.ts} +12 -38
- package/src/core/navigators/filters/userGoalStub.ts +136 -0
- package/src/core/navigators/filters/userTagPreference.ts +217 -0
- package/src/core/navigators/{CompositeGenerator.ts → generators/CompositeGenerator.ts} +15 -64
- package/src/core/navigators/{elo.ts → generators/elo.ts} +13 -63
- package/src/core/navigators/{srs.ts → generators/srs.ts} +11 -40
- package/src/core/navigators/generators/types.ts +1 -1
- package/src/core/navigators/index.ts +95 -91
- 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 +100 -103
- package/src/impl/couch/courseDB.ts +35 -91
- package/src/impl/couch/pouchdb-setup.ts +7 -0
- package/src/impl/static/StaticDataUnpacker.ts +50 -1
- package/src/impl/static/courseDB.ts +87 -37
- package/src/study/SessionController.ts +122 -202
- package/src/study/SourceMixer.ts +65 -0
- package/src/study/TagFilteredContentSource.ts +49 -92
- package/src/study/index.ts +1 -0
- package/src/study/services/CardHydrationService.ts +165 -81
- package/src/util/dataDirectory.ts +1 -1
- package/src/util/index.ts +0 -1
- package/tests/core/navigators/CompositeGenerator.test.ts +44 -168
- package/tests/core/navigators/Pipeline.test.ts +6 -72
- package/tests/core/navigators/PipelineAssembler.test.ts +8 -58
- package/tests/core/navigators/navigators.test.ts +118 -151
- package/docs/todo-pipeline-optimization.md +0 -117
- package/docs/todo-strategy-state-storage.md +0 -278
- package/src/core/navigators/hardcodedOrder.ts +0 -163
- package/src/util/tuiLogger.ts +0 -139
|
@@ -1,11 +1,9 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type {
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import type {
|
|
6
|
-
import type {
|
|
7
|
-
import type { StudySessionReviewItem, StudySessionNewItem } from '..';
|
|
8
|
-
import type { CardFilter, FilterContext } from './filters/types';
|
|
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 { CardFilter, FilterContext } from './types';
|
|
9
7
|
|
|
10
8
|
/**
|
|
11
9
|
* Configuration for the RelativePriority strategy.
|
|
@@ -85,7 +83,6 @@ const DEFAULT_COMBINE_MODE: 'max' | 'average' | 'min' = 'max';
|
|
|
85
83
|
*/
|
|
86
84
|
export default class RelativePriorityNavigator extends ContentNavigator implements CardFilter {
|
|
87
85
|
private config: RelativePriorityConfig;
|
|
88
|
-
private _strategyData: ContentNavigationStrategyData;
|
|
89
86
|
|
|
90
87
|
/** Human-readable name for CardFilter interface */
|
|
91
88
|
name: string;
|
|
@@ -93,12 +90,11 @@ export default class RelativePriorityNavigator extends ContentNavigator implemen
|
|
|
93
90
|
constructor(
|
|
94
91
|
user: UserDBInterface,
|
|
95
92
|
course: CourseDBInterface,
|
|
96
|
-
|
|
93
|
+
strategyData: ContentNavigationStrategyData
|
|
97
94
|
) {
|
|
98
|
-
super(user, course,
|
|
99
|
-
this.
|
|
100
|
-
this.
|
|
101
|
-
this.name = _strategyData.name || 'Relative Priority';
|
|
95
|
+
super(user, course, strategyData);
|
|
96
|
+
this.config = this.parseConfig(strategyData.serializedData);
|
|
97
|
+
this.name = strategyData.name || 'Relative Priority';
|
|
102
98
|
}
|
|
103
99
|
|
|
104
100
|
private parseConfig(serializedData: string): RelativePriorityConfig {
|
|
@@ -190,28 +186,16 @@ export default class RelativePriorityNavigator extends ContentNavigator implemen
|
|
|
190
186
|
}
|
|
191
187
|
}
|
|
192
188
|
|
|
193
|
-
/**
|
|
194
|
-
* Get tags for a single card.
|
|
195
|
-
*/
|
|
196
|
-
private async getCardTags(cardId: string, course: CourseDBInterface): Promise<string[]> {
|
|
197
|
-
try {
|
|
198
|
-
const tagResponse = await course.getAppliedTags(cardId);
|
|
199
|
-
return tagResponse.rows.map((r) => r.doc?.name).filter((x): x is string => !!x);
|
|
200
|
-
} catch {
|
|
201
|
-
return [];
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
189
|
/**
|
|
206
190
|
* CardFilter.transform implementation.
|
|
207
191
|
*
|
|
208
192
|
* Apply priority-adjusted scoring. Cards with high-priority tags get boosted,
|
|
209
193
|
* cards with low-priority tags get reduced scores.
|
|
210
194
|
*/
|
|
211
|
-
async transform(cards: WeightedCard[],
|
|
195
|
+
async transform(cards: WeightedCard[], _context: FilterContext): Promise<WeightedCard[]> {
|
|
212
196
|
const adjusted: WeightedCard[] = await Promise.all(
|
|
213
197
|
cards.map(async (card) => {
|
|
214
|
-
const cardTags =
|
|
198
|
+
const cardTags = card.tags ?? [];
|
|
215
199
|
const priority = this.computeCardPriority(cardTags);
|
|
216
200
|
const boostFactor = this.computeBoostFactor(priority);
|
|
217
201
|
const finalScore = Math.max(0, Math.min(1, card.score * boostFactor));
|
|
@@ -254,14 +238,4 @@ export default class RelativePriorityNavigator extends ContentNavigator implemen
|
|
|
254
238
|
'Use Pipeline with a generator and this filter via transform().'
|
|
255
239
|
);
|
|
256
240
|
}
|
|
257
|
-
|
|
258
|
-
// Legacy methods - stub implementations since filters don't generate cards
|
|
259
|
-
|
|
260
|
-
async getNewCards(_n?: number): Promise<StudySessionNewItem[]> {
|
|
261
|
-
return [];
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
async getPendingReviews(): Promise<(StudySessionReviewItem & ScheduledCard)[]> {
|
|
265
|
-
return [];
|
|
266
|
-
}
|
|
267
241
|
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// USER GOAL NAVIGATOR — STUB
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// STATUS: NOT IMPLEMENTED — This file documents architectural intent only.
|
|
6
|
+
//
|
|
7
|
+
// ============================================================================
|
|
8
|
+
//
|
|
9
|
+
// ## Purpose
|
|
10
|
+
//
|
|
11
|
+
// Goals define WHAT the user wants to learn, as opposed to preferences which
|
|
12
|
+
// define HOW they want to learn. Goals affect:
|
|
13
|
+
//
|
|
14
|
+
// 1. **Content scoping**: Which tags/content are relevant to this user
|
|
15
|
+
// 2. **Progress tracking**: ELO is measured against goal-relevant content
|
|
16
|
+
// 3. **Completion criteria**: User is "done" when goal mastery is achieved
|
|
17
|
+
// 4. **Curriculum composition**: Goals enable cross-curriculum dependencies
|
|
18
|
+
//
|
|
19
|
+
// ## Goals vs Preferences
|
|
20
|
+
//
|
|
21
|
+
// | Aspect | Goal | Preference |
|
|
22
|
+
// |---------------|-------------------------------|-------------------------------|
|
|
23
|
+
// | Defines | Destination (what to learn) | Path (how to learn) |
|
|
24
|
+
// | Example | "Master ear-training" | "Skip text-heavy cards" |
|
|
25
|
+
// | Affects ELO | Yes — scopes what's tracked | No — just filters cards |
|
|
26
|
+
// | Completion | Yes — defines "done" | No — persists indefinitely |
|
|
27
|
+
// | Filter impl | UserGoalNavigator | UserTagPreferenceFilter |
|
|
28
|
+
//
|
|
29
|
+
// ## Curriculum Composition
|
|
30
|
+
//
|
|
31
|
+
// Goals enable software-style composition for curricula. A physics course
|
|
32
|
+
// can teach classical mechanics without owning the calculus prerequisites.
|
|
33
|
+
//
|
|
34
|
+
// Instead, it declares a dependency:
|
|
35
|
+
//
|
|
36
|
+
// ```typescript
|
|
37
|
+
// interface CurriculumDependency {
|
|
38
|
+
// // NPM-style package resolution
|
|
39
|
+
// curriculumId: string; // e.g., "@skuilder/calculus"
|
|
40
|
+
// version: string; // e.g., "^2.0.0" (semver)
|
|
41
|
+
//
|
|
42
|
+
// // Goal within that curriculum
|
|
43
|
+
// goal: string; // e.g., "differential-calculus"
|
|
44
|
+
//
|
|
45
|
+
// // How this maps to local prerequisites
|
|
46
|
+
// satisfiesLocalTags: string[]; // e.g., ["calculus-prereq"]
|
|
47
|
+
// }
|
|
48
|
+
// ```
|
|
49
|
+
//
|
|
50
|
+
// When a physics card requires "calculus-prereq", the system:
|
|
51
|
+
// 1. Checks if user has achieved the "differential-calculus" goal in @skuilder/calculus
|
|
52
|
+
// 2. If not, defers to that curriculum to teach the prerequisite
|
|
53
|
+
// 3. Returns to physics once the goal is satisfied
|
|
54
|
+
//
|
|
55
|
+
// This allows:
|
|
56
|
+
// - Specialized curricula (calculus experts author calculus content)
|
|
57
|
+
// - Reusable prerequisites across multiple courses
|
|
58
|
+
// - User can bring their own "calculus credential" from prior learning
|
|
59
|
+
//
|
|
60
|
+
// ## User Goal State (Proposed)
|
|
61
|
+
//
|
|
62
|
+
// ```typescript
|
|
63
|
+
// interface UserGoalState {
|
|
64
|
+
// // Primary goals — what the user wants to achieve
|
|
65
|
+
// targetTags: string[];
|
|
66
|
+
//
|
|
67
|
+
// // Excluded goals — content the user explicitly doesn't care about
|
|
68
|
+
// excludedTags: string[];
|
|
69
|
+
//
|
|
70
|
+
// // Cross-curriculum goals (for composition)
|
|
71
|
+
// externalGoals?: {
|
|
72
|
+
// curriculumId: string;
|
|
73
|
+
// goal: string;
|
|
74
|
+
// status: 'not-started' | 'in-progress' | 'achieved';
|
|
75
|
+
// }[];
|
|
76
|
+
//
|
|
77
|
+
// // When this goal configuration was set
|
|
78
|
+
// updatedAt: string;
|
|
79
|
+
// }
|
|
80
|
+
// ```
|
|
81
|
+
//
|
|
82
|
+
// ## Implementation Considerations
|
|
83
|
+
//
|
|
84
|
+
// 1. **ELO Scoping**: When goals are set, user ELO tracking should focus on
|
|
85
|
+
// goal-relevant tags. This may require changes to ELO update logic.
|
|
86
|
+
//
|
|
87
|
+
// 2. **Progress Reporting**: UI should show progress toward goals, not just
|
|
88
|
+
// overall course completion.
|
|
89
|
+
//
|
|
90
|
+
// 3. **Goal Achievement**: Need to define when a goal is "achieved" —
|
|
91
|
+
// probably ELO threshold + mastery percentage on goal-tagged content.
|
|
92
|
+
//
|
|
93
|
+
// 4. **Curriculum Registry**: For cross-curriculum composition, need a
|
|
94
|
+
// registry/resolver for curriculum packages (similar to npm registry).
|
|
95
|
+
//
|
|
96
|
+
// 5. **Interaction with HierarchyDefinition**: Goals should work with
|
|
97
|
+
// prerequisite chains — user can't skip prerequisites just because
|
|
98
|
+
// they're not part of their goal.
|
|
99
|
+
//
|
|
100
|
+
// ## Related Files
|
|
101
|
+
//
|
|
102
|
+
// - `filters/userTagPreference.ts` — Preferences (path constraints)
|
|
103
|
+
// - `hierarchyDefinition.ts` — Prerequisites (enforced regardless of goals)
|
|
104
|
+
// - `../types/strategyState.ts` — Storage mechanism for user state
|
|
105
|
+
//
|
|
106
|
+
// ## Next Steps
|
|
107
|
+
//
|
|
108
|
+
// 1. Design goal state schema in detail
|
|
109
|
+
// 2. Define goal achievement criteria
|
|
110
|
+
// 3. Implement goal-scoped ELO tracking
|
|
111
|
+
// 4. Build UI for goal configuration
|
|
112
|
+
// 5. Design curriculum dependency resolution
|
|
113
|
+
//
|
|
114
|
+
// ============================================================================
|
|
115
|
+
|
|
116
|
+
// Placeholder export to make this a valid module
|
|
117
|
+
export const USER_GOAL_NAVIGATOR_STUB = true;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @stub UserGoalNavigator
|
|
121
|
+
*
|
|
122
|
+
* A navigator that scopes learning to user-defined goals.
|
|
123
|
+
* See module-level documentation for architectural intent.
|
|
124
|
+
*
|
|
125
|
+
* NOT IMPLEMENTED — This is a design placeholder.
|
|
126
|
+
*/
|
|
127
|
+
export interface UserGoalState {
|
|
128
|
+
/** Tags the user wants to master (defines "success") */
|
|
129
|
+
targetTags: string[];
|
|
130
|
+
|
|
131
|
+
/** Tags the user explicitly doesn't care about */
|
|
132
|
+
excludedTags: string[];
|
|
133
|
+
|
|
134
|
+
/** ISO timestamp of last update */
|
|
135
|
+
updatedAt: string;
|
|
136
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
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 { CardFilter, FilterContext } from './types';
|
|
7
|
+
|
|
8
|
+
// ============================================================================
|
|
9
|
+
// USER TAG PREFERENCE FILTER
|
|
10
|
+
// ============================================================================
|
|
11
|
+
//
|
|
12
|
+
// Allows users to personalize their learning experience by specifying:
|
|
13
|
+
// - Tags to boost/penalize (score multiplied by boost factor)
|
|
14
|
+
//
|
|
15
|
+
// User preferences are stored in STRATEGY_STATE documents in the user's
|
|
16
|
+
// database, enabling persistence across sessions and sync across devices.
|
|
17
|
+
//
|
|
18
|
+
// Use cases:
|
|
19
|
+
// - Goal-based learning: "I want to learn piano by ear, skip sight-reading"
|
|
20
|
+
// - Selective focus: "I only want to practice chess endgames"
|
|
21
|
+
// - Accessibility: "Skip text-heavy cards, prefer visual content"
|
|
22
|
+
// - Difficulty customization: "Skip beginner content I already know"
|
|
23
|
+
//
|
|
24
|
+
// ============================================================================
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* User's tag preference state, stored in STRATEGY_STATE document.
|
|
28
|
+
*
|
|
29
|
+
* This interface defines what gets persisted to the user's database.
|
|
30
|
+
* UI components write to this structure, and the filter reads from it.
|
|
31
|
+
*
|
|
32
|
+
* ## Preferences vs Goals
|
|
33
|
+
*
|
|
34
|
+
* Preferences are **path constraints** — they affect HOW the user learns,
|
|
35
|
+
* not WHAT they're trying to learn. Examples:
|
|
36
|
+
* - "Skip text-heavy cards" (accessibility)
|
|
37
|
+
* - "Prefer visual content"
|
|
38
|
+
*
|
|
39
|
+
* For **goal-based** filtering (defining WHAT to learn), see the separate
|
|
40
|
+
* UserGoalNavigator (stub). Goals affect progress tracking and completion
|
|
41
|
+
* criteria; preferences only affect card selection.
|
|
42
|
+
*
|
|
43
|
+
* ## Slider Semantics
|
|
44
|
+
*
|
|
45
|
+
* Each tag maps to a multiplier value in the `boost` record:
|
|
46
|
+
* - `0` = banish/exclude (card score = 0)
|
|
47
|
+
* - `0.5` = penalize by 50%
|
|
48
|
+
* - `1.0` = neutral/no effect (default when tag added)
|
|
49
|
+
* - `2.0` = 2x preference boost
|
|
50
|
+
* - Higher values = stronger preference
|
|
51
|
+
*
|
|
52
|
+
* If multiple tags on a card have preferences, the maximum multiplier wins.
|
|
53
|
+
*/
|
|
54
|
+
export interface UserTagPreferenceState {
|
|
55
|
+
/**
|
|
56
|
+
* Tag-specific multipliers.
|
|
57
|
+
* Maps tag name to score multiplier (0 = exclude, 1 = neutral, >1 = boost).
|
|
58
|
+
*/
|
|
59
|
+
boost: Record<string, number>;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* ISO timestamp of last update.
|
|
63
|
+
* Use `moment.utc(updatedAt)` to parse into a Moment object.
|
|
64
|
+
*/
|
|
65
|
+
updatedAt: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A filter that applies user-configured tag preferences.
|
|
70
|
+
*
|
|
71
|
+
* Reads preferences from STRATEGY_STATE document in user's database.
|
|
72
|
+
* If no preferences exist, passes through unchanged (no-op).
|
|
73
|
+
*
|
|
74
|
+
* Implements CardFilter for use in Pipeline architecture.
|
|
75
|
+
* Also extends ContentNavigator for compatibility with dynamic loading.
|
|
76
|
+
*/
|
|
77
|
+
export default class UserTagPreferenceFilter extends ContentNavigator implements CardFilter {
|
|
78
|
+
private _strategyData: ContentNavigationStrategyData;
|
|
79
|
+
|
|
80
|
+
/** Human-readable name for CardFilter interface */
|
|
81
|
+
name: string;
|
|
82
|
+
|
|
83
|
+
constructor(
|
|
84
|
+
user: UserDBInterface,
|
|
85
|
+
course: CourseDBInterface,
|
|
86
|
+
strategyData: ContentNavigationStrategyData
|
|
87
|
+
) {
|
|
88
|
+
super(user, course, strategyData);
|
|
89
|
+
this._strategyData = strategyData;
|
|
90
|
+
this.name = strategyData.name || 'User Tag Preferences';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Compute multiplier for a card based on its tags and user preferences.
|
|
95
|
+
* Returns the maximum multiplier among all matching tags, or 1.0 if no matches.
|
|
96
|
+
*/
|
|
97
|
+
private computeMultiplier(cardTags: string[], boostMap: Record<string, number>): number {
|
|
98
|
+
const multipliers = cardTags.map((tag) => boostMap[tag]).filter((val) => val !== undefined);
|
|
99
|
+
|
|
100
|
+
if (multipliers.length === 0) {
|
|
101
|
+
return 1.0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Use max multiplier among matching tags
|
|
105
|
+
return Math.max(...multipliers);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Build human-readable reason for the filter's decision.
|
|
110
|
+
*/
|
|
111
|
+
private buildReason(
|
|
112
|
+
cardTags: string[],
|
|
113
|
+
boostMap: Record<string, number>,
|
|
114
|
+
multiplier: number
|
|
115
|
+
): string {
|
|
116
|
+
// Find which tag(s) contributed to the multiplier
|
|
117
|
+
const matchingTags = cardTags.filter((tag) => boostMap[tag] === multiplier);
|
|
118
|
+
|
|
119
|
+
if (multiplier === 0) {
|
|
120
|
+
return `Excluded by user preference: ${matchingTags.join(', ')} (${multiplier}x)`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (multiplier < 1.0) {
|
|
124
|
+
return `Penalized by user preference: ${matchingTags.join(', ')} (${multiplier.toFixed(2)}x)`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (multiplier > 1.0) {
|
|
128
|
+
return `Boosted by user preference: ${matchingTags.join(', ')} (${multiplier.toFixed(2)}x)`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return 'No matching user preferences';
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* CardFilter.transform implementation.
|
|
136
|
+
*
|
|
137
|
+
* Apply user tag preferences:
|
|
138
|
+
* 1. Read preferences from strategy state
|
|
139
|
+
* 2. If no preferences, pass through unchanged
|
|
140
|
+
* 3. For each card:
|
|
141
|
+
* - Look up tag in boost record
|
|
142
|
+
* - If tag found: apply multiplier (0 = exclude, 1 = neutral, >1 = boost)
|
|
143
|
+
* - If multiple tags match: use max multiplier
|
|
144
|
+
* - Append provenance with clear reason
|
|
145
|
+
*/
|
|
146
|
+
async transform(cards: WeightedCard[], _context: FilterContext): Promise<WeightedCard[]> {
|
|
147
|
+
// Read user preferences from strategy state
|
|
148
|
+
const prefs = await this.getStrategyState<UserTagPreferenceState>();
|
|
149
|
+
|
|
150
|
+
// No preferences configured → pass through unchanged
|
|
151
|
+
if (!prefs || Object.keys(prefs.boost).length === 0) {
|
|
152
|
+
return cards.map((card) => ({
|
|
153
|
+
...card,
|
|
154
|
+
provenance: [
|
|
155
|
+
...card.provenance,
|
|
156
|
+
{
|
|
157
|
+
strategy: 'userTagPreference',
|
|
158
|
+
strategyName: this.strategyName || this.name,
|
|
159
|
+
strategyId: this.strategyId || this._strategyData._id,
|
|
160
|
+
action: 'passed' as const,
|
|
161
|
+
score: card.score,
|
|
162
|
+
reason: 'No user tag preferences configured',
|
|
163
|
+
},
|
|
164
|
+
],
|
|
165
|
+
}));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Process each card
|
|
169
|
+
const adjusted: WeightedCard[] = await Promise.all(
|
|
170
|
+
cards.map(async (card) => {
|
|
171
|
+
const cardTags = card.tags ?? [];
|
|
172
|
+
|
|
173
|
+
// Compute multiplier based on card tags and user preferences
|
|
174
|
+
const multiplier = this.computeMultiplier(cardTags, prefs.boost);
|
|
175
|
+
const finalScore = Math.min(1, card.score * multiplier);
|
|
176
|
+
|
|
177
|
+
// Determine action for provenance
|
|
178
|
+
let action: 'passed' | 'boosted' | 'penalized';
|
|
179
|
+
if (multiplier === 0 || multiplier < 1.0) {
|
|
180
|
+
action = 'penalized';
|
|
181
|
+
} else if (multiplier > 1.0) {
|
|
182
|
+
action = 'boosted';
|
|
183
|
+
} else {
|
|
184
|
+
action = 'passed';
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
...card,
|
|
189
|
+
score: finalScore,
|
|
190
|
+
provenance: [
|
|
191
|
+
...card.provenance,
|
|
192
|
+
{
|
|
193
|
+
strategy: 'userTagPreference',
|
|
194
|
+
strategyName: this.strategyName || this.name,
|
|
195
|
+
strategyId: this.strategyId || this._strategyData._id,
|
|
196
|
+
action,
|
|
197
|
+
score: finalScore,
|
|
198
|
+
reason: this.buildReason(cardTags, prefs.boost, multiplier),
|
|
199
|
+
},
|
|
200
|
+
],
|
|
201
|
+
};
|
|
202
|
+
})
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
return adjusted;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Legacy getWeightedCards - throws as filters should not be used as generators.
|
|
210
|
+
*/
|
|
211
|
+
async getWeightedCards(_limit: number): Promise<WeightedCard[]> {
|
|
212
|
+
throw new Error(
|
|
213
|
+
'UserTagPreferenceFilter is a filter and should not be used as a generator. ' +
|
|
214
|
+
'Use Pipeline with a generator and this filter via transform().'
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
import { ContentNavigator } from '
|
|
2
|
-
import type { WeightedCard } from '
|
|
3
|
-
import type { ContentNavigationStrategyData } from '
|
|
4
|
-
import type { CourseDBInterface } from '
|
|
5
|
-
import type { UserDBInterface } from '
|
|
6
|
-
import type {
|
|
7
|
-
import
|
|
8
|
-
import type { CardGenerator, GeneratorContext } from './generators/types';
|
|
9
|
-
import { logger } from '../../util/logger';
|
|
1
|
+
import { ContentNavigator } from '../index';
|
|
2
|
+
import type { WeightedCard } from '../index';
|
|
3
|
+
import type { ContentNavigationStrategyData } from '../../types/contentNavigationStrategy';
|
|
4
|
+
import type { CourseDBInterface } from '../../interfaces/courseDB';
|
|
5
|
+
import type { UserDBInterface } from '../../interfaces/userDB';
|
|
6
|
+
import type { CardGenerator, GeneratorContext } from './types';
|
|
7
|
+
import { logger } from '../../../util/logger';
|
|
10
8
|
|
|
11
9
|
// ============================================================================
|
|
12
10
|
// COMPOSITE GENERATOR
|
|
@@ -100,9 +98,16 @@ export default class CompositeGenerator extends ContentNavigator implements Card
|
|
|
100
98
|
* CardGenerator interface signature (limit, context).
|
|
101
99
|
*
|
|
102
100
|
* @param limit - Maximum number of cards to return
|
|
103
|
-
* @param context -
|
|
101
|
+
* @param context - GeneratorContext passed to child generators (required when called via Pipeline)
|
|
104
102
|
*/
|
|
105
103
|
async getWeightedCards(limit: number, context?: GeneratorContext): Promise<WeightedCard[]> {
|
|
104
|
+
if (!context) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
'CompositeGenerator.getWeightedCards requires a GeneratorContext. ' +
|
|
107
|
+
'It should be called via Pipeline, not directly.'
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
106
111
|
// Fetch from all generators in parallel
|
|
107
112
|
const results = await Promise.all(
|
|
108
113
|
this.generators.map((g) => g.getWeightedCards(limit, context))
|
|
@@ -211,58 +216,4 @@ export default class CompositeGenerator extends ContentNavigator implements Card
|
|
|
211
216
|
return scores[0];
|
|
212
217
|
}
|
|
213
218
|
}
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* Get new cards from all generators, merged and deduplicated.
|
|
217
|
-
*/
|
|
218
|
-
async getNewCards(n?: number): Promise<StudySessionNewItem[]> {
|
|
219
|
-
// For legacy method, need to filter to generators that have getNewCards
|
|
220
|
-
const legacyGenerators = this.generators.filter(
|
|
221
|
-
(g): g is CardGenerator & ContentNavigator => g instanceof ContentNavigator
|
|
222
|
-
);
|
|
223
|
-
|
|
224
|
-
const results = await Promise.all(legacyGenerators.map((g) => g.getNewCards(n)));
|
|
225
|
-
|
|
226
|
-
// Deduplicate by cardID
|
|
227
|
-
const seen = new Set<string>();
|
|
228
|
-
const merged: StudySessionNewItem[] = [];
|
|
229
|
-
|
|
230
|
-
for (const cards of results) {
|
|
231
|
-
for (const card of cards) {
|
|
232
|
-
if (!seen.has(card.cardID)) {
|
|
233
|
-
seen.add(card.cardID);
|
|
234
|
-
merged.push(card);
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
return n ? merged.slice(0, n) : merged;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/**
|
|
243
|
-
* Get pending reviews from all generators, merged and deduplicated.
|
|
244
|
-
*/
|
|
245
|
-
async getPendingReviews(): Promise<(StudySessionReviewItem & ScheduledCard)[]> {
|
|
246
|
-
// For legacy method, need to filter to generators that have getPendingReviews
|
|
247
|
-
const legacyGenerators = this.generators.filter(
|
|
248
|
-
(g): g is CardGenerator & ContentNavigator => g instanceof ContentNavigator
|
|
249
|
-
);
|
|
250
|
-
|
|
251
|
-
const results = await Promise.all(legacyGenerators.map((g) => g.getPendingReviews()));
|
|
252
|
-
|
|
253
|
-
// Deduplicate by cardID
|
|
254
|
-
const seen = new Set<string>();
|
|
255
|
-
const merged: (StudySessionReviewItem & ScheduledCard)[] = [];
|
|
256
|
-
|
|
257
|
-
for (const reviews of results) {
|
|
258
|
-
for (const review of reviews) {
|
|
259
|
-
if (!seen.has(review.cardID)) {
|
|
260
|
-
seen.add(review.cardID);
|
|
261
|
-
merged.push(review);
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
return merged;
|
|
267
|
-
}
|
|
268
219
|
}
|
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type {
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import type { WeightedCard } from './index';
|
|
6
|
-
import type { CourseElo } from '@vue-skuilder/common';
|
|
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';
|
|
7
5
|
import { toCourseElo } from '@vue-skuilder/common';
|
|
8
|
-
import type {
|
|
9
|
-
import type { CardGenerator, GeneratorContext } from './
|
|
6
|
+
import type { QualifiedCardID } from '../..';
|
|
7
|
+
import type { CardGenerator, GeneratorContext } from './types';
|
|
10
8
|
|
|
11
9
|
// ============================================================================
|
|
12
10
|
// ELO NAVIGATOR
|
|
@@ -51,59 +49,6 @@ export default class ELONavigator extends ContentNavigator implements CardGenera
|
|
|
51
49
|
this.name = strategyData?.name || 'ELO';
|
|
52
50
|
}
|
|
53
51
|
|
|
54
|
-
async getPendingReviews(): Promise<(StudySessionReviewItem & ScheduledCard)[]> {
|
|
55
|
-
type ratedReview = ScheduledCard & CourseElo;
|
|
56
|
-
|
|
57
|
-
const reviews = await this.user.getPendingReviews(this.course.getCourseID()); // todo: this adds a db round trip - should be server side
|
|
58
|
-
const elo = await this.course.getCardEloData(reviews.map((r) => r.cardId));
|
|
59
|
-
|
|
60
|
-
const ratedReviews = reviews.map((r, i) => {
|
|
61
|
-
const ratedR: ratedReview = {
|
|
62
|
-
...r,
|
|
63
|
-
...elo[i],
|
|
64
|
-
};
|
|
65
|
-
return ratedR;
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
ratedReviews.sort((a, b) => {
|
|
69
|
-
return a.global.score - b.global.score;
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
return ratedReviews.map((r) => {
|
|
73
|
-
return {
|
|
74
|
-
...r,
|
|
75
|
-
contentSourceType: 'course',
|
|
76
|
-
contentSourceID: this.course.getCourseID(),
|
|
77
|
-
cardID: r.cardId,
|
|
78
|
-
courseID: r.courseId,
|
|
79
|
-
qualifiedID: `${r.courseId}-${r.cardId}`,
|
|
80
|
-
reviewID: r._id,
|
|
81
|
-
status: 'review',
|
|
82
|
-
};
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
async getNewCards(limit: number = 99): Promise<StudySessionNewItem[]> {
|
|
87
|
-
const activeCards = await this.user.getActiveCards();
|
|
88
|
-
return (
|
|
89
|
-
await this.course.getCardsCenteredAtELO(
|
|
90
|
-
{ limit: limit, elo: 'user' },
|
|
91
|
-
(c: QualifiedCardID) => {
|
|
92
|
-
if (activeCards.some((ac) => c.cardID === ac.cardID)) {
|
|
93
|
-
return false;
|
|
94
|
-
} else {
|
|
95
|
-
return true;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
)
|
|
99
|
-
).map((c) => {
|
|
100
|
-
return {
|
|
101
|
-
...c,
|
|
102
|
-
status: 'new',
|
|
103
|
-
};
|
|
104
|
-
});
|
|
105
|
-
}
|
|
106
|
-
|
|
107
52
|
/**
|
|
108
53
|
* Get new cards with suitability scores based on ELO distance.
|
|
109
54
|
*
|
|
@@ -130,8 +75,13 @@ export default class ELONavigator extends ContentNavigator implements CardGenera
|
|
|
130
75
|
userGlobalElo = userElo.global.score;
|
|
131
76
|
}
|
|
132
77
|
|
|
133
|
-
|
|
134
|
-
const newCards =
|
|
78
|
+
const activeCards = await this.user.getActiveCards();
|
|
79
|
+
const newCards = (
|
|
80
|
+
await this.course.getCardsCenteredAtELO(
|
|
81
|
+
{ limit, elo: 'user' },
|
|
82
|
+
(c: QualifiedCardID) => !activeCards.some((ac) => c.cardID === ac.cardID)
|
|
83
|
+
)
|
|
84
|
+
).map((c) => ({ ...c, status: 'new' as const }));
|
|
135
85
|
|
|
136
86
|
// Get ELO data for all cards in one batch
|
|
137
87
|
const cardIds = newCards.map((c) => c.cardID);
|