@vue-skuilder/db 0.1.20 → 0.1.21
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/CLAUDE.md +2 -2
- package/dist/{classroomDB-CZdMBiTU.d.ts → contentSource-BP9hznNV.d.ts} +150 -196
- package/dist/{classroomDB-PxDZTky3.d.cts → contentSource-DsJadoBU.d.cts} +150 -196
- package/dist/core/index.d.cts +3 -3
- package/dist/core/index.d.ts +3 -3
- package/dist/core/index.js +615 -1758
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +579 -1727
- package/dist/core/index.mjs.map +1 -1
- package/dist/{dataLayerProvider-D8o6ZnKW.d.ts → dataLayerProvider-CHYrQ5pB.d.cts} +1 -1
- package/dist/{dataLayerProvider-D0MoZMjH.d.cts → dataLayerProvider-MDTxXq2l.d.ts} +1 -1
- package/dist/impl/couch/index.d.cts +6 -22
- package/dist/impl/couch/index.d.ts +6 -22
- package/dist/impl/couch/index.js +598 -1769
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +579 -1755
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.d.cts +22 -6
- package/dist/impl/static/index.d.ts +22 -6
- package/dist/impl/static/index.js +617 -1629
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +607 -1624
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/index.d.cts +64 -56
- package/dist/index.d.ts +64 -56
- package/dist/index.js +1000 -2161
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +970 -2127
- package/dist/index.mjs.map +1 -1
- package/dist/pouch/index.js +3 -0
- package/dist/pouch/index.js.map +1 -1
- package/dist/pouch/index.mjs +3 -0
- package/dist/pouch/index.mjs.map +1 -1
- package/docs/navigators-architecture.md +2 -9
- package/package.json +3 -3
- package/src/core/interfaces/classroomDB.ts +5 -13
- package/src/core/interfaces/contentSource.ts +6 -66
- package/src/core/interfaces/courseDB.ts +2 -7
- package/src/core/navigators/Pipeline.ts +24 -53
- package/src/core/navigators/PipelineAssembler.ts +1 -1
- package/src/core/navigators/defaults.ts +84 -0
- package/src/core/navigators/{hierarchyDefinition.ts → filters/hierarchyDefinition.ts} +11 -25
- package/src/core/navigators/{interferenceMitigator.ts → filters/interferenceMitigator.ts} +10 -24
- package/src/core/navigators/{relativePriority.ts → filters/relativePriority.ts} +10 -24
- package/src/core/navigators/filters/userTagPreference.ts +1 -16
- package/src/core/navigators/{CompositeGenerator.ts → generators/CompositeGenerator.ts} +15 -64
- package/src/core/navigators/{elo.ts → generators/elo.ts} +13 -63
- package/src/core/navigators/{srs.ts → generators/srs.ts} +11 -40
- package/src/core/navigators/generators/types.ts +1 -1
- package/src/core/navigators/index.ts +36 -91
- package/src/impl/couch/classroomDB.ts +100 -103
- package/src/impl/couch/courseDB.ts +5 -81
- package/src/impl/couch/pouchdb-setup.ts +7 -0
- package/src/impl/static/StaticDataUnpacker.ts +50 -1
- package/src/impl/static/courseDB.ts +76 -37
- package/src/study/SessionController.ts +122 -202
- package/src/study/SourceMixer.ts +65 -0
- package/src/study/TagFilteredContentSource.ts +49 -92
- package/src/study/index.ts +1 -0
- package/src/study/services/CardHydrationService.ts +165 -81
- package/src/util/dataDirectory.ts +1 -1
- package/src/util/index.ts +0 -1
- package/tests/core/navigators/CompositeGenerator.test.ts +44 -168
- package/tests/core/navigators/Pipeline.test.ts +5 -72
- package/tests/core/navigators/PipelineAssembler.test.ts +8 -58
- package/tests/core/navigators/navigators.test.ts +118 -151
- package/src/core/navigators/hardcodedOrder.ts +0 -163
- package/src/util/tuiLogger.ts +0 -139
- /package/src/core/navigators/{inferredPreference.ts → filters/inferredPreferenceStub.ts} +0 -0
- /package/src/core/navigators/{userGoal.ts → filters/userGoalStub.ts} +0 -0
package/dist/index.mjs
CHANGED
|
@@ -1,10 +1,5 @@
|
|
|
1
1
|
var __defProp = Object.defineProperty;
|
|
2
2
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
3
|
-
var __glob = (map) => (path3) => {
|
|
4
|
-
var fn = map[path3];
|
|
5
|
-
if (fn) return fn();
|
|
6
|
-
throw new Error("Module not found in bundle: " + path3);
|
|
7
|
-
};
|
|
8
3
|
var __esm = (fn, res) => function __init() {
|
|
9
4
|
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
10
5
|
};
|
|
@@ -166,6 +161,9 @@ var init_pouchdb_setup = __esm({
|
|
|
166
161
|
"use strict";
|
|
167
162
|
PouchDB.plugin(PouchDBFind);
|
|
168
163
|
PouchDB.plugin(PouchDBAuth);
|
|
164
|
+
if (typeof PouchDB.debug !== "undefined") {
|
|
165
|
+
PouchDB.debug.disable();
|
|
166
|
+
}
|
|
169
167
|
PouchDB.defaults({
|
|
170
168
|
// ajax: {
|
|
171
169
|
// timeout: 60000,
|
|
@@ -175,112 +173,21 @@ var init_pouchdb_setup = __esm({
|
|
|
175
173
|
}
|
|
176
174
|
});
|
|
177
175
|
|
|
178
|
-
// src/util/
|
|
176
|
+
// src/util/dataDirectory.ts
|
|
179
177
|
import * as fs from "fs";
|
|
180
178
|
import * as path from "path";
|
|
181
|
-
function initializeTuiLogging() {
|
|
182
|
-
isNodeEnvironment = typeof window === "undefined" && typeof process !== "undefined";
|
|
183
|
-
if (!isNodeEnvironment) {
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
186
|
-
try {
|
|
187
|
-
logFile = path.join(getAppDataDirectory(), "lastrun.log");
|
|
188
|
-
if (fs.existsSync(logFile)) {
|
|
189
|
-
fs.unlinkSync(logFile);
|
|
190
|
-
}
|
|
191
|
-
const startTime = (/* @__PURE__ */ new Date()).toISOString();
|
|
192
|
-
fs.writeFileSync(logFile, `=== TUI Session Started: ${startTime} ===
|
|
193
|
-
`);
|
|
194
|
-
const originalConsole = {
|
|
195
|
-
// eslint-disable-next-line no-console
|
|
196
|
-
log: console.log,
|
|
197
|
-
// eslint-disable-next-line no-console
|
|
198
|
-
error: console.error,
|
|
199
|
-
// eslint-disable-next-line no-console
|
|
200
|
-
warn: console.warn,
|
|
201
|
-
// eslint-disable-next-line no-console
|
|
202
|
-
info: console.info
|
|
203
|
-
};
|
|
204
|
-
const writeToLog = (level, args) => {
|
|
205
|
-
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
206
|
-
const message = args.map(
|
|
207
|
-
(arg) => typeof arg === "object" ? JSON.stringify(arg, null, 2) : String(arg)
|
|
208
|
-
).join(" ");
|
|
209
|
-
const logEntry = `[${timestamp}] ${level}: ${message}
|
|
210
|
-
`;
|
|
211
|
-
try {
|
|
212
|
-
fs.appendFileSync(logFile, logEntry);
|
|
213
|
-
} catch (err) {
|
|
214
|
-
originalConsole.error("Failed to write to log file:", err);
|
|
215
|
-
originalConsole[level.toLowerCase()](...args);
|
|
216
|
-
}
|
|
217
|
-
};
|
|
218
|
-
console.log = (...args) => writeToLog("INFO", args);
|
|
219
|
-
console.info = (...args) => writeToLog("INFO", args);
|
|
220
|
-
console.warn = (...args) => writeToLog("WARN", args);
|
|
221
|
-
console.error = (...args) => writeToLog("ERROR", args);
|
|
222
|
-
console._originalMethods = originalConsole;
|
|
223
|
-
console.log("TUI logging initialized - logs redirected to", logFile);
|
|
224
|
-
} catch (err) {
|
|
225
|
-
console.error("Failed to initialize TUI logging:", err);
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
function getLogFilePath() {
|
|
229
|
-
return logFile;
|
|
230
|
-
}
|
|
231
|
-
function showUserMessage(message) {
|
|
232
|
-
if (isNodeEnvironment) {
|
|
233
|
-
process.stdout.write(message + "\n");
|
|
234
|
-
} else {
|
|
235
|
-
console.log(message);
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
function showUserError(message) {
|
|
239
|
-
if (isNodeEnvironment) {
|
|
240
|
-
process.stderr.write("Error: " + message + "\n");
|
|
241
|
-
} else {
|
|
242
|
-
console.error(message);
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
var logFile, isNodeEnvironment, logger2;
|
|
246
|
-
var init_tuiLogger = __esm({
|
|
247
|
-
"src/util/tuiLogger.ts"() {
|
|
248
|
-
"use strict";
|
|
249
|
-
init_dataDirectory();
|
|
250
|
-
logFile = null;
|
|
251
|
-
isNodeEnvironment = false;
|
|
252
|
-
logger2 = {
|
|
253
|
-
debug: (message, ...args) => {
|
|
254
|
-
console.log(`[DEBUG] ${message}`, ...args);
|
|
255
|
-
},
|
|
256
|
-
info: (message, ...args) => {
|
|
257
|
-
console.info(`[INFO] ${message}`, ...args);
|
|
258
|
-
},
|
|
259
|
-
warn: (message, ...args) => {
|
|
260
|
-
console.warn(`[WARN] ${message}`, ...args);
|
|
261
|
-
},
|
|
262
|
-
error: (message, ...args) => {
|
|
263
|
-
console.error(`[ERROR] ${message}`, ...args);
|
|
264
|
-
}
|
|
265
|
-
};
|
|
266
|
-
}
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
// src/util/dataDirectory.ts
|
|
270
|
-
import * as fs2 from "fs";
|
|
271
|
-
import * as path2 from "path";
|
|
272
179
|
import * as os from "os";
|
|
273
180
|
function getAppDataDirectory() {
|
|
274
181
|
if (ENV.LOCAL_STORAGE_PREFIX) {
|
|
275
|
-
return
|
|
182
|
+
return path.join(os.homedir(), `.tuilder`, ENV.LOCAL_STORAGE_PREFIX);
|
|
276
183
|
} else {
|
|
277
|
-
return
|
|
184
|
+
return path.join(os.homedir(), ".tuilder");
|
|
278
185
|
}
|
|
279
186
|
}
|
|
280
187
|
async function ensureAppDataDirectory() {
|
|
281
188
|
const appDataDir = getAppDataDirectory();
|
|
282
189
|
try {
|
|
283
|
-
await
|
|
190
|
+
await fs.promises.mkdir(appDataDir, { recursive: true });
|
|
284
191
|
} catch (err) {
|
|
285
192
|
if (err.code !== "EEXIST") {
|
|
286
193
|
throw new Error(`Failed to create app data directory ${appDataDir}: ${err.message}`);
|
|
@@ -289,16 +196,16 @@ async function ensureAppDataDirectory() {
|
|
|
289
196
|
return appDataDir;
|
|
290
197
|
}
|
|
291
198
|
function getDbPath(dbName) {
|
|
292
|
-
return
|
|
199
|
+
return path.join(getAppDataDirectory(), dbName);
|
|
293
200
|
}
|
|
294
201
|
async function initializeDataDirectory() {
|
|
295
202
|
await ensureAppDataDirectory();
|
|
296
|
-
|
|
203
|
+
logger.info(`PouchDB data directory initialized: ${getAppDataDirectory()}`);
|
|
297
204
|
}
|
|
298
205
|
var init_dataDirectory = __esm({
|
|
299
206
|
"src/util/dataDirectory.ts"() {
|
|
300
207
|
"use strict";
|
|
301
|
-
|
|
208
|
+
init_logger();
|
|
302
209
|
init_factory();
|
|
303
210
|
}
|
|
304
211
|
});
|
|
@@ -924,195 +831,187 @@ var init_courseLookupDB = __esm({
|
|
|
924
831
|
}
|
|
925
832
|
});
|
|
926
833
|
|
|
927
|
-
// src/core/navigators/
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
834
|
+
// src/core/navigators/index.ts
|
|
835
|
+
function getCardOrigin(card) {
|
|
836
|
+
if (card.provenance.length === 0) {
|
|
837
|
+
throw new Error("Card has no provenance - cannot determine origin");
|
|
838
|
+
}
|
|
839
|
+
const firstEntry = card.provenance[0];
|
|
840
|
+
const reason = firstEntry.reason.toLowerCase();
|
|
841
|
+
if (reason.includes("failed")) {
|
|
842
|
+
return "failed";
|
|
843
|
+
}
|
|
844
|
+
if (reason.includes("review")) {
|
|
845
|
+
return "review";
|
|
846
|
+
}
|
|
847
|
+
return "new";
|
|
848
|
+
}
|
|
849
|
+
function isGenerator(impl) {
|
|
850
|
+
return NavigatorRoles[impl] === "generator" /* GENERATOR */;
|
|
851
|
+
}
|
|
852
|
+
function isFilter(impl) {
|
|
853
|
+
return NavigatorRoles[impl] === "filter" /* FILTER */;
|
|
854
|
+
}
|
|
855
|
+
var Navigators, NavigatorRole, NavigatorRoles, ContentNavigator;
|
|
856
|
+
var init_navigators = __esm({
|
|
857
|
+
"src/core/navigators/index.ts"() {
|
|
936
858
|
"use strict";
|
|
937
|
-
init_navigators();
|
|
938
859
|
init_logger();
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
860
|
+
Navigators = /* @__PURE__ */ ((Navigators2) => {
|
|
861
|
+
Navigators2["ELO"] = "elo";
|
|
862
|
+
Navigators2["SRS"] = "srs";
|
|
863
|
+
Navigators2["HIERARCHY"] = "hierarchyDefinition";
|
|
864
|
+
Navigators2["INTERFERENCE"] = "interferenceMitigator";
|
|
865
|
+
Navigators2["RELATIVE_PRIORITY"] = "relativePriority";
|
|
866
|
+
Navigators2["USER_TAG_PREFERENCE"] = "userTagPreference";
|
|
867
|
+
return Navigators2;
|
|
868
|
+
})(Navigators || {});
|
|
869
|
+
NavigatorRole = /* @__PURE__ */ ((NavigatorRole2) => {
|
|
870
|
+
NavigatorRole2["GENERATOR"] = "generator";
|
|
871
|
+
NavigatorRole2["FILTER"] = "filter";
|
|
872
|
+
return NavigatorRole2;
|
|
873
|
+
})(NavigatorRole || {});
|
|
874
|
+
NavigatorRoles = {
|
|
875
|
+
["elo" /* ELO */]: "generator" /* GENERATOR */,
|
|
876
|
+
["srs" /* SRS */]: "generator" /* GENERATOR */,
|
|
877
|
+
["hierarchyDefinition" /* HIERARCHY */]: "filter" /* FILTER */,
|
|
878
|
+
["interferenceMitigator" /* INTERFERENCE */]: "filter" /* FILTER */,
|
|
879
|
+
["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */,
|
|
880
|
+
["userTagPreference" /* USER_TAG_PREFERENCE */]: "filter" /* FILTER */
|
|
881
|
+
};
|
|
882
|
+
ContentNavigator = class {
|
|
883
|
+
/** User interface for this navigation session */
|
|
884
|
+
user;
|
|
885
|
+
/** Course interface for this navigation session */
|
|
886
|
+
course;
|
|
887
|
+
/** Human-readable name for this strategy instance (from ContentNavigationStrategyData.name) */
|
|
888
|
+
strategyName;
|
|
889
|
+
/** Unique document ID for this strategy instance (from ContentNavigationStrategyData._id) */
|
|
890
|
+
strategyId;
|
|
891
|
+
/**
|
|
892
|
+
* Constructor for standard navigators.
|
|
893
|
+
* Call this from subclass constructors to initialize common fields.
|
|
894
|
+
*
|
|
895
|
+
* Note: CompositeGenerator and Pipeline call super() without args, then set
|
|
896
|
+
* user/course fields directly if needed.
|
|
897
|
+
*/
|
|
898
|
+
constructor(user, course, strategyData) {
|
|
899
|
+
this.user = user;
|
|
900
|
+
this.course = course;
|
|
901
|
+
if (strategyData) {
|
|
902
|
+
this.strategyName = strategyData.name;
|
|
903
|
+
this.strategyId = strategyData._id;
|
|
958
904
|
}
|
|
959
|
-
logger.debug(
|
|
960
|
-
`[CompositeGenerator] Created with ${generators.length} generators, mode: ${aggregationMode}`
|
|
961
|
-
);
|
|
962
905
|
}
|
|
906
|
+
// ============================================================================
|
|
907
|
+
// STRATEGY STATE HELPERS
|
|
908
|
+
// ============================================================================
|
|
909
|
+
//
|
|
910
|
+
// These methods allow strategies to persist their own state (user preferences,
|
|
911
|
+
// learned patterns, temporal tracking) in the user database.
|
|
912
|
+
//
|
|
913
|
+
// ============================================================================
|
|
963
914
|
/**
|
|
964
|
-
*
|
|
915
|
+
* Unique key identifying this strategy for state storage.
|
|
965
916
|
*
|
|
966
|
-
*
|
|
917
|
+
* Defaults to the constructor name (e.g., "UserTagPreferenceFilter").
|
|
918
|
+
* Override in subclasses if multiple instances of the same strategy type
|
|
919
|
+
* need separate state storage.
|
|
967
920
|
*/
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
strategies.map((s) => ContentNavigator.create(user, course, s))
|
|
971
|
-
);
|
|
972
|
-
return new _CompositeGenerator(generators, aggregationMode);
|
|
921
|
+
get strategyKey() {
|
|
922
|
+
return this.constructor.name;
|
|
973
923
|
}
|
|
974
924
|
/**
|
|
975
|
-
* Get
|
|
976
|
-
*
|
|
977
|
-
* Cards appearing in multiple generators receive a score boost.
|
|
978
|
-
* Provenance tracks which generators produced each card and how scores were aggregated.
|
|
979
|
-
*
|
|
980
|
-
* This method supports both the legacy signature (limit only) and the
|
|
981
|
-
* CardGenerator interface signature (limit, context).
|
|
925
|
+
* Get this strategy's persisted state for the current course.
|
|
982
926
|
*
|
|
983
|
-
* @
|
|
984
|
-
* @
|
|
927
|
+
* @returns The strategy's data payload, or null if no state exists
|
|
928
|
+
* @throws Error if user or course is not initialized
|
|
985
929
|
*/
|
|
986
|
-
async
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
for (const cards of results) {
|
|
992
|
-
for (const card of cards) {
|
|
993
|
-
const existing = byCardId.get(card.cardId) || [];
|
|
994
|
-
existing.push(card);
|
|
995
|
-
byCardId.set(card.cardId, existing);
|
|
996
|
-
}
|
|
997
|
-
}
|
|
998
|
-
const merged = [];
|
|
999
|
-
for (const [, cards] of byCardId) {
|
|
1000
|
-
const aggregatedScore = this.aggregateScores(cards);
|
|
1001
|
-
const finalScore = Math.min(1, aggregatedScore);
|
|
1002
|
-
const mergedProvenance = cards.flatMap((c) => c.provenance);
|
|
1003
|
-
const initialScore = cards[0].score;
|
|
1004
|
-
const action = finalScore > initialScore ? "boosted" : finalScore < initialScore ? "penalized" : "passed";
|
|
1005
|
-
const reason = this.buildAggregationReason(cards, finalScore);
|
|
1006
|
-
merged.push({
|
|
1007
|
-
...cards[0],
|
|
1008
|
-
score: finalScore,
|
|
1009
|
-
provenance: [
|
|
1010
|
-
...mergedProvenance,
|
|
1011
|
-
{
|
|
1012
|
-
strategy: "composite",
|
|
1013
|
-
strategyName: "Composite Generator",
|
|
1014
|
-
strategyId: "COMPOSITE_GENERATOR",
|
|
1015
|
-
action,
|
|
1016
|
-
score: finalScore,
|
|
1017
|
-
reason
|
|
1018
|
-
}
|
|
1019
|
-
]
|
|
1020
|
-
});
|
|
930
|
+
async getStrategyState() {
|
|
931
|
+
if (!this.user || !this.course) {
|
|
932
|
+
throw new Error(
|
|
933
|
+
`Cannot get strategy state: navigator not properly initialized. Ensure user and course are provided to constructor.`
|
|
934
|
+
);
|
|
1021
935
|
}
|
|
1022
|
-
return
|
|
936
|
+
return this.user.getStrategyState(this.course.getCourseID(), this.strategyKey);
|
|
1023
937
|
}
|
|
1024
938
|
/**
|
|
1025
|
-
*
|
|
939
|
+
* Persist this strategy's state for the current course.
|
|
940
|
+
*
|
|
941
|
+
* @param data - The strategy's data payload to store
|
|
942
|
+
* @throws Error if user or course is not initialized
|
|
1026
943
|
*/
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
}
|
|
1033
|
-
const strategies = cards.map((c) => c.provenance[0]?.strategy || "unknown").join(", ");
|
|
1034
|
-
switch (this.aggregationMode) {
|
|
1035
|
-
case "max" /* MAX */:
|
|
1036
|
-
return `Max of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
|
|
1037
|
-
case "average" /* AVERAGE */:
|
|
1038
|
-
return `Average of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
|
|
1039
|
-
case "frequencyBoost" /* FREQUENCY_BOOST */: {
|
|
1040
|
-
const avg = cards.reduce((sum, c) => sum + c.score, 0) / count;
|
|
1041
|
-
const boost = 1 + FREQUENCY_BOOST_FACTOR * (count - 1);
|
|
1042
|
-
return `Frequency boost from ${count} generators (${strategies}): avg ${avg.toFixed(2)} \xD7 ${boost.toFixed(2)} \u2192 ${finalScore.toFixed(2)}`;
|
|
1043
|
-
}
|
|
1044
|
-
default:
|
|
1045
|
-
return `Aggregated from ${count} generators: ${finalScore.toFixed(2)}`;
|
|
944
|
+
async putStrategyState(data) {
|
|
945
|
+
if (!this.user || !this.course) {
|
|
946
|
+
throw new Error(
|
|
947
|
+
`Cannot put strategy state: navigator not properly initialized. Ensure user and course are provided to constructor.`
|
|
948
|
+
);
|
|
1046
949
|
}
|
|
950
|
+
return this.user.putStrategyState(this.course.getCourseID(), this.strategyKey, data);
|
|
1047
951
|
}
|
|
1048
952
|
/**
|
|
1049
|
-
*
|
|
953
|
+
* Factory method to create navigator instances dynamically.
|
|
954
|
+
*
|
|
955
|
+
* @param user - User interface
|
|
956
|
+
* @param course - Course interface
|
|
957
|
+
* @param strategyData - Strategy configuration document
|
|
958
|
+
* @returns the runtime object used to steer a study session.
|
|
1050
959
|
*/
|
|
1051
|
-
|
|
1052
|
-
const
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
960
|
+
static async create(user, course, strategyData) {
|
|
961
|
+
const implementingClass = strategyData.implementingClass;
|
|
962
|
+
let NavigatorImpl;
|
|
963
|
+
const variations = [".ts", ".js", ""];
|
|
964
|
+
const dirs = ["filters", "generators"];
|
|
965
|
+
for (const ext of variations) {
|
|
966
|
+
for (const dir of dirs) {
|
|
967
|
+
const loadFrom = `./${dir}/${implementingClass}${ext}`;
|
|
968
|
+
try {
|
|
969
|
+
const module = await import(loadFrom);
|
|
970
|
+
NavigatorImpl = module.default;
|
|
971
|
+
break;
|
|
972
|
+
} catch (e) {
|
|
973
|
+
logger.debug(`Failed to load extension from ${loadFrom}:`, e);
|
|
974
|
+
}
|
|
1062
975
|
}
|
|
1063
|
-
default:
|
|
1064
|
-
return scores[0];
|
|
1065
976
|
}
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
* Get new cards from all generators, merged and deduplicated.
|
|
1069
|
-
*/
|
|
1070
|
-
async getNewCards(n) {
|
|
1071
|
-
const legacyGenerators = this.generators.filter(
|
|
1072
|
-
(g) => g instanceof ContentNavigator
|
|
1073
|
-
);
|
|
1074
|
-
const results = await Promise.all(legacyGenerators.map((g) => g.getNewCards(n)));
|
|
1075
|
-
const seen = /* @__PURE__ */ new Set();
|
|
1076
|
-
const merged = [];
|
|
1077
|
-
for (const cards of results) {
|
|
1078
|
-
for (const card of cards) {
|
|
1079
|
-
if (!seen.has(card.cardID)) {
|
|
1080
|
-
seen.add(card.cardID);
|
|
1081
|
-
merged.push(card);
|
|
1082
|
-
}
|
|
1083
|
-
}
|
|
977
|
+
if (!NavigatorImpl) {
|
|
978
|
+
throw new Error(`Could not load navigator implementation for: ${implementingClass}`);
|
|
1084
979
|
}
|
|
1085
|
-
return
|
|
980
|
+
return new NavigatorImpl(user, course, strategyData);
|
|
1086
981
|
}
|
|
1087
982
|
/**
|
|
1088
|
-
* Get
|
|
983
|
+
* Get cards with suitability scores and provenance trails.
|
|
984
|
+
*
|
|
985
|
+
* **This is the PRIMARY API for navigation strategies.**
|
|
986
|
+
*
|
|
987
|
+
* Returns cards ranked by suitability score (0-1). Higher scores indicate
|
|
988
|
+
* better candidates for presentation. Each card includes a provenance trail
|
|
989
|
+
* documenting how strategies contributed to the final score.
|
|
990
|
+
*
|
|
991
|
+
* ## Implementation Required
|
|
992
|
+
* All navigation strategies MUST override this method. The base class does
|
|
993
|
+
* not provide a default implementation.
|
|
994
|
+
*
|
|
995
|
+
* ## For Generators
|
|
996
|
+
* Override this method to generate candidates and compute scores based on
|
|
997
|
+
* your strategy's logic (e.g., ELO proximity, review urgency). Create the
|
|
998
|
+
* initial provenance entry with action='generated'.
|
|
999
|
+
*
|
|
1000
|
+
* ## For Filters
|
|
1001
|
+
* Filters should implement the CardFilter interface instead and be composed
|
|
1002
|
+
* via Pipeline. Filters do not directly implement getWeightedCards().
|
|
1003
|
+
*
|
|
1004
|
+
* @param limit - Maximum cards to return
|
|
1005
|
+
* @returns Cards sorted by score descending, with provenance trails
|
|
1089
1006
|
*/
|
|
1090
|
-
async
|
|
1091
|
-
|
|
1092
|
-
(g) => g instanceof ContentNavigator
|
|
1093
|
-
);
|
|
1094
|
-
const results = await Promise.all(legacyGenerators.map((g) => g.getPendingReviews()));
|
|
1095
|
-
const seen = /* @__PURE__ */ new Set();
|
|
1096
|
-
const merged = [];
|
|
1097
|
-
for (const reviews of results) {
|
|
1098
|
-
for (const review of reviews) {
|
|
1099
|
-
if (!seen.has(review.cardID)) {
|
|
1100
|
-
seen.add(review.cardID);
|
|
1101
|
-
merged.push(review);
|
|
1102
|
-
}
|
|
1103
|
-
}
|
|
1104
|
-
}
|
|
1105
|
-
return merged;
|
|
1007
|
+
async getWeightedCards(_limit) {
|
|
1008
|
+
throw new Error(`${this.constructor.name} must implement getWeightedCards(). `);
|
|
1106
1009
|
}
|
|
1107
1010
|
};
|
|
1108
1011
|
}
|
|
1109
1012
|
});
|
|
1110
1013
|
|
|
1111
1014
|
// src/core/navigators/Pipeline.ts
|
|
1112
|
-
var Pipeline_exports = {};
|
|
1113
|
-
__export(Pipeline_exports, {
|
|
1114
|
-
Pipeline: () => Pipeline
|
|
1115
|
-
});
|
|
1116
1015
|
import { toCourseElo as toCourseElo2 } from "@vue-skuilder/common";
|
|
1117
1016
|
function logPipelineConfig(generator, filters) {
|
|
1118
1017
|
const filterList = filters.length > 0 ? "\n - " + filters.map((f) => f.name).join("\n - ") : " none";
|
|
@@ -1172,6 +1071,11 @@ var init_Pipeline = __esm({
|
|
|
1172
1071
|
this.filters = filters;
|
|
1173
1072
|
this.user = user;
|
|
1174
1073
|
this.course = course;
|
|
1074
|
+
course.getCourseConfig().then((cfg) => {
|
|
1075
|
+
logger.debug(`[pipeline] Crated pipeline for ${cfg.name}`);
|
|
1076
|
+
}).catch((e) => {
|
|
1077
|
+
logger.error(`[pipeline] Failed to lookup courseCfg: ${e}`);
|
|
1078
|
+
});
|
|
1175
1079
|
logPipelineConfig(generator, filters);
|
|
1176
1080
|
}
|
|
1177
1081
|
/**
|
|
@@ -1208,7 +1112,13 @@ var init_Pipeline = __esm({
|
|
|
1208
1112
|
cards.sort((a, b) => b.score - a.score);
|
|
1209
1113
|
const result = cards.slice(0, limit);
|
|
1210
1114
|
const topScores = result.slice(0, 3).map((c) => c.score);
|
|
1211
|
-
logExecutionSummary(
|
|
1115
|
+
logExecutionSummary(
|
|
1116
|
+
this.generator.name,
|
|
1117
|
+
generatedCount,
|
|
1118
|
+
this.filters.length,
|
|
1119
|
+
result.length,
|
|
1120
|
+
topScores
|
|
1121
|
+
);
|
|
1212
1122
|
logCardProvenance(result, 3);
|
|
1213
1123
|
return result;
|
|
1214
1124
|
}
|
|
@@ -1257,48 +1167,155 @@ var init_Pipeline = __esm({
|
|
|
1257
1167
|
userElo
|
|
1258
1168
|
};
|
|
1259
1169
|
}
|
|
1260
|
-
// ===========================================================================
|
|
1261
|
-
// Legacy StudyContentSource methods
|
|
1262
|
-
// ===========================================================================
|
|
1263
|
-
//
|
|
1264
|
-
// These delegate to the generator for backward compatibility.
|
|
1265
|
-
// Eventually SessionController will use getWeightedCards() exclusively.
|
|
1266
|
-
//
|
|
1267
1170
|
/**
|
|
1268
|
-
* Get
|
|
1269
|
-
* Delegates to the generator if it supports the legacy interface.
|
|
1171
|
+
* Get the course ID for this pipeline.
|
|
1270
1172
|
*/
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1173
|
+
getCourseID() {
|
|
1174
|
+
return this.course.getCourseID();
|
|
1175
|
+
}
|
|
1176
|
+
};
|
|
1177
|
+
}
|
|
1178
|
+
});
|
|
1179
|
+
|
|
1180
|
+
// src/core/navigators/generators/CompositeGenerator.ts
|
|
1181
|
+
var DEFAULT_AGGREGATION_MODE, FREQUENCY_BOOST_FACTOR, CompositeGenerator;
|
|
1182
|
+
var init_CompositeGenerator = __esm({
|
|
1183
|
+
"src/core/navigators/generators/CompositeGenerator.ts"() {
|
|
1184
|
+
"use strict";
|
|
1185
|
+
init_navigators();
|
|
1186
|
+
init_logger();
|
|
1187
|
+
DEFAULT_AGGREGATION_MODE = "frequencyBoost" /* FREQUENCY_BOOST */;
|
|
1188
|
+
FREQUENCY_BOOST_FACTOR = 0.1;
|
|
1189
|
+
CompositeGenerator = class _CompositeGenerator extends ContentNavigator {
|
|
1190
|
+
/** Human-readable name for CardGenerator interface */
|
|
1191
|
+
name = "Composite Generator";
|
|
1192
|
+
generators;
|
|
1193
|
+
aggregationMode;
|
|
1194
|
+
constructor(generators, aggregationMode = DEFAULT_AGGREGATION_MODE) {
|
|
1195
|
+
super();
|
|
1196
|
+
this.generators = generators;
|
|
1197
|
+
this.aggregationMode = aggregationMode;
|
|
1198
|
+
if (generators.length === 0) {
|
|
1199
|
+
throw new Error("CompositeGenerator requires at least one generator");
|
|
1274
1200
|
}
|
|
1275
|
-
|
|
1201
|
+
logger.debug(
|
|
1202
|
+
`[CompositeGenerator] Created with ${generators.length} generators, mode: ${aggregationMode}`
|
|
1203
|
+
);
|
|
1276
1204
|
}
|
|
1277
1205
|
/**
|
|
1278
|
-
*
|
|
1279
|
-
*
|
|
1206
|
+
* Creates a CompositeGenerator from strategy data.
|
|
1207
|
+
*
|
|
1208
|
+
* This is a convenience factory for use by PipelineAssembler.
|
|
1280
1209
|
*/
|
|
1281
|
-
async
|
|
1282
|
-
|
|
1283
|
-
|
|
1210
|
+
static async fromStrategies(user, course, strategies, aggregationMode = DEFAULT_AGGREGATION_MODE) {
|
|
1211
|
+
const generators = await Promise.all(
|
|
1212
|
+
strategies.map((s) => ContentNavigator.create(user, course, s))
|
|
1213
|
+
);
|
|
1214
|
+
return new _CompositeGenerator(generators, aggregationMode);
|
|
1215
|
+
}
|
|
1216
|
+
/**
|
|
1217
|
+
* Get weighted cards from all generators, merge and deduplicate.
|
|
1218
|
+
*
|
|
1219
|
+
* Cards appearing in multiple generators receive a score boost.
|
|
1220
|
+
* Provenance tracks which generators produced each card and how scores were aggregated.
|
|
1221
|
+
*
|
|
1222
|
+
* This method supports both the legacy signature (limit only) and the
|
|
1223
|
+
* CardGenerator interface signature (limit, context).
|
|
1224
|
+
*
|
|
1225
|
+
* @param limit - Maximum number of cards to return
|
|
1226
|
+
* @param context - GeneratorContext passed to child generators (required when called via Pipeline)
|
|
1227
|
+
*/
|
|
1228
|
+
async getWeightedCards(limit, context) {
|
|
1229
|
+
if (!context) {
|
|
1230
|
+
throw new Error(
|
|
1231
|
+
"CompositeGenerator.getWeightedCards requires a GeneratorContext. It should be called via Pipeline, not directly."
|
|
1232
|
+
);
|
|
1284
1233
|
}
|
|
1285
|
-
|
|
1234
|
+
const results = await Promise.all(
|
|
1235
|
+
this.generators.map((g) => g.getWeightedCards(limit, context))
|
|
1236
|
+
);
|
|
1237
|
+
const byCardId = /* @__PURE__ */ new Map();
|
|
1238
|
+
for (const cards of results) {
|
|
1239
|
+
for (const card of cards) {
|
|
1240
|
+
const existing = byCardId.get(card.cardId) || [];
|
|
1241
|
+
existing.push(card);
|
|
1242
|
+
byCardId.set(card.cardId, existing);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
const merged = [];
|
|
1246
|
+
for (const [, cards] of byCardId) {
|
|
1247
|
+
const aggregatedScore = this.aggregateScores(cards);
|
|
1248
|
+
const finalScore = Math.min(1, aggregatedScore);
|
|
1249
|
+
const mergedProvenance = cards.flatMap((c) => c.provenance);
|
|
1250
|
+
const initialScore = cards[0].score;
|
|
1251
|
+
const action = finalScore > initialScore ? "boosted" : finalScore < initialScore ? "penalized" : "passed";
|
|
1252
|
+
const reason = this.buildAggregationReason(cards, finalScore);
|
|
1253
|
+
merged.push({
|
|
1254
|
+
...cards[0],
|
|
1255
|
+
score: finalScore,
|
|
1256
|
+
provenance: [
|
|
1257
|
+
...mergedProvenance,
|
|
1258
|
+
{
|
|
1259
|
+
strategy: "composite",
|
|
1260
|
+
strategyName: "Composite Generator",
|
|
1261
|
+
strategyId: "COMPOSITE_GENERATOR",
|
|
1262
|
+
action,
|
|
1263
|
+
score: finalScore,
|
|
1264
|
+
reason
|
|
1265
|
+
}
|
|
1266
|
+
]
|
|
1267
|
+
});
|
|
1268
|
+
}
|
|
1269
|
+
return merged.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
1286
1270
|
}
|
|
1287
1271
|
/**
|
|
1288
|
-
*
|
|
1272
|
+
* Build human-readable reason for score aggregation.
|
|
1289
1273
|
*/
|
|
1290
|
-
|
|
1291
|
-
|
|
1274
|
+
buildAggregationReason(cards, finalScore) {
|
|
1275
|
+
const count = cards.length;
|
|
1276
|
+
const scores = cards.map((c) => c.score.toFixed(2)).join(", ");
|
|
1277
|
+
if (count === 1) {
|
|
1278
|
+
return `Single generator, score ${finalScore.toFixed(2)}`;
|
|
1279
|
+
}
|
|
1280
|
+
const strategies = cards.map((c) => c.provenance[0]?.strategy || "unknown").join(", ");
|
|
1281
|
+
switch (this.aggregationMode) {
|
|
1282
|
+
case "max" /* MAX */:
|
|
1283
|
+
return `Max of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
|
|
1284
|
+
case "average" /* AVERAGE */:
|
|
1285
|
+
return `Average of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
|
|
1286
|
+
case "frequencyBoost" /* FREQUENCY_BOOST */: {
|
|
1287
|
+
const avg = cards.reduce((sum, c) => sum + c.score, 0) / count;
|
|
1288
|
+
const boost = 1 + FREQUENCY_BOOST_FACTOR * (count - 1);
|
|
1289
|
+
return `Frequency boost from ${count} generators (${strategies}): avg ${avg.toFixed(2)} \xD7 ${boost.toFixed(2)} \u2192 ${finalScore.toFixed(2)}`;
|
|
1290
|
+
}
|
|
1291
|
+
default:
|
|
1292
|
+
return `Aggregated from ${count} generators: ${finalScore.toFixed(2)}`;
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
/**
|
|
1296
|
+
* Aggregate scores from multiple generators for the same card.
|
|
1297
|
+
*/
|
|
1298
|
+
aggregateScores(cards) {
|
|
1299
|
+
const scores = cards.map((c) => c.score);
|
|
1300
|
+
switch (this.aggregationMode) {
|
|
1301
|
+
case "max" /* MAX */:
|
|
1302
|
+
return Math.max(...scores);
|
|
1303
|
+
case "average" /* AVERAGE */:
|
|
1304
|
+
return scores.reduce((sum, s) => sum + s, 0) / scores.length;
|
|
1305
|
+
case "frequencyBoost" /* FREQUENCY_BOOST */: {
|
|
1306
|
+
const avg = scores.reduce((sum, s) => sum + s, 0) / scores.length;
|
|
1307
|
+
const frequencyBoost = 1 + FREQUENCY_BOOST_FACTOR * (cards.length - 1);
|
|
1308
|
+
return avg * frequencyBoost;
|
|
1309
|
+
}
|
|
1310
|
+
default:
|
|
1311
|
+
return scores[0];
|
|
1312
|
+
}
|
|
1292
1313
|
}
|
|
1293
1314
|
};
|
|
1294
1315
|
}
|
|
1295
1316
|
});
|
|
1296
1317
|
|
|
1297
1318
|
// src/core/navigators/PipelineAssembler.ts
|
|
1298
|
-
var PipelineAssembler_exports = {};
|
|
1299
|
-
__export(PipelineAssembler_exports, {
|
|
1300
|
-
PipelineAssembler: () => PipelineAssembler
|
|
1301
|
-
});
|
|
1302
1319
|
var PipelineAssembler;
|
|
1303
1320
|
var init_PipelineAssembler = __esm({
|
|
1304
1321
|
"src/core/navigators/PipelineAssembler.ts"() {
|
|
@@ -1419,15 +1436,11 @@ var init_PipelineAssembler = __esm({
|
|
|
1419
1436
|
}
|
|
1420
1437
|
});
|
|
1421
1438
|
|
|
1422
|
-
// src/core/navigators/elo.ts
|
|
1423
|
-
var elo_exports = {};
|
|
1424
|
-
__export(elo_exports, {
|
|
1425
|
-
default: () => ELONavigator
|
|
1426
|
-
});
|
|
1439
|
+
// src/core/navigators/generators/elo.ts
|
|
1427
1440
|
import { toCourseElo as toCourseElo3 } from "@vue-skuilder/common";
|
|
1428
1441
|
var ELONavigator;
|
|
1429
1442
|
var init_elo = __esm({
|
|
1430
|
-
"src/core/navigators/elo.ts"() {
|
|
1443
|
+
"src/core/navigators/generators/elo.ts"() {
|
|
1431
1444
|
"use strict";
|
|
1432
1445
|
init_navigators();
|
|
1433
1446
|
ELONavigator = class extends ContentNavigator {
|
|
@@ -1437,50 +1450,6 @@ var init_elo = __esm({
|
|
|
1437
1450
|
super(user, course, strategyData);
|
|
1438
1451
|
this.name = strategyData?.name || "ELO";
|
|
1439
1452
|
}
|
|
1440
|
-
async getPendingReviews() {
|
|
1441
|
-
const reviews = await this.user.getPendingReviews(this.course.getCourseID());
|
|
1442
|
-
const elo = await this.course.getCardEloData(reviews.map((r) => r.cardId));
|
|
1443
|
-
const ratedReviews = reviews.map((r, i) => {
|
|
1444
|
-
const ratedR = {
|
|
1445
|
-
...r,
|
|
1446
|
-
...elo[i]
|
|
1447
|
-
};
|
|
1448
|
-
return ratedR;
|
|
1449
|
-
});
|
|
1450
|
-
ratedReviews.sort((a, b) => {
|
|
1451
|
-
return a.global.score - b.global.score;
|
|
1452
|
-
});
|
|
1453
|
-
return ratedReviews.map((r) => {
|
|
1454
|
-
return {
|
|
1455
|
-
...r,
|
|
1456
|
-
contentSourceType: "course",
|
|
1457
|
-
contentSourceID: this.course.getCourseID(),
|
|
1458
|
-
cardID: r.cardId,
|
|
1459
|
-
courseID: r.courseId,
|
|
1460
|
-
qualifiedID: `${r.courseId}-${r.cardId}`,
|
|
1461
|
-
reviewID: r._id,
|
|
1462
|
-
status: "review"
|
|
1463
|
-
};
|
|
1464
|
-
});
|
|
1465
|
-
}
|
|
1466
|
-
async getNewCards(limit = 99) {
|
|
1467
|
-
const activeCards = await this.user.getActiveCards();
|
|
1468
|
-
return (await this.course.getCardsCenteredAtELO(
|
|
1469
|
-
{ limit, elo: "user" },
|
|
1470
|
-
(c) => {
|
|
1471
|
-
if (activeCards.some((ac) => c.cardID === ac.cardID)) {
|
|
1472
|
-
return false;
|
|
1473
|
-
} else {
|
|
1474
|
-
return true;
|
|
1475
|
-
}
|
|
1476
|
-
}
|
|
1477
|
-
)).map((c) => {
|
|
1478
|
-
return {
|
|
1479
|
-
...c,
|
|
1480
|
-
status: "new"
|
|
1481
|
-
};
|
|
1482
|
-
});
|
|
1483
|
-
}
|
|
1484
1453
|
/**
|
|
1485
1454
|
* Get new cards with suitability scores based on ELO distance.
|
|
1486
1455
|
*
|
|
@@ -1505,7 +1474,11 @@ var init_elo = __esm({
|
|
|
1505
1474
|
const userElo = toCourseElo3(courseReg.elo);
|
|
1506
1475
|
userGlobalElo = userElo.global.score;
|
|
1507
1476
|
}
|
|
1508
|
-
const
|
|
1477
|
+
const activeCards = await this.user.getActiveCards();
|
|
1478
|
+
const newCards = (await this.course.getCardsCenteredAtELO(
|
|
1479
|
+
{ limit, elo: "user" },
|
|
1480
|
+
(c) => !activeCards.some((ac) => c.cardID === ac.cardID)
|
|
1481
|
+
)).map((c) => ({ ...c, status: "new" }));
|
|
1509
1482
|
const cardIds = newCards.map((c) => c.cardID);
|
|
1510
1483
|
const cardEloData = await this.course.getCardEloData(cardIds);
|
|
1511
1484
|
const scored = newCards.map((c, i) => {
|
|
@@ -1535,925 +1508,14 @@ var init_elo = __esm({
|
|
|
1535
1508
|
}
|
|
1536
1509
|
});
|
|
1537
1510
|
|
|
1538
|
-
// src/core/navigators/
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
DEFAULT_MIN_MULTIPLIER: () => DEFAULT_MIN_MULTIPLIER,
|
|
1544
|
-
createEloDistanceFilter: () => createEloDistanceFilter
|
|
1545
|
-
});
|
|
1546
|
-
function computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier) {
|
|
1547
|
-
const normalizedDistance = distance / halfLife;
|
|
1548
|
-
const decay = Math.exp(-(normalizedDistance * normalizedDistance));
|
|
1549
|
-
return minMultiplier + (maxMultiplier - minMultiplier) * decay;
|
|
1550
|
-
}
|
|
1551
|
-
function createEloDistanceFilter(config) {
|
|
1552
|
-
const halfLife = config?.halfLife ?? DEFAULT_HALF_LIFE;
|
|
1553
|
-
const minMultiplier = config?.minMultiplier ?? DEFAULT_MIN_MULTIPLIER;
|
|
1554
|
-
const maxMultiplier = config?.maxMultiplier ?? DEFAULT_MAX_MULTIPLIER;
|
|
1555
|
-
return {
|
|
1556
|
-
name: "ELO Distance Filter",
|
|
1557
|
-
async transform(cards, context) {
|
|
1558
|
-
const { course, userElo } = context;
|
|
1559
|
-
const cardIds = cards.map((c) => c.cardId);
|
|
1560
|
-
const cardElos = await course.getCardEloData(cardIds);
|
|
1561
|
-
return cards.map((card, i) => {
|
|
1562
|
-
const cardElo = cardElos[i]?.global?.score ?? 1e3;
|
|
1563
|
-
const distance = Math.abs(cardElo - userElo);
|
|
1564
|
-
const multiplier = computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier);
|
|
1565
|
-
const newScore = card.score * multiplier;
|
|
1566
|
-
const action = multiplier < maxMultiplier - 0.01 ? "penalized" : "passed";
|
|
1567
|
-
return {
|
|
1568
|
-
...card,
|
|
1569
|
-
score: newScore,
|
|
1570
|
-
provenance: [
|
|
1571
|
-
...card.provenance,
|
|
1572
|
-
{
|
|
1573
|
-
strategy: "eloDistance",
|
|
1574
|
-
strategyName: "ELO Distance Filter",
|
|
1575
|
-
strategyId: "ELO_DISTANCE_FILTER",
|
|
1576
|
-
action,
|
|
1577
|
-
score: newScore,
|
|
1578
|
-
reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userElo)}) \u2192 ${multiplier.toFixed(2)}x`
|
|
1579
|
-
}
|
|
1580
|
-
]
|
|
1581
|
-
};
|
|
1582
|
-
});
|
|
1583
|
-
}
|
|
1584
|
-
};
|
|
1585
|
-
}
|
|
1586
|
-
var DEFAULT_HALF_LIFE, DEFAULT_MIN_MULTIPLIER, DEFAULT_MAX_MULTIPLIER;
|
|
1587
|
-
var init_eloDistance = __esm({
|
|
1588
|
-
"src/core/navigators/filters/eloDistance.ts"() {
|
|
1589
|
-
"use strict";
|
|
1590
|
-
DEFAULT_HALF_LIFE = 200;
|
|
1591
|
-
DEFAULT_MIN_MULTIPLIER = 0.3;
|
|
1592
|
-
DEFAULT_MAX_MULTIPLIER = 1;
|
|
1593
|
-
}
|
|
1594
|
-
});
|
|
1595
|
-
|
|
1596
|
-
// src/core/navigators/filters/userTagPreference.ts
|
|
1597
|
-
var userTagPreference_exports = {};
|
|
1598
|
-
__export(userTagPreference_exports, {
|
|
1599
|
-
default: () => UserTagPreferenceFilter
|
|
1600
|
-
});
|
|
1601
|
-
var UserTagPreferenceFilter;
|
|
1602
|
-
var init_userTagPreference = __esm({
|
|
1603
|
-
"src/core/navigators/filters/userTagPreference.ts"() {
|
|
1604
|
-
"use strict";
|
|
1605
|
-
init_navigators();
|
|
1606
|
-
UserTagPreferenceFilter = class extends ContentNavigator {
|
|
1607
|
-
_strategyData;
|
|
1608
|
-
/** Human-readable name for CardFilter interface */
|
|
1609
|
-
name;
|
|
1610
|
-
constructor(user, course, strategyData) {
|
|
1611
|
-
super(user, course, strategyData);
|
|
1612
|
-
this._strategyData = strategyData;
|
|
1613
|
-
this.name = strategyData.name || "User Tag Preferences";
|
|
1614
|
-
}
|
|
1615
|
-
/**
|
|
1616
|
-
* Compute multiplier for a card based on its tags and user preferences.
|
|
1617
|
-
* Returns the maximum multiplier among all matching tags, or 1.0 if no matches.
|
|
1618
|
-
*/
|
|
1619
|
-
computeMultiplier(cardTags, boostMap) {
|
|
1620
|
-
const multipliers = cardTags.map((tag) => boostMap[tag]).filter((val) => val !== void 0);
|
|
1621
|
-
if (multipliers.length === 0) {
|
|
1622
|
-
return 1;
|
|
1623
|
-
}
|
|
1624
|
-
return Math.max(...multipliers);
|
|
1625
|
-
}
|
|
1626
|
-
/**
|
|
1627
|
-
* Build human-readable reason for the filter's decision.
|
|
1628
|
-
*/
|
|
1629
|
-
buildReason(cardTags, boostMap, multiplier) {
|
|
1630
|
-
const matchingTags = cardTags.filter((tag) => boostMap[tag] === multiplier);
|
|
1631
|
-
if (multiplier === 0) {
|
|
1632
|
-
return `Excluded by user preference: ${matchingTags.join(", ")} (${multiplier}x)`;
|
|
1633
|
-
}
|
|
1634
|
-
if (multiplier < 1) {
|
|
1635
|
-
return `Penalized by user preference: ${matchingTags.join(", ")} (${multiplier.toFixed(2)}x)`;
|
|
1636
|
-
}
|
|
1637
|
-
if (multiplier > 1) {
|
|
1638
|
-
return `Boosted by user preference: ${matchingTags.join(", ")} (${multiplier.toFixed(2)}x)`;
|
|
1639
|
-
}
|
|
1640
|
-
return "No matching user preferences";
|
|
1641
|
-
}
|
|
1642
|
-
/**
|
|
1643
|
-
* CardFilter.transform implementation.
|
|
1644
|
-
*
|
|
1645
|
-
* Apply user tag preferences:
|
|
1646
|
-
* 1. Read preferences from strategy state
|
|
1647
|
-
* 2. If no preferences, pass through unchanged
|
|
1648
|
-
* 3. For each card:
|
|
1649
|
-
* - Look up tag in boost record
|
|
1650
|
-
* - If tag found: apply multiplier (0 = exclude, 1 = neutral, >1 = boost)
|
|
1651
|
-
* - If multiple tags match: use max multiplier
|
|
1652
|
-
* - Append provenance with clear reason
|
|
1653
|
-
*/
|
|
1654
|
-
async transform(cards, _context) {
|
|
1655
|
-
const prefs = await this.getStrategyState();
|
|
1656
|
-
if (!prefs || Object.keys(prefs.boost).length === 0) {
|
|
1657
|
-
return cards.map((card) => ({
|
|
1658
|
-
...card,
|
|
1659
|
-
provenance: [
|
|
1660
|
-
...card.provenance,
|
|
1661
|
-
{
|
|
1662
|
-
strategy: "userTagPreference",
|
|
1663
|
-
strategyName: this.strategyName || this.name,
|
|
1664
|
-
strategyId: this.strategyId || this._strategyData._id,
|
|
1665
|
-
action: "passed",
|
|
1666
|
-
score: card.score,
|
|
1667
|
-
reason: "No user tag preferences configured"
|
|
1668
|
-
}
|
|
1669
|
-
]
|
|
1670
|
-
}));
|
|
1671
|
-
}
|
|
1672
|
-
const adjusted = await Promise.all(
|
|
1673
|
-
cards.map(async (card) => {
|
|
1674
|
-
const cardTags = card.tags ?? [];
|
|
1675
|
-
const multiplier = this.computeMultiplier(cardTags, prefs.boost);
|
|
1676
|
-
const finalScore = Math.min(1, card.score * multiplier);
|
|
1677
|
-
let action;
|
|
1678
|
-
if (multiplier === 0 || multiplier < 1) {
|
|
1679
|
-
action = "penalized";
|
|
1680
|
-
} else if (multiplier > 1) {
|
|
1681
|
-
action = "boosted";
|
|
1682
|
-
} else {
|
|
1683
|
-
action = "passed";
|
|
1684
|
-
}
|
|
1685
|
-
return {
|
|
1686
|
-
...card,
|
|
1687
|
-
score: finalScore,
|
|
1688
|
-
provenance: [
|
|
1689
|
-
...card.provenance,
|
|
1690
|
-
{
|
|
1691
|
-
strategy: "userTagPreference",
|
|
1692
|
-
strategyName: this.strategyName || this.name,
|
|
1693
|
-
strategyId: this.strategyId || this._strategyData._id,
|
|
1694
|
-
action,
|
|
1695
|
-
score: finalScore,
|
|
1696
|
-
reason: this.buildReason(cardTags, prefs.boost, multiplier)
|
|
1697
|
-
}
|
|
1698
|
-
]
|
|
1699
|
-
};
|
|
1700
|
-
})
|
|
1701
|
-
);
|
|
1702
|
-
return adjusted;
|
|
1703
|
-
}
|
|
1704
|
-
/**
|
|
1705
|
-
* Legacy getWeightedCards - throws as filters should not be used as generators.
|
|
1706
|
-
*/
|
|
1707
|
-
async getWeightedCards(_limit) {
|
|
1708
|
-
throw new Error(
|
|
1709
|
-
"UserTagPreferenceFilter is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
|
|
1710
|
-
);
|
|
1711
|
-
}
|
|
1712
|
-
// Legacy methods - stub implementations since filters don't generate cards
|
|
1713
|
-
async getNewCards(_n) {
|
|
1714
|
-
return [];
|
|
1715
|
-
}
|
|
1716
|
-
async getPendingReviews() {
|
|
1717
|
-
return [];
|
|
1718
|
-
}
|
|
1719
|
-
};
|
|
1720
|
-
}
|
|
1721
|
-
});
|
|
1722
|
-
|
|
1723
|
-
// src/core/navigators/filters/index.ts
|
|
1724
|
-
var filters_exports = {};
|
|
1725
|
-
__export(filters_exports, {
|
|
1726
|
-
UserTagPreferenceFilter: () => UserTagPreferenceFilter,
|
|
1727
|
-
createEloDistanceFilter: () => createEloDistanceFilter
|
|
1728
|
-
});
|
|
1729
|
-
var init_filters = __esm({
|
|
1730
|
-
"src/core/navigators/filters/index.ts"() {
|
|
1731
|
-
"use strict";
|
|
1732
|
-
init_eloDistance();
|
|
1733
|
-
init_userTagPreference();
|
|
1734
|
-
}
|
|
1735
|
-
});
|
|
1736
|
-
|
|
1737
|
-
// src/core/navigators/filters/types.ts
|
|
1738
|
-
var types_exports = {};
|
|
1739
|
-
var init_types = __esm({
|
|
1740
|
-
"src/core/navigators/filters/types.ts"() {
|
|
1741
|
-
"use strict";
|
|
1742
|
-
}
|
|
1743
|
-
});
|
|
1744
|
-
|
|
1745
|
-
// src/core/navigators/generators/index.ts
|
|
1746
|
-
var generators_exports = {};
|
|
1747
|
-
var init_generators = __esm({
|
|
1748
|
-
"src/core/navigators/generators/index.ts"() {
|
|
1749
|
-
"use strict";
|
|
1750
|
-
}
|
|
1751
|
-
});
|
|
1752
|
-
|
|
1753
|
-
// src/core/navigators/generators/types.ts
|
|
1754
|
-
var types_exports2 = {};
|
|
1755
|
-
var init_types2 = __esm({
|
|
1756
|
-
"src/core/navigators/generators/types.ts"() {
|
|
1757
|
-
"use strict";
|
|
1758
|
-
}
|
|
1759
|
-
});
|
|
1760
|
-
|
|
1761
|
-
// src/core/navigators/hardcodedOrder.ts
|
|
1762
|
-
var hardcodedOrder_exports = {};
|
|
1763
|
-
__export(hardcodedOrder_exports, {
|
|
1764
|
-
default: () => HardcodedOrderNavigator
|
|
1765
|
-
});
|
|
1766
|
-
var HardcodedOrderNavigator;
|
|
1767
|
-
var init_hardcodedOrder = __esm({
|
|
1768
|
-
"src/core/navigators/hardcodedOrder.ts"() {
|
|
1769
|
-
"use strict";
|
|
1770
|
-
init_navigators();
|
|
1771
|
-
init_logger();
|
|
1772
|
-
HardcodedOrderNavigator = class extends ContentNavigator {
|
|
1773
|
-
/** Human-readable name for CardGenerator interface */
|
|
1774
|
-
name;
|
|
1775
|
-
orderedCardIds = [];
|
|
1776
|
-
constructor(user, course, strategyData) {
|
|
1777
|
-
super(user, course, strategyData);
|
|
1778
|
-
this.name = strategyData.name || "Hardcoded Order";
|
|
1779
|
-
if (strategyData.serializedData) {
|
|
1780
|
-
try {
|
|
1781
|
-
this.orderedCardIds = JSON.parse(strategyData.serializedData);
|
|
1782
|
-
} catch (e) {
|
|
1783
|
-
logger.error("Failed to parse serializedData for HardcodedOrderNavigator", e);
|
|
1784
|
-
}
|
|
1785
|
-
}
|
|
1786
|
-
}
|
|
1787
|
-
async getPendingReviews() {
|
|
1788
|
-
const reviews = await this.user.getPendingReviews(this.course.getCourseID());
|
|
1789
|
-
return reviews.map((r) => {
|
|
1790
|
-
return {
|
|
1791
|
-
...r,
|
|
1792
|
-
contentSourceType: "course",
|
|
1793
|
-
contentSourceID: this.course.getCourseID(),
|
|
1794
|
-
cardID: r.cardId,
|
|
1795
|
-
courseID: r.courseId,
|
|
1796
|
-
reviewID: r._id,
|
|
1797
|
-
status: "review"
|
|
1798
|
-
};
|
|
1799
|
-
});
|
|
1800
|
-
}
|
|
1801
|
-
async getNewCards(limit = 99) {
|
|
1802
|
-
const activeCardIds = (await this.user.getActiveCards()).map((c) => c.cardID);
|
|
1803
|
-
const newCardIds = this.orderedCardIds.filter((cardId) => !activeCardIds.includes(cardId));
|
|
1804
|
-
const cardsToReturn = newCardIds.slice(0, limit);
|
|
1805
|
-
return cardsToReturn.map((cardId) => {
|
|
1806
|
-
return {
|
|
1807
|
-
cardID: cardId,
|
|
1808
|
-
courseID: this.course.getCourseID(),
|
|
1809
|
-
contentSourceType: "course",
|
|
1810
|
-
contentSourceID: this.course.getCourseID(),
|
|
1811
|
-
status: "new"
|
|
1812
|
-
};
|
|
1813
|
-
});
|
|
1814
|
-
}
|
|
1815
|
-
/**
|
|
1816
|
-
* Get cards in hardcoded order with scores based on position.
|
|
1817
|
-
*
|
|
1818
|
-
* Earlier cards in the sequence get higher scores.
|
|
1819
|
-
* Score formula: 1.0 - (position / totalCards) * 0.5
|
|
1820
|
-
* This ensures scores range from 1.0 (first card) to 0.5+ (last card).
|
|
1821
|
-
*
|
|
1822
|
-
* This method supports both the legacy signature (limit only) and the
|
|
1823
|
-
* CardGenerator interface signature (limit, context).
|
|
1824
|
-
*
|
|
1825
|
-
* @param limit - Maximum number of cards to return
|
|
1826
|
-
* @param _context - Optional GeneratorContext (currently unused, but required for interface)
|
|
1827
|
-
*/
|
|
1828
|
-
async getWeightedCards(limit, _context) {
|
|
1829
|
-
const activeCardIds = (await this.user.getActiveCards()).map((c) => c.cardID);
|
|
1830
|
-
const reviews = await this.getPendingReviews();
|
|
1831
|
-
const newCardIds = this.orderedCardIds.filter((cardId) => !activeCardIds.includes(cardId));
|
|
1832
|
-
const totalCards = newCardIds.length;
|
|
1833
|
-
const scoredNew = newCardIds.slice(0, limit).map((cardId, index) => {
|
|
1834
|
-
const position = index + 1;
|
|
1835
|
-
const score = Math.max(0.5, 1 - index / totalCards * 0.5);
|
|
1836
|
-
return {
|
|
1837
|
-
cardId,
|
|
1838
|
-
courseId: this.course.getCourseID(),
|
|
1839
|
-
score,
|
|
1840
|
-
provenance: [
|
|
1841
|
-
{
|
|
1842
|
-
strategy: "hardcodedOrder",
|
|
1843
|
-
strategyName: this.strategyName || this.name,
|
|
1844
|
-
strategyId: this.strategyId || "NAVIGATION_STRATEGY-hardcoded",
|
|
1845
|
-
action: "generated",
|
|
1846
|
-
score,
|
|
1847
|
-
reason: `Position ${position} of ${totalCards} in fixed sequence, new card`
|
|
1848
|
-
}
|
|
1849
|
-
]
|
|
1850
|
-
};
|
|
1851
|
-
});
|
|
1852
|
-
const scoredReviews = reviews.map((r) => ({
|
|
1853
|
-
cardId: r.cardID,
|
|
1854
|
-
courseId: r.courseID,
|
|
1855
|
-
score: 1,
|
|
1856
|
-
provenance: [
|
|
1857
|
-
{
|
|
1858
|
-
strategy: "hardcodedOrder",
|
|
1859
|
-
strategyName: this.strategyName || this.name,
|
|
1860
|
-
strategyId: this.strategyId || "NAVIGATION_STRATEGY-hardcoded",
|
|
1861
|
-
action: "generated",
|
|
1862
|
-
score: 1,
|
|
1863
|
-
reason: "Scheduled review, highest priority"
|
|
1864
|
-
}
|
|
1865
|
-
]
|
|
1866
|
-
}));
|
|
1867
|
-
const all = [...scoredReviews, ...scoredNew];
|
|
1868
|
-
all.sort((a, b) => b.score - a.score);
|
|
1869
|
-
return all.slice(0, limit);
|
|
1870
|
-
}
|
|
1871
|
-
};
|
|
1872
|
-
}
|
|
1873
|
-
});
|
|
1874
|
-
|
|
1875
|
-
// src/core/navigators/hierarchyDefinition.ts
|
|
1876
|
-
var hierarchyDefinition_exports = {};
|
|
1877
|
-
__export(hierarchyDefinition_exports, {
|
|
1878
|
-
default: () => HierarchyDefinitionNavigator
|
|
1879
|
-
});
|
|
1880
|
-
import { toCourseElo as toCourseElo4 } from "@vue-skuilder/common";
|
|
1881
|
-
var DEFAULT_MIN_COUNT, HierarchyDefinitionNavigator;
|
|
1882
|
-
var init_hierarchyDefinition = __esm({
|
|
1883
|
-
"src/core/navigators/hierarchyDefinition.ts"() {
|
|
1884
|
-
"use strict";
|
|
1885
|
-
init_navigators();
|
|
1886
|
-
DEFAULT_MIN_COUNT = 3;
|
|
1887
|
-
HierarchyDefinitionNavigator = class extends ContentNavigator {
|
|
1888
|
-
config;
|
|
1889
|
-
_strategyData;
|
|
1890
|
-
/** Human-readable name for CardFilter interface */
|
|
1891
|
-
name;
|
|
1892
|
-
constructor(user, course, _strategyData) {
|
|
1893
|
-
super(user, course, _strategyData);
|
|
1894
|
-
this._strategyData = _strategyData;
|
|
1895
|
-
this.config = this.parseConfig(_strategyData.serializedData);
|
|
1896
|
-
this.name = _strategyData.name || "Hierarchy Definition";
|
|
1897
|
-
}
|
|
1898
|
-
parseConfig(serializedData) {
|
|
1899
|
-
try {
|
|
1900
|
-
const parsed = JSON.parse(serializedData);
|
|
1901
|
-
return {
|
|
1902
|
-
prerequisites: parsed.prerequisites || {}
|
|
1903
|
-
};
|
|
1904
|
-
} catch {
|
|
1905
|
-
return {
|
|
1906
|
-
prerequisites: {}
|
|
1907
|
-
};
|
|
1908
|
-
}
|
|
1909
|
-
}
|
|
1910
|
-
/**
|
|
1911
|
-
* Check if a specific prerequisite is satisfied
|
|
1912
|
-
*/
|
|
1913
|
-
isPrerequisiteMet(prereq, userTagElo, userGlobalElo) {
|
|
1914
|
-
if (!userTagElo) return false;
|
|
1915
|
-
const minCount = prereq.masteryThreshold?.minCount ?? DEFAULT_MIN_COUNT;
|
|
1916
|
-
if (userTagElo.count < minCount) return false;
|
|
1917
|
-
if (prereq.masteryThreshold?.minElo !== void 0) {
|
|
1918
|
-
return userTagElo.score >= prereq.masteryThreshold.minElo;
|
|
1919
|
-
} else {
|
|
1920
|
-
return userTagElo.score >= userGlobalElo;
|
|
1921
|
-
}
|
|
1922
|
-
}
|
|
1923
|
-
/**
|
|
1924
|
-
* Get the set of tags the user has mastered.
|
|
1925
|
-
* A tag is "mastered" if it appears as a prerequisite somewhere and meets its threshold.
|
|
1926
|
-
*/
|
|
1927
|
-
async getMasteredTags(context) {
|
|
1928
|
-
const mastered = /* @__PURE__ */ new Set();
|
|
1929
|
-
try {
|
|
1930
|
-
const courseReg = await context.user.getCourseRegDoc(context.course.getCourseID());
|
|
1931
|
-
const userElo = toCourseElo4(courseReg.elo);
|
|
1932
|
-
for (const prereqs of Object.values(this.config.prerequisites)) {
|
|
1933
|
-
for (const prereq of prereqs) {
|
|
1934
|
-
const tagElo = userElo.tags[prereq.tag];
|
|
1935
|
-
if (this.isPrerequisiteMet(prereq, tagElo, userElo.global.score)) {
|
|
1936
|
-
mastered.add(prereq.tag);
|
|
1937
|
-
}
|
|
1938
|
-
}
|
|
1939
|
-
}
|
|
1940
|
-
} catch {
|
|
1941
|
-
}
|
|
1942
|
-
return mastered;
|
|
1943
|
-
}
|
|
1944
|
-
/**
|
|
1945
|
-
* Get the set of tags that are unlocked (prerequisites met)
|
|
1946
|
-
*/
|
|
1947
|
-
getUnlockedTags(masteredTags) {
|
|
1948
|
-
const unlocked = /* @__PURE__ */ new Set();
|
|
1949
|
-
for (const [tagId, prereqs] of Object.entries(this.config.prerequisites)) {
|
|
1950
|
-
const allPrereqsMet = prereqs.every((prereq) => masteredTags.has(prereq.tag));
|
|
1951
|
-
if (allPrereqsMet) {
|
|
1952
|
-
unlocked.add(tagId);
|
|
1953
|
-
}
|
|
1954
|
-
}
|
|
1955
|
-
return unlocked;
|
|
1956
|
-
}
|
|
1957
|
-
/**
|
|
1958
|
-
* Check if a tag has prerequisites defined in config
|
|
1959
|
-
*/
|
|
1960
|
-
hasPrerequisites(tagId) {
|
|
1961
|
-
return tagId in this.config.prerequisites;
|
|
1962
|
-
}
|
|
1963
|
-
/**
|
|
1964
|
-
* Check if a card is unlocked and generate reason.
|
|
1965
|
-
*/
|
|
1966
|
-
async checkCardUnlock(card, course, unlockedTags, masteredTags) {
|
|
1967
|
-
try {
|
|
1968
|
-
const cardTags = card.tags ?? [];
|
|
1969
|
-
const lockedTags = cardTags.filter(
|
|
1970
|
-
(tag) => this.hasPrerequisites(tag) && !unlockedTags.has(tag)
|
|
1971
|
-
);
|
|
1972
|
-
if (lockedTags.length === 0) {
|
|
1973
|
-
const tagList = cardTags.length > 0 ? cardTags.join(", ") : "none";
|
|
1974
|
-
return {
|
|
1975
|
-
isUnlocked: true,
|
|
1976
|
-
reason: `Prerequisites met, tags: ${tagList}`
|
|
1977
|
-
};
|
|
1978
|
-
}
|
|
1979
|
-
const missingPrereqs = lockedTags.flatMap((tag) => {
|
|
1980
|
-
const prereqs = this.config.prerequisites[tag] || [];
|
|
1981
|
-
return prereqs.filter((p) => !masteredTags.has(p.tag)).map((p) => p.tag);
|
|
1982
|
-
});
|
|
1983
|
-
return {
|
|
1984
|
-
isUnlocked: false,
|
|
1985
|
-
reason: `Blocked: missing prerequisites ${missingPrereqs.join(", ")} for tags ${lockedTags.join(", ")}`
|
|
1986
|
-
};
|
|
1987
|
-
} catch {
|
|
1988
|
-
return {
|
|
1989
|
-
isUnlocked: true,
|
|
1990
|
-
reason: "Prerequisites check skipped (tag lookup failed)"
|
|
1991
|
-
};
|
|
1992
|
-
}
|
|
1993
|
-
}
|
|
1994
|
-
/**
|
|
1995
|
-
* CardFilter.transform implementation.
|
|
1996
|
-
*
|
|
1997
|
-
* Apply prerequisite gating to cards. Cards with locked tags receive score: 0.
|
|
1998
|
-
*/
|
|
1999
|
-
async transform(cards, context) {
|
|
2000
|
-
const masteredTags = await this.getMasteredTags(context);
|
|
2001
|
-
const unlockedTags = this.getUnlockedTags(masteredTags);
|
|
2002
|
-
const gated = [];
|
|
2003
|
-
for (const card of cards) {
|
|
2004
|
-
const { isUnlocked, reason } = await this.checkCardUnlock(
|
|
2005
|
-
card,
|
|
2006
|
-
context.course,
|
|
2007
|
-
unlockedTags,
|
|
2008
|
-
masteredTags
|
|
2009
|
-
);
|
|
2010
|
-
const finalScore = isUnlocked ? card.score : 0;
|
|
2011
|
-
const action = isUnlocked ? "passed" : "penalized";
|
|
2012
|
-
gated.push({
|
|
2013
|
-
...card,
|
|
2014
|
-
score: finalScore,
|
|
2015
|
-
provenance: [
|
|
2016
|
-
...card.provenance,
|
|
2017
|
-
{
|
|
2018
|
-
strategy: "hierarchyDefinition",
|
|
2019
|
-
strategyName: this.strategyName || this.name,
|
|
2020
|
-
strategyId: this.strategyId || "NAVIGATION_STRATEGY-hierarchy",
|
|
2021
|
-
action,
|
|
2022
|
-
score: finalScore,
|
|
2023
|
-
reason
|
|
2024
|
-
}
|
|
2025
|
-
]
|
|
2026
|
-
});
|
|
2027
|
-
}
|
|
2028
|
-
return gated;
|
|
2029
|
-
}
|
|
2030
|
-
/**
|
|
2031
|
-
* Legacy getWeightedCards - now throws as filters should not be used as generators.
|
|
2032
|
-
*
|
|
2033
|
-
* Use transform() via Pipeline instead.
|
|
2034
|
-
*/
|
|
2035
|
-
async getWeightedCards(_limit) {
|
|
2036
|
-
throw new Error(
|
|
2037
|
-
"HierarchyDefinitionNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
|
|
2038
|
-
);
|
|
2039
|
-
}
|
|
2040
|
-
// Legacy methods - stub implementations since filters don't generate cards
|
|
2041
|
-
async getNewCards(_n) {
|
|
2042
|
-
return [];
|
|
2043
|
-
}
|
|
2044
|
-
async getPendingReviews() {
|
|
2045
|
-
return [];
|
|
2046
|
-
}
|
|
2047
|
-
};
|
|
2048
|
-
}
|
|
2049
|
-
});
|
|
2050
|
-
|
|
2051
|
-
// src/core/navigators/inferredPreference.ts
|
|
2052
|
-
var inferredPreference_exports = {};
|
|
2053
|
-
__export(inferredPreference_exports, {
|
|
2054
|
-
INFERRED_PREFERENCE_NAVIGATOR_STUB: () => INFERRED_PREFERENCE_NAVIGATOR_STUB
|
|
2055
|
-
});
|
|
2056
|
-
var INFERRED_PREFERENCE_NAVIGATOR_STUB;
|
|
2057
|
-
var init_inferredPreference = __esm({
|
|
2058
|
-
"src/core/navigators/inferredPreference.ts"() {
|
|
2059
|
-
"use strict";
|
|
2060
|
-
INFERRED_PREFERENCE_NAVIGATOR_STUB = true;
|
|
2061
|
-
}
|
|
2062
|
-
});
|
|
2063
|
-
|
|
2064
|
-
// src/core/navigators/interferenceMitigator.ts
|
|
2065
|
-
var interferenceMitigator_exports = {};
|
|
2066
|
-
__export(interferenceMitigator_exports, {
|
|
2067
|
-
default: () => InterferenceMitigatorNavigator
|
|
2068
|
-
});
|
|
2069
|
-
import { toCourseElo as toCourseElo5 } from "@vue-skuilder/common";
|
|
2070
|
-
var DEFAULT_MIN_COUNT2, DEFAULT_MIN_ELAPSED_DAYS, DEFAULT_INTERFERENCE_DECAY, InterferenceMitigatorNavigator;
|
|
2071
|
-
var init_interferenceMitigator = __esm({
|
|
2072
|
-
"src/core/navigators/interferenceMitigator.ts"() {
|
|
2073
|
-
"use strict";
|
|
2074
|
-
init_navigators();
|
|
2075
|
-
DEFAULT_MIN_COUNT2 = 10;
|
|
2076
|
-
DEFAULT_MIN_ELAPSED_DAYS = 3;
|
|
2077
|
-
DEFAULT_INTERFERENCE_DECAY = 0.8;
|
|
2078
|
-
InterferenceMitigatorNavigator = class extends ContentNavigator {
|
|
2079
|
-
config;
|
|
2080
|
-
_strategyData;
|
|
2081
|
-
/** Human-readable name for CardFilter interface */
|
|
2082
|
-
name;
|
|
2083
|
-
/** Precomputed map: tag -> set of { partner, decay } it interferes with */
|
|
2084
|
-
interferenceMap;
|
|
2085
|
-
constructor(user, course, _strategyData) {
|
|
2086
|
-
super(user, course, _strategyData);
|
|
2087
|
-
this._strategyData = _strategyData;
|
|
2088
|
-
this.config = this.parseConfig(_strategyData.serializedData);
|
|
2089
|
-
this.interferenceMap = this.buildInterferenceMap();
|
|
2090
|
-
this.name = _strategyData.name || "Interference Mitigator";
|
|
2091
|
-
}
|
|
2092
|
-
parseConfig(serializedData) {
|
|
2093
|
-
try {
|
|
2094
|
-
const parsed = JSON.parse(serializedData);
|
|
2095
|
-
let sets = parsed.interferenceSets || [];
|
|
2096
|
-
if (sets.length > 0 && Array.isArray(sets[0])) {
|
|
2097
|
-
sets = sets.map((tags) => ({ tags }));
|
|
2098
|
-
}
|
|
2099
|
-
return {
|
|
2100
|
-
interferenceSets: sets,
|
|
2101
|
-
maturityThreshold: {
|
|
2102
|
-
minCount: parsed.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT2,
|
|
2103
|
-
minElo: parsed.maturityThreshold?.minElo,
|
|
2104
|
-
minElapsedDays: parsed.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS
|
|
2105
|
-
},
|
|
2106
|
-
defaultDecay: parsed.defaultDecay ?? DEFAULT_INTERFERENCE_DECAY
|
|
2107
|
-
};
|
|
2108
|
-
} catch {
|
|
2109
|
-
return {
|
|
2110
|
-
interferenceSets: [],
|
|
2111
|
-
maturityThreshold: {
|
|
2112
|
-
minCount: DEFAULT_MIN_COUNT2,
|
|
2113
|
-
minElapsedDays: DEFAULT_MIN_ELAPSED_DAYS
|
|
2114
|
-
},
|
|
2115
|
-
defaultDecay: DEFAULT_INTERFERENCE_DECAY
|
|
2116
|
-
};
|
|
2117
|
-
}
|
|
2118
|
-
}
|
|
2119
|
-
/**
|
|
2120
|
-
* Build a map from each tag to its interference partners with decay coefficients.
|
|
2121
|
-
* If tags A, B, C are in an interference group with decay 0.8, then:
|
|
2122
|
-
* - A interferes with B (decay 0.8) and C (decay 0.8)
|
|
2123
|
-
* - B interferes with A (decay 0.8) and C (decay 0.8)
|
|
2124
|
-
* - etc.
|
|
2125
|
-
*/
|
|
2126
|
-
buildInterferenceMap() {
|
|
2127
|
-
const map = /* @__PURE__ */ new Map();
|
|
2128
|
-
for (const group of this.config.interferenceSets) {
|
|
2129
|
-
const decay = group.decay ?? this.config.defaultDecay ?? DEFAULT_INTERFERENCE_DECAY;
|
|
2130
|
-
for (const tag of group.tags) {
|
|
2131
|
-
if (!map.has(tag)) {
|
|
2132
|
-
map.set(tag, []);
|
|
2133
|
-
}
|
|
2134
|
-
const partners = map.get(tag);
|
|
2135
|
-
for (const other of group.tags) {
|
|
2136
|
-
if (other !== tag) {
|
|
2137
|
-
const existing = partners.find((p) => p.partner === other);
|
|
2138
|
-
if (existing) {
|
|
2139
|
-
existing.decay = Math.max(existing.decay, decay);
|
|
2140
|
-
} else {
|
|
2141
|
-
partners.push({ partner: other, decay });
|
|
2142
|
-
}
|
|
2143
|
-
}
|
|
2144
|
-
}
|
|
2145
|
-
}
|
|
2146
|
-
}
|
|
2147
|
-
return map;
|
|
2148
|
-
}
|
|
2149
|
-
/**
|
|
2150
|
-
* Get the set of tags that are currently immature for this user.
|
|
2151
|
-
* A tag is immature if the user has interacted with it but hasn't
|
|
2152
|
-
* reached the maturity threshold.
|
|
2153
|
-
*/
|
|
2154
|
-
async getImmatureTags(context) {
|
|
2155
|
-
const immature = /* @__PURE__ */ new Set();
|
|
2156
|
-
try {
|
|
2157
|
-
const courseReg = await context.user.getCourseRegDoc(context.course.getCourseID());
|
|
2158
|
-
const userElo = toCourseElo5(courseReg.elo);
|
|
2159
|
-
const minCount = this.config.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT2;
|
|
2160
|
-
const minElo = this.config.maturityThreshold?.minElo;
|
|
2161
|
-
const minElapsedDays = this.config.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS;
|
|
2162
|
-
const minCountForElapsed = minElapsedDays * 2;
|
|
2163
|
-
for (const [tagId, tagElo] of Object.entries(userElo.tags)) {
|
|
2164
|
-
if (tagElo.count === 0) continue;
|
|
2165
|
-
const belowCount = tagElo.count < minCount;
|
|
2166
|
-
const belowElo = minElo !== void 0 && tagElo.score < minElo;
|
|
2167
|
-
const belowElapsed = tagElo.count < minCountForElapsed;
|
|
2168
|
-
if (belowCount || belowElo || belowElapsed) {
|
|
2169
|
-
immature.add(tagId);
|
|
2170
|
-
}
|
|
2171
|
-
}
|
|
2172
|
-
} catch {
|
|
2173
|
-
}
|
|
2174
|
-
return immature;
|
|
2175
|
-
}
|
|
2176
|
-
/**
|
|
2177
|
-
* Get all tags that interfere with any immature tag, along with their decay coefficients.
|
|
2178
|
-
* These are the tags we want to avoid introducing.
|
|
2179
|
-
*/
|
|
2180
|
-
getTagsToAvoid(immatureTags) {
|
|
2181
|
-
const avoid = /* @__PURE__ */ new Map();
|
|
2182
|
-
for (const immatureTag of immatureTags) {
|
|
2183
|
-
const partners = this.interferenceMap.get(immatureTag);
|
|
2184
|
-
if (partners) {
|
|
2185
|
-
for (const { partner, decay } of partners) {
|
|
2186
|
-
if (!immatureTags.has(partner)) {
|
|
2187
|
-
const existing = avoid.get(partner) ?? 0;
|
|
2188
|
-
avoid.set(partner, Math.max(existing, decay));
|
|
2189
|
-
}
|
|
2190
|
-
}
|
|
2191
|
-
}
|
|
2192
|
-
}
|
|
2193
|
-
return avoid;
|
|
2194
|
-
}
|
|
2195
|
-
/**
|
|
2196
|
-
* Compute interference score reduction for a card.
|
|
2197
|
-
* Returns: { multiplier, interfering tags, reason }
|
|
2198
|
-
*/
|
|
2199
|
-
computeInterferenceEffect(cardTags, tagsToAvoid, immatureTags) {
|
|
2200
|
-
if (tagsToAvoid.size === 0) {
|
|
2201
|
-
return {
|
|
2202
|
-
multiplier: 1,
|
|
2203
|
-
interferingTags: [],
|
|
2204
|
-
reason: "No interference detected"
|
|
2205
|
-
};
|
|
2206
|
-
}
|
|
2207
|
-
let multiplier = 1;
|
|
2208
|
-
const interferingTags = [];
|
|
2209
|
-
for (const tag of cardTags) {
|
|
2210
|
-
const decay = tagsToAvoid.get(tag);
|
|
2211
|
-
if (decay !== void 0) {
|
|
2212
|
-
interferingTags.push(tag);
|
|
2213
|
-
multiplier *= 1 - decay;
|
|
2214
|
-
}
|
|
2215
|
-
}
|
|
2216
|
-
if (interferingTags.length === 0) {
|
|
2217
|
-
return {
|
|
2218
|
-
multiplier: 1,
|
|
2219
|
-
interferingTags: [],
|
|
2220
|
-
reason: "No interference detected"
|
|
2221
|
-
};
|
|
2222
|
-
}
|
|
2223
|
-
const causingTags = /* @__PURE__ */ new Set();
|
|
2224
|
-
for (const tag of interferingTags) {
|
|
2225
|
-
for (const immatureTag of immatureTags) {
|
|
2226
|
-
const partners = this.interferenceMap.get(immatureTag);
|
|
2227
|
-
if (partners?.some((p) => p.partner === tag)) {
|
|
2228
|
-
causingTags.add(immatureTag);
|
|
2229
|
-
}
|
|
2230
|
-
}
|
|
2231
|
-
}
|
|
2232
|
-
const reason = `Interferes with immature tags ${Array.from(causingTags).join(", ")} (tags: ${interferingTags.join(", ")}, multiplier: ${multiplier.toFixed(2)})`;
|
|
2233
|
-
return { multiplier, interferingTags, reason };
|
|
2234
|
-
}
|
|
2235
|
-
/**
|
|
2236
|
-
* CardFilter.transform implementation.
|
|
2237
|
-
*
|
|
2238
|
-
* Apply interference-aware scoring. Cards with tags that interfere with
|
|
2239
|
-
* immature learnings get reduced scores.
|
|
2240
|
-
*/
|
|
2241
|
-
async transform(cards, context) {
|
|
2242
|
-
const immatureTags = await this.getImmatureTags(context);
|
|
2243
|
-
const tagsToAvoid = this.getTagsToAvoid(immatureTags);
|
|
2244
|
-
const adjusted = [];
|
|
2245
|
-
for (const card of cards) {
|
|
2246
|
-
const cardTags = card.tags ?? [];
|
|
2247
|
-
const { multiplier, reason } = this.computeInterferenceEffect(
|
|
2248
|
-
cardTags,
|
|
2249
|
-
tagsToAvoid,
|
|
2250
|
-
immatureTags
|
|
2251
|
-
);
|
|
2252
|
-
const finalScore = card.score * multiplier;
|
|
2253
|
-
const action = multiplier < 1 ? "penalized" : multiplier > 1 ? "boosted" : "passed";
|
|
2254
|
-
adjusted.push({
|
|
2255
|
-
...card,
|
|
2256
|
-
score: finalScore,
|
|
2257
|
-
provenance: [
|
|
2258
|
-
...card.provenance,
|
|
2259
|
-
{
|
|
2260
|
-
strategy: "interferenceMitigator",
|
|
2261
|
-
strategyName: this.strategyName || this.name,
|
|
2262
|
-
strategyId: this.strategyId || "NAVIGATION_STRATEGY-interference",
|
|
2263
|
-
action,
|
|
2264
|
-
score: finalScore,
|
|
2265
|
-
reason
|
|
2266
|
-
}
|
|
2267
|
-
]
|
|
2268
|
-
});
|
|
2269
|
-
}
|
|
2270
|
-
return adjusted;
|
|
2271
|
-
}
|
|
2272
|
-
/**
|
|
2273
|
-
* Legacy getWeightedCards - now throws as filters should not be used as generators.
|
|
2274
|
-
*
|
|
2275
|
-
* Use transform() via Pipeline instead.
|
|
2276
|
-
*/
|
|
2277
|
-
async getWeightedCards(_limit) {
|
|
2278
|
-
throw new Error(
|
|
2279
|
-
"InterferenceMitigatorNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
|
|
2280
|
-
);
|
|
2281
|
-
}
|
|
2282
|
-
// Legacy methods - stub implementations since filters don't generate cards
|
|
2283
|
-
async getNewCards(_n) {
|
|
2284
|
-
return [];
|
|
2285
|
-
}
|
|
2286
|
-
async getPendingReviews() {
|
|
2287
|
-
return [];
|
|
2288
|
-
}
|
|
2289
|
-
};
|
|
2290
|
-
}
|
|
2291
|
-
});
|
|
2292
|
-
|
|
2293
|
-
// src/core/navigators/relativePriority.ts
|
|
2294
|
-
var relativePriority_exports = {};
|
|
2295
|
-
__export(relativePriority_exports, {
|
|
2296
|
-
default: () => RelativePriorityNavigator
|
|
2297
|
-
});
|
|
2298
|
-
var DEFAULT_PRIORITY, DEFAULT_PRIORITY_INFLUENCE, DEFAULT_COMBINE_MODE, RelativePriorityNavigator;
|
|
2299
|
-
var init_relativePriority = __esm({
|
|
2300
|
-
"src/core/navigators/relativePriority.ts"() {
|
|
2301
|
-
"use strict";
|
|
2302
|
-
init_navigators();
|
|
2303
|
-
DEFAULT_PRIORITY = 0.5;
|
|
2304
|
-
DEFAULT_PRIORITY_INFLUENCE = 0.5;
|
|
2305
|
-
DEFAULT_COMBINE_MODE = "max";
|
|
2306
|
-
RelativePriorityNavigator = class extends ContentNavigator {
|
|
2307
|
-
config;
|
|
2308
|
-
_strategyData;
|
|
2309
|
-
/** Human-readable name for CardFilter interface */
|
|
2310
|
-
name;
|
|
2311
|
-
constructor(user, course, _strategyData) {
|
|
2312
|
-
super(user, course, _strategyData);
|
|
2313
|
-
this._strategyData = _strategyData;
|
|
2314
|
-
this.config = this.parseConfig(_strategyData.serializedData);
|
|
2315
|
-
this.name = _strategyData.name || "Relative Priority";
|
|
2316
|
-
}
|
|
2317
|
-
parseConfig(serializedData) {
|
|
2318
|
-
try {
|
|
2319
|
-
const parsed = JSON.parse(serializedData);
|
|
2320
|
-
return {
|
|
2321
|
-
tagPriorities: parsed.tagPriorities || {},
|
|
2322
|
-
defaultPriority: parsed.defaultPriority ?? DEFAULT_PRIORITY,
|
|
2323
|
-
combineMode: parsed.combineMode ?? DEFAULT_COMBINE_MODE,
|
|
2324
|
-
priorityInfluence: parsed.priorityInfluence ?? DEFAULT_PRIORITY_INFLUENCE
|
|
2325
|
-
};
|
|
2326
|
-
} catch {
|
|
2327
|
-
return {
|
|
2328
|
-
tagPriorities: {},
|
|
2329
|
-
defaultPriority: DEFAULT_PRIORITY,
|
|
2330
|
-
combineMode: DEFAULT_COMBINE_MODE,
|
|
2331
|
-
priorityInfluence: DEFAULT_PRIORITY_INFLUENCE
|
|
2332
|
-
};
|
|
2333
|
-
}
|
|
2334
|
-
}
|
|
2335
|
-
/**
|
|
2336
|
-
* Look up the priority for a tag.
|
|
2337
|
-
*/
|
|
2338
|
-
getTagPriority(tagId) {
|
|
2339
|
-
return this.config.tagPriorities[tagId] ?? this.config.defaultPriority ?? DEFAULT_PRIORITY;
|
|
2340
|
-
}
|
|
2341
|
-
/**
|
|
2342
|
-
* Compute combined priority for a card based on its tags.
|
|
2343
|
-
*/
|
|
2344
|
-
computeCardPriority(cardTags) {
|
|
2345
|
-
if (cardTags.length === 0) {
|
|
2346
|
-
return this.config.defaultPriority ?? DEFAULT_PRIORITY;
|
|
2347
|
-
}
|
|
2348
|
-
const priorities = cardTags.map((tag) => this.getTagPriority(tag));
|
|
2349
|
-
switch (this.config.combineMode) {
|
|
2350
|
-
case "max":
|
|
2351
|
-
return Math.max(...priorities);
|
|
2352
|
-
case "min":
|
|
2353
|
-
return Math.min(...priorities);
|
|
2354
|
-
case "average":
|
|
2355
|
-
return priorities.reduce((sum, p) => sum + p, 0) / priorities.length;
|
|
2356
|
-
default:
|
|
2357
|
-
return Math.max(...priorities);
|
|
2358
|
-
}
|
|
2359
|
-
}
|
|
2360
|
-
/**
|
|
2361
|
-
* Compute boost factor based on priority.
|
|
2362
|
-
*
|
|
2363
|
-
* The formula: 1 + (priority - 0.5) * priorityInfluence
|
|
2364
|
-
*
|
|
2365
|
-
* This creates a multiplier centered around 1.0:
|
|
2366
|
-
* - Priority 1.0 with influence 0.5 → 1.25 (25% boost)
|
|
2367
|
-
* - Priority 0.5 with any influence → 1.00 (neutral)
|
|
2368
|
-
* - Priority 0.0 with influence 0.5 → 0.75 (25% reduction)
|
|
2369
|
-
*/
|
|
2370
|
-
computeBoostFactor(priority) {
|
|
2371
|
-
const influence = this.config.priorityInfluence ?? DEFAULT_PRIORITY_INFLUENCE;
|
|
2372
|
-
return 1 + (priority - 0.5) * influence;
|
|
2373
|
-
}
|
|
2374
|
-
/**
|
|
2375
|
-
* Build human-readable reason for priority adjustment.
|
|
2376
|
-
*/
|
|
2377
|
-
buildPriorityReason(cardTags, priority, boostFactor, finalScore) {
|
|
2378
|
-
if (cardTags.length === 0) {
|
|
2379
|
-
return `No tags, neutral priority (${priority.toFixed(2)})`;
|
|
2380
|
-
}
|
|
2381
|
-
const tagList = cardTags.slice(0, 3).join(", ");
|
|
2382
|
-
const more = cardTags.length > 3 ? ` (+${cardTags.length - 3} more)` : "";
|
|
2383
|
-
if (boostFactor === 1) {
|
|
2384
|
-
return `Neutral priority (${priority.toFixed(2)}) for tags: ${tagList}${more}`;
|
|
2385
|
-
} else if (boostFactor > 1) {
|
|
2386
|
-
return `High-priority tags: ${tagList}${more} (priority ${priority.toFixed(2)} \u2192 boost ${boostFactor.toFixed(2)}x \u2192 ${finalScore.toFixed(2)})`;
|
|
2387
|
-
} else {
|
|
2388
|
-
return `Low-priority tags: ${tagList}${more} (priority ${priority.toFixed(2)} \u2192 reduce ${boostFactor.toFixed(2)}x \u2192 ${finalScore.toFixed(2)})`;
|
|
2389
|
-
}
|
|
2390
|
-
}
|
|
2391
|
-
/**
|
|
2392
|
-
* CardFilter.transform implementation.
|
|
2393
|
-
*
|
|
2394
|
-
* Apply priority-adjusted scoring. Cards with high-priority tags get boosted,
|
|
2395
|
-
* cards with low-priority tags get reduced scores.
|
|
2396
|
-
*/
|
|
2397
|
-
async transform(cards, _context) {
|
|
2398
|
-
const adjusted = await Promise.all(
|
|
2399
|
-
cards.map(async (card) => {
|
|
2400
|
-
const cardTags = card.tags ?? [];
|
|
2401
|
-
const priority = this.computeCardPriority(cardTags);
|
|
2402
|
-
const boostFactor = this.computeBoostFactor(priority);
|
|
2403
|
-
const finalScore = Math.max(0, Math.min(1, card.score * boostFactor));
|
|
2404
|
-
const action = boostFactor > 1 ? "boosted" : boostFactor < 1 ? "penalized" : "passed";
|
|
2405
|
-
const reason = this.buildPriorityReason(cardTags, priority, boostFactor, finalScore);
|
|
2406
|
-
return {
|
|
2407
|
-
...card,
|
|
2408
|
-
score: finalScore,
|
|
2409
|
-
provenance: [
|
|
2410
|
-
...card.provenance,
|
|
2411
|
-
{
|
|
2412
|
-
strategy: "relativePriority",
|
|
2413
|
-
strategyName: this.strategyName || this.name,
|
|
2414
|
-
strategyId: this.strategyId || "NAVIGATION_STRATEGY-priority",
|
|
2415
|
-
action,
|
|
2416
|
-
score: finalScore,
|
|
2417
|
-
reason
|
|
2418
|
-
}
|
|
2419
|
-
]
|
|
2420
|
-
};
|
|
2421
|
-
})
|
|
2422
|
-
);
|
|
2423
|
-
return adjusted;
|
|
2424
|
-
}
|
|
2425
|
-
/**
|
|
2426
|
-
* Legacy getWeightedCards - now throws as filters should not be used as generators.
|
|
2427
|
-
*
|
|
2428
|
-
* Use transform() via Pipeline instead.
|
|
2429
|
-
*/
|
|
2430
|
-
async getWeightedCards(_limit) {
|
|
2431
|
-
throw new Error(
|
|
2432
|
-
"RelativePriorityNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
|
|
2433
|
-
);
|
|
2434
|
-
}
|
|
2435
|
-
// Legacy methods - stub implementations since filters don't generate cards
|
|
2436
|
-
async getNewCards(_n) {
|
|
2437
|
-
return [];
|
|
2438
|
-
}
|
|
2439
|
-
async getPendingReviews() {
|
|
2440
|
-
return [];
|
|
2441
|
-
}
|
|
2442
|
-
};
|
|
2443
|
-
}
|
|
2444
|
-
});
|
|
2445
|
-
|
|
2446
|
-
// src/core/navigators/srs.ts
|
|
2447
|
-
var srs_exports = {};
|
|
2448
|
-
__export(srs_exports, {
|
|
2449
|
-
default: () => SRSNavigator
|
|
2450
|
-
});
|
|
2451
|
-
import moment3 from "moment";
|
|
2452
|
-
var SRSNavigator;
|
|
2453
|
-
var init_srs = __esm({
|
|
2454
|
-
"src/core/navigators/srs.ts"() {
|
|
1511
|
+
// src/core/navigators/generators/srs.ts
|
|
1512
|
+
import moment3 from "moment";
|
|
1513
|
+
var SRSNavigator;
|
|
1514
|
+
var init_srs = __esm({
|
|
1515
|
+
"src/core/navigators/generators/srs.ts"() {
|
|
2455
1516
|
"use strict";
|
|
2456
1517
|
init_navigators();
|
|
1518
|
+
init_logger();
|
|
2457
1519
|
SRSNavigator = class extends ContentNavigator {
|
|
2458
1520
|
/** Human-readable name for CardGenerator interface */
|
|
2459
1521
|
name;
|
|
@@ -2489,6 +1551,7 @@ var init_srs = __esm({
|
|
|
2489
1551
|
cardId: review.cardId,
|
|
2490
1552
|
courseId: review.courseId,
|
|
2491
1553
|
score,
|
|
1554
|
+
reviewID: review._id,
|
|
2492
1555
|
provenance: [
|
|
2493
1556
|
{
|
|
2494
1557
|
strategy: "srs",
|
|
@@ -2501,6 +1564,7 @@ var init_srs = __esm({
|
|
|
2501
1564
|
]
|
|
2502
1565
|
};
|
|
2503
1566
|
});
|
|
1567
|
+
logger.debug(`[srsNav] got ${scored.length} weighted cards`);
|
|
2504
1568
|
return scored.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
2505
1569
|
}
|
|
2506
1570
|
/**
|
|
@@ -2526,314 +1590,117 @@ var init_srs = __esm({
|
|
|
2526
1590
|
const hoursOverdue = now.diff(due, "hours");
|
|
2527
1591
|
const relativeOverdue = hoursOverdue / intervalHours;
|
|
2528
1592
|
const recencyFactor = 0.3 + 0.7 * Math.exp(-intervalHours / 720);
|
|
2529
|
-
const overdueContribution = Math.min(1, Math.max(0, relativeOverdue));
|
|
2530
|
-
const urgency = overdueContribution * 0.5 + recencyFactor * 0.5;
|
|
2531
|
-
const score = Math.min(0.95, 0.5 + urgency * 0.45);
|
|
2532
|
-
const reason = `${Math.round(hoursOverdue)}h overdue (interval: ${Math.round(intervalHours)}h, relative: ${relativeOverdue.toFixed(2)}), recency: ${recencyFactor.toFixed(2)}, review`;
|
|
2533
|
-
return { score, reason };
|
|
2534
|
-
}
|
|
2535
|
-
/**
|
|
2536
|
-
* Get pending reviews in legacy format.
|
|
2537
|
-
*
|
|
2538
|
-
* Returns all pending reviews for the course, enriched with session item fields.
|
|
2539
|
-
*/
|
|
2540
|
-
async getPendingReviews() {
|
|
2541
|
-
if (!this.user || !this.course) {
|
|
2542
|
-
throw new Error("SRSNavigator requires user and course to be set");
|
|
2543
|
-
}
|
|
2544
|
-
const reviews = await this.user.getPendingReviews(this.course.getCourseID());
|
|
2545
|
-
return reviews.map((r) => ({
|
|
2546
|
-
...r,
|
|
2547
|
-
contentSourceType: "course",
|
|
2548
|
-
contentSourceID: this.course.getCourseID(),
|
|
2549
|
-
cardID: r.cardId,
|
|
2550
|
-
courseID: r.courseId,
|
|
2551
|
-
qualifiedID: `${r.courseId}-${r.cardId}`,
|
|
2552
|
-
reviewID: r._id,
|
|
2553
|
-
status: "review"
|
|
2554
|
-
}));
|
|
2555
|
-
}
|
|
2556
|
-
/**
|
|
2557
|
-
* SRS does not generate new cards.
|
|
2558
|
-
* Use ELONavigator or another generator for new cards.
|
|
2559
|
-
*/
|
|
2560
|
-
async getNewCards(_n) {
|
|
2561
|
-
return [];
|
|
2562
|
-
}
|
|
2563
|
-
};
|
|
2564
|
-
}
|
|
2565
|
-
});
|
|
2566
|
-
|
|
2567
|
-
// src/core/navigators/userGoal.ts
|
|
2568
|
-
var userGoal_exports = {};
|
|
2569
|
-
__export(userGoal_exports, {
|
|
2570
|
-
USER_GOAL_NAVIGATOR_STUB: () => USER_GOAL_NAVIGATOR_STUB
|
|
2571
|
-
});
|
|
2572
|
-
var USER_GOAL_NAVIGATOR_STUB;
|
|
2573
|
-
var init_userGoal = __esm({
|
|
2574
|
-
"src/core/navigators/userGoal.ts"() {
|
|
2575
|
-
"use strict";
|
|
2576
|
-
USER_GOAL_NAVIGATOR_STUB = true;
|
|
2577
|
-
}
|
|
2578
|
-
});
|
|
2579
|
-
|
|
2580
|
-
// import("./**/*") in src/core/navigators/index.ts
|
|
2581
|
-
var globImport;
|
|
2582
|
-
var init_ = __esm({
|
|
2583
|
-
'import("./**/*") in src/core/navigators/index.ts'() {
|
|
2584
|
-
globImport = __glob({
|
|
2585
|
-
"./CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
|
|
2586
|
-
"./Pipeline.ts": () => Promise.resolve().then(() => (init_Pipeline(), Pipeline_exports)),
|
|
2587
|
-
"./PipelineAssembler.ts": () => Promise.resolve().then(() => (init_PipelineAssembler(), PipelineAssembler_exports)),
|
|
2588
|
-
"./elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
|
|
2589
|
-
"./filters/eloDistance.ts": () => Promise.resolve().then(() => (init_eloDistance(), eloDistance_exports)),
|
|
2590
|
-
"./filters/index.ts": () => Promise.resolve().then(() => (init_filters(), filters_exports)),
|
|
2591
|
-
"./filters/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports)),
|
|
2592
|
-
"./filters/userTagPreference.ts": () => Promise.resolve().then(() => (init_userTagPreference(), userTagPreference_exports)),
|
|
2593
|
-
"./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
|
|
2594
|
-
"./generators/types.ts": () => Promise.resolve().then(() => (init_types2(), types_exports2)),
|
|
2595
|
-
"./hardcodedOrder.ts": () => Promise.resolve().then(() => (init_hardcodedOrder(), hardcodedOrder_exports)),
|
|
2596
|
-
"./hierarchyDefinition.ts": () => Promise.resolve().then(() => (init_hierarchyDefinition(), hierarchyDefinition_exports)),
|
|
2597
|
-
"./index.ts": () => Promise.resolve().then(() => (init_navigators(), navigators_exports)),
|
|
2598
|
-
"./inferredPreference.ts": () => Promise.resolve().then(() => (init_inferredPreference(), inferredPreference_exports)),
|
|
2599
|
-
"./interferenceMitigator.ts": () => Promise.resolve().then(() => (init_interferenceMitigator(), interferenceMitigator_exports)),
|
|
2600
|
-
"./relativePriority.ts": () => Promise.resolve().then(() => (init_relativePriority(), relativePriority_exports)),
|
|
2601
|
-
"./srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
|
|
2602
|
-
"./userGoal.ts": () => Promise.resolve().then(() => (init_userGoal(), userGoal_exports))
|
|
2603
|
-
});
|
|
2604
|
-
}
|
|
2605
|
-
});
|
|
2606
|
-
|
|
2607
|
-
// src/core/navigators/index.ts
|
|
2608
|
-
var navigators_exports = {};
|
|
2609
|
-
__export(navigators_exports, {
|
|
2610
|
-
ContentNavigator: () => ContentNavigator,
|
|
2611
|
-
NavigatorRole: () => NavigatorRole,
|
|
2612
|
-
NavigatorRoles: () => NavigatorRoles,
|
|
2613
|
-
Navigators: () => Navigators,
|
|
2614
|
-
getCardOrigin: () => getCardOrigin,
|
|
2615
|
-
isFilter: () => isFilter,
|
|
2616
|
-
isGenerator: () => isGenerator
|
|
2617
|
-
});
|
|
2618
|
-
function getCardOrigin(card) {
|
|
2619
|
-
if (card.provenance.length === 0) {
|
|
2620
|
-
throw new Error("Card has no provenance - cannot determine origin");
|
|
2621
|
-
}
|
|
2622
|
-
const firstEntry = card.provenance[0];
|
|
2623
|
-
const reason = firstEntry.reason.toLowerCase();
|
|
2624
|
-
if (reason.includes("failed")) {
|
|
2625
|
-
return "failed";
|
|
2626
|
-
}
|
|
2627
|
-
if (reason.includes("review")) {
|
|
2628
|
-
return "review";
|
|
2629
|
-
}
|
|
2630
|
-
return "new";
|
|
2631
|
-
}
|
|
2632
|
-
function isGenerator(impl) {
|
|
2633
|
-
return NavigatorRoles[impl] === "generator" /* GENERATOR */;
|
|
2634
|
-
}
|
|
2635
|
-
function isFilter(impl) {
|
|
2636
|
-
return NavigatorRoles[impl] === "filter" /* FILTER */;
|
|
2637
|
-
}
|
|
2638
|
-
var Navigators, NavigatorRole, NavigatorRoles, ContentNavigator;
|
|
2639
|
-
var init_navigators = __esm({
|
|
2640
|
-
"src/core/navigators/index.ts"() {
|
|
2641
|
-
"use strict";
|
|
2642
|
-
init_logger();
|
|
2643
|
-
init_();
|
|
2644
|
-
Navigators = /* @__PURE__ */ ((Navigators2) => {
|
|
2645
|
-
Navigators2["ELO"] = "elo";
|
|
2646
|
-
Navigators2["SRS"] = "srs";
|
|
2647
|
-
Navigators2["HARDCODED"] = "hardcodedOrder";
|
|
2648
|
-
Navigators2["HIERARCHY"] = "hierarchyDefinition";
|
|
2649
|
-
Navigators2["INTERFERENCE"] = "interferenceMitigator";
|
|
2650
|
-
Navigators2["RELATIVE_PRIORITY"] = "relativePriority";
|
|
2651
|
-
Navigators2["USER_TAG_PREFERENCE"] = "userTagPreference";
|
|
2652
|
-
return Navigators2;
|
|
2653
|
-
})(Navigators || {});
|
|
2654
|
-
NavigatorRole = /* @__PURE__ */ ((NavigatorRole2) => {
|
|
2655
|
-
NavigatorRole2["GENERATOR"] = "generator";
|
|
2656
|
-
NavigatorRole2["FILTER"] = "filter";
|
|
2657
|
-
return NavigatorRole2;
|
|
2658
|
-
})(NavigatorRole || {});
|
|
2659
|
-
NavigatorRoles = {
|
|
2660
|
-
["elo" /* ELO */]: "generator" /* GENERATOR */,
|
|
2661
|
-
["srs" /* SRS */]: "generator" /* GENERATOR */,
|
|
2662
|
-
["hardcodedOrder" /* HARDCODED */]: "generator" /* GENERATOR */,
|
|
2663
|
-
["hierarchyDefinition" /* HIERARCHY */]: "filter" /* FILTER */,
|
|
2664
|
-
["interferenceMitigator" /* INTERFERENCE */]: "filter" /* FILTER */,
|
|
2665
|
-
["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */,
|
|
2666
|
-
["userTagPreference" /* USER_TAG_PREFERENCE */]: "filter" /* FILTER */
|
|
2667
|
-
};
|
|
2668
|
-
ContentNavigator = class {
|
|
2669
|
-
/** User interface for this navigation session */
|
|
2670
|
-
user;
|
|
2671
|
-
/** Course interface for this navigation session */
|
|
2672
|
-
course;
|
|
2673
|
-
/** Human-readable name for this strategy instance (from ContentNavigationStrategyData.name) */
|
|
2674
|
-
strategyName;
|
|
2675
|
-
/** Unique document ID for this strategy instance (from ContentNavigationStrategyData._id) */
|
|
2676
|
-
strategyId;
|
|
2677
|
-
/**
|
|
2678
|
-
* Constructor for standard navigators.
|
|
2679
|
-
* Call this from subclass constructors to initialize common fields.
|
|
2680
|
-
*
|
|
2681
|
-
* Note: CompositeGenerator doesn't use this pattern and should call super() without args.
|
|
2682
|
-
*/
|
|
2683
|
-
constructor(user, course, strategyData) {
|
|
2684
|
-
if (user && course && strategyData) {
|
|
2685
|
-
this.user = user;
|
|
2686
|
-
this.course = course;
|
|
2687
|
-
this.strategyName = strategyData.name;
|
|
2688
|
-
this.strategyId = strategyData._id;
|
|
2689
|
-
}
|
|
2690
|
-
}
|
|
2691
|
-
// ============================================================================
|
|
2692
|
-
// STRATEGY STATE HELPERS
|
|
2693
|
-
// ============================================================================
|
|
2694
|
-
//
|
|
2695
|
-
// These methods allow strategies to persist their own state (user preferences,
|
|
2696
|
-
// learned patterns, temporal tracking) in the user database.
|
|
2697
|
-
//
|
|
2698
|
-
// ============================================================================
|
|
2699
|
-
/**
|
|
2700
|
-
* Unique key identifying this strategy for state storage.
|
|
2701
|
-
*
|
|
2702
|
-
* Defaults to the constructor name (e.g., "UserTagPreferenceFilter").
|
|
2703
|
-
* Override in subclasses if multiple instances of the same strategy type
|
|
2704
|
-
* need separate state storage.
|
|
2705
|
-
*/
|
|
2706
|
-
get strategyKey() {
|
|
2707
|
-
return this.constructor.name;
|
|
2708
|
-
}
|
|
2709
|
-
/**
|
|
2710
|
-
* Get this strategy's persisted state for the current course.
|
|
2711
|
-
*
|
|
2712
|
-
* @returns The strategy's data payload, or null if no state exists
|
|
2713
|
-
* @throws Error if user or course is not initialized
|
|
2714
|
-
*/
|
|
2715
|
-
async getStrategyState() {
|
|
2716
|
-
if (!this.user || !this.course) {
|
|
2717
|
-
throw new Error(
|
|
2718
|
-
`Cannot get strategy state: navigator not properly initialized. Ensure user and course are provided to constructor.`
|
|
2719
|
-
);
|
|
2720
|
-
}
|
|
2721
|
-
return this.user.getStrategyState(this.course.getCourseID(), this.strategyKey);
|
|
2722
|
-
}
|
|
2723
|
-
/**
|
|
2724
|
-
* Persist this strategy's state for the current course.
|
|
2725
|
-
*
|
|
2726
|
-
* @param data - The strategy's data payload to store
|
|
2727
|
-
* @throws Error if user or course is not initialized
|
|
2728
|
-
*/
|
|
2729
|
-
async putStrategyState(data) {
|
|
2730
|
-
if (!this.user || !this.course) {
|
|
2731
|
-
throw new Error(
|
|
2732
|
-
`Cannot put strategy state: navigator not properly initialized. Ensure user and course are provided to constructor.`
|
|
2733
|
-
);
|
|
2734
|
-
}
|
|
2735
|
-
return this.user.putStrategyState(this.course.getCourseID(), this.strategyKey, data);
|
|
2736
|
-
}
|
|
2737
|
-
/**
|
|
2738
|
-
* Factory method to create navigator instances dynamically.
|
|
2739
|
-
*
|
|
2740
|
-
* @param user - User interface
|
|
2741
|
-
* @param course - Course interface
|
|
2742
|
-
* @param strategyData - Strategy configuration document
|
|
2743
|
-
* @returns the runtime object used to steer a study session.
|
|
2744
|
-
*/
|
|
2745
|
-
static async create(user, course, strategyData) {
|
|
2746
|
-
const implementingClass = strategyData.implementingClass;
|
|
2747
|
-
let NavigatorImpl;
|
|
2748
|
-
const variations = [".ts", ".js", ""];
|
|
2749
|
-
for (const ext of variations) {
|
|
2750
|
-
try {
|
|
2751
|
-
const module = await globImport(`./${implementingClass}${ext}`);
|
|
2752
|
-
NavigatorImpl = module.default;
|
|
2753
|
-
break;
|
|
2754
|
-
} catch (e) {
|
|
2755
|
-
logger.debug(`Failed to load with extension ${ext}:`, e);
|
|
2756
|
-
}
|
|
2757
|
-
}
|
|
2758
|
-
if (!NavigatorImpl) {
|
|
2759
|
-
throw new Error(`Could not load navigator implementation for: ${implementingClass}`);
|
|
2760
|
-
}
|
|
2761
|
-
return new NavigatorImpl(user, course, strategyData);
|
|
2762
|
-
}
|
|
2763
|
-
/**
|
|
2764
|
-
* Get cards with suitability scores and provenance trails.
|
|
2765
|
-
*
|
|
2766
|
-
* **This is the PRIMARY API for navigation strategies.**
|
|
2767
|
-
*
|
|
2768
|
-
* Returns cards ranked by suitability score (0-1). Higher scores indicate
|
|
2769
|
-
* better candidates for presentation. Each card includes a provenance trail
|
|
2770
|
-
* documenting how strategies contributed to the final score.
|
|
2771
|
-
*
|
|
2772
|
-
* ## For Generators
|
|
2773
|
-
* Override this method to generate candidates and compute scores based on
|
|
2774
|
-
* your strategy's logic (e.g., ELO proximity, review urgency). Create the
|
|
2775
|
-
* initial provenance entry with action='generated'.
|
|
2776
|
-
*
|
|
2777
|
-
* ## Default Implementation
|
|
2778
|
-
* The base class provides a backward-compatible default that:
|
|
2779
|
-
* 1. Calls legacy getNewCards() and getPendingReviews()
|
|
2780
|
-
* 2. Assigns score=1.0 to all cards
|
|
2781
|
-
* 3. Creates minimal provenance from legacy methods
|
|
2782
|
-
* 4. Returns combined results up to limit
|
|
2783
|
-
*
|
|
2784
|
-
* This allows existing strategies to work without modification while
|
|
2785
|
-
* new strategies can override with proper scoring and provenance.
|
|
2786
|
-
*
|
|
2787
|
-
* @param limit - Maximum cards to return
|
|
2788
|
-
* @returns Cards sorted by score descending, with provenance trails
|
|
2789
|
-
*/
|
|
2790
|
-
async getWeightedCards(limit) {
|
|
2791
|
-
const newCards = await this.getNewCards(limit);
|
|
2792
|
-
const reviews = await this.getPendingReviews();
|
|
2793
|
-
const weighted = [
|
|
2794
|
-
...newCards.map((c) => ({
|
|
2795
|
-
cardId: c.cardID,
|
|
2796
|
-
courseId: c.courseID,
|
|
2797
|
-
score: 1,
|
|
2798
|
-
provenance: [
|
|
2799
|
-
{
|
|
2800
|
-
strategy: "legacy",
|
|
2801
|
-
strategyName: this.strategyName || "Legacy API",
|
|
2802
|
-
strategyId: this.strategyId || "legacy-fallback",
|
|
2803
|
-
action: "generated",
|
|
2804
|
-
score: 1,
|
|
2805
|
-
reason: "Generated via legacy getNewCards(), new card"
|
|
2806
|
-
}
|
|
2807
|
-
]
|
|
2808
|
-
})),
|
|
2809
|
-
...reviews.map((r) => ({
|
|
2810
|
-
cardId: r.cardID,
|
|
2811
|
-
courseId: r.courseID,
|
|
2812
|
-
score: 1,
|
|
2813
|
-
provenance: [
|
|
2814
|
-
{
|
|
2815
|
-
strategy: "legacy",
|
|
2816
|
-
strategyName: this.strategyName || "Legacy API",
|
|
2817
|
-
strategyId: this.strategyId || "legacy-fallback",
|
|
2818
|
-
action: "generated",
|
|
2819
|
-
score: 1,
|
|
2820
|
-
reason: "Generated via legacy getPendingReviews(), review"
|
|
2821
|
-
}
|
|
2822
|
-
]
|
|
2823
|
-
}))
|
|
2824
|
-
];
|
|
2825
|
-
return weighted.slice(0, limit);
|
|
1593
|
+
const overdueContribution = Math.min(1, Math.max(0, relativeOverdue));
|
|
1594
|
+
const urgency = overdueContribution * 0.5 + recencyFactor * 0.5;
|
|
1595
|
+
const score = Math.min(0.95, 0.5 + urgency * 0.45);
|
|
1596
|
+
const reason = `${Math.round(hoursOverdue)}h overdue (interval: ${Math.round(intervalHours)}h, relative: ${relativeOverdue.toFixed(2)}), recency: ${recencyFactor.toFixed(2)}, review`;
|
|
1597
|
+
return { score, reason };
|
|
2826
1598
|
}
|
|
2827
1599
|
};
|
|
2828
1600
|
}
|
|
2829
1601
|
});
|
|
2830
1602
|
|
|
1603
|
+
// src/core/navigators/filters/eloDistance.ts
|
|
1604
|
+
function computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier) {
|
|
1605
|
+
const normalizedDistance = distance / halfLife;
|
|
1606
|
+
const decay = Math.exp(-(normalizedDistance * normalizedDistance));
|
|
1607
|
+
return minMultiplier + (maxMultiplier - minMultiplier) * decay;
|
|
1608
|
+
}
|
|
1609
|
+
function createEloDistanceFilter(config) {
|
|
1610
|
+
const halfLife = config?.halfLife ?? DEFAULT_HALF_LIFE;
|
|
1611
|
+
const minMultiplier = config?.minMultiplier ?? DEFAULT_MIN_MULTIPLIER;
|
|
1612
|
+
const maxMultiplier = config?.maxMultiplier ?? DEFAULT_MAX_MULTIPLIER;
|
|
1613
|
+
return {
|
|
1614
|
+
name: "ELO Distance Filter",
|
|
1615
|
+
async transform(cards, context) {
|
|
1616
|
+
const { course, userElo } = context;
|
|
1617
|
+
const cardIds = cards.map((c) => c.cardId);
|
|
1618
|
+
const cardElos = await course.getCardEloData(cardIds);
|
|
1619
|
+
return cards.map((card, i) => {
|
|
1620
|
+
const cardElo = cardElos[i]?.global?.score ?? 1e3;
|
|
1621
|
+
const distance = Math.abs(cardElo - userElo);
|
|
1622
|
+
const multiplier = computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier);
|
|
1623
|
+
const newScore = card.score * multiplier;
|
|
1624
|
+
const action = multiplier < maxMultiplier - 0.01 ? "penalized" : "passed";
|
|
1625
|
+
return {
|
|
1626
|
+
...card,
|
|
1627
|
+
score: newScore,
|
|
1628
|
+
provenance: [
|
|
1629
|
+
...card.provenance,
|
|
1630
|
+
{
|
|
1631
|
+
strategy: "eloDistance",
|
|
1632
|
+
strategyName: "ELO Distance Filter",
|
|
1633
|
+
strategyId: "ELO_DISTANCE_FILTER",
|
|
1634
|
+
action,
|
|
1635
|
+
score: newScore,
|
|
1636
|
+
reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userElo)}) \u2192 ${multiplier.toFixed(2)}x`
|
|
1637
|
+
}
|
|
1638
|
+
]
|
|
1639
|
+
};
|
|
1640
|
+
});
|
|
1641
|
+
}
|
|
1642
|
+
};
|
|
1643
|
+
}
|
|
1644
|
+
var DEFAULT_HALF_LIFE, DEFAULT_MIN_MULTIPLIER, DEFAULT_MAX_MULTIPLIER;
|
|
1645
|
+
var init_eloDistance = __esm({
|
|
1646
|
+
"src/core/navigators/filters/eloDistance.ts"() {
|
|
1647
|
+
"use strict";
|
|
1648
|
+
DEFAULT_HALF_LIFE = 200;
|
|
1649
|
+
DEFAULT_MIN_MULTIPLIER = 0.3;
|
|
1650
|
+
DEFAULT_MAX_MULTIPLIER = 1;
|
|
1651
|
+
}
|
|
1652
|
+
});
|
|
1653
|
+
|
|
1654
|
+
// src/core/navigators/defaults.ts
|
|
1655
|
+
function createDefaultEloStrategy(courseId) {
|
|
1656
|
+
return {
|
|
1657
|
+
_id: "NAVIGATION_STRATEGY-ELO-default",
|
|
1658
|
+
docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
|
|
1659
|
+
name: "ELO (default)",
|
|
1660
|
+
description: "Default ELO-based navigation strategy for new cards",
|
|
1661
|
+
implementingClass: "elo" /* ELO */,
|
|
1662
|
+
course: courseId,
|
|
1663
|
+
serializedData: ""
|
|
1664
|
+
};
|
|
1665
|
+
}
|
|
1666
|
+
function createDefaultSrsStrategy(courseId) {
|
|
1667
|
+
return {
|
|
1668
|
+
_id: "NAVIGATION_STRATEGY-SRS-default",
|
|
1669
|
+
docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
|
|
1670
|
+
name: "SRS (default)",
|
|
1671
|
+
description: "Default SRS-based navigation strategy for reviews",
|
|
1672
|
+
implementingClass: "srs" /* SRS */,
|
|
1673
|
+
course: courseId,
|
|
1674
|
+
serializedData: ""
|
|
1675
|
+
};
|
|
1676
|
+
}
|
|
1677
|
+
function createDefaultPipeline(user, course) {
|
|
1678
|
+
const courseId = course.getCourseID();
|
|
1679
|
+
const eloNavigator = new ELONavigator(user, course, createDefaultEloStrategy(courseId));
|
|
1680
|
+
const srsNavigator = new SRSNavigator(user, course, createDefaultSrsStrategy(courseId));
|
|
1681
|
+
const compositeGenerator = new CompositeGenerator([eloNavigator, srsNavigator]);
|
|
1682
|
+
const eloDistanceFilter = createEloDistanceFilter();
|
|
1683
|
+
return new Pipeline(compositeGenerator, [eloDistanceFilter], user, course);
|
|
1684
|
+
}
|
|
1685
|
+
var init_defaults = __esm({
|
|
1686
|
+
"src/core/navigators/defaults.ts"() {
|
|
1687
|
+
"use strict";
|
|
1688
|
+
init_navigators();
|
|
1689
|
+
init_Pipeline();
|
|
1690
|
+
init_CompositeGenerator();
|
|
1691
|
+
init_elo();
|
|
1692
|
+
init_srs();
|
|
1693
|
+
init_eloDistance();
|
|
1694
|
+
init_types_legacy();
|
|
1695
|
+
}
|
|
1696
|
+
});
|
|
1697
|
+
|
|
2831
1698
|
// src/impl/couch/courseDB.ts
|
|
2832
1699
|
import {
|
|
2833
1700
|
EloToNumber,
|
|
2834
1701
|
Status,
|
|
2835
1702
|
blankCourseElo as blankCourseElo2,
|
|
2836
|
-
toCourseElo as
|
|
1703
|
+
toCourseElo as toCourseElo4
|
|
2837
1704
|
} from "@vue-skuilder/common";
|
|
2838
1705
|
function randIntWeightedTowardZero(n) {
|
|
2839
1706
|
return Math.floor(Math.random() * Math.random() * Math.random() * n);
|
|
@@ -2922,12 +1789,8 @@ var init_courseDB = __esm({
|
|
|
2922
1789
|
init_courseAPI();
|
|
2923
1790
|
init_courseLookupDB();
|
|
2924
1791
|
init_navigators();
|
|
2925
|
-
init_Pipeline();
|
|
2926
1792
|
init_PipelineAssembler();
|
|
2927
|
-
|
|
2928
|
-
init_elo();
|
|
2929
|
-
init_srs();
|
|
2930
|
-
init_eloDistance();
|
|
1793
|
+
init_defaults();
|
|
2931
1794
|
CoursesDB = class {
|
|
2932
1795
|
_courseIDs;
|
|
2933
1796
|
constructor(courseIDs) {
|
|
@@ -3039,7 +1902,7 @@ var init_courseDB = __esm({
|
|
|
3039
1902
|
docs.rows.forEach((r) => {
|
|
3040
1903
|
if (isSuccessRow(r)) {
|
|
3041
1904
|
if (r.doc && r.doc.elo) {
|
|
3042
|
-
ret.push(
|
|
1905
|
+
ret.push(toCourseElo4(r.doc.elo));
|
|
3043
1906
|
} else {
|
|
3044
1907
|
logger.warn("no elo data for card: " + r.id);
|
|
3045
1908
|
ret.push(blankCourseElo2());
|
|
@@ -3341,7 +2204,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3341
2204
|
logger.debug(
|
|
3342
2205
|
"[courseDB] No strategy documents found, using default Pipeline(Composite(ELO, SRS), [eloDistanceFilter])"
|
|
3343
2206
|
);
|
|
3344
|
-
return
|
|
2207
|
+
return createDefaultPipeline(user, this);
|
|
3345
2208
|
}
|
|
3346
2209
|
const assembler = new PipelineAssembler();
|
|
3347
2210
|
const { pipeline, generatorStrategies, filterStrategies, warnings } = await assembler.assemble({
|
|
@@ -3354,7 +2217,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3354
2217
|
}
|
|
3355
2218
|
if (!pipeline) {
|
|
3356
2219
|
logger.debug("[courseDB] Pipeline assembly failed, using default pipeline");
|
|
3357
|
-
return
|
|
2220
|
+
return createDefaultPipeline(user, this);
|
|
3358
2221
|
}
|
|
3359
2222
|
logger.debug(
|
|
3360
2223
|
`[courseDB] Using assembled pipeline with ${generatorStrategies.length} generator(s) and ${filterStrategies.length} filter(s)`
|
|
@@ -3365,69 +2228,12 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3365
2228
|
throw e;
|
|
3366
2229
|
}
|
|
3367
2230
|
}
|
|
3368
|
-
makeDefaultEloStrategy() {
|
|
3369
|
-
return {
|
|
3370
|
-
_id: "NAVIGATION_STRATEGY-ELO-default",
|
|
3371
|
-
docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
|
|
3372
|
-
name: "ELO (default)",
|
|
3373
|
-
description: "Default ELO-based navigation strategy for new cards",
|
|
3374
|
-
implementingClass: "elo" /* ELO */,
|
|
3375
|
-
course: this.id,
|
|
3376
|
-
serializedData: ""
|
|
3377
|
-
};
|
|
3378
|
-
}
|
|
3379
|
-
makeDefaultSrsStrategy() {
|
|
3380
|
-
return {
|
|
3381
|
-
_id: "NAVIGATION_STRATEGY-SRS-default",
|
|
3382
|
-
docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
|
|
3383
|
-
name: "SRS (default)",
|
|
3384
|
-
description: "Default SRS-based navigation strategy for reviews",
|
|
3385
|
-
implementingClass: "srs" /* SRS */,
|
|
3386
|
-
course: this.id,
|
|
3387
|
-
serializedData: ""
|
|
3388
|
-
};
|
|
3389
|
-
}
|
|
3390
|
-
/**
|
|
3391
|
-
* Creates the default navigation pipeline for courses with no configured strategies.
|
|
3392
|
-
*
|
|
3393
|
-
* Default: Pipeline(Composite(ELO, SRS), [eloDistanceFilter])
|
|
3394
|
-
* - ELO generator: scores new cards by skill proximity
|
|
3395
|
-
* - SRS generator: scores reviews by overdueness and interval recency
|
|
3396
|
-
* - ELO distance filter: penalizes cards far from user's current level
|
|
3397
|
-
*/
|
|
3398
|
-
createDefaultPipeline(user) {
|
|
3399
|
-
const eloNavigator = new ELONavigator(user, this, this.makeDefaultEloStrategy());
|
|
3400
|
-
const srsNavigator = new SRSNavigator(user, this, this.makeDefaultSrsStrategy());
|
|
3401
|
-
const compositeGenerator = new CompositeGenerator([eloNavigator, srsNavigator]);
|
|
3402
|
-
const eloDistanceFilter = createEloDistanceFilter();
|
|
3403
|
-
return new Pipeline(compositeGenerator, [eloDistanceFilter], user, this);
|
|
3404
|
-
}
|
|
3405
2231
|
////////////////////////////////////
|
|
3406
2232
|
// END NavigationStrategyManager implementation
|
|
3407
2233
|
////////////////////////////////////
|
|
3408
2234
|
////////////////////////////////////
|
|
3409
2235
|
// StudyContentSource implementation
|
|
3410
2236
|
////////////////////////////////////
|
|
3411
|
-
async getNewCards(limit = 99) {
|
|
3412
|
-
const u = await this._getCurrentUser();
|
|
3413
|
-
try {
|
|
3414
|
-
const navigator = await this.createNavigator(u);
|
|
3415
|
-
return navigator.getNewCards(limit);
|
|
3416
|
-
} catch (e) {
|
|
3417
|
-
logger.error(`[courseDB] Error in getNewCards: ${e}`);
|
|
3418
|
-
throw e;
|
|
3419
|
-
}
|
|
3420
|
-
}
|
|
3421
|
-
async getPendingReviews() {
|
|
3422
|
-
const u = await this._getCurrentUser();
|
|
3423
|
-
try {
|
|
3424
|
-
const navigator = await this.createNavigator(u);
|
|
3425
|
-
return navigator.getPendingReviews();
|
|
3426
|
-
} catch (e) {
|
|
3427
|
-
logger.error(`[courseDB] Error in getPendingReviews: ${e}`);
|
|
3428
|
-
throw e;
|
|
3429
|
-
}
|
|
3430
|
-
}
|
|
3431
2237
|
/**
|
|
3432
2238
|
* Get cards with suitability scores for presentation.
|
|
3433
2239
|
*
|
|
@@ -3667,79 +2473,27 @@ var init_classroomDB2 = __esm({
|
|
|
3667
2473
|
setChangeFcn(f) {
|
|
3668
2474
|
void this.userMessages.on("change", f);
|
|
3669
2475
|
}
|
|
3670
|
-
async getPendingReviews() {
|
|
3671
|
-
const u = this._user;
|
|
3672
|
-
return (await u.getPendingReviews()).filter((r) => r.scheduledFor === "classroom" && r.schedulingAgentId === this._id).map((r) => {
|
|
3673
|
-
return {
|
|
3674
|
-
...r,
|
|
3675
|
-
qualifiedID: `${r.courseId}-${r.cardId}`,
|
|
3676
|
-
courseID: r.courseId,
|
|
3677
|
-
cardID: r.cardId,
|
|
3678
|
-
contentSourceType: "classroom",
|
|
3679
|
-
contentSourceID: this._id,
|
|
3680
|
-
reviewID: r._id,
|
|
3681
|
-
status: "review"
|
|
3682
|
-
};
|
|
3683
|
-
});
|
|
3684
|
-
}
|
|
3685
|
-
async getNewCards() {
|
|
3686
|
-
const activeCards = await this._user.getActiveCards();
|
|
3687
|
-
const now = moment4.utc();
|
|
3688
|
-
const assigned = await this.getAssignedContent();
|
|
3689
|
-
const due = assigned.filter((c) => now.isAfter(moment4.utc(c.activeOn, REVIEW_TIME_FORMAT2)));
|
|
3690
|
-
logger.info(`Due content: ${JSON.stringify(due)}`);
|
|
3691
|
-
let ret = [];
|
|
3692
|
-
for (let i = 0; i < due.length; i++) {
|
|
3693
|
-
const content = due[i];
|
|
3694
|
-
if (content.type === "course") {
|
|
3695
|
-
const db = new CourseDB(content.courseID, async () => this._user);
|
|
3696
|
-
ret = ret.concat(await db.getNewCards());
|
|
3697
|
-
} else if (content.type === "tag") {
|
|
3698
|
-
const tagDoc = await getTag(content.courseID, content.tagID);
|
|
3699
|
-
ret = ret.concat(
|
|
3700
|
-
tagDoc.taggedCards.map((c) => {
|
|
3701
|
-
return {
|
|
3702
|
-
courseID: content.courseID,
|
|
3703
|
-
cardID: c,
|
|
3704
|
-
qualifiedID: `${content.courseID}-${c}`,
|
|
3705
|
-
contentSourceType: "classroom",
|
|
3706
|
-
contentSourceID: this._id,
|
|
3707
|
-
status: "new"
|
|
3708
|
-
};
|
|
3709
|
-
})
|
|
3710
|
-
);
|
|
3711
|
-
} else if (content.type === "card") {
|
|
3712
|
-
ret.push(await getCourseDB2(content.courseID).get(content.cardID));
|
|
3713
|
-
}
|
|
3714
|
-
}
|
|
3715
|
-
logger.info(
|
|
3716
|
-
`New Cards from classroom ${this._cfg.name}: ${ret.map((c) => `${c.courseID}-${c.cardID}`)}`
|
|
3717
|
-
);
|
|
3718
|
-
return ret.filter((c) => {
|
|
3719
|
-
if (activeCards.some((ac) => c.cardID === ac.cardID)) {
|
|
3720
|
-
return false;
|
|
3721
|
-
} else {
|
|
3722
|
-
return true;
|
|
3723
|
-
}
|
|
3724
|
-
});
|
|
3725
|
-
}
|
|
3726
2476
|
/**
|
|
3727
2477
|
* Get cards with suitability scores for presentation.
|
|
3728
2478
|
*
|
|
3729
|
-
*
|
|
3730
|
-
*
|
|
3731
|
-
* support pluggable navigation strategies.
|
|
2479
|
+
* Gathers new cards from assigned content (courses, tags, cards) and
|
|
2480
|
+
* pending reviews scheduled for this classroom. Assigns score=1.0 to all.
|
|
3732
2481
|
*
|
|
3733
2482
|
* @param limit - Maximum number of cards to return
|
|
3734
2483
|
* @returns Cards sorted by score descending (all scores = 1.0)
|
|
3735
2484
|
*/
|
|
3736
2485
|
async getWeightedCards(limit) {
|
|
3737
|
-
const
|
|
3738
|
-
const
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
2486
|
+
const weighted = [];
|
|
2487
|
+
const allUserReviews = await this._user.getPendingReviews();
|
|
2488
|
+
const classroomReviews = allUserReviews.filter(
|
|
2489
|
+
(r) => r.scheduledFor === "classroom" && r.schedulingAgentId === this._id
|
|
2490
|
+
);
|
|
2491
|
+
for (const r of classroomReviews) {
|
|
2492
|
+
weighted.push({
|
|
2493
|
+
cardId: r.cardId,
|
|
2494
|
+
courseId: r.courseId,
|
|
3742
2495
|
score: 1,
|
|
2496
|
+
reviewID: r._id,
|
|
3743
2497
|
provenance: [
|
|
3744
2498
|
{
|
|
3745
2499
|
strategy: "classroom",
|
|
@@ -3747,27 +2501,84 @@ var init_classroomDB2 = __esm({
|
|
|
3747
2501
|
strategyId: "CLASSROOM",
|
|
3748
2502
|
action: "generated",
|
|
3749
2503
|
score: 1,
|
|
3750
|
-
reason: "Classroom
|
|
2504
|
+
reason: "Classroom scheduled review"
|
|
3751
2505
|
}
|
|
3752
2506
|
]
|
|
3753
|
-
})
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
3765
|
-
|
|
2507
|
+
});
|
|
2508
|
+
}
|
|
2509
|
+
const activeCards = await this._user.getActiveCards();
|
|
2510
|
+
const activeCardIds = new Set(activeCards.map((ac) => ac.cardID));
|
|
2511
|
+
const now = moment4.utc();
|
|
2512
|
+
const assigned = await this.getAssignedContent();
|
|
2513
|
+
const due = assigned.filter((c) => now.isAfter(moment4.utc(c.activeOn, REVIEW_TIME_FORMAT2)));
|
|
2514
|
+
logger.info(`[StudentClassroomDB] Due content: ${JSON.stringify(due)}`);
|
|
2515
|
+
for (const content of due) {
|
|
2516
|
+
if (content.type === "course") {
|
|
2517
|
+
const db = new CourseDB(content.courseID, async () => this._user);
|
|
2518
|
+
const courseCards = await db.getWeightedCards(limit);
|
|
2519
|
+
for (const card of courseCards) {
|
|
2520
|
+
if (!activeCardIds.has(card.cardId)) {
|
|
2521
|
+
weighted.push({
|
|
2522
|
+
...card,
|
|
2523
|
+
provenance: [
|
|
2524
|
+
...card.provenance,
|
|
2525
|
+
{
|
|
2526
|
+
strategy: "classroom",
|
|
2527
|
+
strategyName: "Classroom",
|
|
2528
|
+
strategyId: "CLASSROOM",
|
|
2529
|
+
action: "passed",
|
|
2530
|
+
score: card.score,
|
|
2531
|
+
reason: `Assigned via classroom from course ${content.courseID}`
|
|
2532
|
+
}
|
|
2533
|
+
]
|
|
2534
|
+
});
|
|
3766
2535
|
}
|
|
3767
|
-
|
|
3768
|
-
})
|
|
3769
|
-
|
|
3770
|
-
|
|
2536
|
+
}
|
|
2537
|
+
} else if (content.type === "tag") {
|
|
2538
|
+
const tagDoc = await getTag(content.courseID, content.tagID);
|
|
2539
|
+
for (const cardId of tagDoc.taggedCards) {
|
|
2540
|
+
if (!activeCardIds.has(cardId)) {
|
|
2541
|
+
weighted.push({
|
|
2542
|
+
cardId,
|
|
2543
|
+
courseId: content.courseID,
|
|
2544
|
+
score: 1,
|
|
2545
|
+
provenance: [
|
|
2546
|
+
{
|
|
2547
|
+
strategy: "classroom",
|
|
2548
|
+
strategyName: "Classroom",
|
|
2549
|
+
strategyId: "CLASSROOM",
|
|
2550
|
+
action: "generated",
|
|
2551
|
+
score: 1,
|
|
2552
|
+
reason: `Classroom assigned tag: ${content.tagID}, new card`
|
|
2553
|
+
}
|
|
2554
|
+
]
|
|
2555
|
+
});
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
} else if (content.type === "card") {
|
|
2559
|
+
if (!activeCardIds.has(content.cardID)) {
|
|
2560
|
+
weighted.push({
|
|
2561
|
+
cardId: content.cardID,
|
|
2562
|
+
courseId: content.courseID,
|
|
2563
|
+
score: 1,
|
|
2564
|
+
provenance: [
|
|
2565
|
+
{
|
|
2566
|
+
strategy: "classroom",
|
|
2567
|
+
strategyName: "Classroom",
|
|
2568
|
+
strategyId: "CLASSROOM",
|
|
2569
|
+
action: "generated",
|
|
2570
|
+
score: 1,
|
|
2571
|
+
reason: "Classroom assigned card, new card"
|
|
2572
|
+
}
|
|
2573
|
+
]
|
|
2574
|
+
});
|
|
2575
|
+
}
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2578
|
+
logger.info(
|
|
2579
|
+
`[StudentClassroomDB] New cards from classroom ${this._cfg.name}: ${weighted.length} total (reviews + new)`
|
|
2580
|
+
);
|
|
2581
|
+
return weighted.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
3771
2582
|
}
|
|
3772
2583
|
};
|
|
3773
2584
|
TeacherClassroomDB = class _TeacherClassroomDB extends ClassroomDBBase {
|
|
@@ -4203,8 +3014,8 @@ import moment5 from "moment";
|
|
|
4203
3014
|
import process2 from "process";
|
|
4204
3015
|
function createPouchDBConfig() {
|
|
4205
3016
|
const hasExplicitCredentials = ENV.COUCHDB_USERNAME && ENV.COUCHDB_PASSWORD;
|
|
4206
|
-
const
|
|
4207
|
-
if (hasExplicitCredentials &&
|
|
3017
|
+
const isNodeEnvironment = typeof window === "undefined";
|
|
3018
|
+
if (hasExplicitCredentials && isNodeEnvironment) {
|
|
4208
3019
|
return {
|
|
4209
3020
|
fetch(url, opts = {}) {
|
|
4210
3021
|
const basicAuth = btoa(`${ENV.COUCHDB_USERNAME}:${ENV.COUCHDB_PASSWORD}`);
|
|
@@ -5364,8 +4175,8 @@ var init_PouchDataLayerProvider = __esm({
|
|
|
5364
4175
|
}
|
|
5365
4176
|
async initialize() {
|
|
5366
4177
|
if (this.initialized) return;
|
|
5367
|
-
const
|
|
5368
|
-
if (
|
|
4178
|
+
const isNodeEnvironment = typeof process !== "undefined" && process.versions != null && process.versions.node != null;
|
|
4179
|
+
if (isNodeEnvironment) {
|
|
5369
4180
|
logger.info(
|
|
5370
4181
|
"CouchDataLayerProvider: Running in Node.js environment, creating guest UserDB for testing."
|
|
5371
4182
|
);
|
|
@@ -5427,11 +4238,11 @@ var init_StaticDataUnpacker = __esm({
|
|
|
5427
4238
|
init_logger();
|
|
5428
4239
|
init_core();
|
|
5429
4240
|
pathUtils = {
|
|
5430
|
-
isAbsolute: (
|
|
5431
|
-
if (/^[a-zA-Z]:[\\/]/.test(
|
|
4241
|
+
isAbsolute: (path2) => {
|
|
4242
|
+
if (/^[a-zA-Z]:[\\/]/.test(path2) || /^\\\\/.test(path2)) {
|
|
5432
4243
|
return true;
|
|
5433
4244
|
}
|
|
5434
|
-
if (
|
|
4245
|
+
if (path2.startsWith("/")) {
|
|
5435
4246
|
return true;
|
|
5436
4247
|
}
|
|
5437
4248
|
return false;
|
|
@@ -5478,6 +4289,36 @@ var init_StaticDataUnpacker = __esm({
|
|
|
5478
4289
|
logger.error(`Document ${id} not found in chunk ${chunk.id}`);
|
|
5479
4290
|
throw new Error(`Document ${id} not found in chunk ${chunk.id}`);
|
|
5480
4291
|
}
|
|
4292
|
+
/**
|
|
4293
|
+
* Get all documents with IDs starting with a specific prefix.
|
|
4294
|
+
*
|
|
4295
|
+
* This method loads the relevant chunk(s) and returns all matching documents.
|
|
4296
|
+
* Useful for querying documents by type (e.g., all NAVIGATION_STRATEGY documents).
|
|
4297
|
+
*
|
|
4298
|
+
* @param prefix - Document ID prefix to match (e.g., "NAVIGATION_STRATEGY")
|
|
4299
|
+
* @returns Array of all documents with IDs starting with the prefix
|
|
4300
|
+
*/
|
|
4301
|
+
async getAllDocumentsByPrefix(prefix) {
|
|
4302
|
+
const relevantChunks = this.manifest.chunks.filter((chunk) => {
|
|
4303
|
+
const prefixEnd = prefix + "\uFFF0";
|
|
4304
|
+
return chunk.startKey <= prefixEnd && chunk.endKey >= prefix;
|
|
4305
|
+
});
|
|
4306
|
+
if (relevantChunks.length === 0) {
|
|
4307
|
+
logger.debug(`[StaticDataUnpacker] No chunks found for prefix: ${prefix}`);
|
|
4308
|
+
return [];
|
|
4309
|
+
}
|
|
4310
|
+
await Promise.all(relevantChunks.map((chunk) => this.loadChunk(chunk.id)));
|
|
4311
|
+
const matchingDocs = [];
|
|
4312
|
+
for (const [docId, doc] of this.documentCache.entries()) {
|
|
4313
|
+
if (docId.startsWith(prefix)) {
|
|
4314
|
+
matchingDocs.push(await this.hydrateAttachments(doc));
|
|
4315
|
+
}
|
|
4316
|
+
}
|
|
4317
|
+
logger.debug(
|
|
4318
|
+
`[StaticDataUnpacker] Found ${matchingDocs.length} documents with prefix: ${prefix}`
|
|
4319
|
+
);
|
|
4320
|
+
return matchingDocs;
|
|
4321
|
+
}
|
|
5481
4322
|
/**
|
|
5482
4323
|
* Query cards by ELO score, returning card IDs sorted by ELO
|
|
5483
4324
|
*/
|
|
@@ -5514,7 +4355,14 @@ var init_StaticDataUnpacker = __esm({
|
|
|
5514
4355
|
* Get all tag names mapped to their card arrays
|
|
5515
4356
|
*/
|
|
5516
4357
|
async getTagsIndex() {
|
|
5517
|
-
|
|
4358
|
+
try {
|
|
4359
|
+
return await this.loadIndex("tags");
|
|
4360
|
+
} catch {
|
|
4361
|
+
return {
|
|
4362
|
+
byCard: {},
|
|
4363
|
+
byTag: {}
|
|
4364
|
+
};
|
|
4365
|
+
}
|
|
5518
4366
|
}
|
|
5519
4367
|
getDocTypeFromId(id) {
|
|
5520
4368
|
for (const docTypeKey in DocTypePrefixes) {
|
|
@@ -5805,8 +4653,9 @@ var init_courseDB2 = __esm({
|
|
|
5805
4653
|
"src/impl/static/courseDB.ts"() {
|
|
5806
4654
|
"use strict";
|
|
5807
4655
|
init_types_legacy();
|
|
5808
|
-
init_navigators();
|
|
5809
4656
|
init_logger();
|
|
4657
|
+
init_defaults();
|
|
4658
|
+
init_PipelineAssembler();
|
|
5810
4659
|
StaticCourseDB = class {
|
|
5811
4660
|
constructor(courseId, unpacker, userDB, manifest) {
|
|
5812
4661
|
this.courseId = courseId;
|
|
@@ -5885,21 +4734,6 @@ var init_courseDB2 = __esm({
|
|
|
5885
4734
|
async updateCardElo(cardId, _elo) {
|
|
5886
4735
|
return { ok: true, id: cardId, rev: "1-static" };
|
|
5887
4736
|
}
|
|
5888
|
-
async getNewCards(limit = 99) {
|
|
5889
|
-
const activeCards = await this.userDB.getActiveCards();
|
|
5890
|
-
return (await this.getCardsCenteredAtELO({ limit, elo: "user" }, (c) => {
|
|
5891
|
-
if (activeCards.some((ac) => c.cardID === ac.cardID)) {
|
|
5892
|
-
return false;
|
|
5893
|
-
} else {
|
|
5894
|
-
return true;
|
|
5895
|
-
}
|
|
5896
|
-
})).map((c) => {
|
|
5897
|
-
return {
|
|
5898
|
-
...c,
|
|
5899
|
-
status: "new"
|
|
5900
|
-
};
|
|
5901
|
-
});
|
|
5902
|
-
}
|
|
5903
4737
|
async getCardsCenteredAtELO(options, filter) {
|
|
5904
4738
|
let targetElo = typeof options.elo === "number" ? options.elo : 1e3;
|
|
5905
4739
|
if (options.elo === "user") {
|
|
@@ -6085,19 +4919,23 @@ var init_courseDB2 = __esm({
|
|
|
6085
4919
|
return [];
|
|
6086
4920
|
}
|
|
6087
4921
|
// Navigation Strategy Manager implementation
|
|
6088
|
-
async getNavigationStrategy(
|
|
6089
|
-
|
|
6090
|
-
|
|
6091
|
-
|
|
6092
|
-
|
|
6093
|
-
|
|
6094
|
-
|
|
6095
|
-
course: this.courseId,
|
|
6096
|
-
serializedData: ""
|
|
6097
|
-
};
|
|
4922
|
+
async getNavigationStrategy(id) {
|
|
4923
|
+
try {
|
|
4924
|
+
return await this.unpacker.getDocument(id);
|
|
4925
|
+
} catch (error) {
|
|
4926
|
+
logger.error(`[static/courseDB] Strategy ${id} not found: ${error}`);
|
|
4927
|
+
throw error;
|
|
4928
|
+
}
|
|
6098
4929
|
}
|
|
6099
4930
|
async getAllNavigationStrategies() {
|
|
6100
|
-
|
|
4931
|
+
const prefix = DocTypePrefixes["NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */];
|
|
4932
|
+
try {
|
|
4933
|
+
const docs = await this.unpacker.getAllDocumentsByPrefix(prefix);
|
|
4934
|
+
return docs;
|
|
4935
|
+
} catch (error) {
|
|
4936
|
+
logger.warn(`[static/courseDB] Error loading navigation strategies: ${error}`);
|
|
4937
|
+
return [];
|
|
4938
|
+
}
|
|
6101
4939
|
}
|
|
6102
4940
|
async addNavigationStrategy(_data) {
|
|
6103
4941
|
throw new Error("Cannot add navigation strategies in static mode");
|
|
@@ -6105,9 +4943,52 @@ var init_courseDB2 = __esm({
|
|
|
6105
4943
|
async updateNavigationStrategy(_id, _data) {
|
|
6106
4944
|
throw new Error("Cannot update navigation strategies in static mode");
|
|
6107
4945
|
}
|
|
4946
|
+
/**
|
|
4947
|
+
* Create a ContentNavigator for this course.
|
|
4948
|
+
*
|
|
4949
|
+
* Loads navigation strategy documents from static data and uses PipelineAssembler
|
|
4950
|
+
* to build a Pipeline. Falls back to default pipeline if no strategies found.
|
|
4951
|
+
*/
|
|
4952
|
+
async createNavigator(user) {
|
|
4953
|
+
try {
|
|
4954
|
+
const allStrategies = await this.getAllNavigationStrategies();
|
|
4955
|
+
if (allStrategies.length === 0) {
|
|
4956
|
+
logger.debug(
|
|
4957
|
+
"[static/courseDB] No strategy documents found, using default Pipeline(Composite(ELO, SRS), [eloDistanceFilter])"
|
|
4958
|
+
);
|
|
4959
|
+
return createDefaultPipeline(user, this);
|
|
4960
|
+
}
|
|
4961
|
+
const assembler = new PipelineAssembler();
|
|
4962
|
+
const { pipeline, generatorStrategies, filterStrategies, warnings } = await assembler.assemble({
|
|
4963
|
+
strategies: allStrategies,
|
|
4964
|
+
user,
|
|
4965
|
+
course: this
|
|
4966
|
+
});
|
|
4967
|
+
for (const warning of warnings) {
|
|
4968
|
+
logger.warn(`[PipelineAssembler] ${warning}`);
|
|
4969
|
+
}
|
|
4970
|
+
if (!pipeline) {
|
|
4971
|
+
logger.debug("[static/courseDB] Pipeline assembly failed, using default pipeline");
|
|
4972
|
+
return createDefaultPipeline(user, this);
|
|
4973
|
+
}
|
|
4974
|
+
logger.debug(
|
|
4975
|
+
`[static/courseDB] Using assembled pipeline with ${generatorStrategies.length} generator(s) and ${filterStrategies.length} filter(s)`
|
|
4976
|
+
);
|
|
4977
|
+
return pipeline;
|
|
4978
|
+
} catch (e) {
|
|
4979
|
+
logger.error(`[static/courseDB] Error creating navigator: ${e}`);
|
|
4980
|
+
throw e;
|
|
4981
|
+
}
|
|
4982
|
+
}
|
|
6108
4983
|
// Study Content Source implementation
|
|
6109
|
-
async
|
|
6110
|
-
|
|
4984
|
+
async getWeightedCards(limit) {
|
|
4985
|
+
try {
|
|
4986
|
+
const navigator = await this.createNavigator(this.userDB);
|
|
4987
|
+
return navigator.getWeightedCards(limit);
|
|
4988
|
+
} catch (e) {
|
|
4989
|
+
logger.error(`[static/courseDB] Error getting weighted cards: ${e}`);
|
|
4990
|
+
throw e;
|
|
4991
|
+
}
|
|
6111
4992
|
}
|
|
6112
4993
|
// Attachment helper methods (internal use, not part of interface)
|
|
6113
4994
|
/**
|
|
@@ -6506,108 +5387,71 @@ var init_TagFilteredContentSource = __esm({
|
|
|
6506
5387
|
return finalCardIds;
|
|
6507
5388
|
}
|
|
6508
5389
|
/**
|
|
6509
|
-
*
|
|
5390
|
+
* Get cards with suitability scores for presentation.
|
|
5391
|
+
*
|
|
5392
|
+
* Filters cards by tag inclusion/exclusion and assigns score=1.0 to all.
|
|
5393
|
+
* TagFilteredContentSource does not currently support pluggable navigation
|
|
5394
|
+
* strategies - it returns flat-scored candidates.
|
|
5395
|
+
*
|
|
5396
|
+
* @param limit - Maximum number of cards to return
|
|
5397
|
+
* @returns Cards sorted by score descending (all scores = 1.0)
|
|
6510
5398
|
*/
|
|
6511
|
-
async
|
|
5399
|
+
async getWeightedCards(limit) {
|
|
6512
5400
|
if (!hasActiveFilter(this.filter)) {
|
|
6513
|
-
logger.warn("[TagFilteredContentSource]
|
|
5401
|
+
logger.warn("[TagFilteredContentSource] getWeightedCards called with no active filter");
|
|
6514
5402
|
return [];
|
|
6515
5403
|
}
|
|
6516
5404
|
const eligibleCardIds = await this.resolveFilteredCardIds();
|
|
6517
5405
|
const activeCards = await this.user.getActiveCards();
|
|
6518
5406
|
const activeCardIds = new Set(activeCards.map((c) => c.cardID));
|
|
6519
|
-
const
|
|
5407
|
+
const newCardWeighted = [];
|
|
6520
5408
|
for (const cardId of eligibleCardIds) {
|
|
6521
5409
|
if (!activeCardIds.has(cardId)) {
|
|
6522
|
-
|
|
6523
|
-
|
|
6524
|
-
|
|
6525
|
-
|
|
6526
|
-
|
|
6527
|
-
|
|
5410
|
+
newCardWeighted.push({
|
|
5411
|
+
cardId,
|
|
5412
|
+
courseId: this.courseId,
|
|
5413
|
+
score: 1,
|
|
5414
|
+
provenance: [
|
|
5415
|
+
{
|
|
5416
|
+
strategy: "tagFilter",
|
|
5417
|
+
strategyName: "Tag Filter",
|
|
5418
|
+
strategyId: "TAG_FILTER",
|
|
5419
|
+
action: "generated",
|
|
5420
|
+
score: 1,
|
|
5421
|
+
reason: `Tag-filtered new card (tags: ${this.filter.include.join(", ")})`
|
|
5422
|
+
}
|
|
5423
|
+
]
|
|
6528
5424
|
});
|
|
6529
5425
|
}
|
|
6530
|
-
if (
|
|
5426
|
+
if (newCardWeighted.length >= limit) {
|
|
6531
5427
|
break;
|
|
6532
5428
|
}
|
|
6533
5429
|
}
|
|
6534
|
-
logger.info(
|
|
6535
|
-
|
|
6536
|
-
|
|
6537
|
-
/**
|
|
6538
|
-
* Gets pending reviews, filtered to only include cards that match the tag filter.
|
|
6539
|
-
*/
|
|
6540
|
-
async getPendingReviews() {
|
|
6541
|
-
if (!hasActiveFilter(this.filter)) {
|
|
6542
|
-
logger.warn("[TagFilteredContentSource] getPendingReviews called with no active filter");
|
|
6543
|
-
return [];
|
|
6544
|
-
}
|
|
6545
|
-
const eligibleCardIds = await this.resolveFilteredCardIds();
|
|
5430
|
+
logger.info(
|
|
5431
|
+
`[TagFilteredContentSource] Found ${newCardWeighted.length} new cards matching filter`
|
|
5432
|
+
);
|
|
6546
5433
|
const allReviews = await this.user.getPendingReviews(this.courseId);
|
|
6547
|
-
const filteredReviews = allReviews.filter((review) =>
|
|
6548
|
-
return eligibleCardIds.has(review.cardId);
|
|
6549
|
-
});
|
|
5434
|
+
const filteredReviews = allReviews.filter((review) => eligibleCardIds.has(review.cardId));
|
|
6550
5435
|
logger.info(
|
|
6551
5436
|
`[TagFilteredContentSource] Found ${filteredReviews.length} pending reviews matching filter (of ${allReviews.length} total)`
|
|
6552
5437
|
);
|
|
6553
|
-
|
|
6554
|
-
|
|
6555
|
-
|
|
6556
|
-
|
|
6557
|
-
contentSourceType: "course",
|
|
6558
|
-
contentSourceID: this.courseId,
|
|
5438
|
+
const reviewWeighted = filteredReviews.map((r) => ({
|
|
5439
|
+
cardId: r.cardId,
|
|
5440
|
+
courseId: r.courseId,
|
|
5441
|
+
score: 1,
|
|
6559
5442
|
reviewID: r._id,
|
|
6560
|
-
|
|
5443
|
+
provenance: [
|
|
5444
|
+
{
|
|
5445
|
+
strategy: "tagFilter",
|
|
5446
|
+
strategyName: "Tag Filter",
|
|
5447
|
+
strategyId: "TAG_FILTER",
|
|
5448
|
+
action: "generated",
|
|
5449
|
+
score: 1,
|
|
5450
|
+
reason: `Tag-filtered review (tags: ${this.filter.include.join(", ")})`
|
|
5451
|
+
}
|
|
5452
|
+
]
|
|
6561
5453
|
}));
|
|
6562
|
-
|
|
6563
|
-
/**
|
|
6564
|
-
* Get cards with suitability scores for presentation.
|
|
6565
|
-
*
|
|
6566
|
-
* This implementation wraps the legacy getNewCards/getPendingReviews methods,
|
|
6567
|
-
* assigning score=1.0 to all cards. TagFilteredContentSource does not currently
|
|
6568
|
-
* support pluggable navigation strategies - it returns flat-scored candidates.
|
|
6569
|
-
*
|
|
6570
|
-
* @param limit - Maximum number of cards to return
|
|
6571
|
-
* @returns Cards sorted by score descending (all scores = 1.0)
|
|
6572
|
-
*/
|
|
6573
|
-
async getWeightedCards(limit) {
|
|
6574
|
-
const [newCards, reviews] = await Promise.all([
|
|
6575
|
-
this.getNewCards(limit),
|
|
6576
|
-
this.getPendingReviews()
|
|
6577
|
-
]);
|
|
6578
|
-
const weighted = [
|
|
6579
|
-
...reviews.map((r) => ({
|
|
6580
|
-
cardId: r.cardID,
|
|
6581
|
-
courseId: r.courseID,
|
|
6582
|
-
score: 1,
|
|
6583
|
-
provenance: [
|
|
6584
|
-
{
|
|
6585
|
-
strategy: "tagFilter",
|
|
6586
|
-
strategyName: "Tag Filter",
|
|
6587
|
-
strategyId: "TAG_FILTER",
|
|
6588
|
-
action: "generated",
|
|
6589
|
-
score: 1,
|
|
6590
|
-
reason: `Tag-filtered review (tags: ${this.filter.include.join(", ")})`
|
|
6591
|
-
}
|
|
6592
|
-
]
|
|
6593
|
-
})),
|
|
6594
|
-
...newCards.map((c) => ({
|
|
6595
|
-
cardId: c.cardID,
|
|
6596
|
-
courseId: c.courseID,
|
|
6597
|
-
score: 1,
|
|
6598
|
-
provenance: [
|
|
6599
|
-
{
|
|
6600
|
-
strategy: "tagFilter",
|
|
6601
|
-
strategyName: "Tag Filter",
|
|
6602
|
-
strategyId: "TAG_FILTER",
|
|
6603
|
-
action: "generated",
|
|
6604
|
-
score: 1,
|
|
6605
|
-
reason: `Tag-filtered new card (tags: ${this.filter.include.join(", ")})`
|
|
6606
|
-
}
|
|
6607
|
-
]
|
|
6608
|
-
}))
|
|
6609
|
-
];
|
|
6610
|
-
return weighted.slice(0, limit);
|
|
5454
|
+
return [...reviewWeighted, ...newCardWeighted].slice(0, limit);
|
|
6611
5455
|
}
|
|
6612
5456
|
/**
|
|
6613
5457
|
* Clears the cached resolved card IDs.
|
|
@@ -6829,7 +5673,7 @@ var init_cardProcessor = __esm({
|
|
|
6829
5673
|
});
|
|
6830
5674
|
|
|
6831
5675
|
// src/core/bulkImport/types.ts
|
|
6832
|
-
var
|
|
5676
|
+
var init_types = __esm({
|
|
6833
5677
|
"src/core/bulkImport/types.ts"() {
|
|
6834
5678
|
"use strict";
|
|
6835
5679
|
}
|
|
@@ -6840,7 +5684,7 @@ var init_bulkImport = __esm({
|
|
|
6840
5684
|
"src/core/bulkImport/index.ts"() {
|
|
6841
5685
|
"use strict";
|
|
6842
5686
|
init_cardProcessor();
|
|
6843
|
-
|
|
5687
|
+
init_types();
|
|
6844
5688
|
}
|
|
6845
5689
|
});
|
|
6846
5690
|
|
|
@@ -6974,7 +5818,7 @@ var SrsService = class {
|
|
|
6974
5818
|
|
|
6975
5819
|
// src/study/services/EloService.ts
|
|
6976
5820
|
init_logger();
|
|
6977
|
-
import { adjustCourseScores, toCourseElo as
|
|
5821
|
+
import { adjustCourseScores, toCourseElo as toCourseElo5 } from "@vue-skuilder/common";
|
|
6978
5822
|
var EloService = class {
|
|
6979
5823
|
dataLayer;
|
|
6980
5824
|
user;
|
|
@@ -6996,7 +5840,7 @@ var EloService = class {
|
|
|
6996
5840
|
logger.warn(`k value interpretation not currently implemented`);
|
|
6997
5841
|
}
|
|
6998
5842
|
const courseDB = this.dataLayer.getCourseDB(currentCard.card.course_id);
|
|
6999
|
-
const userElo =
|
|
5843
|
+
const userElo = toCourseElo5(userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo);
|
|
7000
5844
|
const cardElo = (await courseDB.getCardEloData([currentCard.card.card_id]))[0];
|
|
7001
5845
|
if (cardElo && userElo) {
|
|
7002
5846
|
const eloUpdate = adjustCourseScores(userElo, cardElo, userScore);
|
|
@@ -7205,156 +6049,124 @@ init_logger();
|
|
|
7205
6049
|
import {
|
|
7206
6050
|
displayableDataToViewData,
|
|
7207
6051
|
isCourseElo,
|
|
7208
|
-
toCourseElo as
|
|
6052
|
+
toCourseElo as toCourseElo6
|
|
7209
6053
|
} from "@vue-skuilder/common";
|
|
7210
|
-
|
|
7211
|
-
|
|
7212
|
-
|
|
7213
|
-
|
|
7214
|
-
|
|
7215
|
-
|
|
7216
|
-
|
|
7217
|
-
|
|
7218
|
-
|
|
7219
|
-
|
|
7220
|
-
|
|
7221
|
-
|
|
7222
|
-
}
|
|
7223
|
-
|
|
7224
|
-
|
|
7225
|
-
|
|
7226
|
-
|
|
7227
|
-
|
|
7228
|
-
|
|
7229
|
-
|
|
7230
|
-
|
|
7231
|
-
|
|
7232
|
-
|
|
7233
|
-
|
|
7234
|
-
|
|
7235
|
-
dequeue(cardIdExtractor) {
|
|
7236
|
-
if (this.q.length !== 0) {
|
|
7237
|
-
this._dequeueCount++;
|
|
7238
|
-
const item = this.q.splice(0, 1)[0];
|
|
7239
|
-
if (cardIdExtractor) {
|
|
7240
|
-
const cardId = cardIdExtractor(item);
|
|
7241
|
-
const index = this.seenCardIds.indexOf(cardId);
|
|
7242
|
-
if (index > -1) {
|
|
7243
|
-
this.seenCardIds.splice(index, 1);
|
|
7244
|
-
}
|
|
7245
|
-
}
|
|
7246
|
-
return item;
|
|
7247
|
-
} else {
|
|
7248
|
-
return null;
|
|
7249
|
-
}
|
|
7250
|
-
}
|
|
7251
|
-
get toString() {
|
|
7252
|
-
return `${typeof this.q[0]}:
|
|
7253
|
-
` + this.q.map((i) => ` ${i.courseID}+${i.cardID}: ${i.status}`).join("\n");
|
|
7254
|
-
}
|
|
7255
|
-
};
|
|
7256
|
-
|
|
7257
|
-
// src/study/services/CardHydrationService.ts
|
|
6054
|
+
function parseAudioURIs(data) {
|
|
6055
|
+
if (typeof data !== "string") return [];
|
|
6056
|
+
const audioPattern = /https?:\/\/[^\s"'<>]+\.(wav|mp3|ogg|m4a|aac|webm)/gi;
|
|
6057
|
+
return data.match(audioPattern) ?? [];
|
|
6058
|
+
}
|
|
6059
|
+
function prefetchAudio(url) {
|
|
6060
|
+
return new Promise((resolve) => {
|
|
6061
|
+
const audio = new Audio();
|
|
6062
|
+
audio.preload = "auto";
|
|
6063
|
+
const cleanup = () => {
|
|
6064
|
+
audio.oncanplaythrough = null;
|
|
6065
|
+
audio.onerror = null;
|
|
6066
|
+
};
|
|
6067
|
+
audio.oncanplaythrough = () => {
|
|
6068
|
+
cleanup();
|
|
6069
|
+
resolve();
|
|
6070
|
+
};
|
|
6071
|
+
audio.onerror = () => {
|
|
6072
|
+
cleanup();
|
|
6073
|
+
logger.warn(`[CardHydrationService] Failed to prefetch audio: ${url}`);
|
|
6074
|
+
resolve();
|
|
6075
|
+
};
|
|
6076
|
+
audio.src = url;
|
|
6077
|
+
});
|
|
6078
|
+
}
|
|
7258
6079
|
var CardHydrationService = class {
|
|
7259
|
-
constructor(getViewComponent, getCourseDB3,
|
|
6080
|
+
constructor(getViewComponent, getCourseDB3, getItemsToHydrate) {
|
|
7260
6081
|
this.getViewComponent = getViewComponent;
|
|
7261
6082
|
this.getCourseDB = getCourseDB3;
|
|
7262
|
-
this.
|
|
7263
|
-
this.removeItemFromQueue = removeItemFromQueue;
|
|
7264
|
-
this.hasAvailableCards = hasAvailableCards;
|
|
6083
|
+
this.getItemsToHydrate = getItemsToHydrate;
|
|
7265
6084
|
}
|
|
7266
|
-
|
|
7267
|
-
|
|
6085
|
+
hydratedCards = /* @__PURE__ */ new Map();
|
|
6086
|
+
hydrationInFlight = /* @__PURE__ */ new Set();
|
|
7268
6087
|
hydrationInProgress = false;
|
|
7269
|
-
BUFFER_SIZE = 5;
|
|
7270
6088
|
/**
|
|
7271
|
-
* Get
|
|
7272
|
-
* @returns Hydrated card or null if
|
|
6089
|
+
* Get a hydrated card by ID.
|
|
6090
|
+
* @returns Hydrated card or null if not in cache
|
|
6091
|
+
*/
|
|
6092
|
+
getHydratedCard(cardId) {
|
|
6093
|
+
return this.hydratedCards.get(cardId) ?? null;
|
|
6094
|
+
}
|
|
6095
|
+
/**
|
|
6096
|
+
* Check if a card is hydrated.
|
|
7273
6097
|
*/
|
|
7274
|
-
|
|
7275
|
-
return this.
|
|
6098
|
+
hasHydratedCard(cardId) {
|
|
6099
|
+
return this.hydratedCards.has(cardId);
|
|
6100
|
+
}
|
|
6101
|
+
/**
|
|
6102
|
+
* Remove a card from the cache (call on successful dismiss to free memory).
|
|
6103
|
+
*/
|
|
6104
|
+
removeCard(cardId) {
|
|
6105
|
+
this.hydratedCards.delete(cardId);
|
|
7276
6106
|
}
|
|
7277
6107
|
/**
|
|
7278
6108
|
* Check if hydration should be triggered and start background hydration if needed.
|
|
7279
6109
|
*/
|
|
7280
6110
|
async ensureHydratedCards() {
|
|
7281
|
-
|
|
7282
|
-
void this.fillHydratedQueue();
|
|
7283
|
-
}
|
|
6111
|
+
void this.fillHydratedCards();
|
|
7284
6112
|
}
|
|
7285
6113
|
/**
|
|
7286
|
-
* Wait for a
|
|
6114
|
+
* Wait for a specific card to become hydrated.
|
|
7287
6115
|
* @returns Promise that resolves to a hydrated card or null
|
|
7288
6116
|
*/
|
|
7289
|
-
async
|
|
7290
|
-
if (this.
|
|
7291
|
-
|
|
6117
|
+
async waitForCard(cardId) {
|
|
6118
|
+
if (this.hydratedCards.has(cardId)) {
|
|
6119
|
+
return this.hydratedCards.get(cardId);
|
|
7292
6120
|
}
|
|
7293
|
-
|
|
7294
|
-
|
|
6121
|
+
if (!this.hydrationInProgress) {
|
|
6122
|
+
void this.fillHydratedCards();
|
|
7295
6123
|
}
|
|
7296
|
-
|
|
6124
|
+
const maxWaitMs = 1e4;
|
|
6125
|
+
const pollIntervalMs = 25;
|
|
6126
|
+
let elapsed = 0;
|
|
6127
|
+
while (elapsed < maxWaitMs) {
|
|
6128
|
+
if (this.hydratedCards.has(cardId)) {
|
|
6129
|
+
return this.hydratedCards.get(cardId);
|
|
6130
|
+
}
|
|
6131
|
+
if (!this.hydrationInFlight.has(cardId) && !this.hydrationInProgress) {
|
|
6132
|
+
break;
|
|
6133
|
+
}
|
|
6134
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
6135
|
+
elapsed += pollIntervalMs;
|
|
6136
|
+
}
|
|
6137
|
+
return this.hydratedCards.get(cardId) ?? null;
|
|
7297
6138
|
}
|
|
7298
6139
|
/**
|
|
7299
|
-
* Get current hydrated
|
|
6140
|
+
* Get current hydrated cache size.
|
|
7300
6141
|
*/
|
|
7301
6142
|
get hydratedCount() {
|
|
7302
|
-
return this.
|
|
6143
|
+
return this.hydratedCards.size;
|
|
7303
6144
|
}
|
|
7304
6145
|
/**
|
|
7305
|
-
* Get
|
|
6146
|
+
* Get list of currently hydrated card IDs (for debugging).
|
|
7306
6147
|
*/
|
|
7307
|
-
|
|
7308
|
-
return this.
|
|
6148
|
+
getHydratedCardIds() {
|
|
6149
|
+
return Array.from(this.hydratedCards.keys());
|
|
7309
6150
|
}
|
|
7310
6151
|
/**
|
|
7311
|
-
* Fill the hydrated
|
|
6152
|
+
* Fill the hydrated cache by hydrating items from getItemsToHydrate().
|
|
6153
|
+
* This is a pure cache-warming operation - no queue mutation.
|
|
7312
6154
|
*/
|
|
7313
|
-
async
|
|
6155
|
+
async fillHydratedCards() {
|
|
7314
6156
|
if (this.hydrationInProgress) {
|
|
7315
6157
|
return;
|
|
7316
6158
|
}
|
|
7317
6159
|
this.hydrationInProgress = true;
|
|
7318
6160
|
try {
|
|
7319
|
-
|
|
7320
|
-
|
|
7321
|
-
if (
|
|
7322
|
-
|
|
6161
|
+
const itemsToHydrate = this.getItemsToHydrate();
|
|
6162
|
+
for (const item of itemsToHydrate) {
|
|
6163
|
+
if (this.hydratedCards.has(item.cardID) || this.hydrationInFlight.has(item.cardID)) {
|
|
6164
|
+
continue;
|
|
7323
6165
|
}
|
|
7324
6166
|
try {
|
|
7325
|
-
|
|
7326
|
-
const cachedCard = this.failedCardCache.get(nextItem.cardID);
|
|
7327
|
-
this.hydratedQ.add(cachedCard, cachedCard.item.cardID);
|
|
7328
|
-
this.failedCardCache.delete(nextItem.cardID);
|
|
7329
|
-
} else {
|
|
7330
|
-
const courseDB = this.getCourseDB(nextItem.courseID);
|
|
7331
|
-
const cardData = await courseDB.getCourseDoc(nextItem.cardID);
|
|
7332
|
-
if (!isCourseElo(cardData.elo)) {
|
|
7333
|
-
cardData.elo = toCourseElo8(cardData.elo);
|
|
7334
|
-
}
|
|
7335
|
-
const view = this.getViewComponent(cardData.id_view);
|
|
7336
|
-
const dataDocs = await Promise.all(
|
|
7337
|
-
cardData.id_displayable_data.map(
|
|
7338
|
-
(id) => courseDB.getCourseDoc(id, {
|
|
7339
|
-
attachments: true,
|
|
7340
|
-
binary: true
|
|
7341
|
-
})
|
|
7342
|
-
)
|
|
7343
|
-
);
|
|
7344
|
-
const data = dataDocs.map(displayableDataToViewData).reverse();
|
|
7345
|
-
this.hydratedQ.add(
|
|
7346
|
-
{
|
|
7347
|
-
item: nextItem,
|
|
7348
|
-
view,
|
|
7349
|
-
data
|
|
7350
|
-
},
|
|
7351
|
-
nextItem.cardID
|
|
7352
|
-
);
|
|
7353
|
-
}
|
|
6167
|
+
await this.hydrateCard(item);
|
|
7354
6168
|
} catch (e) {
|
|
7355
|
-
logger.error(`Error hydrating card ${
|
|
7356
|
-
} finally {
|
|
7357
|
-
this.removeItemFromQueue(nextItem);
|
|
6169
|
+
logger.error(`[CardHydrationService] Error hydrating card ${item.cardID}:`, e);
|
|
7358
6170
|
}
|
|
7359
6171
|
}
|
|
7360
6172
|
} finally {
|
|
@@ -7362,10 +6174,97 @@ var CardHydrationService = class {
|
|
|
7362
6174
|
}
|
|
7363
6175
|
}
|
|
7364
6176
|
/**
|
|
7365
|
-
*
|
|
6177
|
+
* Hydrate a single card and add to cache.
|
|
7366
6178
|
*/
|
|
7367
|
-
|
|
7368
|
-
this.
|
|
6179
|
+
async hydrateCard(item) {
|
|
6180
|
+
if (this.hydratedCards.has(item.cardID) || this.hydrationInFlight.has(item.cardID)) {
|
|
6181
|
+
return;
|
|
6182
|
+
}
|
|
6183
|
+
this.hydrationInFlight.add(item.cardID);
|
|
6184
|
+
try {
|
|
6185
|
+
const courseDB = this.getCourseDB(item.courseID);
|
|
6186
|
+
const cardData = await courseDB.getCourseDoc(item.cardID);
|
|
6187
|
+
if (!isCourseElo(cardData.elo)) {
|
|
6188
|
+
cardData.elo = toCourseElo6(cardData.elo);
|
|
6189
|
+
}
|
|
6190
|
+
const view = this.getViewComponent(cardData.id_view);
|
|
6191
|
+
const dataDocs = await Promise.all(
|
|
6192
|
+
cardData.id_displayable_data.map(
|
|
6193
|
+
(id) => courseDB.getCourseDoc(id, {
|
|
6194
|
+
attachments: true,
|
|
6195
|
+
binary: true
|
|
6196
|
+
})
|
|
6197
|
+
)
|
|
6198
|
+
);
|
|
6199
|
+
const audioToPrefetch = [];
|
|
6200
|
+
dataDocs.forEach((dd) => {
|
|
6201
|
+
dd.data.forEach((f) => {
|
|
6202
|
+
audioToPrefetch.push(...parseAudioURIs(f.data));
|
|
6203
|
+
});
|
|
6204
|
+
});
|
|
6205
|
+
const uniqueAudioUrls = [...new Set(audioToPrefetch)];
|
|
6206
|
+
if (uniqueAudioUrls.length > 0) {
|
|
6207
|
+
logger.debug(
|
|
6208
|
+
`[CardHydrationService] Prefetching ${uniqueAudioUrls.length} audio files for card ${item.cardID}`
|
|
6209
|
+
);
|
|
6210
|
+
await Promise.allSettled(uniqueAudioUrls.map(prefetchAudio));
|
|
6211
|
+
}
|
|
6212
|
+
const data = dataDocs.map(displayableDataToViewData).reverse();
|
|
6213
|
+
this.hydratedCards.set(item.cardID, {
|
|
6214
|
+
item,
|
|
6215
|
+
view,
|
|
6216
|
+
data
|
|
6217
|
+
});
|
|
6218
|
+
logger.debug(`[CardHydrationService] Hydrated card ${item.cardID}`);
|
|
6219
|
+
} finally {
|
|
6220
|
+
this.hydrationInFlight.delete(item.cardID);
|
|
6221
|
+
}
|
|
6222
|
+
}
|
|
6223
|
+
};
|
|
6224
|
+
|
|
6225
|
+
// src/study/ItemQueue.ts
|
|
6226
|
+
var ItemQueue = class {
|
|
6227
|
+
q = [];
|
|
6228
|
+
seenCardIds = [];
|
|
6229
|
+
_dequeueCount = 0;
|
|
6230
|
+
get dequeueCount() {
|
|
6231
|
+
return this._dequeueCount;
|
|
6232
|
+
}
|
|
6233
|
+
add(item, cardId) {
|
|
6234
|
+
if (this.seenCardIds.find((d) => d === cardId)) {
|
|
6235
|
+
return;
|
|
6236
|
+
}
|
|
6237
|
+
this.seenCardIds.push(cardId);
|
|
6238
|
+
this.q.push(item);
|
|
6239
|
+
}
|
|
6240
|
+
addAll(items, cardIdExtractor) {
|
|
6241
|
+
items.forEach((i) => this.add(i, cardIdExtractor(i)));
|
|
6242
|
+
}
|
|
6243
|
+
get length() {
|
|
6244
|
+
return this.q.length;
|
|
6245
|
+
}
|
|
6246
|
+
peek(index) {
|
|
6247
|
+
return this.q[index];
|
|
6248
|
+
}
|
|
6249
|
+
dequeue(cardIdExtractor) {
|
|
6250
|
+
if (this.q.length !== 0) {
|
|
6251
|
+
this._dequeueCount++;
|
|
6252
|
+
const item = this.q.splice(0, 1)[0];
|
|
6253
|
+
if (cardIdExtractor) {
|
|
6254
|
+
const cardId = cardIdExtractor(item);
|
|
6255
|
+
const index = this.seenCardIds.indexOf(cardId);
|
|
6256
|
+
if (index > -1) {
|
|
6257
|
+
this.seenCardIds.splice(index, 1);
|
|
6258
|
+
}
|
|
6259
|
+
}
|
|
6260
|
+
return item;
|
|
6261
|
+
} else {
|
|
6262
|
+
return null;
|
|
6263
|
+
}
|
|
6264
|
+
}
|
|
6265
|
+
get toString() {
|
|
6266
|
+
return `${typeof this.q[0]}:
|
|
6267
|
+
` + this.q.map((i) => ` ${i.courseID}+${i.cardID}: ${i.status}`).join("\n");
|
|
7369
6268
|
}
|
|
7370
6269
|
};
|
|
7371
6270
|
|
|
@@ -7907,7 +6806,7 @@ try {
|
|
|
7907
6806
|
}
|
|
7908
6807
|
} catch {
|
|
7909
6808
|
}
|
|
7910
|
-
async function validateStaticCourse(staticPath,
|
|
6809
|
+
async function validateStaticCourse(staticPath, fs2) {
|
|
7911
6810
|
const validation = {
|
|
7912
6811
|
valid: true,
|
|
7913
6812
|
manifestExists: false,
|
|
@@ -7917,8 +6816,8 @@ async function validateStaticCourse(staticPath, fs3) {
|
|
|
7917
6816
|
warnings: []
|
|
7918
6817
|
};
|
|
7919
6818
|
try {
|
|
7920
|
-
if (
|
|
7921
|
-
const stats = await
|
|
6819
|
+
if (fs2) {
|
|
6820
|
+
const stats = await fs2.stat(staticPath);
|
|
7922
6821
|
if (!stats.isDirectory()) {
|
|
7923
6822
|
validation.errors.push(`Path is not a directory: ${staticPath}`);
|
|
7924
6823
|
validation.valid = false;
|
|
@@ -7938,11 +6837,11 @@ async function validateStaticCourse(staticPath, fs3) {
|
|
|
7938
6837
|
}
|
|
7939
6838
|
let manifestPath = `${staticPath}/manifest.json`;
|
|
7940
6839
|
try {
|
|
7941
|
-
if (
|
|
7942
|
-
manifestPath =
|
|
7943
|
-
if (await
|
|
6840
|
+
if (fs2) {
|
|
6841
|
+
manifestPath = fs2.joinPath(staticPath, "manifest.json");
|
|
6842
|
+
if (await fs2.exists(manifestPath)) {
|
|
7944
6843
|
validation.manifestExists = true;
|
|
7945
|
-
const manifestContent = await
|
|
6844
|
+
const manifestContent = await fs2.readFile(manifestPath);
|
|
7946
6845
|
const manifest = JSON.parse(manifestContent);
|
|
7947
6846
|
validation.courseId = manifest.courseId;
|
|
7948
6847
|
validation.courseName = manifest.courseName;
|
|
@@ -7974,10 +6873,10 @@ async function validateStaticCourse(staticPath, fs3) {
|
|
|
7974
6873
|
}
|
|
7975
6874
|
let chunksPath = `${staticPath}/chunks`;
|
|
7976
6875
|
try {
|
|
7977
|
-
if (
|
|
7978
|
-
chunksPath =
|
|
7979
|
-
if (await
|
|
7980
|
-
const chunksStats = await
|
|
6876
|
+
if (fs2) {
|
|
6877
|
+
chunksPath = fs2.joinPath(staticPath, "chunks");
|
|
6878
|
+
if (await fs2.exists(chunksPath)) {
|
|
6879
|
+
const chunksStats = await fs2.stat(chunksPath);
|
|
7981
6880
|
if (chunksStats.isDirectory()) {
|
|
7982
6881
|
validation.chunksExist = true;
|
|
7983
6882
|
} else {
|
|
@@ -8005,10 +6904,10 @@ async function validateStaticCourse(staticPath, fs3) {
|
|
|
8005
6904
|
}
|
|
8006
6905
|
let attachmentsPath;
|
|
8007
6906
|
try {
|
|
8008
|
-
if (
|
|
8009
|
-
attachmentsPath =
|
|
8010
|
-
if (await
|
|
8011
|
-
const attachmentsStats = await
|
|
6907
|
+
if (fs2) {
|
|
6908
|
+
attachmentsPath = fs2.joinPath(staticPath, "attachments");
|
|
6909
|
+
if (await fs2.exists(attachmentsPath)) {
|
|
6910
|
+
const attachmentsStats = await fs2.stat(attachmentsPath);
|
|
8012
6911
|
if (attachmentsStats.isDirectory()) {
|
|
8013
6912
|
validation.attachmentsExist = true;
|
|
8014
6913
|
}
|
|
@@ -8786,26 +7685,43 @@ var StaticToCouchDBMigrator = class {
|
|
|
8786
7685
|
/**
|
|
8787
7686
|
* Check if a path is a local file path (vs URL)
|
|
8788
7687
|
*/
|
|
8789
|
-
isLocalPath(
|
|
8790
|
-
return !
|
|
7688
|
+
isLocalPath(path2) {
|
|
7689
|
+
return !path2.startsWith("http://") && !path2.startsWith("https://");
|
|
8791
7690
|
}
|
|
8792
7691
|
};
|
|
8793
7692
|
|
|
8794
7693
|
// src/util/index.ts
|
|
8795
7694
|
init_dataDirectory();
|
|
8796
|
-
init_tuiLogger();
|
|
8797
7695
|
|
|
8798
7696
|
// src/study/SessionController.ts
|
|
8799
7697
|
init_navigators();
|
|
8800
|
-
|
|
8801
|
-
|
|
8802
|
-
|
|
7698
|
+
|
|
7699
|
+
// src/study/SourceMixer.ts
|
|
7700
|
+
var QuotaRoundRobinMixer = class {
|
|
7701
|
+
mix(batches, limit) {
|
|
7702
|
+
if (batches.length === 0) {
|
|
7703
|
+
return [];
|
|
7704
|
+
}
|
|
7705
|
+
const quotaPerSource = Math.ceil(limit / batches.length);
|
|
7706
|
+
const mixed = [];
|
|
7707
|
+
for (const batch of batches) {
|
|
7708
|
+
const sortedBatch = [...batch.weighted].sort((a, b) => b.score - a.score);
|
|
7709
|
+
const topFromSource = sortedBatch.slice(0, quotaPerSource);
|
|
7710
|
+
mixed.push(...topFromSource);
|
|
7711
|
+
}
|
|
7712
|
+
return mixed.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
7713
|
+
}
|
|
7714
|
+
};
|
|
7715
|
+
|
|
7716
|
+
// src/study/SessionController.ts
|
|
7717
|
+
init_logger();
|
|
8803
7718
|
var SessionController = class extends Loggable {
|
|
8804
7719
|
_className = "SessionController";
|
|
8805
7720
|
services;
|
|
8806
7721
|
srsService;
|
|
8807
7722
|
eloService;
|
|
8808
7723
|
hydrationService;
|
|
7724
|
+
mixer;
|
|
8809
7725
|
sources;
|
|
8810
7726
|
// dataLayer and getViewComponent now injected into CardHydrationService
|
|
8811
7727
|
_sessionRecord = [];
|
|
@@ -8833,18 +7749,21 @@ var SessionController = class extends Loggable {
|
|
|
8833
7749
|
// @ts-expect-error NodeJS.Timeout type not available in browser context
|
|
8834
7750
|
_intervalHandle;
|
|
8835
7751
|
/**
|
|
8836
|
-
*
|
|
7752
|
+
* @param sources - Array of content sources to mix for the session
|
|
7753
|
+
* @param time - Session duration in seconds
|
|
7754
|
+
* @param dataLayer - Data layer provider
|
|
7755
|
+
* @param getViewComponent - Function to resolve view components
|
|
7756
|
+
* @param mixer - Optional source mixer strategy (defaults to QuotaRoundRobinMixer)
|
|
8837
7757
|
*/
|
|
8838
|
-
constructor(sources, time, dataLayer, getViewComponent) {
|
|
7758
|
+
constructor(sources, time, dataLayer, getViewComponent, mixer) {
|
|
8839
7759
|
super();
|
|
7760
|
+
this.mixer = mixer || new QuotaRoundRobinMixer();
|
|
8840
7761
|
this.srsService = new SrsService(dataLayer.getUserDB());
|
|
8841
7762
|
this.eloService = new EloService(dataLayer, dataLayer.getUserDB());
|
|
8842
7763
|
this.hydrationService = new CardHydrationService(
|
|
8843
7764
|
getViewComponent,
|
|
8844
7765
|
(courseId) => dataLayer.getCourseDB(courseId),
|
|
8845
|
-
() => this.
|
|
8846
|
-
(item) => this.removeItemFromQueue(item),
|
|
8847
|
-
() => this.hasAvailableCards()
|
|
7766
|
+
() => this._getItemsToHydrate()
|
|
8848
7767
|
);
|
|
8849
7768
|
this.services = {
|
|
8850
7769
|
response: new ResponseProcessor(this.srsService, this.eloService)
|
|
@@ -8898,16 +7817,12 @@ var SessionController = class extends Loggable {
|
|
|
8898
7817
|
return ret;
|
|
8899
7818
|
}
|
|
8900
7819
|
async prepareSession() {
|
|
8901
|
-
|
|
8902
|
-
|
|
8903
|
-
|
|
8904
|
-
|
|
8905
|
-
} else {
|
|
8906
|
-
await Promise.all([this.getScheduledReviews(), this.getNewCards()]);
|
|
8907
|
-
}
|
|
8908
|
-
} catch (e) {
|
|
8909
|
-
this.error("Error preparing study session:", e);
|
|
7820
|
+
if (this.sources.some((s) => typeof s.getWeightedCards !== "function")) {
|
|
7821
|
+
throw new Error(
|
|
7822
|
+
"[SessionController] All content sources must implement getWeightedCards()."
|
|
7823
|
+
);
|
|
8910
7824
|
}
|
|
7825
|
+
await this.getWeightedContent();
|
|
8911
7826
|
await this.hydrationService.ensureHydratedCards();
|
|
8912
7827
|
this._intervalHandle = setInterval(() => {
|
|
8913
7828
|
this.tick();
|
|
@@ -8945,14 +7860,10 @@ var SessionController = class extends Loggable {
|
|
|
8945
7860
|
}
|
|
8946
7861
|
return items;
|
|
8947
7862
|
};
|
|
8948
|
-
const extractHydratedItems = () => {
|
|
8949
|
-
const items = [];
|
|
8950
|
-
return items;
|
|
8951
|
-
};
|
|
8952
7863
|
return {
|
|
8953
7864
|
api: {
|
|
8954
7865
|
mode: supportsWeightedCards ? "weighted" : "legacy",
|
|
8955
|
-
description: supportsWeightedCards ? "Using getWeightedCards() API with scored candidates" : "
|
|
7866
|
+
description: supportsWeightedCards ? "Using getWeightedCards() API with scored candidates" : "ERROR: getWeightedCards() not a function."
|
|
8956
7867
|
},
|
|
8957
7868
|
reviewQueue: {
|
|
8958
7869
|
length: this.reviewQ.length,
|
|
@@ -8971,162 +7882,97 @@ var SessionController = class extends Loggable {
|
|
|
8971
7882
|
},
|
|
8972
7883
|
hydratedCache: {
|
|
8973
7884
|
count: this.hydrationService.hydratedCount,
|
|
8974
|
-
|
|
8975
|
-
items: extractHydratedItems()
|
|
7885
|
+
cardIds: this.hydrationService.getHydratedCardIds()
|
|
8976
7886
|
}
|
|
8977
7887
|
};
|
|
8978
7888
|
}
|
|
8979
7889
|
/**
|
|
8980
|
-
* Fetch content using the
|
|
7890
|
+
* Fetch content using the getWeightedCards API and mix across sources.
|
|
8981
7891
|
*
|
|
8982
|
-
* This method
|
|
8983
|
-
*
|
|
8984
|
-
*
|
|
8985
|
-
*
|
|
8986
|
-
*
|
|
8987
|
-
* 1. Fetch weighted cards to get scoring/ordering information
|
|
8988
|
-
* 2. Fetch full review data via legacy getPendingReviews()
|
|
8989
|
-
* 3. Order reviews by their weighted scores
|
|
8990
|
-
* 4. Add new cards ordered by their weighted scores
|
|
7892
|
+
* This method:
|
|
7893
|
+
* 1. Fetches weighted cards from each source
|
|
7894
|
+
* 2. Fetches full review data (we need ScheduledCard fields for queue)
|
|
7895
|
+
* 3. Uses SourceMixer to balance content across sources
|
|
7896
|
+
* 4. Populates review and new card queues with mixed results
|
|
8991
7897
|
*/
|
|
8992
7898
|
async getWeightedContent() {
|
|
8993
7899
|
const limit = 20;
|
|
8994
|
-
const
|
|
8995
|
-
|
|
8996
|
-
|
|
8997
|
-
for (const source of this.sources) {
|
|
7900
|
+
const batches = [];
|
|
7901
|
+
for (let i = 0; i < this.sources.length; i++) {
|
|
7902
|
+
const source = this.sources[i];
|
|
8998
7903
|
try {
|
|
8999
|
-
const
|
|
9000
|
-
|
|
9001
|
-
|
|
7904
|
+
const weighted = await source.getWeightedCards(limit);
|
|
7905
|
+
batches.push({
|
|
7906
|
+
sourceIndex: i,
|
|
7907
|
+
weighted
|
|
9002
7908
|
});
|
|
9003
|
-
allReviews.push(...reviews);
|
|
9004
|
-
if (typeof source.getWeightedCards === "function") {
|
|
9005
|
-
const weighted = await source.getWeightedCards(limit);
|
|
9006
|
-
allWeighted.push(...weighted);
|
|
9007
|
-
} else {
|
|
9008
|
-
const newCards = await source.getNewCards(limit);
|
|
9009
|
-
allNewCards.push(...newCards);
|
|
9010
|
-
allWeighted.push(
|
|
9011
|
-
...newCards.map((c) => ({
|
|
9012
|
-
cardId: c.cardID,
|
|
9013
|
-
courseId: c.courseID,
|
|
9014
|
-
score: 1,
|
|
9015
|
-
provenance: [
|
|
9016
|
-
{
|
|
9017
|
-
strategy: "legacy",
|
|
9018
|
-
strategyName: "Legacy Fallback",
|
|
9019
|
-
strategyId: "legacy-fallback",
|
|
9020
|
-
action: "generated",
|
|
9021
|
-
score: 1,
|
|
9022
|
-
reason: "Fallback to legacy getNewCards(), new card"
|
|
9023
|
-
}
|
|
9024
|
-
]
|
|
9025
|
-
})),
|
|
9026
|
-
...reviews.map((r) => ({
|
|
9027
|
-
cardId: r.cardID,
|
|
9028
|
-
courseId: r.courseID,
|
|
9029
|
-
score: 1,
|
|
9030
|
-
provenance: [
|
|
9031
|
-
{
|
|
9032
|
-
strategy: "legacy",
|
|
9033
|
-
strategyName: "Legacy Fallback",
|
|
9034
|
-
strategyId: "legacy-fallback",
|
|
9035
|
-
action: "generated",
|
|
9036
|
-
score: 1,
|
|
9037
|
-
reason: "Fallback to legacy getPendingReviews(), review"
|
|
9038
|
-
}
|
|
9039
|
-
]
|
|
9040
|
-
}))
|
|
9041
|
-
);
|
|
9042
|
-
}
|
|
9043
7909
|
} catch (error) {
|
|
9044
|
-
this.error(`Failed to get content from source:`, error);
|
|
7910
|
+
this.error(`Failed to get content from source ${i}:`, error);
|
|
7911
|
+
if (this.sources.length === 1) {
|
|
7912
|
+
throw new Error(`Cannot start session: failed to load content from source ${i}`);
|
|
7913
|
+
}
|
|
9045
7914
|
}
|
|
9046
7915
|
}
|
|
9047
|
-
|
|
9048
|
-
|
|
9049
|
-
|
|
9050
|
-
|
|
7916
|
+
if (batches.length === 0) {
|
|
7917
|
+
throw new Error(
|
|
7918
|
+
`Cannot start session: failed to load content from all ${this.sources.length} source(s). Check logs for details.`
|
|
7919
|
+
);
|
|
9051
7920
|
}
|
|
9052
|
-
const
|
|
9053
|
-
|
|
9054
|
-
|
|
9055
|
-
})
|
|
9056
|
-
|
|
9057
|
-
|
|
9058
|
-
|
|
9059
|
-
|
|
9060
|
-
|
|
7921
|
+
const mixedWeighted = this.mixer.mix(batches, limit * this.sources.length);
|
|
7922
|
+
const reviewWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "review");
|
|
7923
|
+
const newWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "new");
|
|
7924
|
+
logger.debug(`[reviews] got ${reviewWeighted.length} reviews from mixer`);
|
|
7925
|
+
let report = "Mixed content session created with:\n";
|
|
7926
|
+
for (const w of reviewWeighted) {
|
|
7927
|
+
const reviewItem = {
|
|
7928
|
+
cardID: w.cardId,
|
|
7929
|
+
courseID: w.courseId,
|
|
7930
|
+
contentSourceType: "course",
|
|
7931
|
+
contentSourceID: w.courseId,
|
|
7932
|
+
reviewID: w.reviewID,
|
|
7933
|
+
status: "review"
|
|
7934
|
+
};
|
|
7935
|
+
this.reviewQ.add(reviewItem, reviewItem.cardID);
|
|
7936
|
+
report += `Review: ${w.courseId}::${w.cardId} (score: ${w.score.toFixed(2)})
|
|
9061
7937
|
`;
|
|
9062
7938
|
}
|
|
9063
|
-
|
|
9064
|
-
for (const card of newCardWeighted) {
|
|
7939
|
+
for (const w of newWeighted) {
|
|
9065
7940
|
const newItem = {
|
|
9066
|
-
cardID:
|
|
9067
|
-
courseID:
|
|
7941
|
+
cardID: w.cardId,
|
|
7942
|
+
courseID: w.courseId,
|
|
9068
7943
|
contentSourceType: "course",
|
|
9069
|
-
contentSourceID:
|
|
7944
|
+
contentSourceID: w.courseId,
|
|
9070
7945
|
status: "new"
|
|
9071
7946
|
};
|
|
9072
|
-
this.newQ.add(newItem,
|
|
9073
|
-
report += `New: ${
|
|
7947
|
+
this.newQ.add(newItem, newItem.cardID);
|
|
7948
|
+
report += `New: ${w.courseId}::${w.cardId} (score: ${w.score.toFixed(2)})
|
|
9074
7949
|
`;
|
|
9075
7950
|
}
|
|
9076
7951
|
this.log(report);
|
|
9077
7952
|
}
|
|
9078
7953
|
/**
|
|
9079
|
-
*
|
|
9080
|
-
*
|
|
7954
|
+
* Returns items that should be pre-hydrated.
|
|
7955
|
+
* Deterministic: top N items from each queue to ensure coverage.
|
|
7956
|
+
* Failed queue items will typically already be hydrated (from initial render).
|
|
9081
7957
|
*/
|
|
9082
|
-
|
|
9083
|
-
const
|
|
9084
|
-
|
|
9085
|
-
|
|
9086
|
-
|
|
9087
|
-
return [];
|
|
9088
|
-
})
|
|
9089
|
-
)
|
|
9090
|
-
);
|
|
9091
|
-
const dueCards = [];
|
|
9092
|
-
while (reviews.length != 0 && reviews.some((r) => r.length > 0)) {
|
|
9093
|
-
const index = randomInt(0, reviews.length - 1);
|
|
9094
|
-
const source = reviews[index];
|
|
9095
|
-
if (source.length === 0) {
|
|
9096
|
-
reviews.splice(index, 1);
|
|
9097
|
-
continue;
|
|
9098
|
-
} else {
|
|
9099
|
-
dueCards.push(source.shift());
|
|
9100
|
-
}
|
|
7958
|
+
_getItemsToHydrate() {
|
|
7959
|
+
const items = [];
|
|
7960
|
+
const ITEMS_PER_QUEUE = 2;
|
|
7961
|
+
for (let i = 0; i < Math.min(ITEMS_PER_QUEUE, this.reviewQ.length); i++) {
|
|
7962
|
+
items.push(this.reviewQ.peek(i));
|
|
9101
7963
|
}
|
|
9102
|
-
let
|
|
9103
|
-
|
|
9104
|
-
|
|
9105
|
-
this.
|
|
7964
|
+
for (let i = 0; i < Math.min(ITEMS_PER_QUEUE, this.newQ.length); i++) {
|
|
7965
|
+
items.push(this.newQ.peek(i));
|
|
7966
|
+
}
|
|
7967
|
+
for (let i = 0; i < Math.min(ITEMS_PER_QUEUE, this.failedQ.length); i++) {
|
|
7968
|
+
items.push(this.failedQ.peek(i));
|
|
7969
|
+
}
|
|
7970
|
+
return items;
|
|
9106
7971
|
}
|
|
9107
7972
|
/**
|
|
9108
|
-
*
|
|
9109
|
-
*
|
|
7973
|
+
* Selects the next item to present to the user.
|
|
7974
|
+
* Nondeterministic: uses probability to balance between queues based on session state.
|
|
9110
7975
|
*/
|
|
9111
|
-
async getNewCards(n = 10) {
|
|
9112
|
-
const perCourse = Math.ceil(n / this.sources.length);
|
|
9113
|
-
const newContent = await Promise.all(this.sources.map((c) => c.getNewCards(perCourse)));
|
|
9114
|
-
newContent.forEach((newContentFromSource) => {
|
|
9115
|
-
newContentFromSource.filter((c) => {
|
|
9116
|
-
return this._sessionRecord.find((record) => record.card.card_id === c.cardID) === void 0;
|
|
9117
|
-
});
|
|
9118
|
-
});
|
|
9119
|
-
while (n > 0 && newContent.some((nc) => nc.length > 0)) {
|
|
9120
|
-
for (let i = 0; i < newContent.length; i++) {
|
|
9121
|
-
if (newContent[i].length > 0) {
|
|
9122
|
-
const item = newContent[i].splice(0, 1)[0];
|
|
9123
|
-
this.log(`Adding new card: ${item.courseID}::${item.cardID}`);
|
|
9124
|
-
this.newQ.add(item, item.cardID);
|
|
9125
|
-
n--;
|
|
9126
|
-
}
|
|
9127
|
-
}
|
|
9128
|
-
}
|
|
9129
|
-
}
|
|
9130
7976
|
_selectNextItemToHydrate() {
|
|
9131
7977
|
const choice = Math.random();
|
|
9132
7978
|
let newBound = 0.1;
|
|
@@ -9183,16 +8029,18 @@ var SessionController = class extends Loggable {
|
|
|
9183
8029
|
this._currentCard = null;
|
|
9184
8030
|
return null;
|
|
9185
8031
|
}
|
|
9186
|
-
|
|
9187
|
-
if (!
|
|
9188
|
-
card = await this.hydrationService.waitForHydratedCard();
|
|
9189
|
-
}
|
|
9190
|
-
await this.hydrationService.ensureHydratedCards();
|
|
9191
|
-
if (card) {
|
|
9192
|
-
this._currentCard = card;
|
|
9193
|
-
} else {
|
|
8032
|
+
const nextItem = this._selectNextItemToHydrate();
|
|
8033
|
+
if (!nextItem) {
|
|
9194
8034
|
this._currentCard = null;
|
|
8035
|
+
return null;
|
|
9195
8036
|
}
|
|
8037
|
+
let card = this.hydrationService.getHydratedCard(nextItem.cardID);
|
|
8038
|
+
if (!card) {
|
|
8039
|
+
card = await this.hydrationService.waitForCard(nextItem.cardID);
|
|
8040
|
+
}
|
|
8041
|
+
this.removeItemFromQueue(nextItem);
|
|
8042
|
+
await this.hydrationService.ensureHydratedCards();
|
|
8043
|
+
this._currentCard = card;
|
|
9196
8044
|
return card;
|
|
9197
8045
|
}
|
|
9198
8046
|
/**
|
|
@@ -9228,8 +8076,8 @@ var SessionController = class extends Loggable {
|
|
|
9228
8076
|
dismissCurrentCard(action = "dismiss-success") {
|
|
9229
8077
|
if (this._currentCard) {
|
|
9230
8078
|
if (action === "dismiss-success") {
|
|
8079
|
+
this.hydrationService.removeCard(this._currentCard.item.cardID);
|
|
9231
8080
|
} else if (action === "marked-failed") {
|
|
9232
|
-
this.hydrationService.cacheFailedCard(this._currentCard);
|
|
9233
8081
|
let failedItem;
|
|
9234
8082
|
if (isReview(this._currentCard.item)) {
|
|
9235
8083
|
failedItem = {
|
|
@@ -9251,22 +8099,21 @@ var SessionController = class extends Loggable {
|
|
|
9251
8099
|
}
|
|
9252
8100
|
this.failedQ.add(failedItem, failedItem.cardID);
|
|
9253
8101
|
} else if (action === "dismiss-error") {
|
|
8102
|
+
this.hydrationService.removeCard(this._currentCard.item.cardID);
|
|
9254
8103
|
} else if (action === "dismiss-failed") {
|
|
8104
|
+
this.hydrationService.removeCard(this._currentCard.item.cardID);
|
|
9255
8105
|
}
|
|
9256
8106
|
}
|
|
9257
8107
|
}
|
|
9258
|
-
hasAvailableCards() {
|
|
9259
|
-
return this.reviewQ.length > 0 || this.newQ.length > 0 || this.failedQ.length > 0;
|
|
9260
|
-
}
|
|
9261
8108
|
/**
|
|
9262
|
-
*
|
|
8109
|
+
* Remove an item from its source queue after consumption by nextCard().
|
|
9263
8110
|
*/
|
|
9264
8111
|
removeItemFromQueue(item) {
|
|
9265
|
-
if (this.reviewQ.peek(0) === item) {
|
|
8112
|
+
if (this.reviewQ.peek(0)?.cardID === item.cardID) {
|
|
9266
8113
|
this.reviewQ.dequeue((queueItem) => queueItem.cardID);
|
|
9267
|
-
} else if (this.newQ.peek(0) === item) {
|
|
8114
|
+
} else if (this.newQ.peek(0)?.cardID === item.cardID) {
|
|
9268
8115
|
this.newQ.dequeue((queueItem) => queueItem.cardID);
|
|
9269
|
-
} else {
|
|
8116
|
+
} else if (this.failedQ.peek(0)?.cardID === item.cardID) {
|
|
9270
8117
|
this.failedQ.dequeue((queueItem) => queueItem.cardID);
|
|
9271
8118
|
}
|
|
9272
8119
|
}
|
|
@@ -9291,6 +8138,7 @@ export {
|
|
|
9291
8138
|
NavigatorRole,
|
|
9292
8139
|
NavigatorRoles,
|
|
9293
8140
|
Navigators,
|
|
8141
|
+
QuotaRoundRobinMixer,
|
|
9294
8142
|
SessionController,
|
|
9295
8143
|
StaticToCouchDBMigrator,
|
|
9296
8144
|
TagFilteredContentSource,
|
|
@@ -9304,22 +8152,17 @@ export {
|
|
|
9304
8152
|
getCardOrigin,
|
|
9305
8153
|
getDataLayer,
|
|
9306
8154
|
getDbPath,
|
|
9307
|
-
getLogFilePath,
|
|
9308
8155
|
getStudySource,
|
|
9309
8156
|
importParsedCards,
|
|
9310
8157
|
initializeDataDirectory,
|
|
9311
8158
|
initializeDataLayer,
|
|
9312
|
-
initializeTuiLogging,
|
|
9313
8159
|
isFilter,
|
|
9314
8160
|
isGenerator,
|
|
9315
8161
|
isQuestionRecord,
|
|
9316
8162
|
isReview,
|
|
9317
8163
|
log,
|
|
9318
|
-
logger2 as logger,
|
|
9319
8164
|
newInterval,
|
|
9320
8165
|
parseCardHistoryID,
|
|
9321
|
-
showUserError,
|
|
9322
|
-
showUserMessage,
|
|
9323
8166
|
validateMigration,
|
|
9324
8167
|
validateProcessorConfig,
|
|
9325
8168
|
validateStaticCourse
|