@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.
Files changed (80) hide show
  1. package/dist/{userDB-DNa0XPtn.d.ts → classroomDB-BgfrVb8d.d.ts} +357 -103
  2. package/dist/{userDB-BqwxtJ_7.d.mts → classroomDB-CTOenngH.d.cts} +358 -104
  3. package/dist/core/index.d.cts +230 -0
  4. package/dist/core/index.d.ts +161 -23
  5. package/dist/core/index.js +1964 -154
  6. package/dist/core/index.js.map +1 -1
  7. package/dist/core/index.mjs +1925 -121
  8. package/dist/core/index.mjs.map +1 -1
  9. package/dist/{dataLayerProvider-BV5iZqt_.d.ts → dataLayerProvider-CZxC9GtB.d.ts} +1 -1
  10. package/dist/{dataLayerProvider-VlngD19_.d.mts → dataLayerProvider-D6PoCwS6.d.cts} +1 -1
  11. package/dist/impl/couch/{index.d.mts → index.d.cts} +46 -5
  12. package/dist/impl/couch/index.d.ts +44 -3
  13. package/dist/impl/couch/index.js +1971 -171
  14. package/dist/impl/couch/index.js.map +1 -1
  15. package/dist/impl/couch/index.mjs +1933 -134
  16. package/dist/impl/couch/index.mjs.map +1 -1
  17. package/dist/impl/static/{index.d.mts → index.d.cts} +5 -6
  18. package/dist/impl/static/index.d.ts +2 -3
  19. package/dist/impl/static/index.js +1614 -119
  20. package/dist/impl/static/index.js.map +1 -1
  21. package/dist/impl/static/index.mjs +1585 -92
  22. package/dist/impl/static/index.mjs.map +1 -1
  23. package/dist/{index-Bmll7Xse.d.mts → index-D-Fa4Smt.d.cts} +1 -1
  24. package/dist/{index.d.mts → index.d.cts} +97 -13
  25. package/dist/index.d.ts +90 -6
  26. package/dist/index.js +2085 -153
  27. package/dist/index.js.map +1 -1
  28. package/dist/index.mjs +2031 -106
  29. package/dist/index.mjs.map +1 -1
  30. package/dist/pouch/index.js +3 -3
  31. package/dist/{types-Dbp5DaRR.d.mts → types-CzPDLAK6.d.cts} +1 -1
  32. package/dist/util/packer/{index.d.mts → index.d.cts} +3 -3
  33. package/dist/util/packer/index.js.map +1 -1
  34. package/dist/util/packer/index.mjs.map +1 -1
  35. package/docs/brainstorm-navigation-paradigm.md +369 -0
  36. package/docs/navigators-architecture.md +265 -0
  37. package/docs/todo-evolutionary-orchestration.md +310 -0
  38. package/docs/todo-nominal-tag-types.md +121 -0
  39. package/docs/todo-pipeline-optimization.md +117 -0
  40. package/docs/todo-strategy-authoring.md +401 -0
  41. package/docs/todo-strategy-state-storage.md +278 -0
  42. package/eslint.config.mjs +1 -1
  43. package/package.json +9 -4
  44. package/src/core/interfaces/contentSource.ts +88 -4
  45. package/src/core/interfaces/navigationStrategyManager.ts +0 -5
  46. package/src/core/navigators/CompositeGenerator.ts +268 -0
  47. package/src/core/navigators/Pipeline.ts +205 -0
  48. package/src/core/navigators/PipelineAssembler.ts +194 -0
  49. package/src/core/navigators/elo.ts +104 -15
  50. package/src/core/navigators/filters/eloDistance.ts +132 -0
  51. package/src/core/navigators/filters/index.ts +6 -0
  52. package/src/core/navigators/filters/types.ts +115 -0
  53. package/src/core/navigators/generators/index.ts +2 -0
  54. package/src/core/navigators/generators/types.ts +107 -0
  55. package/src/core/navigators/hardcodedOrder.ts +111 -12
  56. package/src/core/navigators/hierarchyDefinition.ts +266 -0
  57. package/src/core/navigators/index.ts +345 -3
  58. package/src/core/navigators/interferenceMitigator.ts +367 -0
  59. package/src/core/navigators/relativePriority.ts +267 -0
  60. package/src/core/navigators/srs.ts +195 -0
  61. package/src/impl/couch/classroomDB.ts +51 -0
  62. package/src/impl/couch/courseDB.ts +117 -39
  63. package/src/impl/static/courseDB.ts +0 -4
  64. package/src/study/SessionController.ts +149 -1
  65. package/src/study/TagFilteredContentSource.ts +255 -0
  66. package/src/study/index.ts +1 -0
  67. package/src/util/dataDirectory.test.ts +51 -22
  68. package/src/util/logger.ts +0 -1
  69. package/tests/core/navigators/CompositeGenerator.test.ts +455 -0
  70. package/tests/core/navigators/Pipeline.test.ts +405 -0
  71. package/tests/core/navigators/PipelineAssembler.test.ts +351 -0
  72. package/tests/core/navigators/SRSNavigator.test.ts +344 -0
  73. package/tests/core/navigators/eloDistanceFilter.test.ts +192 -0
  74. package/tests/core/navigators/navigators.test.ts +710 -0
  75. package/tsconfig.json +1 -1
  76. package/vitest.config.ts +29 -0
  77. package/dist/core/index.d.mts +0 -92
  78. /package/dist/{SyncStrategy-CyATpyLQ.d.mts → SyncStrategy-CyATpyLQ.d.cts} +0 -0
  79. /package/dist/pouch/{index.d.mts → index.d.cts} +0 -0
  80. /package/dist/{types-legacy-6ettoclI.d.mts → types-legacy-6ettoclI.d.cts} +0 -0
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "@vue-skuilder/db",
3
+ "type": "module",
3
4
  "publishConfig": {
4
5
  "access": "public"
5
6
  },
