@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.js
CHANGED
|
@@ -5,11 +5,6 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
|
5
5
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
6
|
var __getProtoOf = Object.getPrototypeOf;
|
|
7
7
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
-
var __glob = (map) => (path3) => {
|
|
9
|
-
var fn = map[path3];
|
|
10
|
-
if (fn) return fn();
|
|
11
|
-
throw new Error("Module not found in bundle: " + path3);
|
|
12
|
-
};
|
|
13
8
|
var __esm = (fn, res) => function __init() {
|
|
14
9
|
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
15
10
|
};
|
|
@@ -188,6 +183,9 @@ var init_pouchdb_setup = __esm({
|
|
|
188
183
|
import_pouchdb_authentication = __toESM(require("@nilock2/pouchdb-authentication"), 1);
|
|
189
184
|
import_pouchdb.default.plugin(import_pouchdb_find.default);
|
|
190
185
|
import_pouchdb.default.plugin(import_pouchdb_authentication.default);
|
|
186
|
+
if (typeof import_pouchdb.default.debug !== "undefined") {
|
|
187
|
+
import_pouchdb.default.debug.disable();
|
|
188
|
+
}
|
|
191
189
|
import_pouchdb.default.defaults({
|
|
192
190
|
// ajax: {
|
|
193
191
|
// timeout: 60000,
|
|
@@ -197,109 +195,18 @@ var init_pouchdb_setup = __esm({
|
|
|
197
195
|
}
|
|
198
196
|
});
|
|
199
197
|
|
|
200
|
-
// src/util/tuiLogger.ts
|
|
201
|
-
function initializeTuiLogging() {
|
|
202
|
-
isNodeEnvironment = typeof window === "undefined" && typeof process !== "undefined";
|
|
203
|
-
if (!isNodeEnvironment) {
|
|
204
|
-
return;
|
|
205
|
-
}
|
|
206
|
-
try {
|
|
207
|
-
logFile = path.join(getAppDataDirectory(), "lastrun.log");
|
|
208
|
-
if (fs.existsSync(logFile)) {
|
|
209
|
-
fs.unlinkSync(logFile);
|
|
210
|
-
}
|
|
211
|
-
const startTime = (/* @__PURE__ */ new Date()).toISOString();
|
|
212
|
-
fs.writeFileSync(logFile, `=== TUI Session Started: ${startTime} ===
|
|
213
|
-
`);
|
|
214
|
-
const originalConsole = {
|
|
215
|
-
// eslint-disable-next-line no-console
|
|
216
|
-
log: console.log,
|
|
217
|
-
// eslint-disable-next-line no-console
|
|
218
|
-
error: console.error,
|
|
219
|
-
// eslint-disable-next-line no-console
|
|
220
|
-
warn: console.warn,
|
|
221
|
-
// eslint-disable-next-line no-console
|
|
222
|
-
info: console.info
|
|
223
|
-
};
|
|
224
|
-
const writeToLog = (level, args) => {
|
|
225
|
-
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
226
|
-
const message = args.map(
|
|
227
|
-
(arg) => typeof arg === "object" ? JSON.stringify(arg, null, 2) : String(arg)
|
|
228
|
-
).join(" ");
|
|
229
|
-
const logEntry = `[${timestamp}] ${level}: ${message}
|
|
230
|
-
`;
|
|
231
|
-
try {
|
|
232
|
-
fs.appendFileSync(logFile, logEntry);
|
|
233
|
-
} catch (err) {
|
|
234
|
-
originalConsole.error("Failed to write to log file:", err);
|
|
235
|
-
originalConsole[level.toLowerCase()](...args);
|
|
236
|
-
}
|
|
237
|
-
};
|
|
238
|
-
console.log = (...args) => writeToLog("INFO", args);
|
|
239
|
-
console.info = (...args) => writeToLog("INFO", args);
|
|
240
|
-
console.warn = (...args) => writeToLog("WARN", args);
|
|
241
|
-
console.error = (...args) => writeToLog("ERROR", args);
|
|
242
|
-
console._originalMethods = originalConsole;
|
|
243
|
-
console.log("TUI logging initialized - logs redirected to", logFile);
|
|
244
|
-
} catch (err) {
|
|
245
|
-
console.error("Failed to initialize TUI logging:", err);
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
function getLogFilePath() {
|
|
249
|
-
return logFile;
|
|
250
|
-
}
|
|
251
|
-
function showUserMessage(message) {
|
|
252
|
-
if (isNodeEnvironment) {
|
|
253
|
-
process.stdout.write(message + "\n");
|
|
254
|
-
} else {
|
|
255
|
-
console.log(message);
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
function showUserError(message) {
|
|
259
|
-
if (isNodeEnvironment) {
|
|
260
|
-
process.stderr.write("Error: " + message + "\n");
|
|
261
|
-
} else {
|
|
262
|
-
console.error(message);
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
var fs, path, logFile, isNodeEnvironment, logger2;
|
|
266
|
-
var init_tuiLogger = __esm({
|
|
267
|
-
"src/util/tuiLogger.ts"() {
|
|
268
|
-
"use strict";
|
|
269
|
-
fs = __toESM(require("fs"), 1);
|
|
270
|
-
path = __toESM(require("path"), 1);
|
|
271
|
-
init_dataDirectory();
|
|
272
|
-
logFile = null;
|
|
273
|
-
isNodeEnvironment = false;
|
|
274
|
-
logger2 = {
|
|
275
|
-
debug: (message, ...args) => {
|
|
276
|
-
console.log(`[DEBUG] ${message}`, ...args);
|
|
277
|
-
},
|
|
278
|
-
info: (message, ...args) => {
|
|
279
|
-
console.info(`[INFO] ${message}`, ...args);
|
|
280
|
-
},
|
|
281
|
-
warn: (message, ...args) => {
|
|
282
|
-
console.warn(`[WARN] ${message}`, ...args);
|
|
283
|
-
},
|
|
284
|
-
error: (message, ...args) => {
|
|
285
|
-
console.error(`[ERROR] ${message}`, ...args);
|
|
286
|
-
}
|
|
287
|
-
};
|
|
288
|
-
}
|
|
289
|
-
});
|
|
290
|
-
|
|
291
198
|
// src/util/dataDirectory.ts
|
|
292
199
|
function getAppDataDirectory() {
|
|
293
200
|
if (ENV.LOCAL_STORAGE_PREFIX) {
|
|
294
|
-
return
|
|
201
|
+
return path.join(os.homedir(), `.tuilder`, ENV.LOCAL_STORAGE_PREFIX);
|
|
295
202
|
} else {
|
|
296
|
-
return
|
|
203
|
+
return path.join(os.homedir(), ".tuilder");
|
|
297
204
|
}
|
|
298
205
|
}
|
|
299
206
|
async function ensureAppDataDirectory() {
|
|
300
207
|
const appDataDir = getAppDataDirectory();
|
|
301
208
|
try {
|
|
302
|
-
await
|
|
209
|
+
await fs.promises.mkdir(appDataDir, { recursive: true });
|
|
303
210
|
} catch (err) {
|
|
304
211
|
if (err.code !== "EEXIST") {
|
|
305
212
|
throw new Error(`Failed to create app data directory ${appDataDir}: ${err.message}`);
|
|
@@ -308,20 +215,20 @@ async function ensureAppDataDirectory() {
|
|
|
308
215
|
return appDataDir;
|
|
309
216
|
}
|
|
310
217
|
function getDbPath(dbName) {
|
|
311
|
-
return
|
|
218
|
+
return path.join(getAppDataDirectory(), dbName);
|
|
312
219
|
}
|
|
313
220
|
async function initializeDataDirectory() {
|
|
314
221
|
await ensureAppDataDirectory();
|
|
315
|
-
|
|
222
|
+
logger.info(`PouchDB data directory initialized: ${getAppDataDirectory()}`);
|
|
316
223
|
}
|
|
317
|
-
var
|
|
224
|
+
var fs, path, os;
|
|
318
225
|
var init_dataDirectory = __esm({
|
|
319
226
|
"src/util/dataDirectory.ts"() {
|
|
320
227
|
"use strict";
|
|
321
|
-
|
|
322
|
-
|
|
228
|
+
fs = __toESM(require("fs"), 1);
|
|
229
|
+
path = __toESM(require("path"), 1);
|
|
323
230
|
os = __toESM(require("os"), 1);
|
|
324
|
-
|
|
231
|
+
init_logger();
|
|
325
232
|
init_factory();
|
|
326
233
|
}
|
|
327
234
|
});
|
|
@@ -947,195 +854,187 @@ var init_courseLookupDB = __esm({
|
|
|
947
854
|
}
|
|
948
855
|
});
|
|
949
856
|
|
|
950
|
-
// src/core/navigators/
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
857
|
+
// src/core/navigators/index.ts
|
|
858
|
+
function getCardOrigin(card) {
|
|
859
|
+
if (card.provenance.length === 0) {
|
|
860
|
+
throw new Error("Card has no provenance - cannot determine origin");
|
|
861
|
+
}
|
|
862
|
+
const firstEntry = card.provenance[0];
|
|
863
|
+
const reason = firstEntry.reason.toLowerCase();
|
|
864
|
+
if (reason.includes("failed")) {
|
|
865
|
+
return "failed";
|
|
866
|
+
}
|
|
867
|
+
if (reason.includes("review")) {
|
|
868
|
+
return "review";
|
|
869
|
+
}
|
|
870
|
+
return "new";
|
|
871
|
+
}
|
|
872
|
+
function isGenerator(impl) {
|
|
873
|
+
return NavigatorRoles[impl] === "generator" /* GENERATOR */;
|
|
874
|
+
}
|
|
875
|
+
function isFilter(impl) {
|
|
876
|
+
return NavigatorRoles[impl] === "filter" /* FILTER */;
|
|
877
|
+
}
|
|
878
|
+
var Navigators, NavigatorRole, NavigatorRoles, ContentNavigator;
|
|
879
|
+
var init_navigators = __esm({
|
|
880
|
+
"src/core/navigators/index.ts"() {
|
|
959
881
|
"use strict";
|
|
960
|
-
init_navigators();
|
|
961
882
|
init_logger();
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
883
|
+
Navigators = /* @__PURE__ */ ((Navigators2) => {
|
|
884
|
+
Navigators2["ELO"] = "elo";
|
|
885
|
+
Navigators2["SRS"] = "srs";
|
|
886
|
+
Navigators2["HIERARCHY"] = "hierarchyDefinition";
|
|
887
|
+
Navigators2["INTERFERENCE"] = "interferenceMitigator";
|
|
888
|
+
Navigators2["RELATIVE_PRIORITY"] = "relativePriority";
|
|
889
|
+
Navigators2["USER_TAG_PREFERENCE"] = "userTagPreference";
|
|
890
|
+
return Navigators2;
|
|
891
|
+
})(Navigators || {});
|
|
892
|
+
NavigatorRole = /* @__PURE__ */ ((NavigatorRole2) => {
|
|
893
|
+
NavigatorRole2["GENERATOR"] = "generator";
|
|
894
|
+
NavigatorRole2["FILTER"] = "filter";
|
|
895
|
+
return NavigatorRole2;
|
|
896
|
+
})(NavigatorRole || {});
|
|
897
|
+
NavigatorRoles = {
|
|
898
|
+
["elo" /* ELO */]: "generator" /* GENERATOR */,
|
|
899
|
+
["srs" /* SRS */]: "generator" /* GENERATOR */,
|
|
900
|
+
["hierarchyDefinition" /* HIERARCHY */]: "filter" /* FILTER */,
|
|
901
|
+
["interferenceMitigator" /* INTERFERENCE */]: "filter" /* FILTER */,
|
|
902
|
+
["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */,
|
|
903
|
+
["userTagPreference" /* USER_TAG_PREFERENCE */]: "filter" /* FILTER */
|
|
904
|
+
};
|
|
905
|
+
ContentNavigator = class {
|
|
906
|
+
/** User interface for this navigation session */
|
|
907
|
+
user;
|
|
908
|
+
/** Course interface for this navigation session */
|
|
909
|
+
course;
|
|
910
|
+
/** Human-readable name for this strategy instance (from ContentNavigationStrategyData.name) */
|
|
911
|
+
strategyName;
|
|
912
|
+
/** Unique document ID for this strategy instance (from ContentNavigationStrategyData._id) */
|
|
913
|
+
strategyId;
|
|
914
|
+
/**
|
|
915
|
+
* Constructor for standard navigators.
|
|
916
|
+
* Call this from subclass constructors to initialize common fields.
|
|
917
|
+
*
|
|
918
|
+
* Note: CompositeGenerator and Pipeline call super() without args, then set
|
|
919
|
+
* user/course fields directly if needed.
|
|
920
|
+
*/
|
|
921
|
+
constructor(user, course, strategyData) {
|
|
922
|
+
this.user = user;
|
|
923
|
+
this.course = course;
|
|
924
|
+
if (strategyData) {
|
|
925
|
+
this.strategyName = strategyData.name;
|
|
926
|
+
this.strategyId = strategyData._id;
|
|
981
927
|
}
|
|
982
|
-
logger.debug(
|
|
983
|
-
`[CompositeGenerator] Created with ${generators.length} generators, mode: ${aggregationMode}`
|
|
984
|
-
);
|
|
985
928
|
}
|
|
929
|
+
// ============================================================================
|
|
930
|
+
// STRATEGY STATE HELPERS
|
|
931
|
+
// ============================================================================
|
|
932
|
+
//
|
|
933
|
+
// These methods allow strategies to persist their own state (user preferences,
|
|
934
|
+
// learned patterns, temporal tracking) in the user database.
|
|
935
|
+
//
|
|
936
|
+
// ============================================================================
|
|
986
937
|
/**
|
|
987
|
-
*
|
|
938
|
+
* Unique key identifying this strategy for state storage.
|
|
988
939
|
*
|
|
989
|
-
*
|
|
940
|
+
* Defaults to the constructor name (e.g., "UserTagPreferenceFilter").
|
|
941
|
+
* Override in subclasses if multiple instances of the same strategy type
|
|
942
|
+
* need separate state storage.
|
|
990
943
|
*/
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
strategies.map((s) => ContentNavigator.create(user, course, s))
|
|
994
|
-
);
|
|
995
|
-
return new _CompositeGenerator(generators, aggregationMode);
|
|
944
|
+
get strategyKey() {
|
|
945
|
+
return this.constructor.name;
|
|
996
946
|
}
|
|
997
947
|
/**
|
|
998
|
-
* Get
|
|
999
|
-
*
|
|
1000
|
-
* Cards appearing in multiple generators receive a score boost.
|
|
1001
|
-
* Provenance tracks which generators produced each card and how scores were aggregated.
|
|
1002
|
-
*
|
|
1003
|
-
* This method supports both the legacy signature (limit only) and the
|
|
1004
|
-
* CardGenerator interface signature (limit, context).
|
|
948
|
+
* Get this strategy's persisted state for the current course.
|
|
1005
949
|
*
|
|
1006
|
-
* @
|
|
1007
|
-
* @
|
|
950
|
+
* @returns The strategy's data payload, or null if no state exists
|
|
951
|
+
* @throws Error if user or course is not initialized
|
|
1008
952
|
*/
|
|
1009
|
-
async
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
for (const cards of results) {
|
|
1015
|
-
for (const card of cards) {
|
|
1016
|
-
const existing = byCardId.get(card.cardId) || [];
|
|
1017
|
-
existing.push(card);
|
|
1018
|
-
byCardId.set(card.cardId, existing);
|
|
1019
|
-
}
|
|
1020
|
-
}
|
|
1021
|
-
const merged = [];
|
|
1022
|
-
for (const [, cards] of byCardId) {
|
|
1023
|
-
const aggregatedScore = this.aggregateScores(cards);
|
|
1024
|
-
const finalScore = Math.min(1, aggregatedScore);
|
|
1025
|
-
const mergedProvenance = cards.flatMap((c) => c.provenance);
|
|
1026
|
-
const initialScore = cards[0].score;
|
|
1027
|
-
const action = finalScore > initialScore ? "boosted" : finalScore < initialScore ? "penalized" : "passed";
|
|
1028
|
-
const reason = this.buildAggregationReason(cards, finalScore);
|
|
1029
|
-
merged.push({
|
|
1030
|
-
...cards[0],
|
|
1031
|
-
score: finalScore,
|
|
1032
|
-
provenance: [
|
|
1033
|
-
...mergedProvenance,
|
|
1034
|
-
{
|
|
1035
|
-
strategy: "composite",
|
|
1036
|
-
strategyName: "Composite Generator",
|
|
1037
|
-
strategyId: "COMPOSITE_GENERATOR",
|
|
1038
|
-
action,
|
|
1039
|
-
score: finalScore,
|
|
1040
|
-
reason
|
|
1041
|
-
}
|
|
1042
|
-
]
|
|
1043
|
-
});
|
|
953
|
+
async getStrategyState() {
|
|
954
|
+
if (!this.user || !this.course) {
|
|
955
|
+
throw new Error(
|
|
956
|
+
`Cannot get strategy state: navigator not properly initialized. Ensure user and course are provided to constructor.`
|
|
957
|
+
);
|
|
1044
958
|
}
|
|
1045
|
-
return
|
|
959
|
+
return this.user.getStrategyState(this.course.getCourseID(), this.strategyKey);
|
|
1046
960
|
}
|
|
1047
961
|
/**
|
|
1048
|
-
*
|
|
962
|
+
* Persist this strategy's state for the current course.
|
|
963
|
+
*
|
|
964
|
+
* @param data - The strategy's data payload to store
|
|
965
|
+
* @throws Error if user or course is not initialized
|
|
1049
966
|
*/
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
}
|
|
1056
|
-
const strategies = cards.map((c) => c.provenance[0]?.strategy || "unknown").join(", ");
|
|
1057
|
-
switch (this.aggregationMode) {
|
|
1058
|
-
case "max" /* MAX */:
|
|
1059
|
-
return `Max of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
|
|
1060
|
-
case "average" /* AVERAGE */:
|
|
1061
|
-
return `Average of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
|
|
1062
|
-
case "frequencyBoost" /* FREQUENCY_BOOST */: {
|
|
1063
|
-
const avg = cards.reduce((sum, c) => sum + c.score, 0) / count;
|
|
1064
|
-
const boost = 1 + FREQUENCY_BOOST_FACTOR * (count - 1);
|
|
1065
|
-
return `Frequency boost from ${count} generators (${strategies}): avg ${avg.toFixed(2)} \xD7 ${boost.toFixed(2)} \u2192 ${finalScore.toFixed(2)}`;
|
|
1066
|
-
}
|
|
1067
|
-
default:
|
|
1068
|
-
return `Aggregated from ${count} generators: ${finalScore.toFixed(2)}`;
|
|
967
|
+
async putStrategyState(data) {
|
|
968
|
+
if (!this.user || !this.course) {
|
|
969
|
+
throw new Error(
|
|
970
|
+
`Cannot put strategy state: navigator not properly initialized. Ensure user and course are provided to constructor.`
|
|
971
|
+
);
|
|
1069
972
|
}
|
|
973
|
+
return this.user.putStrategyState(this.course.getCourseID(), this.strategyKey, data);
|
|
1070
974
|
}
|
|
1071
975
|
/**
|
|
1072
|
-
*
|
|
976
|
+
* Factory method to create navigator instances dynamically.
|
|
977
|
+
*
|
|
978
|
+
* @param user - User interface
|
|
979
|
+
* @param course - Course interface
|
|
980
|
+
* @param strategyData - Strategy configuration document
|
|
981
|
+
* @returns the runtime object used to steer a study session.
|
|
1073
982
|
*/
|
|
1074
|
-
|
|
1075
|
-
const
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
983
|
+
static async create(user, course, strategyData) {
|
|
984
|
+
const implementingClass = strategyData.implementingClass;
|
|
985
|
+
let NavigatorImpl;
|
|
986
|
+
const variations = [".ts", ".js", ""];
|
|
987
|
+
const dirs = ["filters", "generators"];
|
|
988
|
+
for (const ext of variations) {
|
|
989
|
+
for (const dir of dirs) {
|
|
990
|
+
const loadFrom = `./${dir}/${implementingClass}${ext}`;
|
|
991
|
+
try {
|
|
992
|
+
const module2 = await import(loadFrom);
|
|
993
|
+
NavigatorImpl = module2.default;
|
|
994
|
+
break;
|
|
995
|
+
} catch (e) {
|
|
996
|
+
logger.debug(`Failed to load extension from ${loadFrom}:`, e);
|
|
997
|
+
}
|
|
1085
998
|
}
|
|
1086
|
-
default:
|
|
1087
|
-
return scores[0];
|
|
1088
999
|
}
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
* Get new cards from all generators, merged and deduplicated.
|
|
1092
|
-
*/
|
|
1093
|
-
async getNewCards(n) {
|
|
1094
|
-
const legacyGenerators = this.generators.filter(
|
|
1095
|
-
(g) => g instanceof ContentNavigator
|
|
1096
|
-
);
|
|
1097
|
-
const results = await Promise.all(legacyGenerators.map((g) => g.getNewCards(n)));
|
|
1098
|
-
const seen = /* @__PURE__ */ new Set();
|
|
1099
|
-
const merged = [];
|
|
1100
|
-
for (const cards of results) {
|
|
1101
|
-
for (const card of cards) {
|
|
1102
|
-
if (!seen.has(card.cardID)) {
|
|
1103
|
-
seen.add(card.cardID);
|
|
1104
|
-
merged.push(card);
|
|
1105
|
-
}
|
|
1106
|
-
}
|
|
1000
|
+
if (!NavigatorImpl) {
|
|
1001
|
+
throw new Error(`Could not load navigator implementation for: ${implementingClass}`);
|
|
1107
1002
|
}
|
|
1108
|
-
return
|
|
1003
|
+
return new NavigatorImpl(user, course, strategyData);
|
|
1109
1004
|
}
|
|
1110
1005
|
/**
|
|
1111
|
-
* Get
|
|
1006
|
+
* Get cards with suitability scores and provenance trails.
|
|
1007
|
+
*
|
|
1008
|
+
* **This is the PRIMARY API for navigation strategies.**
|
|
1009
|
+
*
|
|
1010
|
+
* Returns cards ranked by suitability score (0-1). Higher scores indicate
|
|
1011
|
+
* better candidates for presentation. Each card includes a provenance trail
|
|
1012
|
+
* documenting how strategies contributed to the final score.
|
|
1013
|
+
*
|
|
1014
|
+
* ## Implementation Required
|
|
1015
|
+
* All navigation strategies MUST override this method. The base class does
|
|
1016
|
+
* not provide a default implementation.
|
|
1017
|
+
*
|
|
1018
|
+
* ## For Generators
|
|
1019
|
+
* Override this method to generate candidates and compute scores based on
|
|
1020
|
+
* your strategy's logic (e.g., ELO proximity, review urgency). Create the
|
|
1021
|
+
* initial provenance entry with action='generated'.
|
|
1022
|
+
*
|
|
1023
|
+
* ## For Filters
|
|
1024
|
+
* Filters should implement the CardFilter interface instead and be composed
|
|
1025
|
+
* via Pipeline. Filters do not directly implement getWeightedCards().
|
|
1026
|
+
*
|
|
1027
|
+
* @param limit - Maximum cards to return
|
|
1028
|
+
* @returns Cards sorted by score descending, with provenance trails
|
|
1112
1029
|
*/
|
|
1113
|
-
async
|
|
1114
|
-
|
|
1115
|
-
(g) => g instanceof ContentNavigator
|
|
1116
|
-
);
|
|
1117
|
-
const results = await Promise.all(legacyGenerators.map((g) => g.getPendingReviews()));
|
|
1118
|
-
const seen = /* @__PURE__ */ new Set();
|
|
1119
|
-
const merged = [];
|
|
1120
|
-
for (const reviews of results) {
|
|
1121
|
-
for (const review of reviews) {
|
|
1122
|
-
if (!seen.has(review.cardID)) {
|
|
1123
|
-
seen.add(review.cardID);
|
|
1124
|
-
merged.push(review);
|
|
1125
|
-
}
|
|
1126
|
-
}
|
|
1127
|
-
}
|
|
1128
|
-
return merged;
|
|
1030
|
+
async getWeightedCards(_limit) {
|
|
1031
|
+
throw new Error(`${this.constructor.name} must implement getWeightedCards(). `);
|
|
1129
1032
|
}
|
|
1130
1033
|
};
|
|
1131
1034
|
}
|
|
1132
1035
|
});
|
|
1133
1036
|
|
|
1134
1037
|
// src/core/navigators/Pipeline.ts
|
|
1135
|
-
var Pipeline_exports = {};
|
|
1136
|
-
__export(Pipeline_exports, {
|
|
1137
|
-
Pipeline: () => Pipeline
|
|
1138
|
-
});
|
|
1139
1038
|
function logPipelineConfig(generator, filters) {
|
|
1140
1039
|
const filterList = filters.length > 0 ? "\n - " + filters.map((f) => f.name).join("\n - ") : " none";
|
|
1141
1040
|
logger.info(
|
|
@@ -1195,6 +1094,11 @@ var init_Pipeline = __esm({
|
|
|
1195
1094
|
this.filters = filters;
|
|
1196
1095
|
this.user = user;
|
|
1197
1096
|
this.course = course;
|
|
1097
|
+
course.getCourseConfig().then((cfg) => {
|
|
1098
|
+
logger.debug(`[pipeline] Crated pipeline for ${cfg.name}`);
|
|
1099
|
+
}).catch((e) => {
|
|
1100
|
+
logger.error(`[pipeline] Failed to lookup courseCfg: ${e}`);
|
|
1101
|
+
});
|
|
1198
1102
|
logPipelineConfig(generator, filters);
|
|
1199
1103
|
}
|
|
1200
1104
|
/**
|
|
@@ -1231,7 +1135,13 @@ var init_Pipeline = __esm({
|
|
|
1231
1135
|
cards.sort((a, b) => b.score - a.score);
|
|
1232
1136
|
const result = cards.slice(0, limit);
|
|
1233
1137
|
const topScores = result.slice(0, 3).map((c) => c.score);
|
|
1234
|
-
logExecutionSummary(
|
|
1138
|
+
logExecutionSummary(
|
|
1139
|
+
this.generator.name,
|
|
1140
|
+
generatedCount,
|
|
1141
|
+
this.filters.length,
|
|
1142
|
+
result.length,
|
|
1143
|
+
topScores
|
|
1144
|
+
);
|
|
1235
1145
|
logCardProvenance(result, 3);
|
|
1236
1146
|
return result;
|
|
1237
1147
|
}
|
|
@@ -1280,48 +1190,155 @@ var init_Pipeline = __esm({
|
|
|
1280
1190
|
userElo
|
|
1281
1191
|
};
|
|
1282
1192
|
}
|
|
1283
|
-
// ===========================================================================
|
|
1284
|
-
// Legacy StudyContentSource methods
|
|
1285
|
-
// ===========================================================================
|
|
1286
|
-
//
|
|
1287
|
-
// These delegate to the generator for backward compatibility.
|
|
1288
|
-
// Eventually SessionController will use getWeightedCards() exclusively.
|
|
1289
|
-
//
|
|
1290
1193
|
/**
|
|
1291
|
-
* Get
|
|
1292
|
-
* Delegates to the generator if it supports the legacy interface.
|
|
1194
|
+
* Get the course ID for this pipeline.
|
|
1293
1195
|
*/
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1196
|
+
getCourseID() {
|
|
1197
|
+
return this.course.getCourseID();
|
|
1198
|
+
}
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
});
|
|
1202
|
+
|
|
1203
|
+
// src/core/navigators/generators/CompositeGenerator.ts
|
|
1204
|
+
var DEFAULT_AGGREGATION_MODE, FREQUENCY_BOOST_FACTOR, CompositeGenerator;
|
|
1205
|
+
var init_CompositeGenerator = __esm({
|
|
1206
|
+
"src/core/navigators/generators/CompositeGenerator.ts"() {
|
|
1207
|
+
"use strict";
|
|
1208
|
+
init_navigators();
|
|
1209
|
+
init_logger();
|
|
1210
|
+
DEFAULT_AGGREGATION_MODE = "frequencyBoost" /* FREQUENCY_BOOST */;
|
|
1211
|
+
FREQUENCY_BOOST_FACTOR = 0.1;
|
|
1212
|
+
CompositeGenerator = class _CompositeGenerator extends ContentNavigator {
|
|
1213
|
+
/** Human-readable name for CardGenerator interface */
|
|
1214
|
+
name = "Composite Generator";
|
|
1215
|
+
generators;
|
|
1216
|
+
aggregationMode;
|
|
1217
|
+
constructor(generators, aggregationMode = DEFAULT_AGGREGATION_MODE) {
|
|
1218
|
+
super();
|
|
1219
|
+
this.generators = generators;
|
|
1220
|
+
this.aggregationMode = aggregationMode;
|
|
1221
|
+
if (generators.length === 0) {
|
|
1222
|
+
throw new Error("CompositeGenerator requires at least one generator");
|
|
1297
1223
|
}
|
|
1298
|
-
|
|
1224
|
+
logger.debug(
|
|
1225
|
+
`[CompositeGenerator] Created with ${generators.length} generators, mode: ${aggregationMode}`
|
|
1226
|
+
);
|
|
1299
1227
|
}
|
|
1300
1228
|
/**
|
|
1301
|
-
*
|
|
1302
|
-
*
|
|
1229
|
+
* Creates a CompositeGenerator from strategy data.
|
|
1230
|
+
*
|
|
1231
|
+
* This is a convenience factory for use by PipelineAssembler.
|
|
1303
1232
|
*/
|
|
1304
|
-
async
|
|
1305
|
-
|
|
1306
|
-
|
|
1233
|
+
static async fromStrategies(user, course, strategies, aggregationMode = DEFAULT_AGGREGATION_MODE) {
|
|
1234
|
+
const generators = await Promise.all(
|
|
1235
|
+
strategies.map((s) => ContentNavigator.create(user, course, s))
|
|
1236
|
+
);
|
|
1237
|
+
return new _CompositeGenerator(generators, aggregationMode);
|
|
1238
|
+
}
|
|
1239
|
+
/**
|
|
1240
|
+
* Get weighted cards from all generators, merge and deduplicate.
|
|
1241
|
+
*
|
|
1242
|
+
* Cards appearing in multiple generators receive a score boost.
|
|
1243
|
+
* Provenance tracks which generators produced each card and how scores were aggregated.
|
|
1244
|
+
*
|
|
1245
|
+
* This method supports both the legacy signature (limit only) and the
|
|
1246
|
+
* CardGenerator interface signature (limit, context).
|
|
1247
|
+
*
|
|
1248
|
+
* @param limit - Maximum number of cards to return
|
|
1249
|
+
* @param context - GeneratorContext passed to child generators (required when called via Pipeline)
|
|
1250
|
+
*/
|
|
1251
|
+
async getWeightedCards(limit, context) {
|
|
1252
|
+
if (!context) {
|
|
1253
|
+
throw new Error(
|
|
1254
|
+
"CompositeGenerator.getWeightedCards requires a GeneratorContext. It should be called via Pipeline, not directly."
|
|
1255
|
+
);
|
|
1307
1256
|
}
|
|
1308
|
-
|
|
1257
|
+
const results = await Promise.all(
|
|
1258
|
+
this.generators.map((g) => g.getWeightedCards(limit, context))
|
|
1259
|
+
);
|
|
1260
|
+
const byCardId = /* @__PURE__ */ new Map();
|
|
1261
|
+
for (const cards of results) {
|
|
1262
|
+
for (const card of cards) {
|
|
1263
|
+
const existing = byCardId.get(card.cardId) || [];
|
|
1264
|
+
existing.push(card);
|
|
1265
|
+
byCardId.set(card.cardId, existing);
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
const merged = [];
|
|
1269
|
+
for (const [, cards] of byCardId) {
|
|
1270
|
+
const aggregatedScore = this.aggregateScores(cards);
|
|
1271
|
+
const finalScore = Math.min(1, aggregatedScore);
|
|
1272
|
+
const mergedProvenance = cards.flatMap((c) => c.provenance);
|
|
1273
|
+
const initialScore = cards[0].score;
|
|
1274
|
+
const action = finalScore > initialScore ? "boosted" : finalScore < initialScore ? "penalized" : "passed";
|
|
1275
|
+
const reason = this.buildAggregationReason(cards, finalScore);
|
|
1276
|
+
merged.push({
|
|
1277
|
+
...cards[0],
|
|
1278
|
+
score: finalScore,
|
|
1279
|
+
provenance: [
|
|
1280
|
+
...mergedProvenance,
|
|
1281
|
+
{
|
|
1282
|
+
strategy: "composite",
|
|
1283
|
+
strategyName: "Composite Generator",
|
|
1284
|
+
strategyId: "COMPOSITE_GENERATOR",
|
|
1285
|
+
action,
|
|
1286
|
+
score: finalScore,
|
|
1287
|
+
reason
|
|
1288
|
+
}
|
|
1289
|
+
]
|
|
1290
|
+
});
|
|
1291
|
+
}
|
|
1292
|
+
return merged.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
1309
1293
|
}
|
|
1310
1294
|
/**
|
|
1311
|
-
*
|
|
1295
|
+
* Build human-readable reason for score aggregation.
|
|
1312
1296
|
*/
|
|
1313
|
-
|
|
1314
|
-
|
|
1297
|
+
buildAggregationReason(cards, finalScore) {
|
|
1298
|
+
const count = cards.length;
|
|
1299
|
+
const scores = cards.map((c) => c.score.toFixed(2)).join(", ");
|
|
1300
|
+
if (count === 1) {
|
|
1301
|
+
return `Single generator, score ${finalScore.toFixed(2)}`;
|
|
1302
|
+
}
|
|
1303
|
+
const strategies = cards.map((c) => c.provenance[0]?.strategy || "unknown").join(", ");
|
|
1304
|
+
switch (this.aggregationMode) {
|
|
1305
|
+
case "max" /* MAX */:
|
|
1306
|
+
return `Max of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
|
|
1307
|
+
case "average" /* AVERAGE */:
|
|
1308
|
+
return `Average of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
|
|
1309
|
+
case "frequencyBoost" /* FREQUENCY_BOOST */: {
|
|
1310
|
+
const avg = cards.reduce((sum, c) => sum + c.score, 0) / count;
|
|
1311
|
+
const boost = 1 + FREQUENCY_BOOST_FACTOR * (count - 1);
|
|
1312
|
+
return `Frequency boost from ${count} generators (${strategies}): avg ${avg.toFixed(2)} \xD7 ${boost.toFixed(2)} \u2192 ${finalScore.toFixed(2)}`;
|
|
1313
|
+
}
|
|
1314
|
+
default:
|
|
1315
|
+
return `Aggregated from ${count} generators: ${finalScore.toFixed(2)}`;
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
/**
|
|
1319
|
+
* Aggregate scores from multiple generators for the same card.
|
|
1320
|
+
*/
|
|
1321
|
+
aggregateScores(cards) {
|
|
1322
|
+
const scores = cards.map((c) => c.score);
|
|
1323
|
+
switch (this.aggregationMode) {
|
|
1324
|
+
case "max" /* MAX */:
|
|
1325
|
+
return Math.max(...scores);
|
|
1326
|
+
case "average" /* AVERAGE */:
|
|
1327
|
+
return scores.reduce((sum, s) => sum + s, 0) / scores.length;
|
|
1328
|
+
case "frequencyBoost" /* FREQUENCY_BOOST */: {
|
|
1329
|
+
const avg = scores.reduce((sum, s) => sum + s, 0) / scores.length;
|
|
1330
|
+
const frequencyBoost = 1 + FREQUENCY_BOOST_FACTOR * (cards.length - 1);
|
|
1331
|
+
return avg * frequencyBoost;
|
|
1332
|
+
}
|
|
1333
|
+
default:
|
|
1334
|
+
return scores[0];
|
|
1335
|
+
}
|
|
1315
1336
|
}
|
|
1316
1337
|
};
|
|
1317
1338
|
}
|
|
1318
1339
|
});
|
|
1319
1340
|
|
|
1320
1341
|
// src/core/navigators/PipelineAssembler.ts
|
|
1321
|
-
var PipelineAssembler_exports = {};
|
|
1322
|
-
__export(PipelineAssembler_exports, {
|
|
1323
|
-
PipelineAssembler: () => PipelineAssembler
|
|
1324
|
-
});
|
|
1325
1342
|
var PipelineAssembler;
|
|
1326
1343
|
var init_PipelineAssembler = __esm({
|
|
1327
1344
|
"src/core/navigators/PipelineAssembler.ts"() {
|
|
@@ -1442,14 +1459,10 @@ var init_PipelineAssembler = __esm({
|
|
|
1442
1459
|
}
|
|
1443
1460
|
});
|
|
1444
1461
|
|
|
1445
|
-
// src/core/navigators/elo.ts
|
|
1446
|
-
var elo_exports = {};
|
|
1447
|
-
__export(elo_exports, {
|
|
1448
|
-
default: () => ELONavigator
|
|
1449
|
-
});
|
|
1462
|
+
// src/core/navigators/generators/elo.ts
|
|
1450
1463
|
var import_common6, ELONavigator;
|
|
1451
1464
|
var init_elo = __esm({
|
|
1452
|
-
"src/core/navigators/elo.ts"() {
|
|
1465
|
+
"src/core/navigators/generators/elo.ts"() {
|
|
1453
1466
|
"use strict";
|
|
1454
1467
|
init_navigators();
|
|
1455
1468
|
import_common6 = require("@vue-skuilder/common");
|
|
@@ -1460,50 +1473,6 @@ var init_elo = __esm({
|
|
|
1460
1473
|
super(user, course, strategyData);
|
|
1461
1474
|
this.name = strategyData?.name || "ELO";
|
|
1462
1475
|
}
|
|
1463
|
-
async getPendingReviews() {
|
|
1464
|
-
const reviews = await this.user.getPendingReviews(this.course.getCourseID());
|
|
1465
|
-
const elo = await this.course.getCardEloData(reviews.map((r) => r.cardId));
|
|
1466
|
-
const ratedReviews = reviews.map((r, i) => {
|
|
1467
|
-
const ratedR = {
|
|
1468
|
-
...r,
|
|
1469
|
-
...elo[i]
|
|
1470
|
-
};
|
|
1471
|
-
return ratedR;
|
|
1472
|
-
});
|
|
1473
|
-
ratedReviews.sort((a, b) => {
|
|
1474
|
-
return a.global.score - b.global.score;
|
|
1475
|
-
});
|
|
1476
|
-
return ratedReviews.map((r) => {
|
|
1477
|
-
return {
|
|
1478
|
-
...r,
|
|
1479
|
-
contentSourceType: "course",
|
|
1480
|
-
contentSourceID: this.course.getCourseID(),
|
|
1481
|
-
cardID: r.cardId,
|
|
1482
|
-
courseID: r.courseId,
|
|
1483
|
-
qualifiedID: `${r.courseId}-${r.cardId}`,
|
|
1484
|
-
reviewID: r._id,
|
|
1485
|
-
status: "review"
|
|
1486
|
-
};
|
|
1487
|
-
});
|
|
1488
|
-
}
|
|
1489
|
-
async getNewCards(limit = 99) {
|
|
1490
|
-
const activeCards = await this.user.getActiveCards();
|
|
1491
|
-
return (await this.course.getCardsCenteredAtELO(
|
|
1492
|
-
{ limit, elo: "user" },
|
|
1493
|
-
(c) => {
|
|
1494
|
-
if (activeCards.some((ac) => c.cardID === ac.cardID)) {
|
|
1495
|
-
return false;
|
|
1496
|
-
} else {
|
|
1497
|
-
return true;
|
|
1498
|
-
}
|
|
1499
|
-
}
|
|
1500
|
-
)).map((c) => {
|
|
1501
|
-
return {
|
|
1502
|
-
...c,
|
|
1503
|
-
status: "new"
|
|
1504
|
-
};
|
|
1505
|
-
});
|
|
1506
|
-
}
|
|
1507
1476
|
/**
|
|
1508
1477
|
* Get new cards with suitability scores based on ELO distance.
|
|
1509
1478
|
*
|
|
@@ -1528,7 +1497,11 @@ var init_elo = __esm({
|
|
|
1528
1497
|
const userElo = (0, import_common6.toCourseElo)(courseReg.elo);
|
|
1529
1498
|
userGlobalElo = userElo.global.score;
|
|
1530
1499
|
}
|
|
1531
|
-
const
|
|
1500
|
+
const activeCards = await this.user.getActiveCards();
|
|
1501
|
+
const newCards = (await this.course.getCardsCenteredAtELO(
|
|
1502
|
+
{ limit, elo: "user" },
|
|
1503
|
+
(c) => !activeCards.some((ac) => c.cardID === ac.cardID)
|
|
1504
|
+
)).map((c) => ({ ...c, status: "new" }));
|
|
1532
1505
|
const cardIds = newCards.map((c) => c.cardID);
|
|
1533
1506
|
const cardEloData = await this.course.getCardEloData(cardIds);
|
|
1534
1507
|
const scored = newCards.map((c, i) => {
|
|
@@ -1558,925 +1531,14 @@ var init_elo = __esm({
|
|
|
1558
1531
|
}
|
|
1559
1532
|
});
|
|
1560
1533
|
|
|
1561
|
-
// src/core/navigators/
|
|
1562
|
-
var
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
DEFAULT_MAX_MULTIPLIER: () => DEFAULT_MAX_MULTIPLIER,
|
|
1566
|
-
DEFAULT_MIN_MULTIPLIER: () => DEFAULT_MIN_MULTIPLIER,
|
|
1567
|
-
createEloDistanceFilter: () => createEloDistanceFilter
|
|
1568
|
-
});
|
|
1569
|
-
function computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier) {
|
|
1570
|
-
const normalizedDistance = distance / halfLife;
|
|
1571
|
-
const decay = Math.exp(-(normalizedDistance * normalizedDistance));
|
|
1572
|
-
return minMultiplier + (maxMultiplier - minMultiplier) * decay;
|
|
1573
|
-
}
|
|
1574
|
-
function createEloDistanceFilter(config) {
|
|
1575
|
-
const halfLife = config?.halfLife ?? DEFAULT_HALF_LIFE;
|
|
1576
|
-
const minMultiplier = config?.minMultiplier ?? DEFAULT_MIN_MULTIPLIER;
|
|
1577
|
-
const maxMultiplier = config?.maxMultiplier ?? DEFAULT_MAX_MULTIPLIER;
|
|
1578
|
-
return {
|
|
1579
|
-
name: "ELO Distance Filter",
|
|
1580
|
-
async transform(cards, context) {
|
|
1581
|
-
const { course, userElo } = context;
|
|
1582
|
-
const cardIds = cards.map((c) => c.cardId);
|
|
1583
|
-
const cardElos = await course.getCardEloData(cardIds);
|
|
1584
|
-
return cards.map((card, i) => {
|
|
1585
|
-
const cardElo = cardElos[i]?.global?.score ?? 1e3;
|
|
1586
|
-
const distance = Math.abs(cardElo - userElo);
|
|
1587
|
-
const multiplier = computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier);
|
|
1588
|
-
const newScore = card.score * multiplier;
|
|
1589
|
-
const action = multiplier < maxMultiplier - 0.01 ? "penalized" : "passed";
|
|
1590
|
-
return {
|
|
1591
|
-
...card,
|
|
1592
|
-
score: newScore,
|
|
1593
|
-
provenance: [
|
|
1594
|
-
...card.provenance,
|
|
1595
|
-
{
|
|
1596
|
-
strategy: "eloDistance",
|
|
1597
|
-
strategyName: "ELO Distance Filter",
|
|
1598
|
-
strategyId: "ELO_DISTANCE_FILTER",
|
|
1599
|
-
action,
|
|
1600
|
-
score: newScore,
|
|
1601
|
-
reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userElo)}) \u2192 ${multiplier.toFixed(2)}x`
|
|
1602
|
-
}
|
|
1603
|
-
]
|
|
1604
|
-
};
|
|
1605
|
-
});
|
|
1606
|
-
}
|
|
1607
|
-
};
|
|
1608
|
-
}
|
|
1609
|
-
var DEFAULT_HALF_LIFE, DEFAULT_MIN_MULTIPLIER, DEFAULT_MAX_MULTIPLIER;
|
|
1610
|
-
var init_eloDistance = __esm({
|
|
1611
|
-
"src/core/navigators/filters/eloDistance.ts"() {
|
|
1612
|
-
"use strict";
|
|
1613
|
-
DEFAULT_HALF_LIFE = 200;
|
|
1614
|
-
DEFAULT_MIN_MULTIPLIER = 0.3;
|
|
1615
|
-
DEFAULT_MAX_MULTIPLIER = 1;
|
|
1616
|
-
}
|
|
1617
|
-
});
|
|
1618
|
-
|
|
1619
|
-
// src/core/navigators/filters/userTagPreference.ts
|
|
1620
|
-
var userTagPreference_exports = {};
|
|
1621
|
-
__export(userTagPreference_exports, {
|
|
1622
|
-
default: () => UserTagPreferenceFilter
|
|
1623
|
-
});
|
|
1624
|
-
var UserTagPreferenceFilter;
|
|
1625
|
-
var init_userTagPreference = __esm({
|
|
1626
|
-
"src/core/navigators/filters/userTagPreference.ts"() {
|
|
1534
|
+
// src/core/navigators/generators/srs.ts
|
|
1535
|
+
var import_moment3, SRSNavigator;
|
|
1536
|
+
var init_srs = __esm({
|
|
1537
|
+
"src/core/navigators/generators/srs.ts"() {
|
|
1627
1538
|
"use strict";
|
|
1539
|
+
import_moment3 = __toESM(require("moment"), 1);
|
|
1628
1540
|
init_navigators();
|
|
1629
|
-
|
|
1630
|
-
_strategyData;
|
|
1631
|
-
/** Human-readable name for CardFilter interface */
|
|
1632
|
-
name;
|
|
1633
|
-
constructor(user, course, strategyData) {
|
|
1634
|
-
super(user, course, strategyData);
|
|
1635
|
-
this._strategyData = strategyData;
|
|
1636
|
-
this.name = strategyData.name || "User Tag Preferences";
|
|
1637
|
-
}
|
|
1638
|
-
/**
|
|
1639
|
-
* Compute multiplier for a card based on its tags and user preferences.
|
|
1640
|
-
* Returns the maximum multiplier among all matching tags, or 1.0 if no matches.
|
|
1641
|
-
*/
|
|
1642
|
-
computeMultiplier(cardTags, boostMap) {
|
|
1643
|
-
const multipliers = cardTags.map((tag) => boostMap[tag]).filter((val) => val !== void 0);
|
|
1644
|
-
if (multipliers.length === 0) {
|
|
1645
|
-
return 1;
|
|
1646
|
-
}
|
|
1647
|
-
return Math.max(...multipliers);
|
|
1648
|
-
}
|
|
1649
|
-
/**
|
|
1650
|
-
* Build human-readable reason for the filter's decision.
|
|
1651
|
-
*/
|
|
1652
|
-
buildReason(cardTags, boostMap, multiplier) {
|
|
1653
|
-
const matchingTags = cardTags.filter((tag) => boostMap[tag] === multiplier);
|
|
1654
|
-
if (multiplier === 0) {
|
|
1655
|
-
return `Excluded by user preference: ${matchingTags.join(", ")} (${multiplier}x)`;
|
|
1656
|
-
}
|
|
1657
|
-
if (multiplier < 1) {
|
|
1658
|
-
return `Penalized by user preference: ${matchingTags.join(", ")} (${multiplier.toFixed(2)}x)`;
|
|
1659
|
-
}
|
|
1660
|
-
if (multiplier > 1) {
|
|
1661
|
-
return `Boosted by user preference: ${matchingTags.join(", ")} (${multiplier.toFixed(2)}x)`;
|
|
1662
|
-
}
|
|
1663
|
-
return "No matching user preferences";
|
|
1664
|
-
}
|
|
1665
|
-
/**
|
|
1666
|
-
* CardFilter.transform implementation.
|
|
1667
|
-
*
|
|
1668
|
-
* Apply user tag preferences:
|
|
1669
|
-
* 1. Read preferences from strategy state
|
|
1670
|
-
* 2. If no preferences, pass through unchanged
|
|
1671
|
-
* 3. For each card:
|
|
1672
|
-
* - Look up tag in boost record
|
|
1673
|
-
* - If tag found: apply multiplier (0 = exclude, 1 = neutral, >1 = boost)
|
|
1674
|
-
* - If multiple tags match: use max multiplier
|
|
1675
|
-
* - Append provenance with clear reason
|
|
1676
|
-
*/
|
|
1677
|
-
async transform(cards, _context) {
|
|
1678
|
-
const prefs = await this.getStrategyState();
|
|
1679
|
-
if (!prefs || Object.keys(prefs.boost).length === 0) {
|
|
1680
|
-
return cards.map((card) => ({
|
|
1681
|
-
...card,
|
|
1682
|
-
provenance: [
|
|
1683
|
-
...card.provenance,
|
|
1684
|
-
{
|
|
1685
|
-
strategy: "userTagPreference",
|
|
1686
|
-
strategyName: this.strategyName || this.name,
|
|
1687
|
-
strategyId: this.strategyId || this._strategyData._id,
|
|
1688
|
-
action: "passed",
|
|
1689
|
-
score: card.score,
|
|
1690
|
-
reason: "No user tag preferences configured"
|
|
1691
|
-
}
|
|
1692
|
-
]
|
|
1693
|
-
}));
|
|
1694
|
-
}
|
|
1695
|
-
const adjusted = await Promise.all(
|
|
1696
|
-
cards.map(async (card) => {
|
|
1697
|
-
const cardTags = card.tags ?? [];
|
|
1698
|
-
const multiplier = this.computeMultiplier(cardTags, prefs.boost);
|
|
1699
|
-
const finalScore = Math.min(1, card.score * multiplier);
|
|
1700
|
-
let action;
|
|
1701
|
-
if (multiplier === 0 || multiplier < 1) {
|
|
1702
|
-
action = "penalized";
|
|
1703
|
-
} else if (multiplier > 1) {
|
|
1704
|
-
action = "boosted";
|
|
1705
|
-
} else {
|
|
1706
|
-
action = "passed";
|
|
1707
|
-
}
|
|
1708
|
-
return {
|
|
1709
|
-
...card,
|
|
1710
|
-
score: finalScore,
|
|
1711
|
-
provenance: [
|
|
1712
|
-
...card.provenance,
|
|
1713
|
-
{
|
|
1714
|
-
strategy: "userTagPreference",
|
|
1715
|
-
strategyName: this.strategyName || this.name,
|
|
1716
|
-
strategyId: this.strategyId || this._strategyData._id,
|
|
1717
|
-
action,
|
|
1718
|
-
score: finalScore,
|
|
1719
|
-
reason: this.buildReason(cardTags, prefs.boost, multiplier)
|
|
1720
|
-
}
|
|
1721
|
-
]
|
|
1722
|
-
};
|
|
1723
|
-
})
|
|
1724
|
-
);
|
|
1725
|
-
return adjusted;
|
|
1726
|
-
}
|
|
1727
|
-
/**
|
|
1728
|
-
* Legacy getWeightedCards - throws as filters should not be used as generators.
|
|
1729
|
-
*/
|
|
1730
|
-
async getWeightedCards(_limit) {
|
|
1731
|
-
throw new Error(
|
|
1732
|
-
"UserTagPreferenceFilter is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
|
|
1733
|
-
);
|
|
1734
|
-
}
|
|
1735
|
-
// Legacy methods - stub implementations since filters don't generate cards
|
|
1736
|
-
async getNewCards(_n) {
|
|
1737
|
-
return [];
|
|
1738
|
-
}
|
|
1739
|
-
async getPendingReviews() {
|
|
1740
|
-
return [];
|
|
1741
|
-
}
|
|
1742
|
-
};
|
|
1743
|
-
}
|
|
1744
|
-
});
|
|
1745
|
-
|
|
1746
|
-
// src/core/navigators/filters/index.ts
|
|
1747
|
-
var filters_exports = {};
|
|
1748
|
-
__export(filters_exports, {
|
|
1749
|
-
UserTagPreferenceFilter: () => UserTagPreferenceFilter,
|
|
1750
|
-
createEloDistanceFilter: () => createEloDistanceFilter
|
|
1751
|
-
});
|
|
1752
|
-
var init_filters = __esm({
|
|
1753
|
-
"src/core/navigators/filters/index.ts"() {
|
|
1754
|
-
"use strict";
|
|
1755
|
-
init_eloDistance();
|
|
1756
|
-
init_userTagPreference();
|
|
1757
|
-
}
|
|
1758
|
-
});
|
|
1759
|
-
|
|
1760
|
-
// src/core/navigators/filters/types.ts
|
|
1761
|
-
var types_exports = {};
|
|
1762
|
-
var init_types = __esm({
|
|
1763
|
-
"src/core/navigators/filters/types.ts"() {
|
|
1764
|
-
"use strict";
|
|
1765
|
-
}
|
|
1766
|
-
});
|
|
1767
|
-
|
|
1768
|
-
// src/core/navigators/generators/index.ts
|
|
1769
|
-
var generators_exports = {};
|
|
1770
|
-
var init_generators = __esm({
|
|
1771
|
-
"src/core/navigators/generators/index.ts"() {
|
|
1772
|
-
"use strict";
|
|
1773
|
-
}
|
|
1774
|
-
});
|
|
1775
|
-
|
|
1776
|
-
// src/core/navigators/generators/types.ts
|
|
1777
|
-
var types_exports2 = {};
|
|
1778
|
-
var init_types2 = __esm({
|
|
1779
|
-
"src/core/navigators/generators/types.ts"() {
|
|
1780
|
-
"use strict";
|
|
1781
|
-
}
|
|
1782
|
-
});
|
|
1783
|
-
|
|
1784
|
-
// src/core/navigators/hardcodedOrder.ts
|
|
1785
|
-
var hardcodedOrder_exports = {};
|
|
1786
|
-
__export(hardcodedOrder_exports, {
|
|
1787
|
-
default: () => HardcodedOrderNavigator
|
|
1788
|
-
});
|
|
1789
|
-
var HardcodedOrderNavigator;
|
|
1790
|
-
var init_hardcodedOrder = __esm({
|
|
1791
|
-
"src/core/navigators/hardcodedOrder.ts"() {
|
|
1792
|
-
"use strict";
|
|
1793
|
-
init_navigators();
|
|
1794
|
-
init_logger();
|
|
1795
|
-
HardcodedOrderNavigator = class extends ContentNavigator {
|
|
1796
|
-
/** Human-readable name for CardGenerator interface */
|
|
1797
|
-
name;
|
|
1798
|
-
orderedCardIds = [];
|
|
1799
|
-
constructor(user, course, strategyData) {
|
|
1800
|
-
super(user, course, strategyData);
|
|
1801
|
-
this.name = strategyData.name || "Hardcoded Order";
|
|
1802
|
-
if (strategyData.serializedData) {
|
|
1803
|
-
try {
|
|
1804
|
-
this.orderedCardIds = JSON.parse(strategyData.serializedData);
|
|
1805
|
-
} catch (e) {
|
|
1806
|
-
logger.error("Failed to parse serializedData for HardcodedOrderNavigator", e);
|
|
1807
|
-
}
|
|
1808
|
-
}
|
|
1809
|
-
}
|
|
1810
|
-
async getPendingReviews() {
|
|
1811
|
-
const reviews = await this.user.getPendingReviews(this.course.getCourseID());
|
|
1812
|
-
return reviews.map((r) => {
|
|
1813
|
-
return {
|
|
1814
|
-
...r,
|
|
1815
|
-
contentSourceType: "course",
|
|
1816
|
-
contentSourceID: this.course.getCourseID(),
|
|
1817
|
-
cardID: r.cardId,
|
|
1818
|
-
courseID: r.courseId,
|
|
1819
|
-
reviewID: r._id,
|
|
1820
|
-
status: "review"
|
|
1821
|
-
};
|
|
1822
|
-
});
|
|
1823
|
-
}
|
|
1824
|
-
async getNewCards(limit = 99) {
|
|
1825
|
-
const activeCardIds = (await this.user.getActiveCards()).map((c) => c.cardID);
|
|
1826
|
-
const newCardIds = this.orderedCardIds.filter((cardId) => !activeCardIds.includes(cardId));
|
|
1827
|
-
const cardsToReturn = newCardIds.slice(0, limit);
|
|
1828
|
-
return cardsToReturn.map((cardId) => {
|
|
1829
|
-
return {
|
|
1830
|
-
cardID: cardId,
|
|
1831
|
-
courseID: this.course.getCourseID(),
|
|
1832
|
-
contentSourceType: "course",
|
|
1833
|
-
contentSourceID: this.course.getCourseID(),
|
|
1834
|
-
status: "new"
|
|
1835
|
-
};
|
|
1836
|
-
});
|
|
1837
|
-
}
|
|
1838
|
-
/**
|
|
1839
|
-
* Get cards in hardcoded order with scores based on position.
|
|
1840
|
-
*
|
|
1841
|
-
* Earlier cards in the sequence get higher scores.
|
|
1842
|
-
* Score formula: 1.0 - (position / totalCards) * 0.5
|
|
1843
|
-
* This ensures scores range from 1.0 (first card) to 0.5+ (last card).
|
|
1844
|
-
*
|
|
1845
|
-
* This method supports both the legacy signature (limit only) and the
|
|
1846
|
-
* CardGenerator interface signature (limit, context).
|
|
1847
|
-
*
|
|
1848
|
-
* @param limit - Maximum number of cards to return
|
|
1849
|
-
* @param _context - Optional GeneratorContext (currently unused, but required for interface)
|
|
1850
|
-
*/
|
|
1851
|
-
async getWeightedCards(limit, _context) {
|
|
1852
|
-
const activeCardIds = (await this.user.getActiveCards()).map((c) => c.cardID);
|
|
1853
|
-
const reviews = await this.getPendingReviews();
|
|
1854
|
-
const newCardIds = this.orderedCardIds.filter((cardId) => !activeCardIds.includes(cardId));
|
|
1855
|
-
const totalCards = newCardIds.length;
|
|
1856
|
-
const scoredNew = newCardIds.slice(0, limit).map((cardId, index) => {
|
|
1857
|
-
const position = index + 1;
|
|
1858
|
-
const score = Math.max(0.5, 1 - index / totalCards * 0.5);
|
|
1859
|
-
return {
|
|
1860
|
-
cardId,
|
|
1861
|
-
courseId: this.course.getCourseID(),
|
|
1862
|
-
score,
|
|
1863
|
-
provenance: [
|
|
1864
|
-
{
|
|
1865
|
-
strategy: "hardcodedOrder",
|
|
1866
|
-
strategyName: this.strategyName || this.name,
|
|
1867
|
-
strategyId: this.strategyId || "NAVIGATION_STRATEGY-hardcoded",
|
|
1868
|
-
action: "generated",
|
|
1869
|
-
score,
|
|
1870
|
-
reason: `Position ${position} of ${totalCards} in fixed sequence, new card`
|
|
1871
|
-
}
|
|
1872
|
-
]
|
|
1873
|
-
};
|
|
1874
|
-
});
|
|
1875
|
-
const scoredReviews = reviews.map((r) => ({
|
|
1876
|
-
cardId: r.cardID,
|
|
1877
|
-
courseId: r.courseID,
|
|
1878
|
-
score: 1,
|
|
1879
|
-
provenance: [
|
|
1880
|
-
{
|
|
1881
|
-
strategy: "hardcodedOrder",
|
|
1882
|
-
strategyName: this.strategyName || this.name,
|
|
1883
|
-
strategyId: this.strategyId || "NAVIGATION_STRATEGY-hardcoded",
|
|
1884
|
-
action: "generated",
|
|
1885
|
-
score: 1,
|
|
1886
|
-
reason: "Scheduled review, highest priority"
|
|
1887
|
-
}
|
|
1888
|
-
]
|
|
1889
|
-
}));
|
|
1890
|
-
const all = [...scoredReviews, ...scoredNew];
|
|
1891
|
-
all.sort((a, b) => b.score - a.score);
|
|
1892
|
-
return all.slice(0, limit);
|
|
1893
|
-
}
|
|
1894
|
-
};
|
|
1895
|
-
}
|
|
1896
|
-
});
|
|
1897
|
-
|
|
1898
|
-
// src/core/navigators/hierarchyDefinition.ts
|
|
1899
|
-
var hierarchyDefinition_exports = {};
|
|
1900
|
-
__export(hierarchyDefinition_exports, {
|
|
1901
|
-
default: () => HierarchyDefinitionNavigator
|
|
1902
|
-
});
|
|
1903
|
-
var import_common7, DEFAULT_MIN_COUNT, HierarchyDefinitionNavigator;
|
|
1904
|
-
var init_hierarchyDefinition = __esm({
|
|
1905
|
-
"src/core/navigators/hierarchyDefinition.ts"() {
|
|
1906
|
-
"use strict";
|
|
1907
|
-
init_navigators();
|
|
1908
|
-
import_common7 = require("@vue-skuilder/common");
|
|
1909
|
-
DEFAULT_MIN_COUNT = 3;
|
|
1910
|
-
HierarchyDefinitionNavigator = class extends ContentNavigator {
|
|
1911
|
-
config;
|
|
1912
|
-
_strategyData;
|
|
1913
|
-
/** Human-readable name for CardFilter interface */
|
|
1914
|
-
name;
|
|
1915
|
-
constructor(user, course, _strategyData) {
|
|
1916
|
-
super(user, course, _strategyData);
|
|
1917
|
-
this._strategyData = _strategyData;
|
|
1918
|
-
this.config = this.parseConfig(_strategyData.serializedData);
|
|
1919
|
-
this.name = _strategyData.name || "Hierarchy Definition";
|
|
1920
|
-
}
|
|
1921
|
-
parseConfig(serializedData) {
|
|
1922
|
-
try {
|
|
1923
|
-
const parsed = JSON.parse(serializedData);
|
|
1924
|
-
return {
|
|
1925
|
-
prerequisites: parsed.prerequisites || {}
|
|
1926
|
-
};
|
|
1927
|
-
} catch {
|
|
1928
|
-
return {
|
|
1929
|
-
prerequisites: {}
|
|
1930
|
-
};
|
|
1931
|
-
}
|
|
1932
|
-
}
|
|
1933
|
-
/**
|
|
1934
|
-
* Check if a specific prerequisite is satisfied
|
|
1935
|
-
*/
|
|
1936
|
-
isPrerequisiteMet(prereq, userTagElo, userGlobalElo) {
|
|
1937
|
-
if (!userTagElo) return false;
|
|
1938
|
-
const minCount = prereq.masteryThreshold?.minCount ?? DEFAULT_MIN_COUNT;
|
|
1939
|
-
if (userTagElo.count < minCount) return false;
|
|
1940
|
-
if (prereq.masteryThreshold?.minElo !== void 0) {
|
|
1941
|
-
return userTagElo.score >= prereq.masteryThreshold.minElo;
|
|
1942
|
-
} else {
|
|
1943
|
-
return userTagElo.score >= userGlobalElo;
|
|
1944
|
-
}
|
|
1945
|
-
}
|
|
1946
|
-
/**
|
|
1947
|
-
* Get the set of tags the user has mastered.
|
|
1948
|
-
* A tag is "mastered" if it appears as a prerequisite somewhere and meets its threshold.
|
|
1949
|
-
*/
|
|
1950
|
-
async getMasteredTags(context) {
|
|
1951
|
-
const mastered = /* @__PURE__ */ new Set();
|
|
1952
|
-
try {
|
|
1953
|
-
const courseReg = await context.user.getCourseRegDoc(context.course.getCourseID());
|
|
1954
|
-
const userElo = (0, import_common7.toCourseElo)(courseReg.elo);
|
|
1955
|
-
for (const prereqs of Object.values(this.config.prerequisites)) {
|
|
1956
|
-
for (const prereq of prereqs) {
|
|
1957
|
-
const tagElo = userElo.tags[prereq.tag];
|
|
1958
|
-
if (this.isPrerequisiteMet(prereq, tagElo, userElo.global.score)) {
|
|
1959
|
-
mastered.add(prereq.tag);
|
|
1960
|
-
}
|
|
1961
|
-
}
|
|
1962
|
-
}
|
|
1963
|
-
} catch {
|
|
1964
|
-
}
|
|
1965
|
-
return mastered;
|
|
1966
|
-
}
|
|
1967
|
-
/**
|
|
1968
|
-
* Get the set of tags that are unlocked (prerequisites met)
|
|
1969
|
-
*/
|
|
1970
|
-
getUnlockedTags(masteredTags) {
|
|
1971
|
-
const unlocked = /* @__PURE__ */ new Set();
|
|
1972
|
-
for (const [tagId, prereqs] of Object.entries(this.config.prerequisites)) {
|
|
1973
|
-
const allPrereqsMet = prereqs.every((prereq) => masteredTags.has(prereq.tag));
|
|
1974
|
-
if (allPrereqsMet) {
|
|
1975
|
-
unlocked.add(tagId);
|
|
1976
|
-
}
|
|
1977
|
-
}
|
|
1978
|
-
return unlocked;
|
|
1979
|
-
}
|
|
1980
|
-
/**
|
|
1981
|
-
* Check if a tag has prerequisites defined in config
|
|
1982
|
-
*/
|
|
1983
|
-
hasPrerequisites(tagId) {
|
|
1984
|
-
return tagId in this.config.prerequisites;
|
|
1985
|
-
}
|
|
1986
|
-
/**
|
|
1987
|
-
* Check if a card is unlocked and generate reason.
|
|
1988
|
-
*/
|
|
1989
|
-
async checkCardUnlock(card, course, unlockedTags, masteredTags) {
|
|
1990
|
-
try {
|
|
1991
|
-
const cardTags = card.tags ?? [];
|
|
1992
|
-
const lockedTags = cardTags.filter(
|
|
1993
|
-
(tag) => this.hasPrerequisites(tag) && !unlockedTags.has(tag)
|
|
1994
|
-
);
|
|
1995
|
-
if (lockedTags.length === 0) {
|
|
1996
|
-
const tagList = cardTags.length > 0 ? cardTags.join(", ") : "none";
|
|
1997
|
-
return {
|
|
1998
|
-
isUnlocked: true,
|
|
1999
|
-
reason: `Prerequisites met, tags: ${tagList}`
|
|
2000
|
-
};
|
|
2001
|
-
}
|
|
2002
|
-
const missingPrereqs = lockedTags.flatMap((tag) => {
|
|
2003
|
-
const prereqs = this.config.prerequisites[tag] || [];
|
|
2004
|
-
return prereqs.filter((p) => !masteredTags.has(p.tag)).map((p) => p.tag);
|
|
2005
|
-
});
|
|
2006
|
-
return {
|
|
2007
|
-
isUnlocked: false,
|
|
2008
|
-
reason: `Blocked: missing prerequisites ${missingPrereqs.join(", ")} for tags ${lockedTags.join(", ")}`
|
|
2009
|
-
};
|
|
2010
|
-
} catch {
|
|
2011
|
-
return {
|
|
2012
|
-
isUnlocked: true,
|
|
2013
|
-
reason: "Prerequisites check skipped (tag lookup failed)"
|
|
2014
|
-
};
|
|
2015
|
-
}
|
|
2016
|
-
}
|
|
2017
|
-
/**
|
|
2018
|
-
* CardFilter.transform implementation.
|
|
2019
|
-
*
|
|
2020
|
-
* Apply prerequisite gating to cards. Cards with locked tags receive score: 0.
|
|
2021
|
-
*/
|
|
2022
|
-
async transform(cards, context) {
|
|
2023
|
-
const masteredTags = await this.getMasteredTags(context);
|
|
2024
|
-
const unlockedTags = this.getUnlockedTags(masteredTags);
|
|
2025
|
-
const gated = [];
|
|
2026
|
-
for (const card of cards) {
|
|
2027
|
-
const { isUnlocked, reason } = await this.checkCardUnlock(
|
|
2028
|
-
card,
|
|
2029
|
-
context.course,
|
|
2030
|
-
unlockedTags,
|
|
2031
|
-
masteredTags
|
|
2032
|
-
);
|
|
2033
|
-
const finalScore = isUnlocked ? card.score : 0;
|
|
2034
|
-
const action = isUnlocked ? "passed" : "penalized";
|
|
2035
|
-
gated.push({
|
|
2036
|
-
...card,
|
|
2037
|
-
score: finalScore,
|
|
2038
|
-
provenance: [
|
|
2039
|
-
...card.provenance,
|
|
2040
|
-
{
|
|
2041
|
-
strategy: "hierarchyDefinition",
|
|
2042
|
-
strategyName: this.strategyName || this.name,
|
|
2043
|
-
strategyId: this.strategyId || "NAVIGATION_STRATEGY-hierarchy",
|
|
2044
|
-
action,
|
|
2045
|
-
score: finalScore,
|
|
2046
|
-
reason
|
|
2047
|
-
}
|
|
2048
|
-
]
|
|
2049
|
-
});
|
|
2050
|
-
}
|
|
2051
|
-
return gated;
|
|
2052
|
-
}
|
|
2053
|
-
/**
|
|
2054
|
-
* Legacy getWeightedCards - now throws as filters should not be used as generators.
|
|
2055
|
-
*
|
|
2056
|
-
* Use transform() via Pipeline instead.
|
|
2057
|
-
*/
|
|
2058
|
-
async getWeightedCards(_limit) {
|
|
2059
|
-
throw new Error(
|
|
2060
|
-
"HierarchyDefinitionNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
|
|
2061
|
-
);
|
|
2062
|
-
}
|
|
2063
|
-
// Legacy methods - stub implementations since filters don't generate cards
|
|
2064
|
-
async getNewCards(_n) {
|
|
2065
|
-
return [];
|
|
2066
|
-
}
|
|
2067
|
-
async getPendingReviews() {
|
|
2068
|
-
return [];
|
|
2069
|
-
}
|
|
2070
|
-
};
|
|
2071
|
-
}
|
|
2072
|
-
});
|
|
2073
|
-
|
|
2074
|
-
// src/core/navigators/inferredPreference.ts
|
|
2075
|
-
var inferredPreference_exports = {};
|
|
2076
|
-
__export(inferredPreference_exports, {
|
|
2077
|
-
INFERRED_PREFERENCE_NAVIGATOR_STUB: () => INFERRED_PREFERENCE_NAVIGATOR_STUB
|
|
2078
|
-
});
|
|
2079
|
-
var INFERRED_PREFERENCE_NAVIGATOR_STUB;
|
|
2080
|
-
var init_inferredPreference = __esm({
|
|
2081
|
-
"src/core/navigators/inferredPreference.ts"() {
|
|
2082
|
-
"use strict";
|
|
2083
|
-
INFERRED_PREFERENCE_NAVIGATOR_STUB = true;
|
|
2084
|
-
}
|
|
2085
|
-
});
|
|
2086
|
-
|
|
2087
|
-
// src/core/navigators/interferenceMitigator.ts
|
|
2088
|
-
var interferenceMitigator_exports = {};
|
|
2089
|
-
__export(interferenceMitigator_exports, {
|
|
2090
|
-
default: () => InterferenceMitigatorNavigator
|
|
2091
|
-
});
|
|
2092
|
-
var import_common8, DEFAULT_MIN_COUNT2, DEFAULT_MIN_ELAPSED_DAYS, DEFAULT_INTERFERENCE_DECAY, InterferenceMitigatorNavigator;
|
|
2093
|
-
var init_interferenceMitigator = __esm({
|
|
2094
|
-
"src/core/navigators/interferenceMitigator.ts"() {
|
|
2095
|
-
"use strict";
|
|
2096
|
-
init_navigators();
|
|
2097
|
-
import_common8 = require("@vue-skuilder/common");
|
|
2098
|
-
DEFAULT_MIN_COUNT2 = 10;
|
|
2099
|
-
DEFAULT_MIN_ELAPSED_DAYS = 3;
|
|
2100
|
-
DEFAULT_INTERFERENCE_DECAY = 0.8;
|
|
2101
|
-
InterferenceMitigatorNavigator = class extends ContentNavigator {
|
|
2102
|
-
config;
|
|
2103
|
-
_strategyData;
|
|
2104
|
-
/** Human-readable name for CardFilter interface */
|
|
2105
|
-
name;
|
|
2106
|
-
/** Precomputed map: tag -> set of { partner, decay } it interferes with */
|
|
2107
|
-
interferenceMap;
|
|
2108
|
-
constructor(user, course, _strategyData) {
|
|
2109
|
-
super(user, course, _strategyData);
|
|
2110
|
-
this._strategyData = _strategyData;
|
|
2111
|
-
this.config = this.parseConfig(_strategyData.serializedData);
|
|
2112
|
-
this.interferenceMap = this.buildInterferenceMap();
|
|
2113
|
-
this.name = _strategyData.name || "Interference Mitigator";
|
|
2114
|
-
}
|
|
2115
|
-
parseConfig(serializedData) {
|
|
2116
|
-
try {
|
|
2117
|
-
const parsed = JSON.parse(serializedData);
|
|
2118
|
-
let sets = parsed.interferenceSets || [];
|
|
2119
|
-
if (sets.length > 0 && Array.isArray(sets[0])) {
|
|
2120
|
-
sets = sets.map((tags) => ({ tags }));
|
|
2121
|
-
}
|
|
2122
|
-
return {
|
|
2123
|
-
interferenceSets: sets,
|
|
2124
|
-
maturityThreshold: {
|
|
2125
|
-
minCount: parsed.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT2,
|
|
2126
|
-
minElo: parsed.maturityThreshold?.minElo,
|
|
2127
|
-
minElapsedDays: parsed.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS
|
|
2128
|
-
},
|
|
2129
|
-
defaultDecay: parsed.defaultDecay ?? DEFAULT_INTERFERENCE_DECAY
|
|
2130
|
-
};
|
|
2131
|
-
} catch {
|
|
2132
|
-
return {
|
|
2133
|
-
interferenceSets: [],
|
|
2134
|
-
maturityThreshold: {
|
|
2135
|
-
minCount: DEFAULT_MIN_COUNT2,
|
|
2136
|
-
minElapsedDays: DEFAULT_MIN_ELAPSED_DAYS
|
|
2137
|
-
},
|
|
2138
|
-
defaultDecay: DEFAULT_INTERFERENCE_DECAY
|
|
2139
|
-
};
|
|
2140
|
-
}
|
|
2141
|
-
}
|
|
2142
|
-
/**
|
|
2143
|
-
* Build a map from each tag to its interference partners with decay coefficients.
|
|
2144
|
-
* If tags A, B, C are in an interference group with decay 0.8, then:
|
|
2145
|
-
* - A interferes with B (decay 0.8) and C (decay 0.8)
|
|
2146
|
-
* - B interferes with A (decay 0.8) and C (decay 0.8)
|
|
2147
|
-
* - etc.
|
|
2148
|
-
*/
|
|
2149
|
-
buildInterferenceMap() {
|
|
2150
|
-
const map = /* @__PURE__ */ new Map();
|
|
2151
|
-
for (const group of this.config.interferenceSets) {
|
|
2152
|
-
const decay = group.decay ?? this.config.defaultDecay ?? DEFAULT_INTERFERENCE_DECAY;
|
|
2153
|
-
for (const tag of group.tags) {
|
|
2154
|
-
if (!map.has(tag)) {
|
|
2155
|
-
map.set(tag, []);
|
|
2156
|
-
}
|
|
2157
|
-
const partners = map.get(tag);
|
|
2158
|
-
for (const other of group.tags) {
|
|
2159
|
-
if (other !== tag) {
|
|
2160
|
-
const existing = partners.find((p) => p.partner === other);
|
|
2161
|
-
if (existing) {
|
|
2162
|
-
existing.decay = Math.max(existing.decay, decay);
|
|
2163
|
-
} else {
|
|
2164
|
-
partners.push({ partner: other, decay });
|
|
2165
|
-
}
|
|
2166
|
-
}
|
|
2167
|
-
}
|
|
2168
|
-
}
|
|
2169
|
-
}
|
|
2170
|
-
return map;
|
|
2171
|
-
}
|
|
2172
|
-
/**
|
|
2173
|
-
* Get the set of tags that are currently immature for this user.
|
|
2174
|
-
* A tag is immature if the user has interacted with it but hasn't
|
|
2175
|
-
* reached the maturity threshold.
|
|
2176
|
-
*/
|
|
2177
|
-
async getImmatureTags(context) {
|
|
2178
|
-
const immature = /* @__PURE__ */ new Set();
|
|
2179
|
-
try {
|
|
2180
|
-
const courseReg = await context.user.getCourseRegDoc(context.course.getCourseID());
|
|
2181
|
-
const userElo = (0, import_common8.toCourseElo)(courseReg.elo);
|
|
2182
|
-
const minCount = this.config.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT2;
|
|
2183
|
-
const minElo = this.config.maturityThreshold?.minElo;
|
|
2184
|
-
const minElapsedDays = this.config.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS;
|
|
2185
|
-
const minCountForElapsed = minElapsedDays * 2;
|
|
2186
|
-
for (const [tagId, tagElo] of Object.entries(userElo.tags)) {
|
|
2187
|
-
if (tagElo.count === 0) continue;
|
|
2188
|
-
const belowCount = tagElo.count < minCount;
|
|
2189
|
-
const belowElo = minElo !== void 0 && tagElo.score < minElo;
|
|
2190
|
-
const belowElapsed = tagElo.count < minCountForElapsed;
|
|
2191
|
-
if (belowCount || belowElo || belowElapsed) {
|
|
2192
|
-
immature.add(tagId);
|
|
2193
|
-
}
|
|
2194
|
-
}
|
|
2195
|
-
} catch {
|
|
2196
|
-
}
|
|
2197
|
-
return immature;
|
|
2198
|
-
}
|
|
2199
|
-
/**
|
|
2200
|
-
* Get all tags that interfere with any immature tag, along with their decay coefficients.
|
|
2201
|
-
* These are the tags we want to avoid introducing.
|
|
2202
|
-
*/
|
|
2203
|
-
getTagsToAvoid(immatureTags) {
|
|
2204
|
-
const avoid = /* @__PURE__ */ new Map();
|
|
2205
|
-
for (const immatureTag of immatureTags) {
|
|
2206
|
-
const partners = this.interferenceMap.get(immatureTag);
|
|
2207
|
-
if (partners) {
|
|
2208
|
-
for (const { partner, decay } of partners) {
|
|
2209
|
-
if (!immatureTags.has(partner)) {
|
|
2210
|
-
const existing = avoid.get(partner) ?? 0;
|
|
2211
|
-
avoid.set(partner, Math.max(existing, decay));
|
|
2212
|
-
}
|
|
2213
|
-
}
|
|
2214
|
-
}
|
|
2215
|
-
}
|
|
2216
|
-
return avoid;
|
|
2217
|
-
}
|
|
2218
|
-
/**
|
|
2219
|
-
* Compute interference score reduction for a card.
|
|
2220
|
-
* Returns: { multiplier, interfering tags, reason }
|
|
2221
|
-
*/
|
|
2222
|
-
computeInterferenceEffect(cardTags, tagsToAvoid, immatureTags) {
|
|
2223
|
-
if (tagsToAvoid.size === 0) {
|
|
2224
|
-
return {
|
|
2225
|
-
multiplier: 1,
|
|
2226
|
-
interferingTags: [],
|
|
2227
|
-
reason: "No interference detected"
|
|
2228
|
-
};
|
|
2229
|
-
}
|
|
2230
|
-
let multiplier = 1;
|
|
2231
|
-
const interferingTags = [];
|
|
2232
|
-
for (const tag of cardTags) {
|
|
2233
|
-
const decay = tagsToAvoid.get(tag);
|
|
2234
|
-
if (decay !== void 0) {
|
|
2235
|
-
interferingTags.push(tag);
|
|
2236
|
-
multiplier *= 1 - decay;
|
|
2237
|
-
}
|
|
2238
|
-
}
|
|
2239
|
-
if (interferingTags.length === 0) {
|
|
2240
|
-
return {
|
|
2241
|
-
multiplier: 1,
|
|
2242
|
-
interferingTags: [],
|
|
2243
|
-
reason: "No interference detected"
|
|
2244
|
-
};
|
|
2245
|
-
}
|
|
2246
|
-
const causingTags = /* @__PURE__ */ new Set();
|
|
2247
|
-
for (const tag of interferingTags) {
|
|
2248
|
-
for (const immatureTag of immatureTags) {
|
|
2249
|
-
const partners = this.interferenceMap.get(immatureTag);
|
|
2250
|
-
if (partners?.some((p) => p.partner === tag)) {
|
|
2251
|
-
causingTags.add(immatureTag);
|
|
2252
|
-
}
|
|
2253
|
-
}
|
|
2254
|
-
}
|
|
2255
|
-
const reason = `Interferes with immature tags ${Array.from(causingTags).join(", ")} (tags: ${interferingTags.join(", ")}, multiplier: ${multiplier.toFixed(2)})`;
|
|
2256
|
-
return { multiplier, interferingTags, reason };
|
|
2257
|
-
}
|
|
2258
|
-
/**
|
|
2259
|
-
* CardFilter.transform implementation.
|
|
2260
|
-
*
|
|
2261
|
-
* Apply interference-aware scoring. Cards with tags that interfere with
|
|
2262
|
-
* immature learnings get reduced scores.
|
|
2263
|
-
*/
|
|
2264
|
-
async transform(cards, context) {
|
|
2265
|
-
const immatureTags = await this.getImmatureTags(context);
|
|
2266
|
-
const tagsToAvoid = this.getTagsToAvoid(immatureTags);
|
|
2267
|
-
const adjusted = [];
|
|
2268
|
-
for (const card of cards) {
|
|
2269
|
-
const cardTags = card.tags ?? [];
|
|
2270
|
-
const { multiplier, reason } = this.computeInterferenceEffect(
|
|
2271
|
-
cardTags,
|
|
2272
|
-
tagsToAvoid,
|
|
2273
|
-
immatureTags
|
|
2274
|
-
);
|
|
2275
|
-
const finalScore = card.score * multiplier;
|
|
2276
|
-
const action = multiplier < 1 ? "penalized" : multiplier > 1 ? "boosted" : "passed";
|
|
2277
|
-
adjusted.push({
|
|
2278
|
-
...card,
|
|
2279
|
-
score: finalScore,
|
|
2280
|
-
provenance: [
|
|
2281
|
-
...card.provenance,
|
|
2282
|
-
{
|
|
2283
|
-
strategy: "interferenceMitigator",
|
|
2284
|
-
strategyName: this.strategyName || this.name,
|
|
2285
|
-
strategyId: this.strategyId || "NAVIGATION_STRATEGY-interference",
|
|
2286
|
-
action,
|
|
2287
|
-
score: finalScore,
|
|
2288
|
-
reason
|
|
2289
|
-
}
|
|
2290
|
-
]
|
|
2291
|
-
});
|
|
2292
|
-
}
|
|
2293
|
-
return adjusted;
|
|
2294
|
-
}
|
|
2295
|
-
/**
|
|
2296
|
-
* Legacy getWeightedCards - now throws as filters should not be used as generators.
|
|
2297
|
-
*
|
|
2298
|
-
* Use transform() via Pipeline instead.
|
|
2299
|
-
*/
|
|
2300
|
-
async getWeightedCards(_limit) {
|
|
2301
|
-
throw new Error(
|
|
2302
|
-
"InterferenceMitigatorNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
|
|
2303
|
-
);
|
|
2304
|
-
}
|
|
2305
|
-
// Legacy methods - stub implementations since filters don't generate cards
|
|
2306
|
-
async getNewCards(_n) {
|
|
2307
|
-
return [];
|
|
2308
|
-
}
|
|
2309
|
-
async getPendingReviews() {
|
|
2310
|
-
return [];
|
|
2311
|
-
}
|
|
2312
|
-
};
|
|
2313
|
-
}
|
|
2314
|
-
});
|
|
2315
|
-
|
|
2316
|
-
// src/core/navigators/relativePriority.ts
|
|
2317
|
-
var relativePriority_exports = {};
|
|
2318
|
-
__export(relativePriority_exports, {
|
|
2319
|
-
default: () => RelativePriorityNavigator
|
|
2320
|
-
});
|
|
2321
|
-
var DEFAULT_PRIORITY, DEFAULT_PRIORITY_INFLUENCE, DEFAULT_COMBINE_MODE, RelativePriorityNavigator;
|
|
2322
|
-
var init_relativePriority = __esm({
|
|
2323
|
-
"src/core/navigators/relativePriority.ts"() {
|
|
2324
|
-
"use strict";
|
|
2325
|
-
init_navigators();
|
|
2326
|
-
DEFAULT_PRIORITY = 0.5;
|
|
2327
|
-
DEFAULT_PRIORITY_INFLUENCE = 0.5;
|
|
2328
|
-
DEFAULT_COMBINE_MODE = "max";
|
|
2329
|
-
RelativePriorityNavigator = class extends ContentNavigator {
|
|
2330
|
-
config;
|
|
2331
|
-
_strategyData;
|
|
2332
|
-
/** Human-readable name for CardFilter interface */
|
|
2333
|
-
name;
|
|
2334
|
-
constructor(user, course, _strategyData) {
|
|
2335
|
-
super(user, course, _strategyData);
|
|
2336
|
-
this._strategyData = _strategyData;
|
|
2337
|
-
this.config = this.parseConfig(_strategyData.serializedData);
|
|
2338
|
-
this.name = _strategyData.name || "Relative Priority";
|
|
2339
|
-
}
|
|
2340
|
-
parseConfig(serializedData) {
|
|
2341
|
-
try {
|
|
2342
|
-
const parsed = JSON.parse(serializedData);
|
|
2343
|
-
return {
|
|
2344
|
-
tagPriorities: parsed.tagPriorities || {},
|
|
2345
|
-
defaultPriority: parsed.defaultPriority ?? DEFAULT_PRIORITY,
|
|
2346
|
-
combineMode: parsed.combineMode ?? DEFAULT_COMBINE_MODE,
|
|
2347
|
-
priorityInfluence: parsed.priorityInfluence ?? DEFAULT_PRIORITY_INFLUENCE
|
|
2348
|
-
};
|
|
2349
|
-
} catch {
|
|
2350
|
-
return {
|
|
2351
|
-
tagPriorities: {},
|
|
2352
|
-
defaultPriority: DEFAULT_PRIORITY,
|
|
2353
|
-
combineMode: DEFAULT_COMBINE_MODE,
|
|
2354
|
-
priorityInfluence: DEFAULT_PRIORITY_INFLUENCE
|
|
2355
|
-
};
|
|
2356
|
-
}
|
|
2357
|
-
}
|
|
2358
|
-
/**
|
|
2359
|
-
* Look up the priority for a tag.
|
|
2360
|
-
*/
|
|
2361
|
-
getTagPriority(tagId) {
|
|
2362
|
-
return this.config.tagPriorities[tagId] ?? this.config.defaultPriority ?? DEFAULT_PRIORITY;
|
|
2363
|
-
}
|
|
2364
|
-
/**
|
|
2365
|
-
* Compute combined priority for a card based on its tags.
|
|
2366
|
-
*/
|
|
2367
|
-
computeCardPriority(cardTags) {
|
|
2368
|
-
if (cardTags.length === 0) {
|
|
2369
|
-
return this.config.defaultPriority ?? DEFAULT_PRIORITY;
|
|
2370
|
-
}
|
|
2371
|
-
const priorities = cardTags.map((tag) => this.getTagPriority(tag));
|
|
2372
|
-
switch (this.config.combineMode) {
|
|
2373
|
-
case "max":
|
|
2374
|
-
return Math.max(...priorities);
|
|
2375
|
-
case "min":
|
|
2376
|
-
return Math.min(...priorities);
|
|
2377
|
-
case "average":
|
|
2378
|
-
return priorities.reduce((sum, p) => sum + p, 0) / priorities.length;
|
|
2379
|
-
default:
|
|
2380
|
-
return Math.max(...priorities);
|
|
2381
|
-
}
|
|
2382
|
-
}
|
|
2383
|
-
/**
|
|
2384
|
-
* Compute boost factor based on priority.
|
|
2385
|
-
*
|
|
2386
|
-
* The formula: 1 + (priority - 0.5) * priorityInfluence
|
|
2387
|
-
*
|
|
2388
|
-
* This creates a multiplier centered around 1.0:
|
|
2389
|
-
* - Priority 1.0 with influence 0.5 → 1.25 (25% boost)
|
|
2390
|
-
* - Priority 0.5 with any influence → 1.00 (neutral)
|
|
2391
|
-
* - Priority 0.0 with influence 0.5 → 0.75 (25% reduction)
|
|
2392
|
-
*/
|
|
2393
|
-
computeBoostFactor(priority) {
|
|
2394
|
-
const influence = this.config.priorityInfluence ?? DEFAULT_PRIORITY_INFLUENCE;
|
|
2395
|
-
return 1 + (priority - 0.5) * influence;
|
|
2396
|
-
}
|
|
2397
|
-
/**
|
|
2398
|
-
* Build human-readable reason for priority adjustment.
|
|
2399
|
-
*/
|
|
2400
|
-
buildPriorityReason(cardTags, priority, boostFactor, finalScore) {
|
|
2401
|
-
if (cardTags.length === 0) {
|
|
2402
|
-
return `No tags, neutral priority (${priority.toFixed(2)})`;
|
|
2403
|
-
}
|
|
2404
|
-
const tagList = cardTags.slice(0, 3).join(", ");
|
|
2405
|
-
const more = cardTags.length > 3 ? ` (+${cardTags.length - 3} more)` : "";
|
|
2406
|
-
if (boostFactor === 1) {
|
|
2407
|
-
return `Neutral priority (${priority.toFixed(2)}) for tags: ${tagList}${more}`;
|
|
2408
|
-
} else if (boostFactor > 1) {
|
|
2409
|
-
return `High-priority tags: ${tagList}${more} (priority ${priority.toFixed(2)} \u2192 boost ${boostFactor.toFixed(2)}x \u2192 ${finalScore.toFixed(2)})`;
|
|
2410
|
-
} else {
|
|
2411
|
-
return `Low-priority tags: ${tagList}${more} (priority ${priority.toFixed(2)} \u2192 reduce ${boostFactor.toFixed(2)}x \u2192 ${finalScore.toFixed(2)})`;
|
|
2412
|
-
}
|
|
2413
|
-
}
|
|
2414
|
-
/**
|
|
2415
|
-
* CardFilter.transform implementation.
|
|
2416
|
-
*
|
|
2417
|
-
* Apply priority-adjusted scoring. Cards with high-priority tags get boosted,
|
|
2418
|
-
* cards with low-priority tags get reduced scores.
|
|
2419
|
-
*/
|
|
2420
|
-
async transform(cards, _context) {
|
|
2421
|
-
const adjusted = await Promise.all(
|
|
2422
|
-
cards.map(async (card) => {
|
|
2423
|
-
const cardTags = card.tags ?? [];
|
|
2424
|
-
const priority = this.computeCardPriority(cardTags);
|
|
2425
|
-
const boostFactor = this.computeBoostFactor(priority);
|
|
2426
|
-
const finalScore = Math.max(0, Math.min(1, card.score * boostFactor));
|
|
2427
|
-
const action = boostFactor > 1 ? "boosted" : boostFactor < 1 ? "penalized" : "passed";
|
|
2428
|
-
const reason = this.buildPriorityReason(cardTags, priority, boostFactor, finalScore);
|
|
2429
|
-
return {
|
|
2430
|
-
...card,
|
|
2431
|
-
score: finalScore,
|
|
2432
|
-
provenance: [
|
|
2433
|
-
...card.provenance,
|
|
2434
|
-
{
|
|
2435
|
-
strategy: "relativePriority",
|
|
2436
|
-
strategyName: this.strategyName || this.name,
|
|
2437
|
-
strategyId: this.strategyId || "NAVIGATION_STRATEGY-priority",
|
|
2438
|
-
action,
|
|
2439
|
-
score: finalScore,
|
|
2440
|
-
reason
|
|
2441
|
-
}
|
|
2442
|
-
]
|
|
2443
|
-
};
|
|
2444
|
-
})
|
|
2445
|
-
);
|
|
2446
|
-
return adjusted;
|
|
2447
|
-
}
|
|
2448
|
-
/**
|
|
2449
|
-
* Legacy getWeightedCards - now throws as filters should not be used as generators.
|
|
2450
|
-
*
|
|
2451
|
-
* Use transform() via Pipeline instead.
|
|
2452
|
-
*/
|
|
2453
|
-
async getWeightedCards(_limit) {
|
|
2454
|
-
throw new Error(
|
|
2455
|
-
"RelativePriorityNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
|
|
2456
|
-
);
|
|
2457
|
-
}
|
|
2458
|
-
// Legacy methods - stub implementations since filters don't generate cards
|
|
2459
|
-
async getNewCards(_n) {
|
|
2460
|
-
return [];
|
|
2461
|
-
}
|
|
2462
|
-
async getPendingReviews() {
|
|
2463
|
-
return [];
|
|
2464
|
-
}
|
|
2465
|
-
};
|
|
2466
|
-
}
|
|
2467
|
-
});
|
|
2468
|
-
|
|
2469
|
-
// src/core/navigators/srs.ts
|
|
2470
|
-
var srs_exports = {};
|
|
2471
|
-
__export(srs_exports, {
|
|
2472
|
-
default: () => SRSNavigator
|
|
2473
|
-
});
|
|
2474
|
-
var import_moment3, SRSNavigator;
|
|
2475
|
-
var init_srs = __esm({
|
|
2476
|
-
"src/core/navigators/srs.ts"() {
|
|
2477
|
-
"use strict";
|
|
2478
|
-
import_moment3 = __toESM(require("moment"), 1);
|
|
2479
|
-
init_navigators();
|
|
1541
|
+
init_logger();
|
|
2480
1542
|
SRSNavigator = class extends ContentNavigator {
|
|
2481
1543
|
/** Human-readable name for CardGenerator interface */
|
|
2482
1544
|
name;
|
|
@@ -2512,6 +1574,7 @@ var init_srs = __esm({
|
|
|
2512
1574
|
cardId: review.cardId,
|
|
2513
1575
|
courseId: review.courseId,
|
|
2514
1576
|
score,
|
|
1577
|
+
reviewID: review._id,
|
|
2515
1578
|
provenance: [
|
|
2516
1579
|
{
|
|
2517
1580
|
strategy: "srs",
|
|
@@ -2524,6 +1587,7 @@ var init_srs = __esm({
|
|
|
2524
1587
|
]
|
|
2525
1588
|
};
|
|
2526
1589
|
});
|
|
1590
|
+
logger.debug(`[srsNav] got ${scored.length} weighted cards`);
|
|
2527
1591
|
return scored.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
2528
1592
|
}
|
|
2529
1593
|
/**
|
|
@@ -2555,299 +1619,102 @@ var init_srs = __esm({
|
|
|
2555
1619
|
const reason = `${Math.round(hoursOverdue)}h overdue (interval: ${Math.round(intervalHours)}h, relative: ${relativeOverdue.toFixed(2)}), recency: ${recencyFactor.toFixed(2)}, review`;
|
|
2556
1620
|
return { score, reason };
|
|
2557
1621
|
}
|
|
2558
|
-
/**
|
|
2559
|
-
* Get pending reviews in legacy format.
|
|
2560
|
-
*
|
|
2561
|
-
* Returns all pending reviews for the course, enriched with session item fields.
|
|
2562
|
-
*/
|
|
2563
|
-
async getPendingReviews() {
|
|
2564
|
-
if (!this.user || !this.course) {
|
|
2565
|
-
throw new Error("SRSNavigator requires user and course to be set");
|
|
2566
|
-
}
|
|
2567
|
-
const reviews = await this.user.getPendingReviews(this.course.getCourseID());
|
|
2568
|
-
return reviews.map((r) => ({
|
|
2569
|
-
...r,
|
|
2570
|
-
contentSourceType: "course",
|
|
2571
|
-
contentSourceID: this.course.getCourseID(),
|
|
2572
|
-
cardID: r.cardId,
|
|
2573
|
-
courseID: r.courseId,
|
|
2574
|
-
qualifiedID: `${r.courseId}-${r.cardId}`,
|
|
2575
|
-
reviewID: r._id,
|
|
2576
|
-
status: "review"
|
|
2577
|
-
}));
|
|
2578
|
-
}
|
|
2579
|
-
/**
|
|
2580
|
-
* SRS does not generate new cards.
|
|
2581
|
-
* Use ELONavigator or another generator for new cards.
|
|
2582
|
-
*/
|
|
2583
|
-
async getNewCards(_n) {
|
|
2584
|
-
return [];
|
|
2585
|
-
}
|
|
2586
1622
|
};
|
|
2587
1623
|
}
|
|
2588
1624
|
});
|
|
2589
1625
|
|
|
2590
|
-
// src/core/navigators/
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
}
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
var
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
NavigatorRoles = {
|
|
2683
|
-
["elo" /* ELO */]: "generator" /* GENERATOR */,
|
|
2684
|
-
["srs" /* SRS */]: "generator" /* GENERATOR */,
|
|
2685
|
-
["hardcodedOrder" /* HARDCODED */]: "generator" /* GENERATOR */,
|
|
2686
|
-
["hierarchyDefinition" /* HIERARCHY */]: "filter" /* FILTER */,
|
|
2687
|
-
["interferenceMitigator" /* INTERFERENCE */]: "filter" /* FILTER */,
|
|
2688
|
-
["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */,
|
|
2689
|
-
["userTagPreference" /* USER_TAG_PREFERENCE */]: "filter" /* FILTER */
|
|
2690
|
-
};
|
|
2691
|
-
ContentNavigator = class {
|
|
2692
|
-
/** User interface for this navigation session */
|
|
2693
|
-
user;
|
|
2694
|
-
/** Course interface for this navigation session */
|
|
2695
|
-
course;
|
|
2696
|
-
/** Human-readable name for this strategy instance (from ContentNavigationStrategyData.name) */
|
|
2697
|
-
strategyName;
|
|
2698
|
-
/** Unique document ID for this strategy instance (from ContentNavigationStrategyData._id) */
|
|
2699
|
-
strategyId;
|
|
2700
|
-
/**
|
|
2701
|
-
* Constructor for standard navigators.
|
|
2702
|
-
* Call this from subclass constructors to initialize common fields.
|
|
2703
|
-
*
|
|
2704
|
-
* Note: CompositeGenerator doesn't use this pattern and should call super() without args.
|
|
2705
|
-
*/
|
|
2706
|
-
constructor(user, course, strategyData) {
|
|
2707
|
-
if (user && course && strategyData) {
|
|
2708
|
-
this.user = user;
|
|
2709
|
-
this.course = course;
|
|
2710
|
-
this.strategyName = strategyData.name;
|
|
2711
|
-
this.strategyId = strategyData._id;
|
|
2712
|
-
}
|
|
2713
|
-
}
|
|
2714
|
-
// ============================================================================
|
|
2715
|
-
// STRATEGY STATE HELPERS
|
|
2716
|
-
// ============================================================================
|
|
2717
|
-
//
|
|
2718
|
-
// These methods allow strategies to persist their own state (user preferences,
|
|
2719
|
-
// learned patterns, temporal tracking) in the user database.
|
|
2720
|
-
//
|
|
2721
|
-
// ============================================================================
|
|
2722
|
-
/**
|
|
2723
|
-
* Unique key identifying this strategy for state storage.
|
|
2724
|
-
*
|
|
2725
|
-
* Defaults to the constructor name (e.g., "UserTagPreferenceFilter").
|
|
2726
|
-
* Override in subclasses if multiple instances of the same strategy type
|
|
2727
|
-
* need separate state storage.
|
|
2728
|
-
*/
|
|
2729
|
-
get strategyKey() {
|
|
2730
|
-
return this.constructor.name;
|
|
2731
|
-
}
|
|
2732
|
-
/**
|
|
2733
|
-
* Get this strategy's persisted state for the current course.
|
|
2734
|
-
*
|
|
2735
|
-
* @returns The strategy's data payload, or null if no state exists
|
|
2736
|
-
* @throws Error if user or course is not initialized
|
|
2737
|
-
*/
|
|
2738
|
-
async getStrategyState() {
|
|
2739
|
-
if (!this.user || !this.course) {
|
|
2740
|
-
throw new Error(
|
|
2741
|
-
`Cannot get strategy state: navigator not properly initialized. Ensure user and course are provided to constructor.`
|
|
2742
|
-
);
|
|
2743
|
-
}
|
|
2744
|
-
return this.user.getStrategyState(this.course.getCourseID(), this.strategyKey);
|
|
2745
|
-
}
|
|
2746
|
-
/**
|
|
2747
|
-
* Persist this strategy's state for the current course.
|
|
2748
|
-
*
|
|
2749
|
-
* @param data - The strategy's data payload to store
|
|
2750
|
-
* @throws Error if user or course is not initialized
|
|
2751
|
-
*/
|
|
2752
|
-
async putStrategyState(data) {
|
|
2753
|
-
if (!this.user || !this.course) {
|
|
2754
|
-
throw new Error(
|
|
2755
|
-
`Cannot put strategy state: navigator not properly initialized. Ensure user and course are provided to constructor.`
|
|
2756
|
-
);
|
|
2757
|
-
}
|
|
2758
|
-
return this.user.putStrategyState(this.course.getCourseID(), this.strategyKey, data);
|
|
2759
|
-
}
|
|
2760
|
-
/**
|
|
2761
|
-
* Factory method to create navigator instances dynamically.
|
|
2762
|
-
*
|
|
2763
|
-
* @param user - User interface
|
|
2764
|
-
* @param course - Course interface
|
|
2765
|
-
* @param strategyData - Strategy configuration document
|
|
2766
|
-
* @returns the runtime object used to steer a study session.
|
|
2767
|
-
*/
|
|
2768
|
-
static async create(user, course, strategyData) {
|
|
2769
|
-
const implementingClass = strategyData.implementingClass;
|
|
2770
|
-
let NavigatorImpl;
|
|
2771
|
-
const variations = [".ts", ".js", ""];
|
|
2772
|
-
for (const ext of variations) {
|
|
2773
|
-
try {
|
|
2774
|
-
const module2 = await globImport(`./${implementingClass}${ext}`);
|
|
2775
|
-
NavigatorImpl = module2.default;
|
|
2776
|
-
break;
|
|
2777
|
-
} catch (e) {
|
|
2778
|
-
logger.debug(`Failed to load with extension ${ext}:`, e);
|
|
2779
|
-
}
|
|
2780
|
-
}
|
|
2781
|
-
if (!NavigatorImpl) {
|
|
2782
|
-
throw new Error(`Could not load navigator implementation for: ${implementingClass}`);
|
|
2783
|
-
}
|
|
2784
|
-
return new NavigatorImpl(user, course, strategyData);
|
|
2785
|
-
}
|
|
2786
|
-
/**
|
|
2787
|
-
* Get cards with suitability scores and provenance trails.
|
|
2788
|
-
*
|
|
2789
|
-
* **This is the PRIMARY API for navigation strategies.**
|
|
2790
|
-
*
|
|
2791
|
-
* Returns cards ranked by suitability score (0-1). Higher scores indicate
|
|
2792
|
-
* better candidates for presentation. Each card includes a provenance trail
|
|
2793
|
-
* documenting how strategies contributed to the final score.
|
|
2794
|
-
*
|
|
2795
|
-
* ## For Generators
|
|
2796
|
-
* Override this method to generate candidates and compute scores based on
|
|
2797
|
-
* your strategy's logic (e.g., ELO proximity, review urgency). Create the
|
|
2798
|
-
* initial provenance entry with action='generated'.
|
|
2799
|
-
*
|
|
2800
|
-
* ## Default Implementation
|
|
2801
|
-
* The base class provides a backward-compatible default that:
|
|
2802
|
-
* 1. Calls legacy getNewCards() and getPendingReviews()
|
|
2803
|
-
* 2. Assigns score=1.0 to all cards
|
|
2804
|
-
* 3. Creates minimal provenance from legacy methods
|
|
2805
|
-
* 4. Returns combined results up to limit
|
|
2806
|
-
*
|
|
2807
|
-
* This allows existing strategies to work without modification while
|
|
2808
|
-
* new strategies can override with proper scoring and provenance.
|
|
2809
|
-
*
|
|
2810
|
-
* @param limit - Maximum cards to return
|
|
2811
|
-
* @returns Cards sorted by score descending, with provenance trails
|
|
2812
|
-
*/
|
|
2813
|
-
async getWeightedCards(limit) {
|
|
2814
|
-
const newCards = await this.getNewCards(limit);
|
|
2815
|
-
const reviews = await this.getPendingReviews();
|
|
2816
|
-
const weighted = [
|
|
2817
|
-
...newCards.map((c) => ({
|
|
2818
|
-
cardId: c.cardID,
|
|
2819
|
-
courseId: c.courseID,
|
|
2820
|
-
score: 1,
|
|
2821
|
-
provenance: [
|
|
2822
|
-
{
|
|
2823
|
-
strategy: "legacy",
|
|
2824
|
-
strategyName: this.strategyName || "Legacy API",
|
|
2825
|
-
strategyId: this.strategyId || "legacy-fallback",
|
|
2826
|
-
action: "generated",
|
|
2827
|
-
score: 1,
|
|
2828
|
-
reason: "Generated via legacy getNewCards(), new card"
|
|
2829
|
-
}
|
|
2830
|
-
]
|
|
2831
|
-
})),
|
|
2832
|
-
...reviews.map((r) => ({
|
|
2833
|
-
cardId: r.cardID,
|
|
2834
|
-
courseId: r.courseID,
|
|
2835
|
-
score: 1,
|
|
2836
|
-
provenance: [
|
|
2837
|
-
{
|
|
2838
|
-
strategy: "legacy",
|
|
2839
|
-
strategyName: this.strategyName || "Legacy API",
|
|
2840
|
-
strategyId: this.strategyId || "legacy-fallback",
|
|
2841
|
-
action: "generated",
|
|
2842
|
-
score: 1,
|
|
2843
|
-
reason: "Generated via legacy getPendingReviews(), review"
|
|
2844
|
-
}
|
|
2845
|
-
]
|
|
2846
|
-
}))
|
|
2847
|
-
];
|
|
2848
|
-
return weighted.slice(0, limit);
|
|
2849
|
-
}
|
|
2850
|
-
};
|
|
1626
|
+
// src/core/navigators/filters/eloDistance.ts
|
|
1627
|
+
function computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier) {
|
|
1628
|
+
const normalizedDistance = distance / halfLife;
|
|
1629
|
+
const decay = Math.exp(-(normalizedDistance * normalizedDistance));
|
|
1630
|
+
return minMultiplier + (maxMultiplier - minMultiplier) * decay;
|
|
1631
|
+
}
|
|
1632
|
+
function createEloDistanceFilter(config) {
|
|
1633
|
+
const halfLife = config?.halfLife ?? DEFAULT_HALF_LIFE;
|
|
1634
|
+
const minMultiplier = config?.minMultiplier ?? DEFAULT_MIN_MULTIPLIER;
|
|
1635
|
+
const maxMultiplier = config?.maxMultiplier ?? DEFAULT_MAX_MULTIPLIER;
|
|
1636
|
+
return {
|
|
1637
|
+
name: "ELO Distance Filter",
|
|
1638
|
+
async transform(cards, context) {
|
|
1639
|
+
const { course, userElo } = context;
|
|
1640
|
+
const cardIds = cards.map((c) => c.cardId);
|
|
1641
|
+
const cardElos = await course.getCardEloData(cardIds);
|
|
1642
|
+
return cards.map((card, i) => {
|
|
1643
|
+
const cardElo = cardElos[i]?.global?.score ?? 1e3;
|
|
1644
|
+
const distance = Math.abs(cardElo - userElo);
|
|
1645
|
+
const multiplier = computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier);
|
|
1646
|
+
const newScore = card.score * multiplier;
|
|
1647
|
+
const action = multiplier < maxMultiplier - 0.01 ? "penalized" : "passed";
|
|
1648
|
+
return {
|
|
1649
|
+
...card,
|
|
1650
|
+
score: newScore,
|
|
1651
|
+
provenance: [
|
|
1652
|
+
...card.provenance,
|
|
1653
|
+
{
|
|
1654
|
+
strategy: "eloDistance",
|
|
1655
|
+
strategyName: "ELO Distance Filter",
|
|
1656
|
+
strategyId: "ELO_DISTANCE_FILTER",
|
|
1657
|
+
action,
|
|
1658
|
+
score: newScore,
|
|
1659
|
+
reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userElo)}) \u2192 ${multiplier.toFixed(2)}x`
|
|
1660
|
+
}
|
|
1661
|
+
]
|
|
1662
|
+
};
|
|
1663
|
+
});
|
|
1664
|
+
}
|
|
1665
|
+
};
|
|
1666
|
+
}
|
|
1667
|
+
var DEFAULT_HALF_LIFE, DEFAULT_MIN_MULTIPLIER, DEFAULT_MAX_MULTIPLIER;
|
|
1668
|
+
var init_eloDistance = __esm({
|
|
1669
|
+
"src/core/navigators/filters/eloDistance.ts"() {
|
|
1670
|
+
"use strict";
|
|
1671
|
+
DEFAULT_HALF_LIFE = 200;
|
|
1672
|
+
DEFAULT_MIN_MULTIPLIER = 0.3;
|
|
1673
|
+
DEFAULT_MAX_MULTIPLIER = 1;
|
|
1674
|
+
}
|
|
1675
|
+
});
|
|
1676
|
+
|
|
1677
|
+
// src/core/navigators/defaults.ts
|
|
1678
|
+
function createDefaultEloStrategy(courseId) {
|
|
1679
|
+
return {
|
|
1680
|
+
_id: "NAVIGATION_STRATEGY-ELO-default",
|
|
1681
|
+
docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
|
|
1682
|
+
name: "ELO (default)",
|
|
1683
|
+
description: "Default ELO-based navigation strategy for new cards",
|
|
1684
|
+
implementingClass: "elo" /* ELO */,
|
|
1685
|
+
course: courseId,
|
|
1686
|
+
serializedData: ""
|
|
1687
|
+
};
|
|
1688
|
+
}
|
|
1689
|
+
function createDefaultSrsStrategy(courseId) {
|
|
1690
|
+
return {
|
|
1691
|
+
_id: "NAVIGATION_STRATEGY-SRS-default",
|
|
1692
|
+
docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
|
|
1693
|
+
name: "SRS (default)",
|
|
1694
|
+
description: "Default SRS-based navigation strategy for reviews",
|
|
1695
|
+
implementingClass: "srs" /* SRS */,
|
|
1696
|
+
course: courseId,
|
|
1697
|
+
serializedData: ""
|
|
1698
|
+
};
|
|
1699
|
+
}
|
|
1700
|
+
function createDefaultPipeline(user, course) {
|
|
1701
|
+
const courseId = course.getCourseID();
|
|
1702
|
+
const eloNavigator = new ELONavigator(user, course, createDefaultEloStrategy(courseId));
|
|
1703
|
+
const srsNavigator = new SRSNavigator(user, course, createDefaultSrsStrategy(courseId));
|
|
1704
|
+
const compositeGenerator = new CompositeGenerator([eloNavigator, srsNavigator]);
|
|
1705
|
+
const eloDistanceFilter = createEloDistanceFilter();
|
|
1706
|
+
return new Pipeline(compositeGenerator, [eloDistanceFilter], user, course);
|
|
1707
|
+
}
|
|
1708
|
+
var init_defaults = __esm({
|
|
1709
|
+
"src/core/navigators/defaults.ts"() {
|
|
1710
|
+
"use strict";
|
|
1711
|
+
init_navigators();
|
|
1712
|
+
init_Pipeline();
|
|
1713
|
+
init_CompositeGenerator();
|
|
1714
|
+
init_elo();
|
|
1715
|
+
init_srs();
|
|
1716
|
+
init_eloDistance();
|
|
1717
|
+
init_types_legacy();
|
|
2851
1718
|
}
|
|
2852
1719
|
});
|
|
2853
1720
|
|
|
@@ -2927,11 +1794,11 @@ ${JSON.stringify(config)}
|
|
|
2927
1794
|
function isSuccessRow(row) {
|
|
2928
1795
|
return "doc" in row && row.doc !== null && row.doc !== void 0;
|
|
2929
1796
|
}
|
|
2930
|
-
var
|
|
1797
|
+
var import_common7, CoursesDB, CourseDB;
|
|
2931
1798
|
var init_courseDB = __esm({
|
|
2932
1799
|
"src/impl/couch/courseDB.ts"() {
|
|
2933
1800
|
"use strict";
|
|
2934
|
-
|
|
1801
|
+
import_common7 = require("@vue-skuilder/common");
|
|
2935
1802
|
init_couch();
|
|
2936
1803
|
init_updateQueue();
|
|
2937
1804
|
init_types_legacy();
|
|
@@ -2940,12 +1807,8 @@ var init_courseDB = __esm({
|
|
|
2940
1807
|
init_courseAPI();
|
|
2941
1808
|
init_courseLookupDB();
|
|
2942
1809
|
init_navigators();
|
|
2943
|
-
init_Pipeline();
|
|
2944
1810
|
init_PipelineAssembler();
|
|
2945
|
-
|
|
2946
|
-
init_elo();
|
|
2947
|
-
init_srs();
|
|
2948
|
-
init_eloDistance();
|
|
1811
|
+
init_defaults();
|
|
2949
1812
|
CoursesDB = class {
|
|
2950
1813
|
_courseIDs;
|
|
2951
1814
|
constructor(courseIDs) {
|
|
@@ -3057,14 +1920,14 @@ var init_courseDB = __esm({
|
|
|
3057
1920
|
docs.rows.forEach((r) => {
|
|
3058
1921
|
if (isSuccessRow(r)) {
|
|
3059
1922
|
if (r.doc && r.doc.elo) {
|
|
3060
|
-
ret.push((0,
|
|
1923
|
+
ret.push((0, import_common7.toCourseElo)(r.doc.elo));
|
|
3061
1924
|
} else {
|
|
3062
1925
|
logger.warn("no elo data for card: " + r.id);
|
|
3063
|
-
ret.push((0,
|
|
1926
|
+
ret.push((0, import_common7.blankCourseElo)());
|
|
3064
1927
|
}
|
|
3065
1928
|
} else {
|
|
3066
1929
|
logger.warn("no elo data for card: " + JSON.stringify(r));
|
|
3067
|
-
ret.push((0,
|
|
1930
|
+
ret.push((0, import_common7.blankCourseElo)());
|
|
3068
1931
|
}
|
|
3069
1932
|
});
|
|
3070
1933
|
return ret;
|
|
@@ -3259,7 +2122,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3259
2122
|
async getCourseTagStubs() {
|
|
3260
2123
|
return getCourseTagStubs(this.id);
|
|
3261
2124
|
}
|
|
3262
|
-
async addNote(codeCourse, shape, data, author, tags, uploads, elo = (0,
|
|
2125
|
+
async addNote(codeCourse, shape, data, author, tags, uploads, elo = (0, import_common7.blankCourseElo)()) {
|
|
3263
2126
|
try {
|
|
3264
2127
|
const resp = await addNote55(this.id, codeCourse, shape, data, author, tags, uploads, elo);
|
|
3265
2128
|
if (resp.ok) {
|
|
@@ -3268,19 +2131,19 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3268
2131
|
`[courseDB.addNote] Note added but card creation failed: ${resp.cardCreationError}`
|
|
3269
2132
|
);
|
|
3270
2133
|
return {
|
|
3271
|
-
status:
|
|
2134
|
+
status: import_common7.Status.error,
|
|
3272
2135
|
message: `Note was added but no cards were created: ${resp.cardCreationError}`,
|
|
3273
2136
|
id: resp.id
|
|
3274
2137
|
};
|
|
3275
2138
|
}
|
|
3276
2139
|
return {
|
|
3277
|
-
status:
|
|
2140
|
+
status: import_common7.Status.ok,
|
|
3278
2141
|
message: "",
|
|
3279
2142
|
id: resp.id
|
|
3280
2143
|
};
|
|
3281
2144
|
} else {
|
|
3282
2145
|
return {
|
|
3283
|
-
status:
|
|
2146
|
+
status: import_common7.Status.error,
|
|
3284
2147
|
message: "Unexpected error adding note"
|
|
3285
2148
|
};
|
|
3286
2149
|
}
|
|
@@ -3292,7 +2155,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3292
2155
|
message: ${err.message}`
|
|
3293
2156
|
);
|
|
3294
2157
|
return {
|
|
3295
|
-
status:
|
|
2158
|
+
status: import_common7.Status.error,
|
|
3296
2159
|
message: `Error adding note to course. ${e.reason || err.message}`
|
|
3297
2160
|
};
|
|
3298
2161
|
}
|
|
@@ -3359,7 +2222,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3359
2222
|
logger.debug(
|
|
3360
2223
|
"[courseDB] No strategy documents found, using default Pipeline(Composite(ELO, SRS), [eloDistanceFilter])"
|
|
3361
2224
|
);
|
|
3362
|
-
return
|
|
2225
|
+
return createDefaultPipeline(user, this);
|
|
3363
2226
|
}
|
|
3364
2227
|
const assembler = new PipelineAssembler();
|
|
3365
2228
|
const { pipeline, generatorStrategies, filterStrategies, warnings } = await assembler.assemble({
|
|
@@ -3372,7 +2235,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3372
2235
|
}
|
|
3373
2236
|
if (!pipeline) {
|
|
3374
2237
|
logger.debug("[courseDB] Pipeline assembly failed, using default pipeline");
|
|
3375
|
-
return
|
|
2238
|
+
return createDefaultPipeline(user, this);
|
|
3376
2239
|
}
|
|
3377
2240
|
logger.debug(
|
|
3378
2241
|
`[courseDB] Using assembled pipeline with ${generatorStrategies.length} generator(s) and ${filterStrategies.length} filter(s)`
|
|
@@ -3383,69 +2246,12 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3383
2246
|
throw e;
|
|
3384
2247
|
}
|
|
3385
2248
|
}
|
|
3386
|
-
makeDefaultEloStrategy() {
|
|
3387
|
-
return {
|
|
3388
|
-
_id: "NAVIGATION_STRATEGY-ELO-default",
|
|
3389
|
-
docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
|
|
3390
|
-
name: "ELO (default)",
|
|
3391
|
-
description: "Default ELO-based navigation strategy for new cards",
|
|
3392
|
-
implementingClass: "elo" /* ELO */,
|
|
3393
|
-
course: this.id,
|
|
3394
|
-
serializedData: ""
|
|
3395
|
-
};
|
|
3396
|
-
}
|
|
3397
|
-
makeDefaultSrsStrategy() {
|
|
3398
|
-
return {
|
|
3399
|
-
_id: "NAVIGATION_STRATEGY-SRS-default",
|
|
3400
|
-
docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
|
|
3401
|
-
name: "SRS (default)",
|
|
3402
|
-
description: "Default SRS-based navigation strategy for reviews",
|
|
3403
|
-
implementingClass: "srs" /* SRS */,
|
|
3404
|
-
course: this.id,
|
|
3405
|
-
serializedData: ""
|
|
3406
|
-
};
|
|
3407
|
-
}
|
|
3408
|
-
/**
|
|
3409
|
-
* Creates the default navigation pipeline for courses with no configured strategies.
|
|
3410
|
-
*
|
|
3411
|
-
* Default: Pipeline(Composite(ELO, SRS), [eloDistanceFilter])
|
|
3412
|
-
* - ELO generator: scores new cards by skill proximity
|
|
3413
|
-
* - SRS generator: scores reviews by overdueness and interval recency
|
|
3414
|
-
* - ELO distance filter: penalizes cards far from user's current level
|
|
3415
|
-
*/
|
|
3416
|
-
createDefaultPipeline(user) {
|
|
3417
|
-
const eloNavigator = new ELONavigator(user, this, this.makeDefaultEloStrategy());
|
|
3418
|
-
const srsNavigator = new SRSNavigator(user, this, this.makeDefaultSrsStrategy());
|
|
3419
|
-
const compositeGenerator = new CompositeGenerator([eloNavigator, srsNavigator]);
|
|
3420
|
-
const eloDistanceFilter = createEloDistanceFilter();
|
|
3421
|
-
return new Pipeline(compositeGenerator, [eloDistanceFilter], user, this);
|
|
3422
|
-
}
|
|
3423
2249
|
////////////////////////////////////
|
|
3424
2250
|
// END NavigationStrategyManager implementation
|
|
3425
2251
|
////////////////////////////////////
|
|
3426
2252
|
////////////////////////////////////
|
|
3427
2253
|
// StudyContentSource implementation
|
|
3428
2254
|
////////////////////////////////////
|
|
3429
|
-
async getNewCards(limit = 99) {
|
|
3430
|
-
const u = await this._getCurrentUser();
|
|
3431
|
-
try {
|
|
3432
|
-
const navigator = await this.createNavigator(u);
|
|
3433
|
-
return navigator.getNewCards(limit);
|
|
3434
|
-
} catch (e) {
|
|
3435
|
-
logger.error(`[courseDB] Error in getNewCards: ${e}`);
|
|
3436
|
-
throw e;
|
|
3437
|
-
}
|
|
3438
|
-
}
|
|
3439
|
-
async getPendingReviews() {
|
|
3440
|
-
const u = await this._getCurrentUser();
|
|
3441
|
-
try {
|
|
3442
|
-
const navigator = await this.createNavigator(u);
|
|
3443
|
-
return navigator.getPendingReviews();
|
|
3444
|
-
} catch (e) {
|
|
3445
|
-
logger.error(`[courseDB] Error in getPendingReviews: ${e}`);
|
|
3446
|
-
throw e;
|
|
3447
|
-
}
|
|
3448
|
-
}
|
|
3449
2255
|
/**
|
|
3450
2256
|
* Get cards with suitability scores for presentation.
|
|
3451
2257
|
*
|
|
@@ -3477,7 +2283,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3477
2283
|
const courseDoc = (await u.getCourseRegistrationsDoc()).courses.find((c) => {
|
|
3478
2284
|
return c.courseID === this.id;
|
|
3479
2285
|
});
|
|
3480
|
-
targetElo = (0,
|
|
2286
|
+
targetElo = (0, import_common7.EloToNumber)(courseDoc.elo);
|
|
3481
2287
|
} catch {
|
|
3482
2288
|
targetElo = 1e3;
|
|
3483
2289
|
}
|
|
@@ -3685,79 +2491,27 @@ var init_classroomDB2 = __esm({
|
|
|
3685
2491
|
setChangeFcn(f) {
|
|
3686
2492
|
void this.userMessages.on("change", f);
|
|
3687
2493
|
}
|
|
3688
|
-
async getPendingReviews() {
|
|
3689
|
-
const u = this._user;
|
|
3690
|
-
return (await u.getPendingReviews()).filter((r) => r.scheduledFor === "classroom" && r.schedulingAgentId === this._id).map((r) => {
|
|
3691
|
-
return {
|
|
3692
|
-
...r,
|
|
3693
|
-
qualifiedID: `${r.courseId}-${r.cardId}`,
|
|
3694
|
-
courseID: r.courseId,
|
|
3695
|
-
cardID: r.cardId,
|
|
3696
|
-
contentSourceType: "classroom",
|
|
3697
|
-
contentSourceID: this._id,
|
|
3698
|
-
reviewID: r._id,
|
|
3699
|
-
status: "review"
|
|
3700
|
-
};
|
|
3701
|
-
});
|
|
3702
|
-
}
|
|
3703
|
-
async getNewCards() {
|
|
3704
|
-
const activeCards = await this._user.getActiveCards();
|
|
3705
|
-
const now = import_moment4.default.utc();
|
|
3706
|
-
const assigned = await this.getAssignedContent();
|
|
3707
|
-
const due = assigned.filter((c) => now.isAfter(import_moment4.default.utc(c.activeOn, REVIEW_TIME_FORMAT2)));
|
|
3708
|
-
logger.info(`Due content: ${JSON.stringify(due)}`);
|
|
3709
|
-
let ret = [];
|
|
3710
|
-
for (let i = 0; i < due.length; i++) {
|
|
3711
|
-
const content = due[i];
|
|
3712
|
-
if (content.type === "course") {
|
|
3713
|
-
const db = new CourseDB(content.courseID, async () => this._user);
|
|
3714
|
-
ret = ret.concat(await db.getNewCards());
|
|
3715
|
-
} else if (content.type === "tag") {
|
|
3716
|
-
const tagDoc = await getTag(content.courseID, content.tagID);
|
|
3717
|
-
ret = ret.concat(
|
|
3718
|
-
tagDoc.taggedCards.map((c) => {
|
|
3719
|
-
return {
|
|
3720
|
-
courseID: content.courseID,
|
|
3721
|
-
cardID: c,
|
|
3722
|
-
qualifiedID: `${content.courseID}-${c}`,
|
|
3723
|
-
contentSourceType: "classroom",
|
|
3724
|
-
contentSourceID: this._id,
|
|
3725
|
-
status: "new"
|
|
3726
|
-
};
|
|
3727
|
-
})
|
|
3728
|
-
);
|
|
3729
|
-
} else if (content.type === "card") {
|
|
3730
|
-
ret.push(await getCourseDB2(content.courseID).get(content.cardID));
|
|
3731
|
-
}
|
|
3732
|
-
}
|
|
3733
|
-
logger.info(
|
|
3734
|
-
`New Cards from classroom ${this._cfg.name}: ${ret.map((c) => `${c.courseID}-${c.cardID}`)}`
|
|
3735
|
-
);
|
|
3736
|
-
return ret.filter((c) => {
|
|
3737
|
-
if (activeCards.some((ac) => c.cardID === ac.cardID)) {
|
|
3738
|
-
return false;
|
|
3739
|
-
} else {
|
|
3740
|
-
return true;
|
|
3741
|
-
}
|
|
3742
|
-
});
|
|
3743
|
-
}
|
|
3744
2494
|
/**
|
|
3745
2495
|
* Get cards with suitability scores for presentation.
|
|
3746
2496
|
*
|
|
3747
|
-
*
|
|
3748
|
-
*
|
|
3749
|
-
* support pluggable navigation strategies.
|
|
2497
|
+
* Gathers new cards from assigned content (courses, tags, cards) and
|
|
2498
|
+
* pending reviews scheduled for this classroom. Assigns score=1.0 to all.
|
|
3750
2499
|
*
|
|
3751
2500
|
* @param limit - Maximum number of cards to return
|
|
3752
2501
|
* @returns Cards sorted by score descending (all scores = 1.0)
|
|
3753
2502
|
*/
|
|
3754
2503
|
async getWeightedCards(limit) {
|
|
3755
|
-
const
|
|
3756
|
-
const
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
2504
|
+
const weighted = [];
|
|
2505
|
+
const allUserReviews = await this._user.getPendingReviews();
|
|
2506
|
+
const classroomReviews = allUserReviews.filter(
|
|
2507
|
+
(r) => r.scheduledFor === "classroom" && r.schedulingAgentId === this._id
|
|
2508
|
+
);
|
|
2509
|
+
for (const r of classroomReviews) {
|
|
2510
|
+
weighted.push({
|
|
2511
|
+
cardId: r.cardId,
|
|
2512
|
+
courseId: r.courseId,
|
|
3760
2513
|
score: 1,
|
|
2514
|
+
reviewID: r._id,
|
|
3761
2515
|
provenance: [
|
|
3762
2516
|
{
|
|
3763
2517
|
strategy: "classroom",
|
|
@@ -3765,27 +2519,84 @@ var init_classroomDB2 = __esm({
|
|
|
3765
2519
|
strategyId: "CLASSROOM",
|
|
3766
2520
|
action: "generated",
|
|
3767
2521
|
score: 1,
|
|
3768
|
-
reason: "Classroom
|
|
2522
|
+
reason: "Classroom scheduled review"
|
|
3769
2523
|
}
|
|
3770
2524
|
]
|
|
3771
|
-
})
|
|
3772
|
-
|
|
3773
|
-
|
|
3774
|
-
|
|
3775
|
-
|
|
3776
|
-
|
|
3777
|
-
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
3781
|
-
|
|
3782
|
-
|
|
3783
|
-
|
|
2525
|
+
});
|
|
2526
|
+
}
|
|
2527
|
+
const activeCards = await this._user.getActiveCards();
|
|
2528
|
+
const activeCardIds = new Set(activeCards.map((ac) => ac.cardID));
|
|
2529
|
+
const now = import_moment4.default.utc();
|
|
2530
|
+
const assigned = await this.getAssignedContent();
|
|
2531
|
+
const due = assigned.filter((c) => now.isAfter(import_moment4.default.utc(c.activeOn, REVIEW_TIME_FORMAT2)));
|
|
2532
|
+
logger.info(`[StudentClassroomDB] Due content: ${JSON.stringify(due)}`);
|
|
2533
|
+
for (const content of due) {
|
|
2534
|
+
if (content.type === "course") {
|
|
2535
|
+
const db = new CourseDB(content.courseID, async () => this._user);
|
|
2536
|
+
const courseCards = await db.getWeightedCards(limit);
|
|
2537
|
+
for (const card of courseCards) {
|
|
2538
|
+
if (!activeCardIds.has(card.cardId)) {
|
|
2539
|
+
weighted.push({
|
|
2540
|
+
...card,
|
|
2541
|
+
provenance: [
|
|
2542
|
+
...card.provenance,
|
|
2543
|
+
{
|
|
2544
|
+
strategy: "classroom",
|
|
2545
|
+
strategyName: "Classroom",
|
|
2546
|
+
strategyId: "CLASSROOM",
|
|
2547
|
+
action: "passed",
|
|
2548
|
+
score: card.score,
|
|
2549
|
+
reason: `Assigned via classroom from course ${content.courseID}`
|
|
2550
|
+
}
|
|
2551
|
+
]
|
|
2552
|
+
});
|
|
3784
2553
|
}
|
|
3785
|
-
|
|
3786
|
-
})
|
|
3787
|
-
|
|
3788
|
-
|
|
2554
|
+
}
|
|
2555
|
+
} else if (content.type === "tag") {
|
|
2556
|
+
const tagDoc = await getTag(content.courseID, content.tagID);
|
|
2557
|
+
for (const cardId of tagDoc.taggedCards) {
|
|
2558
|
+
if (!activeCardIds.has(cardId)) {
|
|
2559
|
+
weighted.push({
|
|
2560
|
+
cardId,
|
|
2561
|
+
courseId: content.courseID,
|
|
2562
|
+
score: 1,
|
|
2563
|
+
provenance: [
|
|
2564
|
+
{
|
|
2565
|
+
strategy: "classroom",
|
|
2566
|
+
strategyName: "Classroom",
|
|
2567
|
+
strategyId: "CLASSROOM",
|
|
2568
|
+
action: "generated",
|
|
2569
|
+
score: 1,
|
|
2570
|
+
reason: `Classroom assigned tag: ${content.tagID}, new card`
|
|
2571
|
+
}
|
|
2572
|
+
]
|
|
2573
|
+
});
|
|
2574
|
+
}
|
|
2575
|
+
}
|
|
2576
|
+
} else if (content.type === "card") {
|
|
2577
|
+
if (!activeCardIds.has(content.cardID)) {
|
|
2578
|
+
weighted.push({
|
|
2579
|
+
cardId: content.cardID,
|
|
2580
|
+
courseId: content.courseID,
|
|
2581
|
+
score: 1,
|
|
2582
|
+
provenance: [
|
|
2583
|
+
{
|
|
2584
|
+
strategy: "classroom",
|
|
2585
|
+
strategyName: "Classroom",
|
|
2586
|
+
strategyId: "CLASSROOM",
|
|
2587
|
+
action: "generated",
|
|
2588
|
+
score: 1,
|
|
2589
|
+
reason: "Classroom assigned card, new card"
|
|
2590
|
+
}
|
|
2591
|
+
]
|
|
2592
|
+
});
|
|
2593
|
+
}
|
|
2594
|
+
}
|
|
2595
|
+
}
|
|
2596
|
+
logger.info(
|
|
2597
|
+
`[StudentClassroomDB] New cards from classroom ${this._cfg.name}: ${weighted.length} total (reviews + new)`
|
|
2598
|
+
);
|
|
2599
|
+
return weighted.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
3789
2600
|
}
|
|
3790
2601
|
};
|
|
3791
2602
|
TeacherClassroomDB = class _TeacherClassroomDB extends ClassroomDBBase {
|
|
@@ -3991,14 +2802,14 @@ var CouchDBSyncStrategy_exports = {};
|
|
|
3991
2802
|
__export(CouchDBSyncStrategy_exports, {
|
|
3992
2803
|
CouchDBSyncStrategy: () => CouchDBSyncStrategy
|
|
3993
2804
|
});
|
|
3994
|
-
var
|
|
2805
|
+
var import_common8, log3, CouchDBSyncStrategy;
|
|
3995
2806
|
var init_CouchDBSyncStrategy = __esm({
|
|
3996
2807
|
"src/impl/couch/CouchDBSyncStrategy.ts"() {
|
|
3997
2808
|
"use strict";
|
|
3998
2809
|
init_factory();
|
|
3999
2810
|
init_types_legacy();
|
|
4000
2811
|
init_logger();
|
|
4001
|
-
|
|
2812
|
+
import_common8 = require("@vue-skuilder/common");
|
|
4002
2813
|
init_common();
|
|
4003
2814
|
init_pouchdb_setup();
|
|
4004
2815
|
init_couch();
|
|
@@ -4069,32 +2880,32 @@ var init_CouchDBSyncStrategy = __esm({
|
|
|
4069
2880
|
}
|
|
4070
2881
|
}
|
|
4071
2882
|
return {
|
|
4072
|
-
status:
|
|
2883
|
+
status: import_common8.Status.ok,
|
|
4073
2884
|
error: void 0
|
|
4074
2885
|
};
|
|
4075
2886
|
} else {
|
|
4076
2887
|
return {
|
|
4077
|
-
status:
|
|
2888
|
+
status: import_common8.Status.error,
|
|
4078
2889
|
error: "Failed to log in after account creation"
|
|
4079
2890
|
};
|
|
4080
2891
|
}
|
|
4081
2892
|
} else {
|
|
4082
2893
|
logger.warn(`Signup not OK: ${JSON.stringify(signupRequest)}`);
|
|
4083
2894
|
return {
|
|
4084
|
-
status:
|
|
2895
|
+
status: import_common8.Status.error,
|
|
4085
2896
|
error: "Account creation failed"
|
|
4086
2897
|
};
|
|
4087
2898
|
}
|
|
4088
2899
|
} catch (e) {
|
|
4089
2900
|
if (e.reason === "Document update conflict.") {
|
|
4090
2901
|
return {
|
|
4091
|
-
status:
|
|
2902
|
+
status: import_common8.Status.error,
|
|
4092
2903
|
error: "This username is taken!"
|
|
4093
2904
|
};
|
|
4094
2905
|
}
|
|
4095
2906
|
logger.error(`Error on signup: ${JSON.stringify(e)}`);
|
|
4096
2907
|
return {
|
|
4097
|
-
status:
|
|
2908
|
+
status: import_common8.Status.error,
|
|
4098
2909
|
error: e.message || "Unknown error during account creation"
|
|
4099
2910
|
};
|
|
4100
2911
|
}
|
|
@@ -4219,8 +3030,8 @@ var init_CouchDBSyncStrategy = __esm({
|
|
|
4219
3030
|
// src/impl/couch/index.ts
|
|
4220
3031
|
function createPouchDBConfig() {
|
|
4221
3032
|
const hasExplicitCredentials = ENV.COUCHDB_USERNAME && ENV.COUCHDB_PASSWORD;
|
|
4222
|
-
const
|
|
4223
|
-
if (hasExplicitCredentials &&
|
|
3033
|
+
const isNodeEnvironment = typeof window === "undefined";
|
|
3034
|
+
if (hasExplicitCredentials && isNodeEnvironment) {
|
|
4224
3035
|
return {
|
|
4225
3036
|
fetch(url, opts = {}) {
|
|
4226
3037
|
const basicAuth = btoa(`${ENV.COUCHDB_USERNAME}:${ENV.COUCHDB_PASSWORD}`);
|
|
@@ -4475,13 +3286,13 @@ async function dropUserFromClassroom(user, classID) {
|
|
|
4475
3286
|
async function getUserClassrooms(user) {
|
|
4476
3287
|
return getOrCreateClassroomRegistrationsDoc(user);
|
|
4477
3288
|
}
|
|
4478
|
-
var
|
|
3289
|
+
var import_common10, import_moment6, log4, BaseUser, userCoursesDoc, userClassroomsDoc;
|
|
4479
3290
|
var init_BaseUserDB = __esm({
|
|
4480
3291
|
"src/impl/common/BaseUserDB.ts"() {
|
|
4481
3292
|
"use strict";
|
|
4482
3293
|
init_core();
|
|
4483
3294
|
init_util();
|
|
4484
|
-
|
|
3295
|
+
import_common10 = require("@vue-skuilder/common");
|
|
4485
3296
|
import_moment6 = __toESM(require("moment"), 1);
|
|
4486
3297
|
init_types_legacy();
|
|
4487
3298
|
init_logger();
|
|
@@ -4531,7 +3342,7 @@ Currently logged-in as ${this._username}.`
|
|
|
4531
3342
|
);
|
|
4532
3343
|
}
|
|
4533
3344
|
const result = await this.syncStrategy.createAccount(username, password);
|
|
4534
|
-
if (result.status ===
|
|
3345
|
+
if (result.status === import_common10.Status.ok) {
|
|
4535
3346
|
log4(`Account created successfully, updating username to ${username}`);
|
|
4536
3347
|
this._username = username;
|
|
4537
3348
|
try {
|
|
@@ -4573,7 +3384,7 @@ Currently logged-in as ${this._username}.`
|
|
|
4573
3384
|
async resetUserData() {
|
|
4574
3385
|
if (this.syncStrategy.canAuthenticate()) {
|
|
4575
3386
|
return {
|
|
4576
|
-
status:
|
|
3387
|
+
status: import_common10.Status.error,
|
|
4577
3388
|
error: "Reset user data is only available for local-only mode. Use logout instead for remote sync."
|
|
4578
3389
|
};
|
|
4579
3390
|
}
|
|
@@ -4592,11 +3403,11 @@ Currently logged-in as ${this._username}.`
|
|
|
4592
3403
|
await localDB.bulkDocs(docsToDelete);
|
|
4593
3404
|
}
|
|
4594
3405
|
await this.init();
|
|
4595
|
-
return { status:
|
|
3406
|
+
return { status: import_common10.Status.ok };
|
|
4596
3407
|
} catch (error) {
|
|
4597
3408
|
logger.error("Failed to reset user data:", error);
|
|
4598
3409
|
return {
|
|
4599
|
-
status:
|
|
3410
|
+
status: import_common10.Status.error,
|
|
4600
3411
|
error: error instanceof Error ? error.message : "Unknown error during reset"
|
|
4601
3412
|
};
|
|
4602
3413
|
}
|
|
@@ -5383,8 +4194,8 @@ var init_PouchDataLayerProvider = __esm({
|
|
|
5383
4194
|
}
|
|
5384
4195
|
async initialize() {
|
|
5385
4196
|
if (this.initialized) return;
|
|
5386
|
-
const
|
|
5387
|
-
if (
|
|
4197
|
+
const isNodeEnvironment = typeof process !== "undefined" && process.versions != null && process.versions.node != null;
|
|
4198
|
+
if (isNodeEnvironment) {
|
|
5388
4199
|
logger.info(
|
|
5389
4200
|
"CouchDataLayerProvider: Running in Node.js environment, creating guest UserDB for testing."
|
|
5390
4201
|
);
|
|
@@ -5446,11 +4257,11 @@ var init_StaticDataUnpacker = __esm({
|
|
|
5446
4257
|
init_logger();
|
|
5447
4258
|
init_core();
|
|
5448
4259
|
pathUtils = {
|
|
5449
|
-
isAbsolute: (
|
|
5450
|
-
if (/^[a-zA-Z]:[\\/]/.test(
|
|
4260
|
+
isAbsolute: (path2) => {
|
|
4261
|
+
if (/^[a-zA-Z]:[\\/]/.test(path2) || /^\\\\/.test(path2)) {
|
|
5451
4262
|
return true;
|
|
5452
4263
|
}
|
|
5453
|
-
if (
|
|
4264
|
+
if (path2.startsWith("/")) {
|
|
5454
4265
|
return true;
|
|
5455
4266
|
}
|
|
5456
4267
|
return false;
|
|
@@ -5497,6 +4308,36 @@ var init_StaticDataUnpacker = __esm({
|
|
|
5497
4308
|
logger.error(`Document ${id} not found in chunk ${chunk.id}`);
|
|
5498
4309
|
throw new Error(`Document ${id} not found in chunk ${chunk.id}`);
|
|
5499
4310
|
}
|
|
4311
|
+
/**
|
|
4312
|
+
* Get all documents with IDs starting with a specific prefix.
|
|
4313
|
+
*
|
|
4314
|
+
* This method loads the relevant chunk(s) and returns all matching documents.
|
|
4315
|
+
* Useful for querying documents by type (e.g., all NAVIGATION_STRATEGY documents).
|
|
4316
|
+
*
|
|
4317
|
+
* @param prefix - Document ID prefix to match (e.g., "NAVIGATION_STRATEGY")
|
|
4318
|
+
* @returns Array of all documents with IDs starting with the prefix
|
|
4319
|
+
*/
|
|
4320
|
+
async getAllDocumentsByPrefix(prefix) {
|
|
4321
|
+
const relevantChunks = this.manifest.chunks.filter((chunk) => {
|
|
4322
|
+
const prefixEnd = prefix + "\uFFF0";
|
|
4323
|
+
return chunk.startKey <= prefixEnd && chunk.endKey >= prefix;
|
|
4324
|
+
});
|
|
4325
|
+
if (relevantChunks.length === 0) {
|
|
4326
|
+
logger.debug(`[StaticDataUnpacker] No chunks found for prefix: ${prefix}`);
|
|
4327
|
+
return [];
|
|
4328
|
+
}
|
|
4329
|
+
await Promise.all(relevantChunks.map((chunk) => this.loadChunk(chunk.id)));
|
|
4330
|
+
const matchingDocs = [];
|
|
4331
|
+
for (const [docId, doc] of this.documentCache.entries()) {
|
|
4332
|
+
if (docId.startsWith(prefix)) {
|
|
4333
|
+
matchingDocs.push(await this.hydrateAttachments(doc));
|
|
4334
|
+
}
|
|
4335
|
+
}
|
|
4336
|
+
logger.debug(
|
|
4337
|
+
`[StaticDataUnpacker] Found ${matchingDocs.length} documents with prefix: ${prefix}`
|
|
4338
|
+
);
|
|
4339
|
+
return matchingDocs;
|
|
4340
|
+
}
|
|
5500
4341
|
/**
|
|
5501
4342
|
* Query cards by ELO score, returning card IDs sorted by ELO
|
|
5502
4343
|
*/
|
|
@@ -5533,7 +4374,14 @@ var init_StaticDataUnpacker = __esm({
|
|
|
5533
4374
|
* Get all tag names mapped to their card arrays
|
|
5534
4375
|
*/
|
|
5535
4376
|
async getTagsIndex() {
|
|
5536
|
-
|
|
4377
|
+
try {
|
|
4378
|
+
return await this.loadIndex("tags");
|
|
4379
|
+
} catch {
|
|
4380
|
+
return {
|
|
4381
|
+
byCard: {},
|
|
4382
|
+
byTag: {}
|
|
4383
|
+
};
|
|
4384
|
+
}
|
|
5537
4385
|
}
|
|
5538
4386
|
getDocTypeFromId(id) {
|
|
5539
4387
|
for (const docTypeKey in DocTypePrefixes) {
|
|
@@ -5818,14 +4666,15 @@ var init_StaticDataUnpacker = __esm({
|
|
|
5818
4666
|
});
|
|
5819
4667
|
|
|
5820
4668
|
// src/impl/static/courseDB.ts
|
|
5821
|
-
var
|
|
4669
|
+
var import_common12, StaticCourseDB;
|
|
5822
4670
|
var init_courseDB2 = __esm({
|
|
5823
4671
|
"src/impl/static/courseDB.ts"() {
|
|
5824
4672
|
"use strict";
|
|
5825
|
-
|
|
4673
|
+
import_common12 = require("@vue-skuilder/common");
|
|
5826
4674
|
init_types_legacy();
|
|
5827
|
-
init_navigators();
|
|
5828
4675
|
init_logger();
|
|
4676
|
+
init_defaults();
|
|
4677
|
+
init_PipelineAssembler();
|
|
5829
4678
|
StaticCourseDB = class {
|
|
5830
4679
|
constructor(courseId, unpacker, userDB, manifest) {
|
|
5831
4680
|
this.courseId = courseId;
|
|
@@ -5904,21 +4753,6 @@ var init_courseDB2 = __esm({
|
|
|
5904
4753
|
async updateCardElo(cardId, _elo) {
|
|
5905
4754
|
return { ok: true, id: cardId, rev: "1-static" };
|
|
5906
4755
|
}
|
|
5907
|
-
async getNewCards(limit = 99) {
|
|
5908
|
-
const activeCards = await this.userDB.getActiveCards();
|
|
5909
|
-
return (await this.getCardsCenteredAtELO({ limit, elo: "user" }, (c) => {
|
|
5910
|
-
if (activeCards.some((ac) => c.cardID === ac.cardID)) {
|
|
5911
|
-
return false;
|
|
5912
|
-
} else {
|
|
5913
|
-
return true;
|
|
5914
|
-
}
|
|
5915
|
-
})).map((c) => {
|
|
5916
|
-
return {
|
|
5917
|
-
...c,
|
|
5918
|
-
status: "new"
|
|
5919
|
-
};
|
|
5920
|
-
});
|
|
5921
|
-
}
|
|
5922
4756
|
async getCardsCenteredAtELO(options, filter) {
|
|
5923
4757
|
let targetElo = typeof options.elo === "number" ? options.elo : 1e3;
|
|
5924
4758
|
if (options.elo === "user") {
|
|
@@ -6093,7 +4927,7 @@ var init_courseDB2 = __esm({
|
|
|
6093
4927
|
}
|
|
6094
4928
|
async addNote(_codeCourse, _shape, _data, _author, _tags, _uploads, _elo) {
|
|
6095
4929
|
return {
|
|
6096
|
-
status:
|
|
4930
|
+
status: import_common12.Status.error,
|
|
6097
4931
|
message: "Cannot add notes in static mode"
|
|
6098
4932
|
};
|
|
6099
4933
|
}
|
|
@@ -6104,19 +4938,23 @@ var init_courseDB2 = __esm({
|
|
|
6104
4938
|
return [];
|
|
6105
4939
|
}
|
|
6106
4940
|
// Navigation Strategy Manager implementation
|
|
6107
|
-
async getNavigationStrategy(
|
|
6108
|
-
|
|
6109
|
-
|
|
6110
|
-
|
|
6111
|
-
|
|
6112
|
-
|
|
6113
|
-
|
|
6114
|
-
course: this.courseId,
|
|
6115
|
-
serializedData: ""
|
|
6116
|
-
};
|
|
4941
|
+
async getNavigationStrategy(id) {
|
|
4942
|
+
try {
|
|
4943
|
+
return await this.unpacker.getDocument(id);
|
|
4944
|
+
} catch (error) {
|
|
4945
|
+
logger.error(`[static/courseDB] Strategy ${id} not found: ${error}`);
|
|
4946
|
+
throw error;
|
|
4947
|
+
}
|
|
6117
4948
|
}
|
|
6118
4949
|
async getAllNavigationStrategies() {
|
|
6119
|
-
|
|
4950
|
+
const prefix = DocTypePrefixes["NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */];
|
|
4951
|
+
try {
|
|
4952
|
+
const docs = await this.unpacker.getAllDocumentsByPrefix(prefix);
|
|
4953
|
+
return docs;
|
|
4954
|
+
} catch (error) {
|
|
4955
|
+
logger.warn(`[static/courseDB] Error loading navigation strategies: ${error}`);
|
|
4956
|
+
return [];
|
|
4957
|
+
}
|
|
6120
4958
|
}
|
|
6121
4959
|
async addNavigationStrategy(_data) {
|
|
6122
4960
|
throw new Error("Cannot add navigation strategies in static mode");
|
|
@@ -6124,9 +4962,52 @@ var init_courseDB2 = __esm({
|
|
|
6124
4962
|
async updateNavigationStrategy(_id, _data) {
|
|
6125
4963
|
throw new Error("Cannot update navigation strategies in static mode");
|
|
6126
4964
|
}
|
|
4965
|
+
/**
|
|
4966
|
+
* Create a ContentNavigator for this course.
|
|
4967
|
+
*
|
|
4968
|
+
* Loads navigation strategy documents from static data and uses PipelineAssembler
|
|
4969
|
+
* to build a Pipeline. Falls back to default pipeline if no strategies found.
|
|
4970
|
+
*/
|
|
4971
|
+
async createNavigator(user) {
|
|
4972
|
+
try {
|
|
4973
|
+
const allStrategies = await this.getAllNavigationStrategies();
|
|
4974
|
+
if (allStrategies.length === 0) {
|
|
4975
|
+
logger.debug(
|
|
4976
|
+
"[static/courseDB] No strategy documents found, using default Pipeline(Composite(ELO, SRS), [eloDistanceFilter])"
|
|
4977
|
+
);
|
|
4978
|
+
return createDefaultPipeline(user, this);
|
|
4979
|
+
}
|
|
4980
|
+
const assembler = new PipelineAssembler();
|
|
4981
|
+
const { pipeline, generatorStrategies, filterStrategies, warnings } = await assembler.assemble({
|
|
4982
|
+
strategies: allStrategies,
|
|
4983
|
+
user,
|
|
4984
|
+
course: this
|
|
4985
|
+
});
|
|
4986
|
+
for (const warning of warnings) {
|
|
4987
|
+
logger.warn(`[PipelineAssembler] ${warning}`);
|
|
4988
|
+
}
|
|
4989
|
+
if (!pipeline) {
|
|
4990
|
+
logger.debug("[static/courseDB] Pipeline assembly failed, using default pipeline");
|
|
4991
|
+
return createDefaultPipeline(user, this);
|
|
4992
|
+
}
|
|
4993
|
+
logger.debug(
|
|
4994
|
+
`[static/courseDB] Using assembled pipeline with ${generatorStrategies.length} generator(s) and ${filterStrategies.length} filter(s)`
|
|
4995
|
+
);
|
|
4996
|
+
return pipeline;
|
|
4997
|
+
} catch (e) {
|
|
4998
|
+
logger.error(`[static/courseDB] Error creating navigator: ${e}`);
|
|
4999
|
+
throw e;
|
|
5000
|
+
}
|
|
5001
|
+
}
|
|
6127
5002
|
// Study Content Source implementation
|
|
6128
|
-
async
|
|
6129
|
-
|
|
5003
|
+
async getWeightedCards(limit) {
|
|
5004
|
+
try {
|
|
5005
|
+
const navigator = await this.createNavigator(this.userDB);
|
|
5006
|
+
return navigator.getWeightedCards(limit);
|
|
5007
|
+
} catch (e) {
|
|
5008
|
+
logger.error(`[static/courseDB] Error getting weighted cards: ${e}`);
|
|
5009
|
+
throw e;
|
|
5010
|
+
}
|
|
6130
5011
|
}
|
|
6131
5012
|
// Attachment helper methods (internal use, not part of interface)
|
|
6132
5013
|
/**
|
|
@@ -6445,11 +5326,11 @@ var init_factory = __esm({
|
|
|
6445
5326
|
});
|
|
6446
5327
|
|
|
6447
5328
|
// src/study/TagFilteredContentSource.ts
|
|
6448
|
-
var
|
|
5329
|
+
var import_common16, TagFilteredContentSource;
|
|
6449
5330
|
var init_TagFilteredContentSource = __esm({
|
|
6450
5331
|
"src/study/TagFilteredContentSource.ts"() {
|
|
6451
5332
|
"use strict";
|
|
6452
|
-
|
|
5333
|
+
import_common16 = require("@vue-skuilder/common");
|
|
6453
5334
|
init_courseDB();
|
|
6454
5335
|
init_logger();
|
|
6455
5336
|
TagFilteredContentSource = class {
|
|
@@ -6525,108 +5406,71 @@ var init_TagFilteredContentSource = __esm({
|
|
|
6525
5406
|
return finalCardIds;
|
|
6526
5407
|
}
|
|
6527
5408
|
/**
|
|
6528
|
-
*
|
|
5409
|
+
* Get cards with suitability scores for presentation.
|
|
5410
|
+
*
|
|
5411
|
+
* Filters cards by tag inclusion/exclusion and assigns score=1.0 to all.
|
|
5412
|
+
* TagFilteredContentSource does not currently support pluggable navigation
|
|
5413
|
+
* strategies - it returns flat-scored candidates.
|
|
5414
|
+
*
|
|
5415
|
+
* @param limit - Maximum number of cards to return
|
|
5416
|
+
* @returns Cards sorted by score descending (all scores = 1.0)
|
|
6529
5417
|
*/
|
|
6530
|
-
async
|
|
6531
|
-
if (!(0,
|
|
6532
|
-
logger.warn("[TagFilteredContentSource]
|
|
5418
|
+
async getWeightedCards(limit) {
|
|
5419
|
+
if (!(0, import_common16.hasActiveFilter)(this.filter)) {
|
|
5420
|
+
logger.warn("[TagFilteredContentSource] getWeightedCards called with no active filter");
|
|
6533
5421
|
return [];
|
|
6534
5422
|
}
|
|
6535
5423
|
const eligibleCardIds = await this.resolveFilteredCardIds();
|
|
6536
5424
|
const activeCards = await this.user.getActiveCards();
|
|
6537
5425
|
const activeCardIds = new Set(activeCards.map((c) => c.cardID));
|
|
6538
|
-
const
|
|
5426
|
+
const newCardWeighted = [];
|
|
6539
5427
|
for (const cardId of eligibleCardIds) {
|
|
6540
5428
|
if (!activeCardIds.has(cardId)) {
|
|
6541
|
-
|
|
6542
|
-
|
|
6543
|
-
|
|
6544
|
-
|
|
6545
|
-
|
|
6546
|
-
|
|
5429
|
+
newCardWeighted.push({
|
|
5430
|
+
cardId,
|
|
5431
|
+
courseId: this.courseId,
|
|
5432
|
+
score: 1,
|
|
5433
|
+
provenance: [
|
|
5434
|
+
{
|
|
5435
|
+
strategy: "tagFilter",
|
|
5436
|
+
strategyName: "Tag Filter",
|
|
5437
|
+
strategyId: "TAG_FILTER",
|
|
5438
|
+
action: "generated",
|
|
5439
|
+
score: 1,
|
|
5440
|
+
reason: `Tag-filtered new card (tags: ${this.filter.include.join(", ")})`
|
|
5441
|
+
}
|
|
5442
|
+
]
|
|
6547
5443
|
});
|
|
6548
5444
|
}
|
|
6549
|
-
if (
|
|
5445
|
+
if (newCardWeighted.length >= limit) {
|
|
6550
5446
|
break;
|
|
6551
5447
|
}
|
|
6552
5448
|
}
|
|
6553
|
-
logger.info(
|
|
6554
|
-
|
|
6555
|
-
|
|
6556
|
-
/**
|
|
6557
|
-
* Gets pending reviews, filtered to only include cards that match the tag filter.
|
|
6558
|
-
*/
|
|
6559
|
-
async getPendingReviews() {
|
|
6560
|
-
if (!(0, import_common18.hasActiveFilter)(this.filter)) {
|
|
6561
|
-
logger.warn("[TagFilteredContentSource] getPendingReviews called with no active filter");
|
|
6562
|
-
return [];
|
|
6563
|
-
}
|
|
6564
|
-
const eligibleCardIds = await this.resolveFilteredCardIds();
|
|
5449
|
+
logger.info(
|
|
5450
|
+
`[TagFilteredContentSource] Found ${newCardWeighted.length} new cards matching filter`
|
|
5451
|
+
);
|
|
6565
5452
|
const allReviews = await this.user.getPendingReviews(this.courseId);
|
|
6566
|
-
const filteredReviews = allReviews.filter((review) =>
|
|
6567
|
-
return eligibleCardIds.has(review.cardId);
|
|
6568
|
-
});
|
|
5453
|
+
const filteredReviews = allReviews.filter((review) => eligibleCardIds.has(review.cardId));
|
|
6569
5454
|
logger.info(
|
|
6570
5455
|
`[TagFilteredContentSource] Found ${filteredReviews.length} pending reviews matching filter (of ${allReviews.length} total)`
|
|
6571
5456
|
);
|
|
6572
|
-
|
|
6573
|
-
|
|
6574
|
-
|
|
6575
|
-
|
|
6576
|
-
contentSourceType: "course",
|
|
6577
|
-
contentSourceID: this.courseId,
|
|
5457
|
+
const reviewWeighted = filteredReviews.map((r) => ({
|
|
5458
|
+
cardId: r.cardId,
|
|
5459
|
+
courseId: r.courseId,
|
|
5460
|
+
score: 1,
|
|
6578
5461
|
reviewID: r._id,
|
|
6579
|
-
|
|
5462
|
+
provenance: [
|
|
5463
|
+
{
|
|
5464
|
+
strategy: "tagFilter",
|
|
5465
|
+
strategyName: "Tag Filter",
|
|
5466
|
+
strategyId: "TAG_FILTER",
|
|
5467
|
+
action: "generated",
|
|
5468
|
+
score: 1,
|
|
5469
|
+
reason: `Tag-filtered review (tags: ${this.filter.include.join(", ")})`
|
|
5470
|
+
}
|
|
5471
|
+
]
|
|
6580
5472
|
}));
|
|
6581
|
-
|
|
6582
|
-
/**
|
|
6583
|
-
* Get cards with suitability scores for presentation.
|
|
6584
|
-
*
|
|
6585
|
-
* This implementation wraps the legacy getNewCards/getPendingReviews methods,
|
|
6586
|
-
* assigning score=1.0 to all cards. TagFilteredContentSource does not currently
|
|
6587
|
-
* support pluggable navigation strategies - it returns flat-scored candidates.
|
|
6588
|
-
*
|
|
6589
|
-
* @param limit - Maximum number of cards to return
|
|
6590
|
-
* @returns Cards sorted by score descending (all scores = 1.0)
|
|
6591
|
-
*/
|
|
6592
|
-
async getWeightedCards(limit) {
|
|
6593
|
-
const [newCards, reviews] = await Promise.all([
|
|
6594
|
-
this.getNewCards(limit),
|
|
6595
|
-
this.getPendingReviews()
|
|
6596
|
-
]);
|
|
6597
|
-
const weighted = [
|
|
6598
|
-
...reviews.map((r) => ({
|
|
6599
|
-
cardId: r.cardID,
|
|
6600
|
-
courseId: r.courseID,
|
|
6601
|
-
score: 1,
|
|
6602
|
-
provenance: [
|
|
6603
|
-
{
|
|
6604
|
-
strategy: "tagFilter",
|
|
6605
|
-
strategyName: "Tag Filter",
|
|
6606
|
-
strategyId: "TAG_FILTER",
|
|
6607
|
-
action: "generated",
|
|
6608
|
-
score: 1,
|
|
6609
|
-
reason: `Tag-filtered review (tags: ${this.filter.include.join(", ")})`
|
|
6610
|
-
}
|
|
6611
|
-
]
|
|
6612
|
-
})),
|
|
6613
|
-
...newCards.map((c) => ({
|
|
6614
|
-
cardId: c.cardID,
|
|
6615
|
-
courseId: c.courseID,
|
|
6616
|
-
score: 1,
|
|
6617
|
-
provenance: [
|
|
6618
|
-
{
|
|
6619
|
-
strategy: "tagFilter",
|
|
6620
|
-
strategyName: "Tag Filter",
|
|
6621
|
-
strategyId: "TAG_FILTER",
|
|
6622
|
-
action: "generated",
|
|
6623
|
-
score: 1,
|
|
6624
|
-
reason: `Tag-filtered new card (tags: ${this.filter.include.join(", ")})`
|
|
6625
|
-
}
|
|
6626
|
-
]
|
|
6627
|
-
}))
|
|
6628
|
-
];
|
|
6629
|
-
return weighted.slice(0, limit);
|
|
5473
|
+
return [...reviewWeighted, ...newCardWeighted].slice(0, limit);
|
|
6630
5474
|
}
|
|
6631
5475
|
/**
|
|
6632
5476
|
* Clears the cached resolved card IDs.
|
|
@@ -6660,19 +5504,19 @@ async function getStudySource(source, user) {
|
|
|
6660
5504
|
if (source.type === "classroom") {
|
|
6661
5505
|
return await StudentClassroomDB.factory(source.id, user);
|
|
6662
5506
|
} else {
|
|
6663
|
-
if ((0,
|
|
5507
|
+
if ((0, import_common17.hasActiveFilter)(source.tagFilter)) {
|
|
6664
5508
|
return new TagFilteredContentSource(source.id, source.tagFilter, user);
|
|
6665
5509
|
}
|
|
6666
5510
|
return getDataLayer().getCourseDB(source.id);
|
|
6667
5511
|
}
|
|
6668
5512
|
}
|
|
6669
|
-
var
|
|
5513
|
+
var import_common17;
|
|
6670
5514
|
var init_contentSource = __esm({
|
|
6671
5515
|
"src/core/interfaces/contentSource.ts"() {
|
|
6672
5516
|
"use strict";
|
|
6673
5517
|
init_factory();
|
|
6674
5518
|
init_classroomDB2();
|
|
6675
|
-
|
|
5519
|
+
import_common17 = require("@vue-skuilder/common");
|
|
6676
5520
|
init_TagFilteredContentSource();
|
|
6677
5521
|
}
|
|
6678
5522
|
});
|
|
@@ -6796,7 +5640,7 @@ elo: ${elo}`;
|
|
|
6796
5640
|
misc: {}
|
|
6797
5641
|
} : void 0
|
|
6798
5642
|
);
|
|
6799
|
-
if (result.status ===
|
|
5643
|
+
if (result.status === import_common18.Status.ok) {
|
|
6800
5644
|
return {
|
|
6801
5645
|
originalText,
|
|
6802
5646
|
status: "success",
|
|
@@ -6840,17 +5684,17 @@ function validateProcessorConfig(config) {
|
|
|
6840
5684
|
}
|
|
6841
5685
|
return { isValid: true };
|
|
6842
5686
|
}
|
|
6843
|
-
var
|
|
5687
|
+
var import_common18;
|
|
6844
5688
|
var init_cardProcessor = __esm({
|
|
6845
5689
|
"src/core/bulkImport/cardProcessor.ts"() {
|
|
6846
5690
|
"use strict";
|
|
6847
|
-
|
|
5691
|
+
import_common18 = require("@vue-skuilder/common");
|
|
6848
5692
|
init_logger();
|
|
6849
5693
|
}
|
|
6850
5694
|
});
|
|
6851
5695
|
|
|
6852
5696
|
// src/core/bulkImport/types.ts
|
|
6853
|
-
var
|
|
5697
|
+
var init_types = __esm({
|
|
6854
5698
|
"src/core/bulkImport/types.ts"() {
|
|
6855
5699
|
"use strict";
|
|
6856
5700
|
}
|
|
@@ -6861,7 +5705,7 @@ var init_bulkImport = __esm({
|
|
|
6861
5705
|
"src/core/bulkImport/index.ts"() {
|
|
6862
5706
|
"use strict";
|
|
6863
5707
|
init_cardProcessor();
|
|
6864
|
-
|
|
5708
|
+
init_types();
|
|
6865
5709
|
}
|
|
6866
5710
|
});
|
|
6867
5711
|
|
|
@@ -6896,6 +5740,7 @@ __export(index_exports, {
|
|
|
6896
5740
|
NavigatorRole: () => NavigatorRole,
|
|
6897
5741
|
NavigatorRoles: () => NavigatorRoles,
|
|
6898
5742
|
Navigators: () => Navigators,
|
|
5743
|
+
QuotaRoundRobinMixer: () => QuotaRoundRobinMixer,
|
|
6899
5744
|
SessionController: () => SessionController,
|
|
6900
5745
|
StaticToCouchDBMigrator: () => StaticToCouchDBMigrator,
|
|
6901
5746
|
TagFilteredContentSource: () => TagFilteredContentSource,
|
|
@@ -6909,22 +5754,17 @@ __export(index_exports, {
|
|
|
6909
5754
|
getCardOrigin: () => getCardOrigin,
|
|
6910
5755
|
getDataLayer: () => getDataLayer,
|
|
6911
5756
|
getDbPath: () => getDbPath,
|
|
6912
|
-
getLogFilePath: () => getLogFilePath,
|
|
6913
5757
|
getStudySource: () => getStudySource,
|
|
6914
5758
|
importParsedCards: () => importParsedCards,
|
|
6915
5759
|
initializeDataDirectory: () => initializeDataDirectory,
|
|
6916
5760
|
initializeDataLayer: () => initializeDataLayer,
|
|
6917
|
-
initializeTuiLogging: () => initializeTuiLogging,
|
|
6918
5761
|
isFilter: () => isFilter,
|
|
6919
5762
|
isGenerator: () => isGenerator,
|
|
6920
5763
|
isQuestionRecord: () => isQuestionRecord,
|
|
6921
5764
|
isReview: () => isReview,
|
|
6922
5765
|
log: () => log,
|
|
6923
|
-
logger: () => logger2,
|
|
6924
5766
|
newInterval: () => newInterval,
|
|
6925
5767
|
parseCardHistoryID: () => parseCardHistoryID,
|
|
6926
|
-
showUserError: () => showUserError,
|
|
6927
|
-
showUserMessage: () => showUserMessage,
|
|
6928
5768
|
validateMigration: () => validateMigration,
|
|
6929
5769
|
validateProcessorConfig: () => validateProcessorConfig,
|
|
6930
5770
|
validateStaticCourse: () => validateStaticCourse
|
|
@@ -7043,7 +5883,7 @@ var SrsService = class {
|
|
|
7043
5883
|
};
|
|
7044
5884
|
|
|
7045
5885
|
// src/study/services/EloService.ts
|
|
7046
|
-
var
|
|
5886
|
+
var import_common19 = require("@vue-skuilder/common");
|
|
7047
5887
|
init_logger();
|
|
7048
5888
|
var EloService = class {
|
|
7049
5889
|
dataLayer;
|
|
@@ -7066,10 +5906,10 @@ var EloService = class {
|
|
|
7066
5906
|
logger.warn(`k value interpretation not currently implemented`);
|
|
7067
5907
|
}
|
|
7068
5908
|
const courseDB = this.dataLayer.getCourseDB(currentCard.card.course_id);
|
|
7069
|
-
const userElo = (0,
|
|
5909
|
+
const userElo = (0, import_common19.toCourseElo)(userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo);
|
|
7070
5910
|
const cardElo = (await courseDB.getCardEloData([currentCard.card.card_id]))[0];
|
|
7071
5911
|
if (cardElo && userElo) {
|
|
7072
|
-
const eloUpdate = (0,
|
|
5912
|
+
const eloUpdate = (0, import_common19.adjustCourseScores)(userElo, cardElo, userScore);
|
|
7073
5913
|
userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo = eloUpdate.userElo;
|
|
7074
5914
|
const results = await Promise.allSettled([
|
|
7075
5915
|
this.user.updateUserElo(course_id, eloUpdate.userElo),
|
|
@@ -7271,156 +6111,124 @@ var ResponseProcessor = class {
|
|
|
7271
6111
|
};
|
|
7272
6112
|
|
|
7273
6113
|
// src/study/services/CardHydrationService.ts
|
|
7274
|
-
var
|
|
6114
|
+
var import_common20 = require("@vue-skuilder/common");
|
|
7275
6115
|
init_logger();
|
|
7276
|
-
|
|
7277
|
-
|
|
7278
|
-
|
|
7279
|
-
|
|
7280
|
-
|
|
7281
|
-
|
|
7282
|
-
|
|
7283
|
-
|
|
7284
|
-
|
|
7285
|
-
|
|
7286
|
-
|
|
7287
|
-
|
|
7288
|
-
}
|
|
7289
|
-
|
|
7290
|
-
|
|
7291
|
-
|
|
7292
|
-
|
|
7293
|
-
|
|
7294
|
-
|
|
7295
|
-
|
|
7296
|
-
|
|
7297
|
-
|
|
7298
|
-
|
|
7299
|
-
|
|
7300
|
-
|
|
7301
|
-
dequeue(cardIdExtractor) {
|
|
7302
|
-
if (this.q.length !== 0) {
|
|
7303
|
-
this._dequeueCount++;
|
|
7304
|
-
const item = this.q.splice(0, 1)[0];
|
|
7305
|
-
if (cardIdExtractor) {
|
|
7306
|
-
const cardId = cardIdExtractor(item);
|
|
7307
|
-
const index = this.seenCardIds.indexOf(cardId);
|
|
7308
|
-
if (index > -1) {
|
|
7309
|
-
this.seenCardIds.splice(index, 1);
|
|
7310
|
-
}
|
|
7311
|
-
}
|
|
7312
|
-
return item;
|
|
7313
|
-
} else {
|
|
7314
|
-
return null;
|
|
7315
|
-
}
|
|
7316
|
-
}
|
|
7317
|
-
get toString() {
|
|
7318
|
-
return `${typeof this.q[0]}:
|
|
7319
|
-
` + this.q.map((i) => ` ${i.courseID}+${i.cardID}: ${i.status}`).join("\n");
|
|
7320
|
-
}
|
|
7321
|
-
};
|
|
7322
|
-
|
|
7323
|
-
// src/study/services/CardHydrationService.ts
|
|
6116
|
+
function parseAudioURIs(data) {
|
|
6117
|
+
if (typeof data !== "string") return [];
|
|
6118
|
+
const audioPattern = /https?:\/\/[^\s"'<>]+\.(wav|mp3|ogg|m4a|aac|webm)/gi;
|
|
6119
|
+
return data.match(audioPattern) ?? [];
|
|
6120
|
+
}
|
|
6121
|
+
function prefetchAudio(url) {
|
|
6122
|
+
return new Promise((resolve) => {
|
|
6123
|
+
const audio = new Audio();
|
|
6124
|
+
audio.preload = "auto";
|
|
6125
|
+
const cleanup = () => {
|
|
6126
|
+
audio.oncanplaythrough = null;
|
|
6127
|
+
audio.onerror = null;
|
|
6128
|
+
};
|
|
6129
|
+
audio.oncanplaythrough = () => {
|
|
6130
|
+
cleanup();
|
|
6131
|
+
resolve();
|
|
6132
|
+
};
|
|
6133
|
+
audio.onerror = () => {
|
|
6134
|
+
cleanup();
|
|
6135
|
+
logger.warn(`[CardHydrationService] Failed to prefetch audio: ${url}`);
|
|
6136
|
+
resolve();
|
|
6137
|
+
};
|
|
6138
|
+
audio.src = url;
|
|
6139
|
+
});
|
|
6140
|
+
}
|
|
7324
6141
|
var CardHydrationService = class {
|
|
7325
|
-
constructor(getViewComponent, getCourseDB3,
|
|
6142
|
+
constructor(getViewComponent, getCourseDB3, getItemsToHydrate) {
|
|
7326
6143
|
this.getViewComponent = getViewComponent;
|
|
7327
6144
|
this.getCourseDB = getCourseDB3;
|
|
7328
|
-
this.
|
|
7329
|
-
this.removeItemFromQueue = removeItemFromQueue;
|
|
7330
|
-
this.hasAvailableCards = hasAvailableCards;
|
|
6145
|
+
this.getItemsToHydrate = getItemsToHydrate;
|
|
7331
6146
|
}
|
|
7332
|
-
|
|
7333
|
-
|
|
6147
|
+
hydratedCards = /* @__PURE__ */ new Map();
|
|
6148
|
+
hydrationInFlight = /* @__PURE__ */ new Set();
|
|
7334
6149
|
hydrationInProgress = false;
|
|
7335
|
-
BUFFER_SIZE = 5;
|
|
7336
6150
|
/**
|
|
7337
|
-
* Get
|
|
7338
|
-
* @returns Hydrated card or null if
|
|
6151
|
+
* Get a hydrated card by ID.
|
|
6152
|
+
* @returns Hydrated card or null if not in cache
|
|
6153
|
+
*/
|
|
6154
|
+
getHydratedCard(cardId) {
|
|
6155
|
+
return this.hydratedCards.get(cardId) ?? null;
|
|
6156
|
+
}
|
|
6157
|
+
/**
|
|
6158
|
+
* Check if a card is hydrated.
|
|
7339
6159
|
*/
|
|
7340
|
-
|
|
7341
|
-
return this.
|
|
6160
|
+
hasHydratedCard(cardId) {
|
|
6161
|
+
return this.hydratedCards.has(cardId);
|
|
6162
|
+
}
|
|
6163
|
+
/**
|
|
6164
|
+
* Remove a card from the cache (call on successful dismiss to free memory).
|
|
6165
|
+
*/
|
|
6166
|
+
removeCard(cardId) {
|
|
6167
|
+
this.hydratedCards.delete(cardId);
|
|
7342
6168
|
}
|
|
7343
6169
|
/**
|
|
7344
6170
|
* Check if hydration should be triggered and start background hydration if needed.
|
|
7345
6171
|
*/
|
|
7346
6172
|
async ensureHydratedCards() {
|
|
7347
|
-
|
|
7348
|
-
void this.fillHydratedQueue();
|
|
7349
|
-
}
|
|
6173
|
+
void this.fillHydratedCards();
|
|
7350
6174
|
}
|
|
7351
6175
|
/**
|
|
7352
|
-
* Wait for a
|
|
6176
|
+
* Wait for a specific card to become hydrated.
|
|
7353
6177
|
* @returns Promise that resolves to a hydrated card or null
|
|
7354
6178
|
*/
|
|
7355
|
-
async
|
|
7356
|
-
if (this.
|
|
7357
|
-
|
|
6179
|
+
async waitForCard(cardId) {
|
|
6180
|
+
if (this.hydratedCards.has(cardId)) {
|
|
6181
|
+
return this.hydratedCards.get(cardId);
|
|
7358
6182
|
}
|
|
7359
|
-
|
|
7360
|
-
|
|
6183
|
+
if (!this.hydrationInProgress) {
|
|
6184
|
+
void this.fillHydratedCards();
|
|
7361
6185
|
}
|
|
7362
|
-
|
|
6186
|
+
const maxWaitMs = 1e4;
|
|
6187
|
+
const pollIntervalMs = 25;
|
|
6188
|
+
let elapsed = 0;
|
|
6189
|
+
while (elapsed < maxWaitMs) {
|
|
6190
|
+
if (this.hydratedCards.has(cardId)) {
|
|
6191
|
+
return this.hydratedCards.get(cardId);
|
|
6192
|
+
}
|
|
6193
|
+
if (!this.hydrationInFlight.has(cardId) && !this.hydrationInProgress) {
|
|
6194
|
+
break;
|
|
6195
|
+
}
|
|
6196
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
6197
|
+
elapsed += pollIntervalMs;
|
|
6198
|
+
}
|
|
6199
|
+
return this.hydratedCards.get(cardId) ?? null;
|
|
7363
6200
|
}
|
|
7364
6201
|
/**
|
|
7365
|
-
* Get current hydrated
|
|
6202
|
+
* Get current hydrated cache size.
|
|
7366
6203
|
*/
|
|
7367
6204
|
get hydratedCount() {
|
|
7368
|
-
return this.
|
|
6205
|
+
return this.hydratedCards.size;
|
|
7369
6206
|
}
|
|
7370
6207
|
/**
|
|
7371
|
-
* Get
|
|
6208
|
+
* Get list of currently hydrated card IDs (for debugging).
|
|
7372
6209
|
*/
|
|
7373
|
-
|
|
7374
|
-
return this.
|
|
6210
|
+
getHydratedCardIds() {
|
|
6211
|
+
return Array.from(this.hydratedCards.keys());
|
|
7375
6212
|
}
|
|
7376
6213
|
/**
|
|
7377
|
-
* Fill the hydrated
|
|
6214
|
+
* Fill the hydrated cache by hydrating items from getItemsToHydrate().
|
|
6215
|
+
* This is a pure cache-warming operation - no queue mutation.
|
|
7378
6216
|
*/
|
|
7379
|
-
async
|
|
6217
|
+
async fillHydratedCards() {
|
|
7380
6218
|
if (this.hydrationInProgress) {
|
|
7381
6219
|
return;
|
|
7382
6220
|
}
|
|
7383
6221
|
this.hydrationInProgress = true;
|
|
7384
6222
|
try {
|
|
7385
|
-
|
|
7386
|
-
|
|
7387
|
-
if (
|
|
7388
|
-
|
|
6223
|
+
const itemsToHydrate = this.getItemsToHydrate();
|
|
6224
|
+
for (const item of itemsToHydrate) {
|
|
6225
|
+
if (this.hydratedCards.has(item.cardID) || this.hydrationInFlight.has(item.cardID)) {
|
|
6226
|
+
continue;
|
|
7389
6227
|
}
|
|
7390
6228
|
try {
|
|
7391
|
-
|
|
7392
|
-
const cachedCard = this.failedCardCache.get(nextItem.cardID);
|
|
7393
|
-
this.hydratedQ.add(cachedCard, cachedCard.item.cardID);
|
|
7394
|
-
this.failedCardCache.delete(nextItem.cardID);
|
|
7395
|
-
} else {
|
|
7396
|
-
const courseDB = this.getCourseDB(nextItem.courseID);
|
|
7397
|
-
const cardData = await courseDB.getCourseDoc(nextItem.cardID);
|
|
7398
|
-
if (!(0, import_common22.isCourseElo)(cardData.elo)) {
|
|
7399
|
-
cardData.elo = (0, import_common22.toCourseElo)(cardData.elo);
|
|
7400
|
-
}
|
|
7401
|
-
const view = this.getViewComponent(cardData.id_view);
|
|
7402
|
-
const dataDocs = await Promise.all(
|
|
7403
|
-
cardData.id_displayable_data.map(
|
|
7404
|
-
(id) => courseDB.getCourseDoc(id, {
|
|
7405
|
-
attachments: true,
|
|
7406
|
-
binary: true
|
|
7407
|
-
})
|
|
7408
|
-
)
|
|
7409
|
-
);
|
|
7410
|
-
const data = dataDocs.map(import_common22.displayableDataToViewData).reverse();
|
|
7411
|
-
this.hydratedQ.add(
|
|
7412
|
-
{
|
|
7413
|
-
item: nextItem,
|
|
7414
|
-
view,
|
|
7415
|
-
data
|
|
7416
|
-
},
|
|
7417
|
-
nextItem.cardID
|
|
7418
|
-
);
|
|
7419
|
-
}
|
|
6229
|
+
await this.hydrateCard(item);
|
|
7420
6230
|
} catch (e) {
|
|
7421
|
-
logger.error(`Error hydrating card ${
|
|
7422
|
-
} finally {
|
|
7423
|
-
this.removeItemFromQueue(nextItem);
|
|
6231
|
+
logger.error(`[CardHydrationService] Error hydrating card ${item.cardID}:`, e);
|
|
7424
6232
|
}
|
|
7425
6233
|
}
|
|
7426
6234
|
} finally {
|
|
@@ -7428,10 +6236,97 @@ var CardHydrationService = class {
|
|
|
7428
6236
|
}
|
|
7429
6237
|
}
|
|
7430
6238
|
/**
|
|
7431
|
-
*
|
|
6239
|
+
* Hydrate a single card and add to cache.
|
|
7432
6240
|
*/
|
|
7433
|
-
|
|
7434
|
-
this.
|
|
6241
|
+
async hydrateCard(item) {
|
|
6242
|
+
if (this.hydratedCards.has(item.cardID) || this.hydrationInFlight.has(item.cardID)) {
|
|
6243
|
+
return;
|
|
6244
|
+
}
|
|
6245
|
+
this.hydrationInFlight.add(item.cardID);
|
|
6246
|
+
try {
|
|
6247
|
+
const courseDB = this.getCourseDB(item.courseID);
|
|
6248
|
+
const cardData = await courseDB.getCourseDoc(item.cardID);
|
|
6249
|
+
if (!(0, import_common20.isCourseElo)(cardData.elo)) {
|
|
6250
|
+
cardData.elo = (0, import_common20.toCourseElo)(cardData.elo);
|
|
6251
|
+
}
|
|
6252
|
+
const view = this.getViewComponent(cardData.id_view);
|
|
6253
|
+
const dataDocs = await Promise.all(
|
|
6254
|
+
cardData.id_displayable_data.map(
|
|
6255
|
+
(id) => courseDB.getCourseDoc(id, {
|
|
6256
|
+
attachments: true,
|
|
6257
|
+
binary: true
|
|
6258
|
+
})
|
|
6259
|
+
)
|
|
6260
|
+
);
|
|
6261
|
+
const audioToPrefetch = [];
|
|
6262
|
+
dataDocs.forEach((dd) => {
|
|
6263
|
+
dd.data.forEach((f) => {
|
|
6264
|
+
audioToPrefetch.push(...parseAudioURIs(f.data));
|
|
6265
|
+
});
|
|
6266
|
+
});
|
|
6267
|
+
const uniqueAudioUrls = [...new Set(audioToPrefetch)];
|
|
6268
|
+
if (uniqueAudioUrls.length > 0) {
|
|
6269
|
+
logger.debug(
|
|
6270
|
+
`[CardHydrationService] Prefetching ${uniqueAudioUrls.length} audio files for card ${item.cardID}`
|
|
6271
|
+
);
|
|
6272
|
+
await Promise.allSettled(uniqueAudioUrls.map(prefetchAudio));
|
|
6273
|
+
}
|
|
6274
|
+
const data = dataDocs.map(import_common20.displayableDataToViewData).reverse();
|
|
6275
|
+
this.hydratedCards.set(item.cardID, {
|
|
6276
|
+
item,
|
|
6277
|
+
view,
|
|
6278
|
+
data
|
|
6279
|
+
});
|
|
6280
|
+
logger.debug(`[CardHydrationService] Hydrated card ${item.cardID}`);
|
|
6281
|
+
} finally {
|
|
6282
|
+
this.hydrationInFlight.delete(item.cardID);
|
|
6283
|
+
}
|
|
6284
|
+
}
|
|
6285
|
+
};
|
|
6286
|
+
|
|
6287
|
+
// src/study/ItemQueue.ts
|
|
6288
|
+
var ItemQueue = class {
|
|
6289
|
+
q = [];
|
|
6290
|
+
seenCardIds = [];
|
|
6291
|
+
_dequeueCount = 0;
|
|
6292
|
+
get dequeueCount() {
|
|
6293
|
+
return this._dequeueCount;
|
|
6294
|
+
}
|
|
6295
|
+
add(item, cardId) {
|
|
6296
|
+
if (this.seenCardIds.find((d) => d === cardId)) {
|
|
6297
|
+
return;
|
|
6298
|
+
}
|
|
6299
|
+
this.seenCardIds.push(cardId);
|
|
6300
|
+
this.q.push(item);
|
|
6301
|
+
}
|
|
6302
|
+
addAll(items, cardIdExtractor) {
|
|
6303
|
+
items.forEach((i) => this.add(i, cardIdExtractor(i)));
|
|
6304
|
+
}
|
|
6305
|
+
get length() {
|
|
6306
|
+
return this.q.length;
|
|
6307
|
+
}
|
|
6308
|
+
peek(index) {
|
|
6309
|
+
return this.q[index];
|
|
6310
|
+
}
|
|
6311
|
+
dequeue(cardIdExtractor) {
|
|
6312
|
+
if (this.q.length !== 0) {
|
|
6313
|
+
this._dequeueCount++;
|
|
6314
|
+
const item = this.q.splice(0, 1)[0];
|
|
6315
|
+
if (cardIdExtractor) {
|
|
6316
|
+
const cardId = cardIdExtractor(item);
|
|
6317
|
+
const index = this.seenCardIds.indexOf(cardId);
|
|
6318
|
+
if (index > -1) {
|
|
6319
|
+
this.seenCardIds.splice(index, 1);
|
|
6320
|
+
}
|
|
6321
|
+
}
|
|
6322
|
+
return item;
|
|
6323
|
+
} else {
|
|
6324
|
+
return null;
|
|
6325
|
+
}
|
|
6326
|
+
}
|
|
6327
|
+
get toString() {
|
|
6328
|
+
return `${typeof this.q[0]}:
|
|
6329
|
+
` + this.q.map((i) => ` ${i.courseID}+${i.cardID}: ${i.status}`).join("\n");
|
|
7435
6330
|
}
|
|
7436
6331
|
};
|
|
7437
6332
|
|
|
@@ -7973,7 +6868,7 @@ try {
|
|
|
7973
6868
|
}
|
|
7974
6869
|
} catch {
|
|
7975
6870
|
}
|
|
7976
|
-
async function validateStaticCourse(staticPath,
|
|
6871
|
+
async function validateStaticCourse(staticPath, fs2) {
|
|
7977
6872
|
const validation = {
|
|
7978
6873
|
valid: true,
|
|
7979
6874
|
manifestExists: false,
|
|
@@ -7983,8 +6878,8 @@ async function validateStaticCourse(staticPath, fs3) {
|
|
|
7983
6878
|
warnings: []
|
|
7984
6879
|
};
|
|
7985
6880
|
try {
|
|
7986
|
-
if (
|
|
7987
|
-
const stats = await
|
|
6881
|
+
if (fs2) {
|
|
6882
|
+
const stats = await fs2.stat(staticPath);
|
|
7988
6883
|
if (!stats.isDirectory()) {
|
|
7989
6884
|
validation.errors.push(`Path is not a directory: ${staticPath}`);
|
|
7990
6885
|
validation.valid = false;
|
|
@@ -8004,11 +6899,11 @@ async function validateStaticCourse(staticPath, fs3) {
|
|
|
8004
6899
|
}
|
|
8005
6900
|
let manifestPath = `${staticPath}/manifest.json`;
|
|
8006
6901
|
try {
|
|
8007
|
-
if (
|
|
8008
|
-
manifestPath =
|
|
8009
|
-
if (await
|
|
6902
|
+
if (fs2) {
|
|
6903
|
+
manifestPath = fs2.joinPath(staticPath, "manifest.json");
|
|
6904
|
+
if (await fs2.exists(manifestPath)) {
|
|
8010
6905
|
validation.manifestExists = true;
|
|
8011
|
-
const manifestContent = await
|
|
6906
|
+
const manifestContent = await fs2.readFile(manifestPath);
|
|
8012
6907
|
const manifest = JSON.parse(manifestContent);
|
|
8013
6908
|
validation.courseId = manifest.courseId;
|
|
8014
6909
|
validation.courseName = manifest.courseName;
|
|
@@ -8040,10 +6935,10 @@ async function validateStaticCourse(staticPath, fs3) {
|
|
|
8040
6935
|
}
|
|
8041
6936
|
let chunksPath = `${staticPath}/chunks`;
|
|
8042
6937
|
try {
|
|
8043
|
-
if (
|
|
8044
|
-
chunksPath =
|
|
8045
|
-
if (await
|
|
8046
|
-
const chunksStats = await
|
|
6938
|
+
if (fs2) {
|
|
6939
|
+
chunksPath = fs2.joinPath(staticPath, "chunks");
|
|
6940
|
+
if (await fs2.exists(chunksPath)) {
|
|
6941
|
+
const chunksStats = await fs2.stat(chunksPath);
|
|
8047
6942
|
if (chunksStats.isDirectory()) {
|
|
8048
6943
|
validation.chunksExist = true;
|
|
8049
6944
|
} else {
|
|
@@ -8071,10 +6966,10 @@ async function validateStaticCourse(staticPath, fs3) {
|
|
|
8071
6966
|
}
|
|
8072
6967
|
let attachmentsPath;
|
|
8073
6968
|
try {
|
|
8074
|
-
if (
|
|
8075
|
-
attachmentsPath =
|
|
8076
|
-
if (await
|
|
8077
|
-
const attachmentsStats = await
|
|
6969
|
+
if (fs2) {
|
|
6970
|
+
attachmentsPath = fs2.joinPath(staticPath, "attachments");
|
|
6971
|
+
if (await fs2.exists(attachmentsPath)) {
|
|
6972
|
+
const attachmentsStats = await fs2.stat(attachmentsPath);
|
|
8078
6973
|
if (attachmentsStats.isDirectory()) {
|
|
8079
6974
|
validation.attachmentsExist = true;
|
|
8080
6975
|
}
|
|
@@ -8852,26 +7747,43 @@ var StaticToCouchDBMigrator = class {
|
|
|
8852
7747
|
/**
|
|
8853
7748
|
* Check if a path is a local file path (vs URL)
|
|
8854
7749
|
*/
|
|
8855
|
-
isLocalPath(
|
|
8856
|
-
return !
|
|
7750
|
+
isLocalPath(path2) {
|
|
7751
|
+
return !path2.startsWith("http://") && !path2.startsWith("https://");
|
|
8857
7752
|
}
|
|
8858
7753
|
};
|
|
8859
7754
|
|
|
8860
7755
|
// src/util/index.ts
|
|
8861
7756
|
init_dataDirectory();
|
|
8862
|
-
init_tuiLogger();
|
|
8863
7757
|
|
|
8864
7758
|
// src/study/SessionController.ts
|
|
8865
7759
|
init_navigators();
|
|
8866
|
-
|
|
8867
|
-
|
|
8868
|
-
|
|
7760
|
+
|
|
7761
|
+
// src/study/SourceMixer.ts
|
|
7762
|
+
var QuotaRoundRobinMixer = class {
|
|
7763
|
+
mix(batches, limit) {
|
|
7764
|
+
if (batches.length === 0) {
|
|
7765
|
+
return [];
|
|
7766
|
+
}
|
|
7767
|
+
const quotaPerSource = Math.ceil(limit / batches.length);
|
|
7768
|
+
const mixed = [];
|
|
7769
|
+
for (const batch of batches) {
|
|
7770
|
+
const sortedBatch = [...batch.weighted].sort((a, b) => b.score - a.score);
|
|
7771
|
+
const topFromSource = sortedBatch.slice(0, quotaPerSource);
|
|
7772
|
+
mixed.push(...topFromSource);
|
|
7773
|
+
}
|
|
7774
|
+
return mixed.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
7775
|
+
}
|
|
7776
|
+
};
|
|
7777
|
+
|
|
7778
|
+
// src/study/SessionController.ts
|
|
7779
|
+
init_logger();
|
|
8869
7780
|
var SessionController = class extends Loggable {
|
|
8870
7781
|
_className = "SessionController";
|
|
8871
7782
|
services;
|
|
8872
7783
|
srsService;
|
|
8873
7784
|
eloService;
|
|
8874
7785
|
hydrationService;
|
|
7786
|
+
mixer;
|
|
8875
7787
|
sources;
|
|
8876
7788
|
// dataLayer and getViewComponent now injected into CardHydrationService
|
|
8877
7789
|
_sessionRecord = [];
|
|
@@ -8899,18 +7811,21 @@ var SessionController = class extends Loggable {
|
|
|
8899
7811
|
// @ts-expect-error NodeJS.Timeout type not available in browser context
|
|
8900
7812
|
_intervalHandle;
|
|
8901
7813
|
/**
|
|
8902
|
-
*
|
|
7814
|
+
* @param sources - Array of content sources to mix for the session
|
|
7815
|
+
* @param time - Session duration in seconds
|
|
7816
|
+
* @param dataLayer - Data layer provider
|
|
7817
|
+
* @param getViewComponent - Function to resolve view components
|
|
7818
|
+
* @param mixer - Optional source mixer strategy (defaults to QuotaRoundRobinMixer)
|
|
8903
7819
|
*/
|
|
8904
|
-
constructor(sources, time, dataLayer, getViewComponent) {
|
|
7820
|
+
constructor(sources, time, dataLayer, getViewComponent, mixer) {
|
|
8905
7821
|
super();
|
|
7822
|
+
this.mixer = mixer || new QuotaRoundRobinMixer();
|
|
8906
7823
|
this.srsService = new SrsService(dataLayer.getUserDB());
|
|
8907
7824
|
this.eloService = new EloService(dataLayer, dataLayer.getUserDB());
|
|
8908
7825
|
this.hydrationService = new CardHydrationService(
|
|
8909
7826
|
getViewComponent,
|
|
8910
7827
|
(courseId) => dataLayer.getCourseDB(courseId),
|
|
8911
|
-
() => this.
|
|
8912
|
-
(item) => this.removeItemFromQueue(item),
|
|
8913
|
-
() => this.hasAvailableCards()
|
|
7828
|
+
() => this._getItemsToHydrate()
|
|
8914
7829
|
);
|
|
8915
7830
|
this.services = {
|
|
8916
7831
|
response: new ResponseProcessor(this.srsService, this.eloService)
|
|
@@ -8964,16 +7879,12 @@ var SessionController = class extends Loggable {
|
|
|
8964
7879
|
return ret;
|
|
8965
7880
|
}
|
|
8966
7881
|
async prepareSession() {
|
|
8967
|
-
|
|
8968
|
-
|
|
8969
|
-
|
|
8970
|
-
|
|
8971
|
-
} else {
|
|
8972
|
-
await Promise.all([this.getScheduledReviews(), this.getNewCards()]);
|
|
8973
|
-
}
|
|
8974
|
-
} catch (e) {
|
|
8975
|
-
this.error("Error preparing study session:", e);
|
|
7882
|
+
if (this.sources.some((s) => typeof s.getWeightedCards !== "function")) {
|
|
7883
|
+
throw new Error(
|
|
7884
|
+
"[SessionController] All content sources must implement getWeightedCards()."
|
|
7885
|
+
);
|
|
8976
7886
|
}
|
|
7887
|
+
await this.getWeightedContent();
|
|
8977
7888
|
await this.hydrationService.ensureHydratedCards();
|
|
8978
7889
|
this._intervalHandle = setInterval(() => {
|
|
8979
7890
|
this.tick();
|
|
@@ -9011,14 +7922,10 @@ var SessionController = class extends Loggable {
|
|
|
9011
7922
|
}
|
|
9012
7923
|
return items;
|
|
9013
7924
|
};
|
|
9014
|
-
const extractHydratedItems = () => {
|
|
9015
|
-
const items = [];
|
|
9016
|
-
return items;
|
|
9017
|
-
};
|
|
9018
7925
|
return {
|
|
9019
7926
|
api: {
|
|
9020
7927
|
mode: supportsWeightedCards ? "weighted" : "legacy",
|
|
9021
|
-
description: supportsWeightedCards ? "Using getWeightedCards() API with scored candidates" : "
|
|
7928
|
+
description: supportsWeightedCards ? "Using getWeightedCards() API with scored candidates" : "ERROR: getWeightedCards() not a function."
|
|
9022
7929
|
},
|
|
9023
7930
|
reviewQueue: {
|
|
9024
7931
|
length: this.reviewQ.length,
|
|
@@ -9037,162 +7944,97 @@ var SessionController = class extends Loggable {
|
|
|
9037
7944
|
},
|
|
9038
7945
|
hydratedCache: {
|
|
9039
7946
|
count: this.hydrationService.hydratedCount,
|
|
9040
|
-
|
|
9041
|
-
items: extractHydratedItems()
|
|
7947
|
+
cardIds: this.hydrationService.getHydratedCardIds()
|
|
9042
7948
|
}
|
|
9043
7949
|
};
|
|
9044
7950
|
}
|
|
9045
7951
|
/**
|
|
9046
|
-
* Fetch content using the
|
|
7952
|
+
* Fetch content using the getWeightedCards API and mix across sources.
|
|
9047
7953
|
*
|
|
9048
|
-
* This method
|
|
9049
|
-
*
|
|
9050
|
-
*
|
|
9051
|
-
*
|
|
9052
|
-
*
|
|
9053
|
-
* 1. Fetch weighted cards to get scoring/ordering information
|
|
9054
|
-
* 2. Fetch full review data via legacy getPendingReviews()
|
|
9055
|
-
* 3. Order reviews by their weighted scores
|
|
9056
|
-
* 4. Add new cards ordered by their weighted scores
|
|
7954
|
+
* This method:
|
|
7955
|
+
* 1. Fetches weighted cards from each source
|
|
7956
|
+
* 2. Fetches full review data (we need ScheduledCard fields for queue)
|
|
7957
|
+
* 3. Uses SourceMixer to balance content across sources
|
|
7958
|
+
* 4. Populates review and new card queues with mixed results
|
|
9057
7959
|
*/
|
|
9058
7960
|
async getWeightedContent() {
|
|
9059
7961
|
const limit = 20;
|
|
9060
|
-
const
|
|
9061
|
-
|
|
9062
|
-
|
|
9063
|
-
for (const source of this.sources) {
|
|
7962
|
+
const batches = [];
|
|
7963
|
+
for (let i = 0; i < this.sources.length; i++) {
|
|
7964
|
+
const source = this.sources[i];
|
|
9064
7965
|
try {
|
|
9065
|
-
const
|
|
9066
|
-
|
|
9067
|
-
|
|
7966
|
+
const weighted = await source.getWeightedCards(limit);
|
|
7967
|
+
batches.push({
|
|
7968
|
+
sourceIndex: i,
|
|
7969
|
+
weighted
|
|
9068
7970
|
});
|
|
9069
|
-
allReviews.push(...reviews);
|
|
9070
|
-
if (typeof source.getWeightedCards === "function") {
|
|
9071
|
-
const weighted = await source.getWeightedCards(limit);
|
|
9072
|
-
allWeighted.push(...weighted);
|
|
9073
|
-
} else {
|
|
9074
|
-
const newCards = await source.getNewCards(limit);
|
|
9075
|
-
allNewCards.push(...newCards);
|
|
9076
|
-
allWeighted.push(
|
|
9077
|
-
...newCards.map((c) => ({
|
|
9078
|
-
cardId: c.cardID,
|
|
9079
|
-
courseId: c.courseID,
|
|
9080
|
-
score: 1,
|
|
9081
|
-
provenance: [
|
|
9082
|
-
{
|
|
9083
|
-
strategy: "legacy",
|
|
9084
|
-
strategyName: "Legacy Fallback",
|
|
9085
|
-
strategyId: "legacy-fallback",
|
|
9086
|
-
action: "generated",
|
|
9087
|
-
score: 1,
|
|
9088
|
-
reason: "Fallback to legacy getNewCards(), new card"
|
|
9089
|
-
}
|
|
9090
|
-
]
|
|
9091
|
-
})),
|
|
9092
|
-
...reviews.map((r) => ({
|
|
9093
|
-
cardId: r.cardID,
|
|
9094
|
-
courseId: r.courseID,
|
|
9095
|
-
score: 1,
|
|
9096
|
-
provenance: [
|
|
9097
|
-
{
|
|
9098
|
-
strategy: "legacy",
|
|
9099
|
-
strategyName: "Legacy Fallback",
|
|
9100
|
-
strategyId: "legacy-fallback",
|
|
9101
|
-
action: "generated",
|
|
9102
|
-
score: 1,
|
|
9103
|
-
reason: "Fallback to legacy getPendingReviews(), review"
|
|
9104
|
-
}
|
|
9105
|
-
]
|
|
9106
|
-
}))
|
|
9107
|
-
);
|
|
9108
|
-
}
|
|
9109
7971
|
} catch (error) {
|
|
9110
|
-
this.error(`Failed to get content from source:`, error);
|
|
7972
|
+
this.error(`Failed to get content from source ${i}:`, error);
|
|
7973
|
+
if (this.sources.length === 1) {
|
|
7974
|
+
throw new Error(`Cannot start session: failed to load content from source ${i}`);
|
|
7975
|
+
}
|
|
9111
7976
|
}
|
|
9112
7977
|
}
|
|
9113
|
-
|
|
9114
|
-
|
|
9115
|
-
|
|
9116
|
-
|
|
7978
|
+
if (batches.length === 0) {
|
|
7979
|
+
throw new Error(
|
|
7980
|
+
`Cannot start session: failed to load content from all ${this.sources.length} source(s). Check logs for details.`
|
|
7981
|
+
);
|
|
9117
7982
|
}
|
|
9118
|
-
const
|
|
9119
|
-
|
|
9120
|
-
|
|
9121
|
-
})
|
|
9122
|
-
|
|
9123
|
-
|
|
9124
|
-
|
|
9125
|
-
|
|
9126
|
-
|
|
7983
|
+
const mixedWeighted = this.mixer.mix(batches, limit * this.sources.length);
|
|
7984
|
+
const reviewWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "review");
|
|
7985
|
+
const newWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "new");
|
|
7986
|
+
logger.debug(`[reviews] got ${reviewWeighted.length} reviews from mixer`);
|
|
7987
|
+
let report = "Mixed content session created with:\n";
|
|
7988
|
+
for (const w of reviewWeighted) {
|
|
7989
|
+
const reviewItem = {
|
|
7990
|
+
cardID: w.cardId,
|
|
7991
|
+
courseID: w.courseId,
|
|
7992
|
+
contentSourceType: "course",
|
|
7993
|
+
contentSourceID: w.courseId,
|
|
7994
|
+
reviewID: w.reviewID,
|
|
7995
|
+
status: "review"
|
|
7996
|
+
};
|
|
7997
|
+
this.reviewQ.add(reviewItem, reviewItem.cardID);
|
|
7998
|
+
report += `Review: ${w.courseId}::${w.cardId} (score: ${w.score.toFixed(2)})
|
|
9127
7999
|
`;
|
|
9128
8000
|
}
|
|
9129
|
-
|
|
9130
|
-
for (const card of newCardWeighted) {
|
|
8001
|
+
for (const w of newWeighted) {
|
|
9131
8002
|
const newItem = {
|
|
9132
|
-
cardID:
|
|
9133
|
-
courseID:
|
|
8003
|
+
cardID: w.cardId,
|
|
8004
|
+
courseID: w.courseId,
|
|
9134
8005
|
contentSourceType: "course",
|
|
9135
|
-
contentSourceID:
|
|
8006
|
+
contentSourceID: w.courseId,
|
|
9136
8007
|
status: "new"
|
|
9137
8008
|
};
|
|
9138
|
-
this.newQ.add(newItem,
|
|
9139
|
-
report += `New: ${
|
|
8009
|
+
this.newQ.add(newItem, newItem.cardID);
|
|
8010
|
+
report += `New: ${w.courseId}::${w.cardId} (score: ${w.score.toFixed(2)})
|
|
9140
8011
|
`;
|
|
9141
8012
|
}
|
|
9142
8013
|
this.log(report);
|
|
9143
8014
|
}
|
|
9144
8015
|
/**
|
|
9145
|
-
*
|
|
9146
|
-
*
|
|
8016
|
+
* Returns items that should be pre-hydrated.
|
|
8017
|
+
* Deterministic: top N items from each queue to ensure coverage.
|
|
8018
|
+
* Failed queue items will typically already be hydrated (from initial render).
|
|
9147
8019
|
*/
|
|
9148
|
-
|
|
9149
|
-
const
|
|
9150
|
-
|
|
9151
|
-
|
|
9152
|
-
|
|
9153
|
-
return [];
|
|
9154
|
-
})
|
|
9155
|
-
)
|
|
9156
|
-
);
|
|
9157
|
-
const dueCards = [];
|
|
9158
|
-
while (reviews.length != 0 && reviews.some((r) => r.length > 0)) {
|
|
9159
|
-
const index = randomInt(0, reviews.length - 1);
|
|
9160
|
-
const source = reviews[index];
|
|
9161
|
-
if (source.length === 0) {
|
|
9162
|
-
reviews.splice(index, 1);
|
|
9163
|
-
continue;
|
|
9164
|
-
} else {
|
|
9165
|
-
dueCards.push(source.shift());
|
|
9166
|
-
}
|
|
8020
|
+
_getItemsToHydrate() {
|
|
8021
|
+
const items = [];
|
|
8022
|
+
const ITEMS_PER_QUEUE = 2;
|
|
8023
|
+
for (let i = 0; i < Math.min(ITEMS_PER_QUEUE, this.reviewQ.length); i++) {
|
|
8024
|
+
items.push(this.reviewQ.peek(i));
|
|
9167
8025
|
}
|
|
9168
|
-
let
|
|
9169
|
-
|
|
9170
|
-
|
|
9171
|
-
this.
|
|
8026
|
+
for (let i = 0; i < Math.min(ITEMS_PER_QUEUE, this.newQ.length); i++) {
|
|
8027
|
+
items.push(this.newQ.peek(i));
|
|
8028
|
+
}
|
|
8029
|
+
for (let i = 0; i < Math.min(ITEMS_PER_QUEUE, this.failedQ.length); i++) {
|
|
8030
|
+
items.push(this.failedQ.peek(i));
|
|
8031
|
+
}
|
|
8032
|
+
return items;
|
|
9172
8033
|
}
|
|
9173
8034
|
/**
|
|
9174
|
-
*
|
|
9175
|
-
*
|
|
8035
|
+
* Selects the next item to present to the user.
|
|
8036
|
+
* Nondeterministic: uses probability to balance between queues based on session state.
|
|
9176
8037
|
*/
|
|
9177
|
-
async getNewCards(n = 10) {
|
|
9178
|
-
const perCourse = Math.ceil(n / this.sources.length);
|
|
9179
|
-
const newContent = await Promise.all(this.sources.map((c) => c.getNewCards(perCourse)));
|
|
9180
|
-
newContent.forEach((newContentFromSource) => {
|
|
9181
|
-
newContentFromSource.filter((c) => {
|
|
9182
|
-
return this._sessionRecord.find((record) => record.card.card_id === c.cardID) === void 0;
|
|
9183
|
-
});
|
|
9184
|
-
});
|
|
9185
|
-
while (n > 0 && newContent.some((nc) => nc.length > 0)) {
|
|
9186
|
-
for (let i = 0; i < newContent.length; i++) {
|
|
9187
|
-
if (newContent[i].length > 0) {
|
|
9188
|
-
const item = newContent[i].splice(0, 1)[0];
|
|
9189
|
-
this.log(`Adding new card: ${item.courseID}::${item.cardID}`);
|
|
9190
|
-
this.newQ.add(item, item.cardID);
|
|
9191
|
-
n--;
|
|
9192
|
-
}
|
|
9193
|
-
}
|
|
9194
|
-
}
|
|
9195
|
-
}
|
|
9196
8038
|
_selectNextItemToHydrate() {
|
|
9197
8039
|
const choice = Math.random();
|
|
9198
8040
|
let newBound = 0.1;
|
|
@@ -9249,16 +8091,18 @@ var SessionController = class extends Loggable {
|
|
|
9249
8091
|
this._currentCard = null;
|
|
9250
8092
|
return null;
|
|
9251
8093
|
}
|
|
9252
|
-
|
|
9253
|
-
if (!
|
|
9254
|
-
card = await this.hydrationService.waitForHydratedCard();
|
|
9255
|
-
}
|
|
9256
|
-
await this.hydrationService.ensureHydratedCards();
|
|
9257
|
-
if (card) {
|
|
9258
|
-
this._currentCard = card;
|
|
9259
|
-
} else {
|
|
8094
|
+
const nextItem = this._selectNextItemToHydrate();
|
|
8095
|
+
if (!nextItem) {
|
|
9260
8096
|
this._currentCard = null;
|
|
8097
|
+
return null;
|
|
9261
8098
|
}
|
|
8099
|
+
let card = this.hydrationService.getHydratedCard(nextItem.cardID);
|
|
8100
|
+
if (!card) {
|
|
8101
|
+
card = await this.hydrationService.waitForCard(nextItem.cardID);
|
|
8102
|
+
}
|
|
8103
|
+
this.removeItemFromQueue(nextItem);
|
|
8104
|
+
await this.hydrationService.ensureHydratedCards();
|
|
8105
|
+
this._currentCard = card;
|
|
9262
8106
|
return card;
|
|
9263
8107
|
}
|
|
9264
8108
|
/**
|
|
@@ -9294,8 +8138,8 @@ var SessionController = class extends Loggable {
|
|
|
9294
8138
|
dismissCurrentCard(action = "dismiss-success") {
|
|
9295
8139
|
if (this._currentCard) {
|
|
9296
8140
|
if (action === "dismiss-success") {
|
|
8141
|
+
this.hydrationService.removeCard(this._currentCard.item.cardID);
|
|
9297
8142
|
} else if (action === "marked-failed") {
|
|
9298
|
-
this.hydrationService.cacheFailedCard(this._currentCard);
|
|
9299
8143
|
let failedItem;
|
|
9300
8144
|
if (isReview(this._currentCard.item)) {
|
|
9301
8145
|
failedItem = {
|
|
@@ -9317,22 +8161,21 @@ var SessionController = class extends Loggable {
|
|
|
9317
8161
|
}
|
|
9318
8162
|
this.failedQ.add(failedItem, failedItem.cardID);
|
|
9319
8163
|
} else if (action === "dismiss-error") {
|
|
8164
|
+
this.hydrationService.removeCard(this._currentCard.item.cardID);
|
|
9320
8165
|
} else if (action === "dismiss-failed") {
|
|
8166
|
+
this.hydrationService.removeCard(this._currentCard.item.cardID);
|
|
9321
8167
|
}
|
|
9322
8168
|
}
|
|
9323
8169
|
}
|
|
9324
|
-
hasAvailableCards() {
|
|
9325
|
-
return this.reviewQ.length > 0 || this.newQ.length > 0 || this.failedQ.length > 0;
|
|
9326
|
-
}
|
|
9327
8170
|
/**
|
|
9328
|
-
*
|
|
8171
|
+
* Remove an item from its source queue after consumption by nextCard().
|
|
9329
8172
|
*/
|
|
9330
8173
|
removeItemFromQueue(item) {
|
|
9331
|
-
if (this.reviewQ.peek(0) === item) {
|
|
8174
|
+
if (this.reviewQ.peek(0)?.cardID === item.cardID) {
|
|
9332
8175
|
this.reviewQ.dequeue((queueItem) => queueItem.cardID);
|
|
9333
|
-
} else if (this.newQ.peek(0) === item) {
|
|
8176
|
+
} else if (this.newQ.peek(0)?.cardID === item.cardID) {
|
|
9334
8177
|
this.newQ.dequeue((queueItem) => queueItem.cardID);
|
|
9335
|
-
} else {
|
|
8178
|
+
} else if (this.failedQ.peek(0)?.cardID === item.cardID) {
|
|
9336
8179
|
this.failedQ.dequeue((queueItem) => queueItem.cardID);
|
|
9337
8180
|
}
|
|
9338
8181
|
}
|
|
@@ -9358,6 +8201,7 @@ init_factory();
|
|
|
9358
8201
|
NavigatorRole,
|
|
9359
8202
|
NavigatorRoles,
|
|
9360
8203
|
Navigators,
|
|
8204
|
+
QuotaRoundRobinMixer,
|
|
9361
8205
|
SessionController,
|
|
9362
8206
|
StaticToCouchDBMigrator,
|
|
9363
8207
|
TagFilteredContentSource,
|
|
@@ -9371,22 +8215,17 @@ init_factory();
|
|
|
9371
8215
|
getCardOrigin,
|
|
9372
8216
|
getDataLayer,
|
|
9373
8217
|
getDbPath,
|
|
9374
|
-
getLogFilePath,
|
|
9375
8218
|
getStudySource,
|
|
9376
8219
|
importParsedCards,
|
|
9377
8220
|
initializeDataDirectory,
|
|
9378
8221
|
initializeDataLayer,
|
|
9379
|
-
initializeTuiLogging,
|
|
9380
8222
|
isFilter,
|
|
9381
8223
|
isGenerator,
|
|
9382
8224
|
isQuestionRecord,
|
|
9383
8225
|
isReview,
|
|
9384
8226
|
log,
|
|
9385
|
-
logger,
|
|
9386
8227
|
newInterval,
|
|
9387
8228
|
parseCardHistoryID,
|
|
9388
|
-
showUserError,
|
|
9389
|
-
showUserMessage,
|
|
9390
8229
|
validateMigration,
|
|
9391
8230
|
validateProcessorConfig,
|
|
9392
8231
|
validateStaticCourse
|