@vue-skuilder/db 0.1.18 → 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.
Files changed (59) hide show
  1. package/dist/{classroomDB-BgfrVb8d.d.ts → classroomDB-CZdMBiTU.d.ts} +71 -2
  2. package/dist/{classroomDB-CTOenngH.d.cts → classroomDB-PxDZTky3.d.cts} +71 -2
  3. package/dist/core/index.d.cts +80 -6
  4. package/dist/core/index.d.ts +80 -6
  5. package/dist/core/index.js +370 -52
  6. package/dist/core/index.js.map +1 -1
  7. package/dist/core/index.mjs +369 -52
  8. package/dist/core/index.mjs.map +1 -1
  9. package/dist/{dataLayerProvider-D6PoCwS6.d.cts → dataLayerProvider-D0MoZMjH.d.cts} +1 -1
  10. package/dist/{dataLayerProvider-CZxC9GtB.d.ts → dataLayerProvider-D8o6ZnKW.d.ts} +1 -1
  11. package/dist/impl/couch/index.d.cts +4 -3
  12. package/dist/impl/couch/index.d.ts +4 -3
  13. package/dist/impl/couch/index.js +371 -55
  14. package/dist/impl/couch/index.js.map +1 -1
  15. package/dist/impl/couch/index.mjs +371 -55
  16. package/dist/impl/couch/index.mjs.map +1 -1
  17. package/dist/impl/static/index.d.cts +5 -4
  18. package/dist/impl/static/index.d.ts +5 -4
  19. package/dist/impl/static/index.js +356 -44
  20. package/dist/impl/static/index.js.map +1 -1
  21. package/dist/impl/static/index.mjs +356 -44
  22. package/dist/impl/static/index.mjs.map +1 -1
  23. package/dist/{index-D-Fa4Smt.d.cts → index-B_j6u5E4.d.cts} +1 -1
  24. package/dist/{index-CD8BZz2k.d.ts → index-Dj0SEgk3.d.ts} +1 -1
  25. package/dist/index.d.cts +10 -10
  26. package/dist/index.d.ts +10 -10
  27. package/dist/index.js +382 -55
  28. package/dist/index.js.map +1 -1
  29. package/dist/index.mjs +381 -55
  30. package/dist/index.mjs.map +1 -1
  31. package/dist/{types-CzPDLAK6.d.cts → types-Bn0itutr.d.cts} +1 -1
  32. package/dist/{types-CewsN87z.d.ts → types-DQaXnuoc.d.ts} +1 -1
  33. package/dist/{types-legacy-6ettoclI.d.cts → types-legacy-DDY4N-Uq.d.cts} +3 -1
  34. package/dist/{types-legacy-6ettoclI.d.ts → types-legacy-DDY4N-Uq.d.ts} +3 -1
  35. package/dist/util/packer/index.d.cts +3 -3
  36. package/dist/util/packer/index.d.ts +3 -3
  37. package/docs/navigators-architecture.md +115 -10
  38. package/package.json +4 -4
  39. package/src/core/index.ts +1 -0
  40. package/src/core/interfaces/courseDB.ts +13 -0
  41. package/src/core/interfaces/userDB.ts +32 -0
  42. package/src/core/navigators/Pipeline.ts +127 -14
  43. package/src/core/navigators/filters/index.ts +3 -0
  44. package/src/core/navigators/filters/userTagPreference.ts +232 -0
  45. package/src/core/navigators/hierarchyDefinition.ts +4 -4
  46. package/src/core/navigators/index.ts +59 -0
  47. package/src/core/navigators/inferredPreference.ts +107 -0
  48. package/src/core/navigators/interferenceMitigator.ts +1 -13
  49. package/src/core/navigators/relativePriority.ts +2 -14
  50. package/src/core/navigators/userGoal.ts +136 -0
  51. package/src/core/types/strategyState.ts +84 -0
  52. package/src/core/types/types-legacy.ts +2 -0
  53. package/src/impl/common/BaseUserDB.ts +74 -7
  54. package/src/impl/couch/adminDB.ts +1 -2
  55. package/src/impl/couch/courseDB.ts +30 -10
  56. package/src/impl/static/courseDB.ts +11 -0
  57. package/tests/core/navigators/Pipeline.test.ts +1 -0
  58. package/docs/todo-pipeline-optimization.md +0 -117
  59. package/docs/todo-strategy-state-storage.md +0 -278