6
- "version": "0.1.16",
7
+ "version": "0.1.18",
7
8
  "description": "Database layer for vue-skuilder",
8
9
  "main": "dist/index.js",
9
10
  "module": "dist/index.mjs",
@@ -39,13 +40,15 @@
39
40
  "build": "tsup",
40
41
  "build:debug": "tsup --sourcemap inline",
41
42
  "dev": "tsup --watch",
43
+ "test": "vitest run",
44
+ "test:watch": "vitest",
42
45
  "lint": "npx eslint .",
43
46
  "lint:fix": "npx eslint . --fix",
44
47
  "lint:check": "npx eslint . --max-warnings 0"
45
48
  },
46
49
  "dependencies": {
47
50
  "@nilock2/pouchdb-authentication": "^1.0.2",
48
- "@vue-skuilder/common": "0.1.16",
51
+ "@vue-skuilder/common": "0.1.18",
49
52
  "cross-fetch": "^4.1.0",
50
53
  "moment": "^2.29.4",
51
54
  "pouchdb": "^9.0.0",
@@ -55,7 +58,9 @@
55
58
  "devDependencies": {
56
59
  "@types/uuid": "^10.0.0",
57
60
  "tsup": "^8.0.2",
58
- "typescript": "~5.7.2"
61
+ "typescript": "~5.9.3",
62
+ "vite": "^7.0.0",
63
+ "vitest": "^4.0.14"
59
64
  },
60
- "stableVersion": "0.1.16"
65
+ "stableVersion": "0.1.18"
61
66
  }
@@ -2,6 +2,41 @@ import { getDataLayer } from '@db/factory';
2
2
  import { UserDBInterface } from '..';
3
3
  import { StudentClassroomDB } from '../../impl/couch/classroomDB';
4
4
  import { ScheduledCard } from '@db/core/types/user';
