@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
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.
|
|
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.
|
|
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.
|
|
61
|
+
"typescript": "~5.9.3",
|
|
62
|
+
"vite": "^7.0.0",
|
|
63
|
+
"vitest": "^4.0.14"
|
|
59
64
|
},
|
|
60
|
-
"stableVersion": "0.1.
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
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
|
+
}
|