@vue-skuilder/db 0.1.16 → 0.1.18
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-DNa0XPtn.d.ts → classroomDB-BgfrVb8d.d.ts} +357 -103
- package/dist/{userDB-BqwxtJ_7.d.mts → classroomDB-CTOenngH.d.cts} +358 -104
- package/dist/core/index.d.cts +230 -0
- package/dist/core/index.d.ts +161 -23
- package/dist/core/index.js +1964 -154
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +1925 -121
- package/dist/core/index.mjs.map +1 -1
- package/dist/{dataLayerProvider-BV5iZqt_.d.ts → dataLayerProvider-CZxC9GtB.d.ts} +1 -1
- package/dist/{dataLayerProvider-VlngD19_.d.mts → dataLayerProvider-D6PoCwS6.d.cts} +1 -1
- package/dist/impl/couch/{index.d.mts → index.d.cts} +46 -5
- package/dist/impl/couch/index.d.ts +44 -3
- package/dist/impl/couch/index.js +1971 -171
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +1933 -134
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/{index.d.mts → index.d.cts} +5 -6
- package/dist/impl/static/index.d.ts +2 -3
- package/dist/impl/static/index.js +1614 -119
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +1585 -92
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/{index-Bmll7Xse.d.mts → index-D-Fa4Smt.d.cts} +1 -1
- package/dist/{index.d.mts → index.d.cts} +97 -13
- package/dist/index.d.ts +90 -6
- package/dist/index.js +2085 -153
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2031 -106
- package/dist/index.mjs.map +1 -1
- package/dist/pouch/index.js +3 -3
- package/dist/{types-Dbp5DaRR.d.mts → types-CzPDLAK6.d.cts} +1 -1
- package/dist/util/packer/{index.d.mts → index.d.cts} +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 +265 -0
- package/docs/todo-evolutionary-orchestration.md +310 -0
- package/docs/todo-nominal-tag-types.md +121 -0
- package/docs/todo-pipeline-optimization.md +117 -0
- package/docs/todo-strategy-authoring.md +401 -0
- package/docs/todo-strategy-state-storage.md +278 -0
- package/eslint.config.mjs +1 -1
- package/package.json +9 -4
- package/src/core/interfaces/contentSource.ts +88 -4
- package/src/core/interfaces/navigationStrategyManager.ts +0 -5
- package/src/core/navigators/CompositeGenerator.ts +268 -0
- package/src/core/navigators/Pipeline.ts +205 -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 +6 -0
- package/src/core/navigators/filters/types.ts +115 -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 +345 -3
- package/src/core/navigators/interferenceMitigator.ts +367 -0
- package/src/core/navigators/relativePriority.ts +267 -0
- package/src/core/navigators/srs.ts +195 -0
- package/src/impl/couch/classroomDB.ts +51 -0
- package/src/impl/couch/courseDB.ts +117 -39
- package/src/impl/static/courseDB.ts +0 -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 +405 -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
- /package/dist/{types-legacy-6ettoclI.d.mts → types-legacy-6ettoclI.d.cts} +0 -0
|
@@ -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
|
}
|
|
@@ -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
|
+
cardId: string,
|
|
162
|
+
course: CourseDBInterface,
|
|
163
|
+
unlockedTags: Set<string>,
|
|
164
|
+
masteredTags: Set<string>
|
|
165
|
+
): Promise<{ isUnlocked: boolean; reason: string }> {
|
|
166
|
+
try {
|
|
167
|
+
const tagResponse = await course.getAppliedTags(cardId);
|
|
168
|
+
const cardTags = tagResponse.rows.map((row) => row.value?.name || row.key);
|
|
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.cardId,
|
|
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
|
+
}
|