5
+ import { WeightedCard } from '../navigators';
6
+ import { TagFilter, hasActiveFilter } from '@vue-skuilder/common';
7
+ import { TagFilteredContentSource } from '../../study/TagFilteredContentSource';
8
+
9
+ // ============================================================================
10
+ // API MIGRATION NOTICE
11
+ // ============================================================================
12
+ //
13
+ // The StudyContentSource interface is being superseded by the ContentNavigator
14
+ // class and its getWeightedCards() API. See:
15
+ // packages/db/src/core/navigators/ARCHITECTURE.md
16
+ //
17
+ // HISTORICAL CONTEXT:
18
+ // - This interface was designed to abstract 'classrooms' and 'courses' as
19
+ // content sources for study sessions.
20
+ // - getNewCards() and getPendingReviews() were artifacts of two hard-coded
21
+ // navigation strategies: ELO proximity (new) and SRS scheduling (reviews).
22
+ // - The new/review split reflected implementation details, not fundamentals.
23
+ //
24
+ // THE PROBLEM:
25
+ // - "What does 'get reviews' mean for an interference mitigator?" - it doesn't.
26
+ // - SRS is just one strategy that could express review urgency as scores.
27
+ // - Some strategies generate candidates, others filter/score them.
28
+ //
29
+ // THE SOLUTION:
30
+ // - ContentNavigator.getWeightedCards() returns unified scored candidates.
31
+ // - WeightedCard.source field distinguishes new/review/failed (metadata, not API).
32
+ // - Strategies compose via delegate pattern (filter wraps generator).
33
+ //
34
+ // MIGRATION PATH:
35
+ // 1. ContentNavigator implements StudyContentSource for backward compat
36
+ // 2. SessionController will migrate to call getWeightedCards()
37
+ // 3. Legacy methods will be deprecated, then removed
38
+ //
39
+ // ============================================================================
5
40
 
6
41
  export type StudySessionFailedItem = StudySessionFailedNewItem | StudySessionFailedReviewItem;
7
42
 