@@ -1,5 +1,5 @@
1
1
  import { CourseConfig } from '@vue-skuilder/common';
2
- import { D as DocType } from './types-legacy-6ettoclI.cjs';
2
+ import { D as DocType } from './types-legacy-DDY4N-Uq.cjs';
3
3
 
4
4
  interface StaticCourseManifest {
5
5
  version: string;
@@ -1,5 +1,5 @@
1
1
  import { CourseConfig } from '@vue-skuilder/common';
2
- import { D as DocType } from './types-legacy-6ettoclI.js';
2
+ import { D as DocType } from './types-legacy-DDY4N-Uq.js';
3
3
 
4
4
  interface StaticCourseManifest {
5
5
  version: string;
@@ -13,7 +13,8 @@ declare enum DocType {
13
13
  CARDRECORD = "CARDRECORD",
14
14
  SCHEDULED_CARD = "SCHEDULED_CARD",
15
15
  TAG = "TAG",
16
- NAVIGATION_STRATEGY = "NAVIGATION_STRATEGY"
16
+ NAVIGATION_STRATEGY = "NAVIGATION_STRATEGY",
17
+ STRATEGY_STATE = "STRATEGY_STATE"
17
18
  }
18
19
  interface QualifiedCardID {
19
20
  courseID: string;
@@ -89,6 +90,7 @@ declare const DocTypePrefixes: {
89
90
  readonly VIEW: "VIEW";
90
91
  readonly PEDAGOGY: "PEDAGOGY";
91
92
  readonly NAVIGATION_STRATEGY: "NAVIGATION_STRATEGY";
93
+ readonly STRATEGY_STATE: "STRATEGY_STATE";
92
94
  };
93
95
  interface CardHistory<T extends CardRecord> {
94
96
  _id: PouchDB.Core.DocumentId;
@@ -13,7 +13,8 @@ declare enum DocType {
13
13
  CARDRECORD = "CARDRECORD",
14
14
  SCHEDULED_CARD = "SCHEDULED_CARD",
15
15
  TAG = "TAG",
16
- NAVIGATION_STRATEGY = "NAVIGATION_STRATEGY"
16
+ NAVIGATION_STRATEGY = "NAVIGATION_STRATEGY",
17
+ STRATEGY_STATE = "STRATEGY_STATE"
17
18
  }
18
19
  interface QualifiedCardID {
19
20
  courseID: string;
@@ -89,6 +90,7 @@ declare const DocTypePrefixes: {
89
90
  readonly VIEW: "VIEW";
90
91
  readonly PEDAGOGY: "PEDAGOGY";
91
92
  readonly NAVIGATION_STRATEGY: "NAVIGATION_STRATEGY";
93
+ readonly STRATEGY_STATE: "STRATEGY_STATE";
92
94
  };
93
95
  interface CardHistory<T extends CardRecord> {
94
96
  _id: PouchDB.Core.DocumentId;
@@ -1,5 +1,5 @@
1
- export { A as AttachmentData, C as ChunkMetadata, D as DesignDocument, I as IndexMetadata, a as PackedCourseData, P as PackerConfig, S as StaticCourseManifest } from '../../types-CzPDLAK6.cjs';
2
- export { C as CouchDBToStaticPacker } from '../../index-D-Fa4Smt.cjs';
1
+ export { A as AttachmentData, C as ChunkMetadata, D as DesignDocument, I as IndexMetadata, a as PackedCourseData, P as PackerConfig, S as StaticCourseManifest } from '../../types-Bn0itutr.cjs';
2
+ export { C as CouchDBToStaticPacker } from '../../index-B_j6u5E4.cjs';
3
3
  import '@vue-skuilder/common';
4
- import '../../types-legacy-6ettoclI.cjs';
4
+ import '../../types-legacy-DDY4N-Uq.cjs';
5
5
  import 'moment';
@@ -1,5 +1,5 @@
1
- export { A as AttachmentData, C as ChunkMetadata, D as DesignDocument, I as IndexMetadata, a as PackedCourseData, P as PackerConfig, S as StaticCourseManifest } from '../../types-CewsN87z.js';
2
- export { C as CouchDBToStaticPacker } from '../../index-CD8BZz2k.js';
1
+ export { A as AttachmentData, C as ChunkMetadata, D as DesignDocument, I as IndexMetadata, a as PackedCourseData, P as PackerConfig, S as StaticCourseManifest } from '../../types-DQaXnuoc.js';
2
+ export { C as CouchDBToStaticPacker } from '../../index-Dj0SEgk3.js';
3
3
  import '@vue-skuilder/common';
4
- import '../../types-legacy-6ettoclI.js';
4
+ import '../../types-legacy-DDY4N-Uq.js';
5
5
  import 'moment';
@@ -9,7 +9,7 @@ The navigation strategy system selects and scores cards for study sessions. It u
9
9
 
10
10
  ### WeightedCard
11
11
 
12
- A card with a suitability score and audit trail:
12
+ A card with a suitability score, audit trail, and pre-fetched data:
13
13
 
14
14
  ```typescript
15
15
  interface WeightedCard {
@@ -17,6 +17,7 @@ interface WeightedCard {
17
17
  courseId: string;
18
18
  score: number; // 0-1 suitability score
19
19
  provenance: StrategyContribution[]; // Audit trail
20
+ tags?: string[]; // Pre-fetched tags (hydrated by Pipeline)
20
21
  }
21
22
 
22
23
  interface StrategyContribution {
@@ -57,15 +58,19 @@ interface CardFilter {
57
58
  }
58
59
  ```
59
60
 
61
+ Filters receive cards with pre-hydrated data (e.g., `card.tags`) from Pipeline, eliminating
62
+ redundant database queries.
63
+
60
64
  **Implementations:**
61
65
  - `HierarchyDefinitionNavigator` — Gates cards by prerequisite mastery (score=0 if locked)
62
66
  - `InterferenceMitigatorNavigator` — Reduces scores for confusable content
63
67
  - `RelativePriorityNavigator` — Boosts scores for high-utility content
68
+ - `UserTagPreferenceFilter` — Applies user-configured tag preferences (path constraints)
64
69
  - `createEloDistanceFilter()` — Penalizes cards far from user's current ELO
65
70
 
66
71
  ### Pipeline
67
72
 
68
- Orchestrates generator and filters:
73
+ Orchestrates generator, data hydration, and filters:
69
74
 
70
75
  ```typescript
71
76
  class Pipeline {
@@ -77,13 +82,20 @@ class Pipeline {
77
82
  )
78
83
 
79
84
  async getWeightedCards(limit: number): Promise<WeightedCard[]> {
85
+ // Build shared context (user ELO, etc.)
80
86
  const context = await this.buildContext();
87
+
88
+ // Generate candidates
81
89
  let cards = await this.generator.getWeightedCards(fetchLimit, context);
82
-
90
+
91
+ // Hydrate shared data (tags, etc.) in single batch query
92
+ cards = await this.hydrateTags(cards);
93
+
94
+ // Apply filters sequentially
83
95
  for (const filter of this.filters) {
84
96
  cards = await filter.transform(cards, context);
85
97
  }
86
-
98
+
87
99
  return cards.filter(c => c.score > 0)
88
100
  .sort((a, b) => b.score - a.score)
89
101
  .slice(0, limit);
@@ -91,6 +103,12 @@ class Pipeline {
91
103
  }
92
104
  ```
93
105
 
106
+ **Responsibilities:**
107
+ - **Context building** — Fetches shared data (user ELO) once for all strategies
108
+ - **Data hydration** — Pre-fetches commonly needed data (tags) in batch queries
109
+ - **Filter orchestration** — Applies filters in sequence, accumulating provenance
110
+ - **Result selection** — Removes zero-scores, sorts, and returns top N
111
+
94
112
  ## Pipeline Assembly
95
113
 
96
114
  `PipelineAssembler` builds pipelines from strategy documents:
@@ -237,6 +255,89 @@ class MyFilter extends ContentNavigator implements CardFilter {
237
255
 
238
256
  Register in `NavigatorRoles` as `NavigatorRole.FILTER`.
239
257
 
258
+ ## Strategy State Storage
259
+
260
+ Strategies can persist user-scoped state (preferences, learned patterns, temporal tracking)
261
+ using the `STRATEGY_STATE` document type in the user database.
262
+
263
+ ### Goals vs Preferences vs Inferred
264
+
265
+ The system distinguishes three types of user-scoped navigation data:
266
+
267
+ | Type | Defines | Example | Affects ELO | Implementation |
268
+ |------|---------|---------|-------------|----------------|
269
+ | **Goal** | Destination (what to learn) | "Master ear-training" | Yes | `userGoal.ts` (stub) |
270
+ | **Preference** | Path (how to learn) | "Skip text-heavy cards" | No | `filters/userTagPreference.ts` |
271
+ | **Inferred** | Learned patterns | "User prefers visual" | No | `inferredPreference.ts` (stub) |
272
+
273
+ - **Goals** redefine the optimization target — they scope which content matters for progress
274
+ - **Preferences** constrain the path — they affect card selection without changing progress tracking
275
+ - **Inferred** preferences are learned from behavior — they act as soft suggestions
276
+
277
+ See stub files for detailed architectural intent on goals and inferred preferences.
278
+
279
+ ### Storage API
280
+
281
+ `ContentNavigator` provides protected helper methods:
282
+
283
+ ```typescript
284
+ // Get this strategy's persisted state for the current course
285
+ protected async getStrategyState<T>(): Promise<T | null>
286
+
287
+ // Persist this strategy's state for the current course
288
+ protected async putStrategyState<T>(data: T): Promise<void>
289
+
290
+ // Override to customize the storage key (default: constructor name)
291
+ protected get strategyKey(): string
292
+ ```
293
+
294
+ ### Document Format
295
+
296
+ ```typescript
297
+ interface StrategyStateDoc<T> {
298
+ _id: StrategyStateId; // "STRATEGY_STATE::{courseId}::{strategyKey}"
299
+ docType: DocType.STRATEGY_STATE;
300
+ courseId: string;
301
+ strategyKey: string;
302
+ data: T; // Strategy-specific payload
303
+ updatedAt: string; // ISO timestamp
304
+ }
305
+ ```
306
+
307
+ ### Example: User Tag Preferences
308
+
309
+ `UserTagPreferenceFilter` reads user preferences from strategy state:
310
+
311
+ ```typescript
312
+ interface UserTagPreferenceState {
313
+ /**
314
+ * Tag-specific multipliers.
315
+ * - 0 = exclude (card score = 0)
316
+ * - 0.5 = penalize by 50%
317
+ * - 1.0 = neutral/no effect
318
+ * - 2.0 = 2x preference boost
319
+ * - Higher = stronger preference
320
+ */
321
+ boost: Record<string, number>;
322
+ updatedAt: string;
323
+ }
324
+
325
+ // In filter's transform():
326
+ const prefs = await this.getStrategyState<UserTagPreferenceState>();
327
+ if (!prefs || Object.keys(prefs.boost).length === 0) {
328
+ return cards; // No preferences configured
329
+ }
330
+
331
+ // Apply multipliers (max wins when multiple tags match)
332
+ const multiplier = computeMultiplier(cardTags, prefs.boost);
333
+ return { ...card, score: card.score * multiplier };
334
+ ```
335
+
336
+ **UI Component**: `packages/common-ui/src/components/UserTagPreferences.vue`
337
+ - Slider-based interface (0-2 default range, expandable to 10)
338
+ - All sliders share global max for consistent visual comparison
339
+ - Writes to strategy state via `userDB.putStrategyState()`
340
+
240
341
  ## File Reference
241
342
 
242
343
  | File | Purpose |
@@ -254,12 +355,16 @@ Register in `NavigatorRoles` as `NavigatorRole.FILTER`.
254
355
  | `core/navigators/interferenceMitigator.ts` | Interference filter |
255
356
  | `core/navigators/relativePriority.ts` | Priority filter |
256
357
  | `core/navigators/filters/eloDistance.ts` | ELO distance filter |
358
+ | `core/navigators/filters/userTagPreference.ts` | User tag preference filter |
359
+ | `common-ui/.../UserTagPreferences.vue` | UI for tag preference sliders |
360
+ | `core/navigators/userGoal.ts` | User goal navigator (stub) |
361
+ | `core/navigators/inferredPreference.ts` | Inferred preference navigator (stub) |
362
+ | `core/types/strategyState.ts` | `StrategyStateDoc`, `StrategyStateId` |
257
363
  | `impl/couch/courseDB.ts` | `createNavigator()` entry point |
258
364
 
259
- ## Related TODOs
365
+ ## Related Documentation
260
366
 
261
- - `todo-pipeline-optimization.md` - Batch tag hydration for filter efficiency
262
- - `todo-strategy-authoring.md` - ux and dx for authoring strategies
263
- - todo-pipeline-optimization.md -
264
- - todo-strategy-state-storage
265
- - todo-evolutionary-orchestration
367
+ - `todo-pipeline-optimization.md` Batch tag hydration implementation (✅ completed)
368
+ - `todo-strategy-authoring.md` UX and DX for authoring strategies
369
+ - `todo-evolutionary-orchestration.md` — Long-term adaptive strategy vision
370
+ - `devlog/1004` — Implementation details for tag hydration optimization
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
- "version": "0.1.18",
7
+ "version": "0.1.20",
8
8
  "description": "Database layer for vue-skuilder",
9
9
  "main": "dist/index.js",
10
10
  "module": "dist/index.mjs",
@@ -48,7 +48,7 @@
48
48
  },
49
49
  "dependencies": {
50
50
  "@nilock2/pouchdb-authentication": "^1.0.2",
51
- "@vue-skuilder/common": "0.1.18",
51
+ "@vue-skuilder/common": "0.1.20",
52
52
  "cross-fetch": "^4.1.0",
53
53
  "moment": "^2.29.4",
54
54
  "pouchdb": "^9.0.0",
@@ -60,7 +60,7 @@
60
60
  "tsup": "^8.0.2",
61
61
  "typescript": "~5.9.3",
62
62
  "vite": "^7.0.0",
63
- "vitest": "^4.0.14"
63
+ "vitest": "^4.0.15"
64
64
  },
65
- "stableVersion": "0.1.18"
65
+ "stableVersion": "0.1.20"
66
66
  }
package/src/core/index.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  export * from './interfaces';
4
4
  export * from './types/types-legacy';
5
5
  export * from './types/user';
6
+ export * from './types/strategyState';
6
7
  export * from '../util/Loggable';
7
8
  export * from './util';
8
9
  export * from './navigators';
@@ -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
  */
@@ -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
  /**
@@ -9,6 +9,88 @@ import type { CardGenerator, GeneratorContext } from './generators/types';
9
9
  import type { StudySessionNewItem, StudySessionReviewItem } from '../interfaces/contentSource';
10
10
  import { logger } from '../../util/logger';
11
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
+
12
94
  // ============================================================================
13
95
  // PIPELINE
14
96
  // ============================================================================
@@ -72,9 +154,8 @@ export class Pipeline extends ContentNavigator {
72
154
  this.user = user;
73
155
  this.course = course;
74
156
 
75
- logger.debug(
76
- `[Pipeline] Created with generator '${generator.name}' and ${filters.length} filters: ${filters.map((f) => f.name).join(', ')}`
77
- );
157
+ // Toggle pipeline configuration logging:
158
+ logPipelineConfig(generator, filters);
78
159
  }
79
160
 
80
161
  /**
@@ -82,10 +163,11 @@ export class Pipeline extends ContentNavigator {
82
163
  *
83
164
  * 1. Build shared context (user ELO, etc.)
84
165
  * 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
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
89
171
  *
90
172
  * @param limit - Maximum number of cards to return
91
173
  * @returns Cards sorted by score descending
@@ -104,8 +186,12 @@ export class Pipeline extends ContentNavigator {
104
186
 
105
187
  // Get candidates from generator, passing context
106
188
  let cards = await this.generator.getWeightedCards(fetchLimit, context);
189
+ const generatedCount = cards.length;
190
+
191
+ logger.debug(`[Pipeline] Generator returned ${generatedCount} candidates`);
107
192
 
108
- logger.debug(`[Pipeline] Generator returned ${cards.length} candidates`);
193
+ // Batch hydrate tags before filters run
194
+ cards = await this.hydrateTags(cards);
109
195
 
110
196
  // Apply filters sequentially
111
197
  for (const filter of this.filters) {
@@ -123,16 +209,43 @@ export class Pipeline extends ContentNavigator {
123
209
  // Return top N
124
210
  const result = cards.slice(0, limit);
125
211
 
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
- );
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);
132
218
 
133
219
  return result;
134
220
  }
135
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
+
136
249
  /**
137
250
  * Build shared context for generator and filters.
138
251
  *
@@ -4,3 +4,6 @@ export type { CardFilter, FilterContext, CardFilterFactory } from './types';
4
4
  // Filter implementations
5
5
  export { createEloDistanceFilter } from './eloDistance';
6
6
  export type { EloDistanceConfig } from './eloDistance';
7
+
8
+ export { default as UserTagPreferenceFilter } from './userTagPreference';
9
+ export type { UserTagPreferenceState } from './userTagPreference';