@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
|
@@ -92,6 +92,19 @@ export interface CourseDBInterface extends NavigationStrategyManager {
|
|
|
92
92
|
*/
|
|
93
93
|
getAppliedTags(cardId: string): Promise<PouchDB.Query.Response<TagStub>>;
|
|
94
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Get tags for multiple cards in a single batch query.
|
|
97
|
+
* More efficient than calling getAppliedTags() for each card.
|
|
98
|
+
*
|
|
99
|
+
* This method reduces redundant database operations when multiple filters
|
|
100
|
+
* need tag data for the same cards. The Pipeline uses this to pre-hydrate
|
|
101
|
+
* tags on WeightedCard objects before filters run.
|
|
102
|
+
*
|
|
103
|
+
* @param cardIds - Array of card IDs to fetch tags for
|
|
104
|
+
* @returns Map from cardId to array of tag names
|
|
105
|
+
*/
|
|
106
|
+
getAppliedTagsBatch(cardIds: string[]): Promise<Map<string, string[]>>;
|
|
107
|
+
|
|
95
108
|
/**
|
|
96
109
|
* Add a tag to a card
|
|
97
110
|
*/
|
|
@@ -33,11 +33,6 @@ export interface NavigationStrategyManager {
|
|
|
33
33
|
*/
|
|
34
34
|
updateNavigationStrategy(id: string, data: ContentNavigationStrategyData): Promise<void>;
|
|
35
35
|
|
|
36
|
-
/**
|
|
37
|
-
* @returns A content navigation strategy suitable to the current context.
|
|
38
|
-
*/
|
|
39
|
-
surfaceNavigationStrategy(): Promise<ContentNavigationStrategyData>;
|
|
40
|
-
|
|
41
36
|
// [ ] addons here like:
|
|
42
37
|
// - determining Navigation Strategy from context of current user
|
|
43
38
|
// - determining weighted averages of navigation strategies
|
|
@@ -56,6 +56,18 @@ export interface UserDBReader {
|
|
|
56
56
|
|
|
57
57
|
getActivityRecords(): Promise<ActivityRecord[]>;
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Get strategy-specific state for a course.
|
|
61
|
+
*
|
|
62
|
+
* Strategies use this to persist preferences, learned patterns, or temporal
|
|
63
|
+
* tracking data across sessions. Each strategy owns its own namespace.
|
|
64
|
+
*
|
|
65
|
+
* @param courseId - The course this state applies to
|
|
66
|
+
* @param strategyKey - Unique key identifying the strategy (typically class name)
|
|
67
|
+
* @returns The strategy's data payload, or null if no state exists
|
|
68
|
+
*/
|
|
69
|
+
getStrategyState<T>(courseId: string, strategyKey: string): Promise<T | null>;
|
|
70
|
+
|
|
59
71
|
/**
|
|
60
72
|
* Get user's classroom registrations
|
|
61
73
|
*/
|
|
@@ -132,6 +144,26 @@ export interface UserDBWriter extends DocumentUpdater {
|
|
|
132
144
|
* Reset all user data (progress, registrations, etc.) while preserving authentication
|
|
133
145
|
*/
|
|
134
146
|
resetUserData(): Promise<{ status: Status; error?: string }>;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Store strategy-specific state for a course.
|
|
150
|
+
*
|
|
151
|
+
* Strategies use this to persist preferences, learned patterns, or temporal
|
|
152
|
+
* tracking data across sessions. Each strategy owns its own namespace.
|
|
153
|
+
*
|
|
154
|
+
* @param courseId - The course this state applies to
|
|
155
|
+
* @param strategyKey - Unique key identifying the strategy (typically class name)
|
|
156
|
+
* @param data - The strategy's data payload to store
|
|
157
|
+
*/
|
|
158
|
+
putStrategyState<T>(courseId: string, strategyKey: string, data: T): Promise<void>;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Delete strategy-specific state for a course.
|
|
162
|
+
*
|
|
163
|
+
* @param courseId - The course this state applies to
|
|
164
|
+
* @param strategyKey - Unique key identifying the strategy (typically class name)
|
|
165
|
+
*/
|
|
166
|
+
deleteStrategyState(courseId: string, strategyKey: string): Promise<void>;
|
|
135
167
|
}
|
|
136
168
|
|
|
137
169
|
/**
|
|
@@ -0,0 +1,268 @@
|
|
|
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 { StudySessionNewItem, StudySessionReviewItem } from '../interfaces/contentSource';
|
|
7
|
+
import type { ScheduledCard } from '../types/user';
|
|
8
|
+
import type { CardGenerator, GeneratorContext } from './generators/types';
|
|
9
|
+
import { logger } from '../../util/logger';
|
|
10
|
+
|
|
11
|
+
// ============================================================================
|
|
12
|
+
// COMPOSITE GENERATOR
|
|
13
|
+
// ============================================================================
|
|
14
|
+
//
|
|
15
|
+
// Composes multiple generator strategies into a single generator.
|
|
16
|
+
//
|
|
17
|
+
// Use case: When a course has multiple generators (e.g., ELO + SRS), this
|
|
18
|
+
// class merges their outputs into a unified candidate list.
|
|
19
|
+
//
|
|
20
|
+
// Aggregation strategy:
|
|
21
|
+
// - Cards appearing in multiple generators get a frequency boost
|
|
22
|
+
// - Score = average(scores) * (1 + 0.1 * (appearances - 1))
|
|
23
|
+
// - This rewards cards that multiple generators agree on
|
|
24
|
+
//
|
|
25
|
+
// ============================================================================
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Aggregation modes for combining scores from multiple generators.
|
|
29
|
+
*/
|
|
30
|
+
export enum AggregationMode {
|
|
31
|
+
/** Use the maximum score from any generator */
|
|
32
|
+
MAX = 'max',
|
|
33
|
+
/** Average all scores */
|
|
34
|
+
AVERAGE = 'average',
|
|
35
|
+
/** Average with frequency boost: avg * (1 + 0.1 * (n-1)) */
|
|
36
|
+
FREQUENCY_BOOST = 'frequencyBoost',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const DEFAULT_AGGREGATION_MODE = AggregationMode.FREQUENCY_BOOST;
|
|
40
|
+
const FREQUENCY_BOOST_FACTOR = 0.1;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Composes multiple generators into a single generator.
|
|
44
|
+
*
|
|
45
|
+
* Implements CardGenerator for use in Pipeline architecture.
|
|
46
|
+
* Also extends ContentNavigator for backward compatibility.
|
|
47
|
+
*
|
|
48
|
+
* Fetches candidates from all generators, deduplicates by cardId,
|
|
49
|
+
* and aggregates scores based on the configured mode.
|
|
50
|
+
*/
|
|
51
|
+
export default class CompositeGenerator extends ContentNavigator implements CardGenerator {
|
|
52
|
+
/** Human-readable name for CardGenerator interface */
|
|
53
|
+
name: string = 'Composite Generator';
|
|
54
|
+
|
|
55
|
+
private generators: CardGenerator[];
|
|
56
|
+
private aggregationMode: AggregationMode;
|
|
57
|
+
|
|
58
|
+
constructor(
|
|
59
|
+
generators: CardGenerator[],
|
|
60
|
+
aggregationMode: AggregationMode = DEFAULT_AGGREGATION_MODE
|
|
61
|
+
) {
|
|
62
|
+
super();
|
|
63
|
+
this.generators = generators;
|
|
64
|
+
this.aggregationMode = aggregationMode;
|
|
65
|
+
|
|
66
|
+
if (generators.length === 0) {
|
|
67
|
+
throw new Error('CompositeGenerator requires at least one generator');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
logger.debug(
|
|
71
|
+
`[CompositeGenerator] Created with ${generators.length} generators, mode: ${aggregationMode}`
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Creates a CompositeGenerator from strategy data.
|
|
77
|
+
*
|
|
78
|
+
* This is a convenience factory for use by PipelineAssembler.
|
|
79
|
+
*/
|
|
80
|
+
static async fromStrategies(
|
|
81
|
+
user: UserDBInterface,
|
|
82
|
+
course: CourseDBInterface,
|
|
83
|
+
strategies: ContentNavigationStrategyData[],
|
|
84
|
+
aggregationMode: AggregationMode = DEFAULT_AGGREGATION_MODE
|
|
85
|
+
): Promise<CompositeGenerator> {
|
|
86
|
+
const generators = await Promise.all(
|
|
87
|
+
strategies.map((s) => ContentNavigator.create(user, course, s))
|
|
88
|
+
);
|
|
89
|
+
// Cast is safe because we know these are generators
|
|
90
|
+
return new CompositeGenerator(generators as unknown as CardGenerator[], aggregationMode);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Get weighted cards from all generators, merge and deduplicate.
|
|
95
|
+
*
|
|
96
|
+
* Cards appearing in multiple generators receive a score boost.
|
|
97
|
+
* Provenance tracks which generators produced each card and how scores were aggregated.
|
|
98
|
+
*
|
|
99
|
+
* This method supports both the legacy signature (limit only) and the
|
|
100
|
+
* CardGenerator interface signature (limit, context).
|
|
101
|
+
*
|
|
102
|
+
* @param limit - Maximum number of cards to return
|
|
103
|
+
* @param context - Optional GeneratorContext passed to child generators
|
|
104
|
+
*/
|
|
105
|
+
async getWeightedCards(limit: number, context?: GeneratorContext): Promise<WeightedCard[]> {
|
|
106
|
+
// Fetch from all generators in parallel
|
|
107
|
+
const results = await Promise.all(
|
|
108
|
+
this.generators.map((g) => g.getWeightedCards(limit, context))
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
// Group by cardId
|
|
112
|
+
const byCardId = new Map<string, WeightedCard[]>();
|
|
113
|
+
for (const cards of results) {
|
|
114
|
+
for (const card of cards) {
|
|
115
|
+
const existing = byCardId.get(card.cardId) || [];
|
|
116
|
+
existing.push(card);
|
|
117
|
+
byCardId.set(card.cardId, existing);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Aggregate scores
|
|
122
|
+
const merged: WeightedCard[] = [];
|
|
123
|
+
for (const [, cards] of byCardId) {
|
|
124
|
+
const aggregatedScore = this.aggregateScores(cards);
|
|
125
|
+
const finalScore = Math.min(1.0, aggregatedScore); // Clamp to [0, 1]
|
|
126
|
+
|
|
127
|
+
// Merge provenance from all generators that produced this card
|
|
128
|
+
const mergedProvenance = cards.flatMap((c) => c.provenance);
|
|
129
|
+
|
|
130
|
+
// Determine action based on whether score changed
|
|
131
|
+
const initialScore = cards[0].score;
|
|
132
|
+
const action =
|
|
133
|
+
finalScore > initialScore ? 'boosted' : finalScore < initialScore ? 'penalized' : 'passed';
|
|
134
|
+
|
|
135
|
+
// Build reason explaining the aggregation
|
|
136
|
+
const reason = this.buildAggregationReason(cards, finalScore);
|
|
137
|
+
|
|
138
|
+
// Append composite provenance entry
|
|
139
|
+
merged.push({
|
|
140
|
+
...cards[0],
|
|
141
|
+
score: finalScore,
|
|
142
|
+
provenance: [
|
|
143
|
+
...mergedProvenance,
|
|
144
|
+
{
|
|
145
|
+
strategy: 'composite',
|
|
146
|
+
strategyName: 'Composite Generator',
|
|
147
|
+
strategyId: 'COMPOSITE_GENERATOR',
|
|
148
|
+
action,
|
|
149
|
+
score: finalScore,
|
|
150
|
+
reason,
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Sort by score descending and limit
|
|
157
|
+
return merged.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Build human-readable reason for score aggregation.
|
|
162
|
+
*/
|
|
163
|
+
private buildAggregationReason(cards: WeightedCard[], finalScore: number): string {
|
|
164
|
+
const count = cards.length;
|
|
165
|
+
const scores = cards.map((c) => c.score.toFixed(2)).join(', ');
|
|
166
|
+
|
|
167
|
+
if (count === 1) {
|
|
168
|
+
return `Single generator, score ${finalScore.toFixed(2)}`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const strategies = cards.map((c) => c.provenance[0]?.strategy || 'unknown').join(', ');
|
|
172
|
+
|
|
173
|
+
switch (this.aggregationMode) {
|
|
174
|
+
case AggregationMode.MAX:
|
|
175
|
+
return `Max of ${count} generators (${strategies}): scores [${scores}] → ${finalScore.toFixed(2)}`;
|
|
176
|
+
|
|
177
|
+
case AggregationMode.AVERAGE:
|
|
178
|
+
return `Average of ${count} generators (${strategies}): scores [${scores}] → ${finalScore.toFixed(2)}`;
|
|
179
|
+
|
|
180
|
+
case AggregationMode.FREQUENCY_BOOST: {
|
|
181
|
+
const avg = cards.reduce((sum, c) => sum + c.score, 0) / count;
|
|
182
|
+
const boost = 1 + FREQUENCY_BOOST_FACTOR * (count - 1);
|
|
183
|
+
return `Frequency boost from ${count} generators (${strategies}): avg ${avg.toFixed(2)} × ${boost.toFixed(2)} → ${finalScore.toFixed(2)}`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
default:
|
|
187
|
+
return `Aggregated from ${count} generators: ${finalScore.toFixed(2)}`;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Aggregate scores from multiple generators for the same card.
|
|
193
|
+
*/
|
|
194
|
+
private aggregateScores(cards: WeightedCard[]): number {
|
|
195
|
+
const scores = cards.map((c) => c.score);
|
|
196
|
+
|
|
197
|
+
switch (this.aggregationMode) {
|
|
198
|
+
case AggregationMode.MAX:
|
|
199
|
+
return Math.max(...scores);
|
|
200
|
+
|
|
201
|
+
case AggregationMode.AVERAGE:
|
|
202
|
+
return scores.reduce((sum, s) => sum + s, 0) / scores.length;
|
|
203
|
+
|
|
204
|
+
case AggregationMode.FREQUENCY_BOOST: {
|
|
205
|
+
const avg = scores.reduce((sum, s) => sum + s, 0) / scores.length;
|
|
206
|
+
const frequencyBoost = 1 + FREQUENCY_BOOST_FACTOR * (cards.length - 1);
|
|
207
|
+
return avg * frequencyBoost;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
default:
|
|
211
|
+
return scores[0];
|
|
212
|
+
}
|
|
213
|
+
}
|
|
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
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { toCourseElo } from '@vue-skuilder/common';
|
|
2
|
+
import type { CourseDBInterface } from '../interfaces/courseDB';
|
|
3
|
+
import type { UserDBInterface } from '../interfaces/userDB';
|
|
4
|
+
import type { ScheduledCard } from '../types/user';
|
|
5
|
+
import { ContentNavigator } from './index';
|
|
6
|
+
import type { WeightedCard } from './index';
|
|
7
|
+
import type { CardFilter, FilterContext } from './filters/types';
|
|
8
|
+
import type { CardGenerator, GeneratorContext } from './generators/types';
|
|
9
|
+
import type { StudySessionNewItem, StudySessionReviewItem } from '../interfaces/contentSource';
|
|
10
|
+
import { logger } from '../../util/logger';
|
|
11
|
+
|
|
12
|
+
// ============================================================================
|
|
13
|
+
// PIPELINE LOGGING HELPERS
|
|
14
|
+
// ============================================================================
|
|
15
|
+
//
|
|
16
|
+
// Focused logging functions that can be toggled by commenting single lines.
|
|
17
|
+
// Use these to inspect pipeline behavior in development/production.
|
|
18
|
+
//
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Log pipeline configuration on construction.
|
|
22
|
+
* Shows generator and filter chain structure.
|
|
23
|
+
*/
|
|
24
|
+
function logPipelineConfig(generator: CardGenerator, filters: CardFilter[]): void {
|
|
25
|
+
const filterList = filters.length > 0
|
|
26
|
+
? '\n - ' + filters.map(f => f.name).join('\n - ')
|
|
27
|
+
: ' none';
|
|
28
|
+
|
|
29
|
+
logger.info(
|
|
30
|
+
`[Pipeline] Configuration:\n` +
|
|
31
|
+
` Generator: ${generator.name}\n` +
|
|
32
|
+
` Filters:${filterList}`
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Log tag hydration results.
|
|
38
|
+
* Shows effectiveness of batch query (how many cards/tags were hydrated).
|
|
39
|
+
*/
|
|
40
|
+
function logTagHydration(cards: WeightedCard[], tagsByCard: Map<string, string[]>): void {
|
|
41
|
+
const totalTags = Array.from(tagsByCard.values()).reduce((sum, tags) => sum + tags.length, 0);
|
|
42
|
+
const cardsWithTags = Array.from(tagsByCard.values()).filter(tags => tags.length > 0).length;
|
|
43
|
+
|
|
44
|
+
logger.debug(
|
|
45
|
+
`[Pipeline] Tag hydration: ${cards.length} cards, ` +
|
|
46
|
+
`${cardsWithTags} have tags (${totalTags} total tags) - single batch query`
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Log pipeline execution summary.
|
|
52
|
+
* Shows complete flow from generator through filters to final results.
|
|
53
|
+
*/
|
|
54
|
+
function logExecutionSummary(
|
|
55
|
+
generatorName: string,
|
|
56
|
+
generatedCount: number,
|
|
57
|
+
filterCount: number,
|
|
58
|
+
finalCount: number,
|
|
59
|
+
topScores: number[]
|
|
60
|
+
): void {
|
|
61
|
+
const scoreDisplay = topScores.length > 0
|
|
62
|
+
? topScores.map(s => s.toFixed(2)).join(', ')
|
|
63
|
+
: 'none';
|
|
64
|
+
|
|
65
|
+
logger.info(
|
|
66
|
+
`[Pipeline] Execution: ${generatorName} produced ${generatedCount} → ` +
|
|
67
|
+
`${filterCount} filters → ${finalCount} results (top scores: ${scoreDisplay})`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Log provenance trails for cards.
|
|
73
|
+
* Shows the complete scoring history for each card through the pipeline.
|
|
74
|
+
* Useful for debugging why cards scored the way they did.
|
|
75
|
+
*/
|
|
76
|
+
function logCardProvenance(cards: WeightedCard[], maxCards: number = 3): void {
|
|
77
|
+
const cardsToLog = cards.slice(0, maxCards);
|
|
78
|
+
|
|
79
|
+
logger.debug(`[Pipeline] Provenance for top ${cardsToLog.length} cards:`);
|
|
80
|
+
|
|
81
|
+
for (const card of cardsToLog) {
|
|
82
|
+
logger.debug(`[Pipeline] ${card.cardId} (final score: ${card.score.toFixed(3)}):`);
|
|
83
|
+
|
|
84
|
+
for (const entry of card.provenance) {
|
|
85
|
+
const scoreChange = entry.score.toFixed(3);
|
|
86
|
+
const action = entry.action.padEnd(9); // Align columns
|
|
87
|
+
logger.debug(
|
|
88
|
+
`[Pipeline] ${action} ${scoreChange} - ${entry.strategyName}: ${entry.reason}`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ============================================================================
|
|
95
|
+
// PIPELINE
|
|
96
|
+
// ============================================================================
|
|
97
|
+
//
|
|
98
|
+
// Executes a navigation pipeline: generator → filters → sorted results.
|
|
99
|
+
//
|
|
100
|
+
// Architecture:
|
|
101
|
+
// cards = generator.getWeightedCards(limit, context)
|
|
102
|
+
// cards = filter1.transform(cards, context)
|
|
103
|
+
// cards = filter2.transform(cards, context)
|
|
104
|
+
// cards = filter3.transform(cards, context)
|
|
105
|
+
// return sorted(cards).slice(0, limit)
|
|
106
|
+
//
|
|
107
|
+
// Benefits:
|
|
108
|
+
// - Clear separation: generators produce, filters transform
|
|
109
|
+
// - No nested instantiation complexity
|
|
110
|
+
// - Filters don't need to know about each other
|
|
111
|
+
// - Shared context built once, passed to all stages
|
|
112
|
+
//
|
|
113
|
+
// ============================================================================
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A navigation pipeline that runs a generator and applies filters sequentially.
|
|
117
|
+
*
|
|
118
|
+
* Implements StudyContentSource for backward compatibility with SessionController.
|
|
119
|
+
*
|
|
120
|
+
* ## Usage
|
|
121
|
+
*
|
|
122
|
+
* ```typescript
|
|
123
|
+
* const pipeline = new Pipeline(
|
|
124
|
+
* compositeGenerator, // or single generator
|
|
125
|
+
* [eloDistanceFilter, interferenceFilter],
|
|
126
|
+
* user,
|
|
127
|
+
* course
|
|
128
|
+
* );
|
|
129
|
+
*
|
|
130
|
+
* const cards = await pipeline.getWeightedCards(20);
|
|
131
|
+
* ```
|
|
132
|
+
*/
|
|
133
|
+
export class Pipeline extends ContentNavigator {
|
|
134
|
+
private generator: CardGenerator;
|
|
135
|
+
private filters: CardFilter[];
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Create a new pipeline.
|
|
139
|
+
*
|
|
140
|
+
* @param generator - The generator (or CompositeGenerator) that produces candidates
|
|
141
|
+
* @param filters - Filters to apply sequentially (order doesn't matter for multipliers)
|
|
142
|
+
* @param user - User database interface
|
|
143
|
+
* @param course - Course database interface
|
|
144
|
+
*/
|
|
145
|
+
constructor(
|
|
146
|
+
generator: CardGenerator,
|
|
147
|
+
filters: CardFilter[],
|
|
148
|
+
user: UserDBInterface,
|
|
149
|
+
course: CourseDBInterface
|
|
150
|
+
) {
|
|
151
|
+
super();
|
|
152
|
+
this.generator = generator;
|
|
153
|
+
this.filters = filters;
|
|
154
|
+
this.user = user;
|
|
155
|
+
this.course = course;
|
|
156
|
+
|
|
157
|
+
// Toggle pipeline configuration logging:
|
|
158
|
+
logPipelineConfig(generator, filters);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Get weighted cards by running generator and applying filters.
|
|
163
|
+
*
|
|
164
|
+
* 1. Build shared context (user ELO, etc.)
|
|
165
|
+
* 2. Get candidates from generator (passing context)
|
|
166
|
+
* 3. Batch hydrate tags for all candidates
|
|
167
|
+
* 4. Apply each filter sequentially
|
|
168
|
+
* 5. Remove zero-score cards
|
|
169
|
+
* 6. Sort by score descending
|
|
170
|
+
* 7. Return top N
|
|
171
|
+
*
|
|
172
|
+
* @param limit - Maximum number of cards to return
|
|
173
|
+
* @returns Cards sorted by score descending
|
|
174
|
+
*/
|
|
175
|
+
async getWeightedCards(limit: number): Promise<WeightedCard[]> {
|
|
176
|
+
// Build shared context once
|
|
177
|
+
const context = await this.buildContext();
|
|
178
|
+
|
|
179
|
+
// Over-fetch from generator to account for filtering
|
|
180
|
+
const overFetchMultiplier = 2 + this.filters.length * 0.5;
|
|
181
|
+
const fetchLimit = Math.ceil(limit * overFetchMultiplier);
|
|
182
|
+
|
|
183
|
+
logger.debug(
|
|
184
|
+
`[Pipeline] Fetching ${fetchLimit} candidates from generator '${this.generator.name}'`
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
// Get candidates from generator, passing context
|
|
188
|
+
let cards = await this.generator.getWeightedCards(fetchLimit, context);
|
|
189
|
+
const generatedCount = cards.length;
|
|
190
|
+
|
|
191
|
+
logger.debug(`[Pipeline] Generator returned ${generatedCount} candidates`);
|
|
192
|
+
|
|
193
|
+
// Batch hydrate tags before filters run
|
|
194
|
+
cards = await this.hydrateTags(cards);
|
|
195
|
+
|
|
196
|
+
// Apply filters sequentially
|
|
197
|
+
for (const filter of this.filters) {
|
|
198
|
+
const beforeCount = cards.length;
|
|
199
|
+
cards = await filter.transform(cards, context);
|
|
200
|
+
logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeCount} → ${cards.length} cards`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Remove zero-score cards (hard filtered)
|
|
204
|
+
cards = cards.filter((c) => c.score > 0);
|
|
205
|
+
|
|
206
|
+
// Sort by score descending
|
|
207
|
+
cards.sort((a, b) => b.score - a.score);
|
|
208
|
+
|
|
209
|
+
// Return top N
|
|
210
|
+
const result = cards.slice(0, limit);
|
|
211
|
+
|
|
212
|
+
// Toggle execution summary logging:
|
|
213
|
+
const topScores = result.slice(0, 3).map(c => c.score);
|
|
214
|
+
logExecutionSummary(this.generator.name, generatedCount, this.filters.length, result.length, topScores);
|
|
215
|
+
|
|
216
|
+
// Toggle provenance logging (shows scoring history for top cards):
|
|
217
|
+
logCardProvenance(result, 3);
|
|
218
|
+
|
|
219
|
+
return result;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Batch hydrate tags for all cards.
|
|
224
|
+
*
|
|
225
|
+
* Fetches tags for all cards in a single database query and attaches them
|
|
226
|
+
* to the WeightedCard objects. Filters can then use card.tags instead of
|
|
227
|
+
* making individual getAppliedTags() calls.
|
|
228
|
+
*
|
|
229
|
+
* @param cards - Cards to hydrate
|
|
230
|
+
* @returns Cards with tags populated
|
|
231
|
+
*/
|
|
232
|
+
private async hydrateTags(cards: WeightedCard[]): Promise<WeightedCard[]> {
|
|
233
|
+
if (cards.length === 0) {
|
|
234
|
+
return cards;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const cardIds = cards.map((c) => c.cardId);
|
|
238
|
+
const tagsByCard = await this.course!.getAppliedTagsBatch(cardIds);
|
|
239
|
+
|
|
240
|
+
// Toggle tag hydration logging:
|
|
241
|
+
logTagHydration(cards, tagsByCard);
|
|
242
|
+
|
|
243
|
+
return cards.map((card) => ({
|
|
244
|
+
...card,
|
|
245
|
+
tags: tagsByCard.get(card.cardId) ?? [],
|
|
246
|
+
}));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Build shared context for generator and filters.
|
|
251
|
+
*
|
|
252
|
+
* Called once per getWeightedCards() invocation.
|
|
253
|
+
* Contains data that the generator and multiple filters might need.
|
|
254
|
+
*
|
|
255
|
+
* The context satisfies both GeneratorContext and FilterContext interfaces.
|
|
256
|
+
*/
|
|
257
|
+
private async buildContext(): Promise<GeneratorContext & FilterContext> {
|
|
258
|
+
let userElo = 1000; // Default ELO
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
const courseReg = await this.user!.getCourseRegDoc(this.course!.getCourseID());
|
|
262
|
+
const courseElo = toCourseElo(courseReg.elo);
|
|
263
|
+
userElo = courseElo.global.score;
|
|
264
|
+
} catch (e) {
|
|
265
|
+
logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
user: this.user!,
|
|
270
|
+
course: this.course!,
|
|
271
|
+
userElo,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ===========================================================================
|
|
276
|
+
// Legacy StudyContentSource methods
|
|
277
|
+
// ===========================================================================
|
|
278
|
+
//
|
|
279
|
+
// These delegate to the generator for backward compatibility.
|
|
280
|
+
// Eventually SessionController will use getWeightedCards() exclusively.
|
|
281
|
+
//
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Get new cards via legacy API.
|
|
285
|
+
* Delegates to the generator if it supports the legacy interface.
|
|
286
|
+
*/
|
|
287
|
+
async getNewCards(n?: number): Promise<StudySessionNewItem[]> {
|
|
288
|
+
// Check if generator has legacy method (ContentNavigator-based generators do)
|
|
289
|
+
if ('getNewCards' in this.generator && typeof this.generator.getNewCards === 'function') {
|
|
290
|
+
return (this.generator as ContentNavigator).getNewCards(n);
|
|
291
|
+
}
|
|
292
|
+
// Pure CardGenerator without legacy support - return empty
|
|
293
|
+
return [];
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Get pending reviews via legacy API.
|
|
298
|
+
* Delegates to the generator if it supports the legacy interface.
|
|
299
|
+
*/
|
|
300
|
+
async getPendingReviews(): Promise<(StudySessionReviewItem & ScheduledCard)[]> {
|
|
301
|
+
// Check if generator has legacy method (ContentNavigator-based generators do)
|
|
302
|
+
if (
|
|
303
|
+
'getPendingReviews' in this.generator &&
|
|
304
|
+
typeof this.generator.getPendingReviews === 'function'
|
|
305
|
+
) {
|
|
306
|
+
return (this.generator as ContentNavigator).getPendingReviews();
|
|
307
|
+
}
|
|
308
|
+
// Pure CardGenerator without legacy support - return empty
|
|
309
|
+
return [];
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Get the course ID for this pipeline.
|
|
314
|
+
*/
|
|
315
|
+
getCourseID(): string {
|
|
316
|
+
return this.course!.getCourseID();
|
|
317
|
+
}
|
|
318
|
+
}
|