@@ -43,12 +78,60 @@ export interface StudySessionItem {
43
78
  export interface ContentSourceID {
44
79
  type: 'course' | 'classroom';
45
80
  id: string;
81
+ /**
82
+ * Optional tag filter for scoped study sessions.
83
+ * When present, creates a TagFilteredContentSource instead of a regular course source.
84
+ */
85
+ tagFilter?: TagFilter;
46
86
  }
47
87
 
48
88
  // #region docs_StudyContentSource
89
+ /**
90
+ * Interface for sources that provide study content to SessionController.
91
+ *
92
+ * @deprecated This interface will be superseded by ContentNavigator.getWeightedCards().
93
+ * The getNewCards/getPendingReviews split was an artifact of hard-coded ELO and SRS
94
+ * strategies. The new API returns unified WeightedCard[] with scores.
95
+ *
96
+ * MIGRATION:
97
+ * - Implement ContentNavigator instead of StudyContentSource directly
98
+ * - Override getWeightedCards() as the primary method
99
+ * - Legacy methods can delegate to getWeightedCards() or be left as-is
100
+ *
101
+ * See: packages/db/src/core/navigators/ARCHITECTURE.md
102
+ */
49
103
  export interface StudyContentSource {
104
+ /**
105
+ * Get cards scheduled for review based on SRS algorithm.
106
+ *
107
+ * @deprecated Will be replaced by getWeightedCards() which returns scored candidates.
108
+ * Review urgency will be expressed as a score rather than a separate method.
109
+ */
50
110
  getPendingReviews(): Promise<(StudySessionReviewItem & ScheduledCard)[]>;
111
+
112
+ /**
113
+ * Get new cards for introduction, typically ordered by ELO proximity.
114
+ *
115
+ * @deprecated Will be replaced by getWeightedCards() which returns scored candidates.
116
+ * New card selection and scoring will be unified with review scoring.
117
+ *
118
+ * @param n - Maximum number of new cards to return
119
+ */
51
120
  getNewCards(n?: number): Promise<StudySessionNewItem[]>;
121
+
122
+ /**
123
+ * Get cards with suitability scores for presentation.
124
+ *
125
+ * This is the PRIMARY API for content sources going forward. Returns unified
126
+ * scored candidates that can be sorted and selected by SessionController.
127
+ *
128
+ * The `source` field on WeightedCard indicates origin ('new' | 'review' | 'failed')
129
+ * for queue routing purposes during the migration period.
130
+ *
131
+ * @param limit - Maximum number of cards to return
132
+ * @returns Cards sorted by score descending
133
+ */
134
+ getWeightedCards?(limit: number): Promise<WeightedCard[]>;
52
135
  }
53
136
  // #endregion docs_StudyContentSource
54
137
 
@@ -59,11 +142,12 @@ export async function getStudySource(
59
142
  if (source.type === 'classroom') {
60
143
  return await StudentClassroomDB.factory(source.id, user);
61
144
  } else {
62
- // if (source.type === 'course') - removed so tsc is certain something returns
63
- // return new CourseDB(source.id, async () => {
64
- // return user;
65
- // });
145
+ // Check if this is a tag-filtered course source
146
+ if (hasActiveFilter(source.tagFilter)) {
147
+ return new TagFilteredContentSource(source.id, source.tagFilter!, user);
148
+ }
66
149
 
150
+ // Regular course source
67
151
  return getDataLayer().getCourseDB(source.id) as unknown as StudyContentSource;
68
152
  }
69
153
  }
@@ -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
@@ -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,205 @@
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
14
+ // ============================================================================
15
+ //
16
+ // Executes a navigation pipeline: generator → filters → sorted results.
17
+ //
18
+ // Architecture:
19
+ // cards = generator.getWeightedCards(limit, context)
20
+ // cards = filter1.transform(cards, context)
21
+ // cards = filter2.transform(cards, context)
22
+ // cards = filter3.transform(cards, context)
23
+ // return sorted(cards).slice(0, limit)
24
+ //
25
+ // Benefits:
26
+ // - Clear separation: generators produce, filters transform
27
+ // - No nested instantiation complexity
28
+ // - Filters don't need to know about each other
29
+ // - Shared context built once, passed to all stages
30
+ //
31
+ // ============================================================================
32
+
33
+ /**
34
+ * A navigation pipeline that runs a generator and applies filters sequentially.
35
+ *
36
+ * Implements StudyContentSource for backward compatibility with SessionController.
37
+ *
38
+ * ## Usage
39
+ *
40
+ * ```typescript
41
+ * const pipeline = new Pipeline(
42
+ * compositeGenerator, // or single generator
43
+ * [eloDistanceFilter, interferenceFilter],
44
+ * user,
45
+ * course
46
+ * );
47
+ *
48
+ * const cards = await pipeline.getWeightedCards(20);
49
+ * ```
50
+ */
51
+ export class Pipeline extends ContentNavigator {
52
+ private generator: CardGenerator;
53
+ private filters: CardFilter[];
54
+
55
+ /**
56
+ * Create a new pipeline.
57
+ *
58
+ * @param generator - The generator (or CompositeGenerator) that produces candidates
59
+ * @param filters - Filters to apply sequentially (order doesn't matter for multipliers)
60
+ * @param user - User database interface
61
+ * @param course - Course database interface
62
+ */
63
+ constructor(
64
+ generator: CardGenerator,
65
+ filters: CardFilter[],
66
+ user: UserDBInterface,
67
+ course: CourseDBInterface
68
+ ) {
69
+ super();
70
+ this.generator = generator;
71
+ this.filters = filters;
72
+ this.user = user;
73
+ this.course = course;
74
+
75
+ logger.debug(
76
+ `[Pipeline] Created with generator '${generator.name}' and ${filters.length} filters: ${filters.map((f) => f.name).join(', ')}`
77
+ );
78
+ }
79
+
80
+ /**
81
+ * Get weighted cards by running generator and applying filters.
82
+ *
83
+ * 1. Build shared context (user ELO, etc.)
84
+ * 2. Get candidates from generator (passing context)
85
+ * 3. Apply each filter sequentially
86
+ * 4. Remove zero-score cards
87
+ * 5. Sort by score descending
88
+ * 6. Return top N
89
+ *
90
+ * @param limit - Maximum number of cards to return
91
+ * @returns Cards sorted by score descending
92
+ */
93
+ async getWeightedCards(limit: number): Promise<WeightedCard[]> {
94
+ // Build shared context once
95
+ const context = await this.buildContext();
96
+
97
+ // Over-fetch from generator to account for filtering
98
+ const overFetchMultiplier = 2 + this.filters.length * 0.5;
99
+ const fetchLimit = Math.ceil(limit * overFetchMultiplier);
100
+
101
+ logger.debug(
102
+ `[Pipeline] Fetching ${fetchLimit} candidates from generator '${this.generator.name}'`
103
+ );
104
+
105
+ // Get candidates from generator, passing context
106
+ let cards = await this.generator.getWeightedCards(fetchLimit, context);
107
+
108
+ logger.debug(`[Pipeline] Generator returned ${cards.length} candidates`);
109
+
110
+ // Apply filters sequentially
111
+ for (const filter of this.filters) {
112
+ const beforeCount = cards.length;
113
+ cards = await filter.transform(cards, context);
114
+ logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeCount} → ${cards.length} cards`);
115
+ }
116
+
117
+ // Remove zero-score cards (hard filtered)
118
+ cards = cards.filter((c) => c.score > 0);
119
+
120
+ // Sort by score descending
121
+ cards.sort((a, b) => b.score - a.score);
122
+
123
+ // Return top N
124
+ const result = cards.slice(0, limit);
125
+
126
+ logger.debug(
127
+ `[Pipeline] Returning ${result.length} cards (top scores: ${result
128
+ .slice(0, 3)
129
+ .map((c) => c.score.toFixed(2))
130
+ .join(', ')}...)`
131
+ );
132
+
133
+ return result;
134
+ }
135
+
136
+ /**
137
+ * Build shared context for generator and filters.
138
+ *
139
+ * Called once per getWeightedCards() invocation.
140
+ * Contains data that the generator and multiple filters might need.
141
+ *
142
+ * The context satisfies both GeneratorContext and FilterContext interfaces.
143
+ */
144
+ private async buildContext(): Promise<GeneratorContext & FilterContext> {
145
+ let userElo = 1000; // Default ELO
146
+
147
+ try {
148
+ const courseReg = await this.user!.getCourseRegDoc(this.course!.getCourseID());
149
+ const courseElo = toCourseElo(courseReg.elo);
150
+ userElo = courseElo.global.score;
151
+ } catch (e) {
152
+ logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`);
153
+ }
154
+
155
+ return {
156
+ user: this.user!,
157
+ course: this.course!,
158
+ userElo,
159
+ };
160
+ }
161
+
162
+ // ===========================================================================
163
+ // Legacy StudyContentSource methods
164
+ // ===========================================================================
165
+ //
166
+ // These delegate to the generator for backward compatibility.
167
+ // Eventually SessionController will use getWeightedCards() exclusively.
168
+ //
169
+
170
+ /**
171
+ * Get new cards via legacy API.
172
+ * Delegates to the generator if it supports the legacy interface.
173
+ */
174
+ async getNewCards(n?: number): Promise<StudySessionNewItem[]> {
175
+ // Check if generator has legacy method (ContentNavigator-based generators do)
176
+ if ('getNewCards' in this.generator && typeof this.generator.getNewCards === 'function') {
177
+ return (this.generator as ContentNavigator).getNewCards(n);
178
+ }
179
+ // Pure CardGenerator without legacy support - return empty
180
+ return [];
181
+ }
182
+
183
+ /**
184
+ * Get pending reviews via legacy API.
185
+ * Delegates to the generator if it supports the legacy interface.
186
+ */
187
+ async getPendingReviews(): Promise<(StudySessionReviewItem & ScheduledCard)[]> {
188
+ // Check if generator has legacy method (ContentNavigator-based generators do)
189
+ if (
190
+ 'getPendingReviews' in this.generator &&
191
+ typeof this.generator.getPendingReviews === 'function'
192
+ ) {
193
+ return (this.generator as ContentNavigator).getPendingReviews();
194
+ }
195
+ // Pure CardGenerator without legacy support - return empty
196
+ return [];
197
+ }
198
+
199
+ /**
200
+ * Get the course ID for this pipeline.
201
+ */
202
+ getCourseID(): string {
203
+ return this.course!.getCourseID();
204
+ }
205
+ }