@vue-skuilder/db 0.1.18 → 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-BgfrVb8d.d.ts → contentSource-BP9hznNV.d.ts} +220 -197
- package/dist/{classroomDB-CTOenngH.d.cts → contentSource-DsJadoBU.d.cts} +220 -197
- package/dist/core/index.d.cts +80 -6
- package/dist/core/index.d.ts +80 -6
- package/dist/core/index.js +735 -1560
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +708 -1539
- package/dist/core/index.mjs.map +1 -1
- package/dist/{dataLayerProvider-D6PoCwS6.d.cts → dataLayerProvider-CHYrQ5pB.d.cts} +1 -1
- package/dist/{dataLayerProvider-CZxC9GtB.d.ts → dataLayerProvider-MDTxXq2l.d.ts} +1 -1
- package/dist/impl/couch/index.d.cts +8 -23
- package/dist/impl/couch/index.d.ts +8 -23
- package/dist/impl/couch/index.js +723 -1578
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +692 -1552
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.d.cts +25 -8
- package/dist/impl/static/index.d.ts +25 -8
- package/dist/impl/static/index.js +700 -1400
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +688 -1393
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/{index-D-Fa4Smt.d.cts → index-B_j6u5E4.d.cts} +1 -1
- package/dist/{index-CD8BZz2k.d.ts → index-Dj0SEgk3.d.ts} +1 -1
- package/dist/index.d.cts +71 -63
- package/dist/index.d.ts +71 -63
- package/dist/index.js +1162 -1996
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1124 -1955
- 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/dist/{types-CzPDLAK6.d.cts → types-Bn0itutr.d.cts} +1 -1
- package/dist/{types-CewsN87z.d.ts → types-DQaXnuoc.d.ts} +1 -1
- package/dist/{types-legacy-6ettoclI.d.cts → types-legacy-DDY4N-Uq.d.cts} +3 -1
- package/dist/{types-legacy-6ettoclI.d.ts → types-legacy-DDY4N-Uq.d.ts} +3 -1
- package/dist/util/packer/index.d.cts +3 -3
- package/dist/util/packer/index.d.ts +3 -3
- package/docs/navigators-architecture.md +115 -17
- package/package.json +4 -4
- package/src/core/index.ts +1 -0
- package/src/core/interfaces/classroomDB.ts +5 -13
- package/src/core/interfaces/contentSource.ts +6 -66
- package/src/core/interfaces/courseDB.ts +15 -7
- package/src/core/interfaces/userDB.ts +32 -0
- package/src/core/navigators/Pipeline.ts +136 -52
- 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} +15 -29
- package/src/core/navigators/filters/index.ts +3 -0
- package/src/core/navigators/filters/inferredPreferenceStub.ts +107 -0
- package/src/core/navigators/{interferenceMitigator.ts → filters/interferenceMitigator.ts} +11 -37
- package/src/core/navigators/{relativePriority.ts → filters/relativePriority.ts} +12 -38
- package/src/core/navigators/filters/userGoalStub.ts +136 -0
- package/src/core/navigators/filters/userTagPreference.ts +217 -0
- 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 +95 -91
- package/src/core/types/strategyState.ts +84 -0
- package/src/core/types/types-legacy.ts +2 -0
- package/src/impl/common/BaseUserDB.ts +74 -7
- package/src/impl/couch/adminDB.ts +1 -2
- package/src/impl/couch/classroomDB.ts +100 -103
- package/src/impl/couch/courseDB.ts +35 -91
- package/src/impl/couch/pouchdb-setup.ts +7 -0
- package/src/impl/static/StaticDataUnpacker.ts +50 -1
- package/src/impl/static/courseDB.ts +87 -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 +6 -72
- package/tests/core/navigators/PipelineAssembler.test.ts +8 -58
- package/tests/core/navigators/navigators.test.ts +118 -151
- package/docs/todo-pipeline-optimization.md +0 -117
- package/docs/todo-strategy-state-storage.md +0 -278
- package/src/core/navigators/hardcodedOrder.ts +0 -163
- package/src/util/tuiLogger.ts +0 -139
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
|
};
|
|
@@ -122,6 +117,7 @@ var init_types_legacy = __esm({
|
|
|
122
117
|
DocType3["SCHEDULED_CARD"] = "SCHEDULED_CARD";
|
|
123
118
|
DocType3["TAG"] = "TAG";
|
|
124
119
|
DocType3["NAVIGATION_STRATEGY"] = "NAVIGATION_STRATEGY";
|
|
120
|
+
DocType3["STRATEGY_STATE"] = "STRATEGY_STATE";
|
|
125
121
|
return DocType3;
|
|
126
122
|
})(DocType || {});
|
|
127
123
|
DocTypePrefixes = {
|
|
@@ -135,7 +131,8 @@ var init_types_legacy = __esm({
|
|
|
135
131
|
["QUESTION" /* QUESTIONTYPE */]: "QUESTION",
|
|
136
132
|
["VIEW" /* VIEW */]: "VIEW",
|
|
137
133
|
["PEDAGOGY" /* PEDAGOGY */]: "PEDAGOGY",
|
|
138
|
-
["NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */]: "NAVIGATION_STRATEGY"
|
|
134
|
+
["NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */]: "NAVIGATION_STRATEGY",
|
|
135
|
+
["STRATEGY_STATE" /* STRATEGY_STATE */]: "STRATEGY_STATE"
|
|
139
136
|
};
|
|
140
137
|
}
|
|
141
138
|
});
|
|
@@ -186,6 +183,9 @@ var init_pouchdb_setup = __esm({
|
|
|
186
183
|
import_pouchdb_authentication = __toESM(require("@nilock2/pouchdb-authentication"), 1);
|
|
187
184
|
import_pouchdb.default.plugin(import_pouchdb_find.default);
|
|
188
185
|
import_pouchdb.default.plugin(import_pouchdb_authentication.default);
|
|
186
|
+
if (typeof import_pouchdb.default.debug !== "undefined") {
|
|
187
|
+
import_pouchdb.default.debug.disable();
|
|
188
|
+
}
|
|
189
189
|
import_pouchdb.default.defaults({
|
|
190
190
|
// ajax: {
|
|
191
191
|
// timeout: 60000,
|
|
@@ -195,109 +195,18 @@ var init_pouchdb_setup = __esm({
|
|
|
195
195
|
}
|
|
196
196
|
});
|
|
197
197
|
|
|
198
|
-
// src/util/tuiLogger.ts
|
|
199
|
-
function initializeTuiLogging() {
|
|
200
|
-
isNodeEnvironment = typeof window === "undefined" && typeof process !== "undefined";
|
|
201
|
-
if (!isNodeEnvironment) {
|
|
202
|
-
return;
|
|
203
|
-
}
|
|
204
|
-
try {
|
|
205
|
-
logFile = path.join(getAppDataDirectory(), "lastrun.log");
|
|
206
|
-
if (fs.existsSync(logFile)) {
|
|
207
|
-
fs.unlinkSync(logFile);
|
|
208
|
-
}
|
|
209
|
-
const startTime = (/* @__PURE__ */ new Date()).toISOString();
|
|
210
|
-
fs.writeFileSync(logFile, `=== TUI Session Started: ${startTime} ===
|
|
211
|
-
`);
|
|
212
|
-
const originalConsole = {
|
|
213
|
-
// eslint-disable-next-line no-console
|
|
214
|
-
log: console.log,
|
|
215
|
-
// eslint-disable-next-line no-console
|
|
216
|
-
error: console.error,
|
|
217
|
-
// eslint-disable-next-line no-console
|
|
218
|
-
warn: console.warn,
|
|
219
|
-
// eslint-disable-next-line no-console
|
|
220
|
-
info: console.info
|
|
221
|
-
};
|
|
222
|
-
const writeToLog = (level, args) => {
|
|
223
|
-
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
224
|
-
const message = args.map(
|
|
225
|
-
(arg) => typeof arg === "object" ? JSON.stringify(arg, null, 2) : String(arg)
|
|
226
|
-
).join(" ");
|
|
227
|
-
const logEntry = `[${timestamp}] ${level}: ${message}
|
|
228
|
-
`;
|
|
229
|
-
try {
|
|
230
|
-
fs.appendFileSync(logFile, logEntry);
|
|
231
|
-
} catch (err) {
|
|
232
|
-
originalConsole.error("Failed to write to log file:", err);
|
|
233
|
-
originalConsole[level.toLowerCase()](...args);
|
|
234
|
-
}
|
|
235
|
-
};
|
|
236
|
-
console.log = (...args) => writeToLog("INFO", args);
|
|
237
|
-
console.info = (...args) => writeToLog("INFO", args);
|
|
238
|
-
console.warn = (...args) => writeToLog("WARN", args);
|
|
239
|
-
console.error = (...args) => writeToLog("ERROR", args);
|
|
240
|
-
console._originalMethods = originalConsole;
|
|
241
|
-
console.log("TUI logging initialized - logs redirected to", logFile);
|
|
242
|
-
} catch (err) {
|
|
243
|
-
console.error("Failed to initialize TUI logging:", err);
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
function getLogFilePath() {
|
|
247
|
-
return logFile;
|
|
248
|
-
}
|
|
249
|
-
function showUserMessage(message) {
|
|
250
|
-
if (isNodeEnvironment) {
|
|
251
|
-
process.stdout.write(message + "\n");
|
|
252
|
-
} else {
|
|
253
|
-
console.log(message);
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
function showUserError(message) {
|
|
257
|
-
if (isNodeEnvironment) {
|
|
258
|
-
process.stderr.write("Error: " + message + "\n");
|
|
259
|
-
} else {
|
|
260
|
-
console.error(message);
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
var fs, path, logFile, isNodeEnvironment, logger2;
|
|
264
|
-
var init_tuiLogger = __esm({
|
|
265
|
-
"src/util/tuiLogger.ts"() {
|
|
266
|
-
"use strict";
|
|
267
|
-
fs = __toESM(require("fs"), 1);
|
|
268
|
-
path = __toESM(require("path"), 1);
|
|
269
|
-
init_dataDirectory();
|
|
270
|
-
logFile = null;
|
|
271
|
-
isNodeEnvironment = false;
|
|
272
|
-
logger2 = {
|
|
273
|
-
debug: (message, ...args) => {
|
|
274
|
-
console.log(`[DEBUG] ${message}`, ...args);
|
|
275
|
-
},
|
|
276
|
-
info: (message, ...args) => {
|
|
277
|
-
console.info(`[INFO] ${message}`, ...args);
|
|
278
|
-
},
|
|
279
|
-
warn: (message, ...args) => {
|
|
280
|
-
console.warn(`[WARN] ${message}`, ...args);
|
|
281
|
-
},
|
|
282
|
-
error: (message, ...args) => {
|
|
283
|
-
console.error(`[ERROR] ${message}`, ...args);
|
|
284
|
-
}
|
|
285
|
-
};
|
|
286
|
-
}
|
|
287
|
-
});
|
|
288
|
-
|
|
289
198
|
// src/util/dataDirectory.ts
|
|
290
199
|
function getAppDataDirectory() {
|
|
291
200
|
if (ENV.LOCAL_STORAGE_PREFIX) {
|
|
292
|
-
return
|
|
201
|
+
return path.join(os.homedir(), `.tuilder`, ENV.LOCAL_STORAGE_PREFIX);
|
|
293
202
|
} else {
|
|
294
|
-
return
|
|
203
|
+
return path.join(os.homedir(), ".tuilder");
|
|
295
204
|
}
|
|
296
205
|
}
|
|
297
206
|
async function ensureAppDataDirectory() {
|
|
298
207
|
const appDataDir = getAppDataDirectory();
|
|
299
208
|
try {
|
|
300
|
-
await
|
|
209
|
+
await fs.promises.mkdir(appDataDir, { recursive: true });
|
|
301
210
|
} catch (err) {
|
|
302
211
|
if (err.code !== "EEXIST") {
|
|
303
212
|
throw new Error(`Failed to create app data directory ${appDataDir}: ${err.message}`);
|
|
@@ -306,20 +215,20 @@ async function ensureAppDataDirectory() {
|
|
|
306
215
|
return appDataDir;
|
|
307
216
|
}
|
|
308
217
|
function getDbPath(dbName) {
|
|
309
|
-
return
|
|
218
|
+
return path.join(getAppDataDirectory(), dbName);
|
|
310
219
|
}
|
|
311
220
|
async function initializeDataDirectory() {
|
|
312
221
|
await ensureAppDataDirectory();
|
|
313
|
-
|
|
222
|
+
logger.info(`PouchDB data directory initialized: ${getAppDataDirectory()}`);
|
|
314
223
|
}
|
|
315
|
-
var
|
|
224
|
+
var fs, path, os;
|
|
316
225
|
var init_dataDirectory = __esm({
|
|
317
226
|
"src/util/dataDirectory.ts"() {
|
|
318
227
|
"use strict";
|
|
319
|
-
|
|
320
|
-
|
|
228
|
+
fs = __toESM(require("fs"), 1);
|
|
229
|
+
path = __toESM(require("path"), 1);
|
|
321
230
|
os = __toESM(require("os"), 1);
|
|
322
|
-
|
|
231
|
+
init_logger();
|
|
323
232
|
init_factory();
|
|
324
233
|
}
|
|
325
234
|
});
|
|
@@ -945,195 +854,222 @@ var init_courseLookupDB = __esm({
|
|
|
945
854
|
}
|
|
946
855
|
});
|
|
947
856
|
|
|
948
|
-
// src/core/navigators/
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
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"() {
|
|
957
881
|
"use strict";
|
|
958
|
-
init_navigators();
|
|
959
882
|
init_logger();
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
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;
|
|
984
914
|
/**
|
|
985
|
-
*
|
|
915
|
+
* Constructor for standard navigators.
|
|
916
|
+
* Call this from subclass constructors to initialize common fields.
|
|
986
917
|
*
|
|
987
|
-
*
|
|
918
|
+
* Note: CompositeGenerator and Pipeline call super() without args, then set
|
|
919
|
+
* user/course fields directly if needed.
|
|
988
920
|
*/
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
)
|
|
993
|
-
|
|
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;
|
|
927
|
+
}
|
|
994
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
|
+
// ============================================================================
|
|
995
937
|
/**
|
|
996
|
-
*
|
|
997
|
-
*
|
|
998
|
-
* Cards appearing in multiple generators receive a score boost.
|
|
999
|
-
* Provenance tracks which generators produced each card and how scores were aggregated.
|
|
1000
|
-
*
|
|
1001
|
-
* This method supports both the legacy signature (limit only) and the
|
|
1002
|
-
* CardGenerator interface signature (limit, context).
|
|
938
|
+
* Unique key identifying this strategy for state storage.
|
|
1003
939
|
*
|
|
1004
|
-
*
|
|
1005
|
-
*
|
|
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.
|
|
1006
943
|
*/
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
this.generators.map((g) => g.getWeightedCards(limit, context))
|
|
1010
|
-
);
|
|
1011
|
-
const byCardId = /* @__PURE__ */ new Map();
|
|
1012
|
-
for (const cards of results) {
|
|
1013
|
-
for (const card of cards) {
|
|
1014
|
-
const existing = byCardId.get(card.cardId) || [];
|
|
1015
|
-
existing.push(card);
|
|
1016
|
-
byCardId.set(card.cardId, existing);
|
|
1017
|
-
}
|
|
1018
|
-
}
|
|
1019
|
-
const merged = [];
|
|
1020
|
-
for (const [, cards] of byCardId) {
|
|
1021
|
-
const aggregatedScore = this.aggregateScores(cards);
|
|
1022
|
-
const finalScore = Math.min(1, aggregatedScore);
|
|
1023
|
-
const mergedProvenance = cards.flatMap((c) => c.provenance);
|
|
1024
|
-
const initialScore = cards[0].score;
|
|
1025
|
-
const action = finalScore > initialScore ? "boosted" : finalScore < initialScore ? "penalized" : "passed";
|
|
1026
|
-
const reason = this.buildAggregationReason(cards, finalScore);
|
|
1027
|
-
merged.push({
|
|
1028
|
-
...cards[0],
|
|
1029
|
-
score: finalScore,
|
|
1030
|
-
provenance: [
|
|
1031
|
-
...mergedProvenance,
|
|
1032
|
-
{
|
|
1033
|
-
strategy: "composite",
|
|
1034
|
-
strategyName: "Composite Generator",
|
|
1035
|
-
strategyId: "COMPOSITE_GENERATOR",
|
|
1036
|
-
action,
|
|
1037
|
-
score: finalScore,
|
|
1038
|
-
reason
|
|
1039
|
-
}
|
|
1040
|
-
]
|
|
1041
|
-
});
|
|
1042
|
-
}
|
|
1043
|
-
return merged.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
944
|
+
get strategyKey() {
|
|
945
|
+
return this.constructor.name;
|
|
1044
946
|
}
|
|
1045
947
|
/**
|
|
1046
|
-
*
|
|
948
|
+
* Get this strategy's persisted state for the current course.
|
|
949
|
+
*
|
|
950
|
+
* @returns The strategy's data payload, or null if no state exists
|
|
951
|
+
* @throws Error if user or course is not initialized
|
|
1047
952
|
*/
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
}
|
|
1054
|
-
const strategies = cards.map((c) => c.provenance[0]?.strategy || "unknown").join(", ");
|
|
1055
|
-
switch (this.aggregationMode) {
|
|
1056
|
-
case "max" /* MAX */:
|
|
1057
|
-
return `Max of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
|
|
1058
|
-
case "average" /* AVERAGE */:
|
|
1059
|
-
return `Average of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
|
|
1060
|
-
case "frequencyBoost" /* FREQUENCY_BOOST */: {
|
|
1061
|
-
const avg = cards.reduce((sum, c) => sum + c.score, 0) / count;
|
|
1062
|
-
const boost = 1 + FREQUENCY_BOOST_FACTOR * (count - 1);
|
|
1063
|
-
return `Frequency boost from ${count} generators (${strategies}): avg ${avg.toFixed(2)} \xD7 ${boost.toFixed(2)} \u2192 ${finalScore.toFixed(2)}`;
|
|
1064
|
-
}
|
|
1065
|
-
default:
|
|
1066
|
-
return `Aggregated from ${count} generators: ${finalScore.toFixed(2)}`;
|
|
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
|
+
);
|
|
1067
958
|
}
|
|
959
|
+
return this.user.getStrategyState(this.course.getCourseID(), this.strategyKey);
|
|
1068
960
|
}
|
|
1069
961
|
/**
|
|
1070
|
-
*
|
|
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
|
|
1071
966
|
*/
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
case "average" /* AVERAGE */:
|
|
1078
|
-
return scores.reduce((sum, s) => sum + s, 0) / scores.length;
|
|
1079
|
-
case "frequencyBoost" /* FREQUENCY_BOOST */: {
|
|
1080
|
-
const avg = scores.reduce((sum, s) => sum + s, 0) / scores.length;
|
|
1081
|
-
const frequencyBoost = 1 + FREQUENCY_BOOST_FACTOR * (cards.length - 1);
|
|
1082
|
-
return avg * frequencyBoost;
|
|
1083
|
-
}
|
|
1084
|
-
default:
|
|
1085
|
-
return scores[0];
|
|
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
|
+
);
|
|
1086
972
|
}
|
|
973
|
+
return this.user.putStrategyState(this.course.getCourseID(), this.strategyKey, data);
|
|
1087
974
|
}
|
|
1088
975
|
/**
|
|
1089
|
-
*
|
|
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.
|
|
1090
982
|
*/
|
|
1091
|
-
async
|
|
1092
|
-
const
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
const
|
|
1096
|
-
const
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
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);
|
|
1103
997
|
}
|
|
1104
998
|
}
|
|
1105
999
|
}
|
|
1106
|
-
|
|
1000
|
+
if (!NavigatorImpl) {
|
|
1001
|
+
throw new Error(`Could not load navigator implementation for: ${implementingClass}`);
|
|
1002
|
+
}
|
|
1003
|
+
return new NavigatorImpl(user, course, strategyData);
|
|
1107
1004
|
}
|
|
1108
1005
|
/**
|
|
1109
|
-
* 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
|
|
1110
1029
|
*/
|
|
1111
|
-
async
|
|
1112
|
-
|
|
1113
|
-
(g) => g instanceof ContentNavigator
|
|
1114
|
-
);
|
|
1115
|
-
const results = await Promise.all(legacyGenerators.map((g) => g.getPendingReviews()));
|
|
1116
|
-
const seen = /* @__PURE__ */ new Set();
|
|
1117
|
-
const merged = [];
|
|
1118
|
-
for (const reviews of results) {
|
|
1119
|
-
for (const review of reviews) {
|
|
1120
|
-
if (!seen.has(review.cardID)) {
|
|
1121
|
-
seen.add(review.cardID);
|
|
1122
|
-
merged.push(review);
|
|
1123
|
-
}
|
|
1124
|
-
}
|
|
1125
|
-
}
|
|
1126
|
-
return merged;
|
|
1030
|
+
async getWeightedCards(_limit) {
|
|
1031
|
+
throw new Error(`${this.constructor.name} must implement getWeightedCards(). `);
|
|
1127
1032
|
}
|
|
1128
1033
|
};
|
|
1129
1034
|
}
|
|
1130
1035
|
});
|
|
1131
1036
|
|
|
1132
1037
|
// src/core/navigators/Pipeline.ts
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1038
|
+
function logPipelineConfig(generator, filters) {
|
|
1039
|
+
const filterList = filters.length > 0 ? "\n - " + filters.map((f) => f.name).join("\n - ") : " none";
|
|
1040
|
+
logger.info(
|
|
1041
|
+
`[Pipeline] Configuration:
|
|
1042
|
+
Generator: ${generator.name}
|
|
1043
|
+
Filters:${filterList}`
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
1046
|
+
function logTagHydration(cards, tagsByCard) {
|
|
1047
|
+
const totalTags = Array.from(tagsByCard.values()).reduce((sum, tags) => sum + tags.length, 0);
|
|
1048
|
+
const cardsWithTags = Array.from(tagsByCard.values()).filter((tags) => tags.length > 0).length;
|
|
1049
|
+
logger.debug(
|
|
1050
|
+
`[Pipeline] Tag hydration: ${cards.length} cards, ${cardsWithTags} have tags (${totalTags} total tags) - single batch query`
|
|
1051
|
+
);
|
|
1052
|
+
}
|
|
1053
|
+
function logExecutionSummary(generatorName, generatedCount, filterCount, finalCount, topScores) {
|
|
1054
|
+
const scoreDisplay = topScores.length > 0 ? topScores.map((s) => s.toFixed(2)).join(", ") : "none";
|
|
1055
|
+
logger.info(
|
|
1056
|
+
`[Pipeline] Execution: ${generatorName} produced ${generatedCount} \u2192 ${filterCount} filters \u2192 ${finalCount} results (top scores: ${scoreDisplay})`
|
|
1057
|
+
);
|
|
1058
|
+
}
|
|
1059
|
+
function logCardProvenance(cards, maxCards = 3) {
|
|
1060
|
+
const cardsToLog = cards.slice(0, maxCards);
|
|
1061
|
+
logger.debug(`[Pipeline] Provenance for top ${cardsToLog.length} cards:`);
|
|
1062
|
+
for (const card of cardsToLog) {
|
|
1063
|
+
logger.debug(`[Pipeline] ${card.cardId} (final score: ${card.score.toFixed(3)}):`);
|
|
1064
|
+
for (const entry of card.provenance) {
|
|
1065
|
+
const scoreChange = entry.score.toFixed(3);
|
|
1066
|
+
const action = entry.action.padEnd(9);
|
|
1067
|
+
logger.debug(
|
|
1068
|
+
`[Pipeline] ${action} ${scoreChange} - ${entry.strategyName}: ${entry.reason}`
|
|
1069
|
+
);
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1137
1073
|
var import_common5, Pipeline;
|
|
1138
1074
|
var init_Pipeline = __esm({
|
|
1139
1075
|
"src/core/navigators/Pipeline.ts"() {
|
|
@@ -1158,19 +1094,23 @@ var init_Pipeline = __esm({
|
|
|
1158
1094
|
this.filters = filters;
|
|
1159
1095
|
this.user = user;
|
|
1160
1096
|
this.course = course;
|
|
1161
|
-
|
|
1162
|
-
`[
|
|
1163
|
-
)
|
|
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
|
+
});
|
|
1102
|
+
logPipelineConfig(generator, filters);
|
|
1164
1103
|
}
|
|
1165
1104
|
/**
|
|
1166
1105
|
* Get weighted cards by running generator and applying filters.
|
|
1167
1106
|
*
|
|
1168
1107
|
* 1. Build shared context (user ELO, etc.)
|
|
1169
1108
|
* 2. Get candidates from generator (passing context)
|
|
1170
|
-
* 3.
|
|
1171
|
-
* 4.
|
|
1172
|
-
* 5.
|
|
1173
|
-
* 6.
|
|
1109
|
+
* 3. Batch hydrate tags for all candidates
|
|
1110
|
+
* 4. Apply each filter sequentially
|
|
1111
|
+
* 5. Remove zero-score cards
|
|
1112
|
+
* 6. Sort by score descending
|
|
1113
|
+
* 7. Return top N
|
|
1174
1114
|
*
|
|
1175
1115
|
* @param limit - Maximum number of cards to return
|
|
1176
1116
|
* @returns Cards sorted by score descending
|
|
@@ -1183,7 +1123,9 @@ var init_Pipeline = __esm({
|
|
|
1183
1123
|
`[Pipeline] Fetching ${fetchLimit} candidates from generator '${this.generator.name}'`
|
|
1184
1124
|
);
|
|
1185
1125
|
let cards = await this.generator.getWeightedCards(fetchLimit, context);
|
|
1186
|
-
|
|
1126
|
+
const generatedCount = cards.length;
|
|
1127
|
+
logger.debug(`[Pipeline] Generator returned ${generatedCount} candidates`);
|
|
1128
|
+
cards = await this.hydrateTags(cards);
|
|
1187
1129
|
for (const filter of this.filters) {
|
|
1188
1130
|
const beforeCount = cards.length;
|
|
1189
1131
|
cards = await filter.transform(cards, context);
|
|
@@ -1192,11 +1134,39 @@ var init_Pipeline = __esm({
|
|
|
1192
1134
|
cards = cards.filter((c) => c.score > 0);
|
|
1193
1135
|
cards.sort((a, b) => b.score - a.score);
|
|
1194
1136
|
const result = cards.slice(0, limit);
|
|
1195
|
-
|
|
1196
|
-
|
|
1137
|
+
const topScores = result.slice(0, 3).map((c) => c.score);
|
|
1138
|
+
logExecutionSummary(
|
|
1139
|
+
this.generator.name,
|
|
1140
|
+
generatedCount,
|
|
1141
|
+
this.filters.length,
|
|
1142
|
+
result.length,
|
|
1143
|
+
topScores
|
|
1197
1144
|
);
|
|
1145
|
+
logCardProvenance(result, 3);
|
|
1198
1146
|
return result;
|
|
1199
1147
|
}
|
|
1148
|
+
/**
|
|
1149
|
+
* Batch hydrate tags for all cards.
|
|
1150
|
+
*
|
|
1151
|
+
* Fetches tags for all cards in a single database query and attaches them
|
|
1152
|
+
* to the WeightedCard objects. Filters can then use card.tags instead of
|
|
1153
|
+
* making individual getAppliedTags() calls.
|
|
1154
|
+
*
|
|
1155
|
+
* @param cards - Cards to hydrate
|
|
1156
|
+
* @returns Cards with tags populated
|
|
1157
|
+
*/
|
|
1158
|
+
async hydrateTags(cards) {
|
|
1159
|
+
if (cards.length === 0) {
|
|
1160
|
+
return cards;
|
|
1161
|
+
}
|
|
1162
|
+
const cardIds = cards.map((c) => c.cardId);
|
|
1163
|
+
const tagsByCard = await this.course.getAppliedTagsBatch(cardIds);
|
|
1164
|
+
logTagHydration(cards, tagsByCard);
|
|
1165
|
+
return cards.map((card) => ({
|
|
1166
|
+
...card,
|
|
1167
|
+
tags: tagsByCard.get(card.cardId) ?? []
|
|
1168
|
+
}));
|
|
1169
|
+
}
|
|
1200
1170
|
/**
|
|
1201
1171
|
* Build shared context for generator and filters.
|
|
1202
1172
|
*
|
|
@@ -1220,48 +1190,155 @@ var init_Pipeline = __esm({
|
|
|
1220
1190
|
userElo
|
|
1221
1191
|
};
|
|
1222
1192
|
}
|
|
1223
|
-
// ===========================================================================
|
|
1224
|
-
// Legacy StudyContentSource methods
|
|
1225
|
-
// ===========================================================================
|
|
1226
|
-
//
|
|
1227
|
-
// These delegate to the generator for backward compatibility.
|
|
1228
|
-
// Eventually SessionController will use getWeightedCards() exclusively.
|
|
1229
|
-
//
|
|
1230
1193
|
/**
|
|
1231
|
-
* Get
|
|
1232
|
-
* Delegates to the generator if it supports the legacy interface.
|
|
1194
|
+
* Get the course ID for this pipeline.
|
|
1233
1195
|
*/
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
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");
|
|
1237
1223
|
}
|
|
1238
|
-
|
|
1224
|
+
logger.debug(
|
|
1225
|
+
`[CompositeGenerator] Created with ${generators.length} generators, mode: ${aggregationMode}`
|
|
1226
|
+
);
|
|
1227
|
+
}
|
|
1228
|
+
/**
|
|
1229
|
+
* Creates a CompositeGenerator from strategy data.
|
|
1230
|
+
*
|
|
1231
|
+
* This is a convenience factory for use by PipelineAssembler.
|
|
1232
|
+
*/
|
|
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);
|
|
1239
1238
|
}
|
|
1240
1239
|
/**
|
|
1241
|
-
* Get
|
|
1242
|
-
*
|
|
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)
|
|
1243
1250
|
*/
|
|
1244
|
-
async
|
|
1245
|
-
if (
|
|
1246
|
-
|
|
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
|
+
);
|
|
1247
1256
|
}
|
|
1248
|
-
|
|
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);
|
|
1249
1293
|
}
|
|
1250
1294
|
/**
|
|
1251
|
-
*
|
|
1295
|
+
* Build human-readable reason for score aggregation.
|
|
1252
1296
|
*/
|
|
1253
|
-
|
|
1254
|
-
|
|
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
|
+
}
|
|
1255
1336
|
}
|
|
1256
1337
|
};
|
|
1257
1338
|
}
|
|
1258
1339
|
});
|
|
1259
1340
|
|
|
1260
1341
|
// src/core/navigators/PipelineAssembler.ts
|
|
1261
|
-
var PipelineAssembler_exports = {};
|
|
1262
|
-
__export(PipelineAssembler_exports, {
|
|
1263
|
-
PipelineAssembler: () => PipelineAssembler
|
|
1264
|
-
});
|
|
1265
1342
|
var PipelineAssembler;
|
|
1266
1343
|
var init_PipelineAssembler = __esm({
|
|
1267
1344
|
"src/core/navigators/PipelineAssembler.ts"() {
|
|
@@ -1382,14 +1459,10 @@ var init_PipelineAssembler = __esm({
|
|
|
1382
1459
|
}
|
|
1383
1460
|
});
|
|
1384
1461
|
|
|
1385
|
-
// src/core/navigators/elo.ts
|
|
1386
|
-
var elo_exports = {};
|
|
1387
|
-
__export(elo_exports, {
|
|
1388
|
-
default: () => ELONavigator
|
|
1389
|
-
});
|
|
1462
|
+
// src/core/navigators/generators/elo.ts
|
|
1390
1463
|
var import_common6, ELONavigator;
|
|
1391
1464
|
var init_elo = __esm({
|
|
1392
|
-
"src/core/navigators/elo.ts"() {
|
|
1465
|
+
"src/core/navigators/generators/elo.ts"() {
|
|
1393
1466
|
"use strict";
|
|
1394
1467
|
init_navigators();
|
|
1395
1468
|
import_common6 = require("@vue-skuilder/common");
|
|
@@ -1400,50 +1473,6 @@ var init_elo = __esm({
|
|
|
1400
1473
|
super(user, course, strategyData);
|
|
1401
1474
|
this.name = strategyData?.name || "ELO";
|
|
1402
1475
|
}
|
|
1403
|
-
async getPendingReviews() {
|
|
1404
|
-
const reviews = await this.user.getPendingReviews(this.course.getCourseID());
|
|
1405
|
-
const elo = await this.course.getCardEloData(reviews.map((r) => r.cardId));
|
|
1406
|
-
const ratedReviews = reviews.map((r, i) => {
|
|
1407
|
-
const ratedR = {
|
|
1408
|
-
...r,
|
|
1409
|
-
...elo[i]
|
|
1410
|
-
};
|
|
1411
|
-
return ratedR;
|
|
1412
|
-
});
|
|
1413
|
-
ratedReviews.sort((a, b) => {
|
|
1414
|
-
return a.global.score - b.global.score;
|
|
1415
|
-
});
|
|
1416
|
-
return ratedReviews.map((r) => {
|
|
1417
|
-
return {
|
|
1418
|
-
...r,
|
|
1419
|
-
contentSourceType: "course",
|
|
1420
|
-
contentSourceID: this.course.getCourseID(),
|
|
1421
|
-
cardID: r.cardId,
|
|
1422
|
-
courseID: r.courseId,
|
|
1423
|
-
qualifiedID: `${r.courseId}-${r.cardId}`,
|
|
1424
|
-
reviewID: r._id,
|
|
1425
|
-
status: "review"
|
|
1426
|
-
};
|
|
1427
|
-
});
|
|
1428
|
-
}
|
|
1429
|
-
async getNewCards(limit = 99) {
|
|
1430
|
-
const activeCards = await this.user.getActiveCards();
|
|
1431
|
-
return (await this.course.getCardsCenteredAtELO(
|
|
1432
|
-
{ limit, elo: "user" },
|
|
1433
|
-
(c) => {
|
|
1434
|
-
if (activeCards.some((ac) => c.cardID === ac.cardID)) {
|
|
1435
|
-
return false;
|
|
1436
|
-
} else {
|
|
1437
|
-
return true;
|
|
1438
|
-
}
|
|
1439
|
-
}
|
|
1440
|
-
)).map((c) => {
|
|
1441
|
-
return {
|
|
1442
|
-
...c,
|
|
1443
|
-
status: "new"
|
|
1444
|
-
};
|
|
1445
|
-
});
|
|
1446
|
-
}
|
|
1447
1476
|
/**
|
|
1448
1477
|
* Get new cards with suitability scores based on ELO distance.
|
|
1449
1478
|
*
|
|
@@ -1468,7 +1497,11 @@ var init_elo = __esm({
|
|
|
1468
1497
|
const userElo = (0, import_common6.toCourseElo)(courseReg.elo);
|
|
1469
1498
|
userGlobalElo = userElo.global.score;
|
|
1470
1499
|
}
|
|
1471
|
-
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" }));
|
|
1472
1505
|
const cardIds = newCards.map((c) => c.cardID);
|
|
1473
1506
|
const cardEloData = await this.course.getCardEloData(cardIds);
|
|
1474
1507
|
const scored = newCards.map((c, i) => {
|
|
@@ -1498,806 +1531,14 @@ var init_elo = __esm({
|
|
|
1498
1531
|
}
|
|
1499
1532
|
});
|
|
1500
1533
|
|
|
1501
|
-
// src/core/navigators/
|
|
1502
|
-
var
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
DEFAULT_MAX_MULTIPLIER: () => DEFAULT_MAX_MULTIPLIER,
|
|
1506
|
-
DEFAULT_MIN_MULTIPLIER: () => DEFAULT_MIN_MULTIPLIER,
|
|
1507
|
-
createEloDistanceFilter: () => createEloDistanceFilter
|
|
1508
|
-
});
|
|
1509
|
-
function computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier) {
|
|
1510
|
-
const normalizedDistance = distance / halfLife;
|
|
1511
|
-
const decay = Math.exp(-(normalizedDistance * normalizedDistance));
|
|
1512
|
-
return minMultiplier + (maxMultiplier - minMultiplier) * decay;
|
|
1513
|
-
}
|
|
1514
|
-
function createEloDistanceFilter(config) {
|
|
1515
|
-
const halfLife = config?.halfLife ?? DEFAULT_HALF_LIFE;
|
|
1516
|
-
const minMultiplier = config?.minMultiplier ?? DEFAULT_MIN_MULTIPLIER;
|
|
1517
|
-
const maxMultiplier = config?.maxMultiplier ?? DEFAULT_MAX_MULTIPLIER;
|
|
1518
|
-
return {
|
|
1519
|
-
name: "ELO Distance Filter",
|
|
1520
|
-
async transform(cards, context) {
|
|
1521
|
-
const { course, userElo } = context;
|
|
1522
|
-
const cardIds = cards.map((c) => c.cardId);
|
|
1523
|
-
const cardElos = await course.getCardEloData(cardIds);
|
|
1524
|
-
return cards.map((card, i) => {
|
|
1525
|
-
const cardElo = cardElos[i]?.global?.score ?? 1e3;
|
|
1526
|
-
const distance = Math.abs(cardElo - userElo);
|
|
1527
|
-
const multiplier = computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier);
|
|
1528
|
-
const newScore = card.score * multiplier;
|
|
1529
|
-
const action = multiplier < maxMultiplier - 0.01 ? "penalized" : "passed";
|
|
1530
|
-
return {
|
|
1531
|
-
...card,
|
|
1532
|
-
score: newScore,
|
|
1533
|
-
provenance: [
|
|
1534
|
-
...card.provenance,
|
|
1535
|
-
{
|
|
1536
|
-
strategy: "eloDistance",
|
|
1537
|
-
strategyName: "ELO Distance Filter",
|
|
1538
|
-
strategyId: "ELO_DISTANCE_FILTER",
|
|
1539
|
-
action,
|
|
1540
|
-
score: newScore,
|
|
1541
|
-
reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userElo)}) \u2192 ${multiplier.toFixed(2)}x`
|
|
1542
|
-
}
|
|
1543
|
-
]
|
|
1544
|
-
};
|
|
1545
|
-
});
|
|
1546
|
-
}
|
|
1547
|
-
};
|
|
1548
|
-
}
|
|
1549
|
-
var DEFAULT_HALF_LIFE, DEFAULT_MIN_MULTIPLIER, DEFAULT_MAX_MULTIPLIER;
|
|
1550
|
-
var init_eloDistance = __esm({
|
|
1551
|
-
"src/core/navigators/filters/eloDistance.ts"() {
|
|
1552
|
-
"use strict";
|
|
1553
|
-
DEFAULT_HALF_LIFE = 200;
|
|
1554
|
-
DEFAULT_MIN_MULTIPLIER = 0.3;
|
|
1555
|
-
DEFAULT_MAX_MULTIPLIER = 1;
|
|
1556
|
-
}
|
|
1557
|
-
});
|
|
1558
|
-
|
|
1559
|
-
// src/core/navigators/filters/index.ts
|
|
1560
|
-
var filters_exports = {};
|
|
1561
|
-
__export(filters_exports, {
|
|
1562
|
-
createEloDistanceFilter: () => createEloDistanceFilter
|
|
1563
|
-
});
|
|
1564
|
-
var init_filters = __esm({
|
|
1565
|
-
"src/core/navigators/filters/index.ts"() {
|
|
1566
|
-
"use strict";
|
|
1567
|
-
init_eloDistance();
|
|
1568
|
-
}
|
|
1569
|
-
});
|
|
1570
|
-
|
|
1571
|
-
// src/core/navigators/filters/types.ts
|
|
1572
|
-
var types_exports = {};
|
|
1573
|
-
var init_types = __esm({
|
|
1574
|
-
"src/core/navigators/filters/types.ts"() {
|
|
1575
|
-
"use strict";
|
|
1576
|
-
}
|
|
1577
|
-
});
|
|
1578
|
-
|
|
1579
|
-
// src/core/navigators/generators/index.ts
|
|
1580
|
-
var generators_exports = {};
|
|
1581
|
-
var init_generators = __esm({
|
|
1582
|
-
"src/core/navigators/generators/index.ts"() {
|
|
1583
|
-
"use strict";
|
|
1584
|
-
}
|
|
1585
|
-
});
|
|
1586
|
-
|
|
1587
|
-
// src/core/navigators/generators/types.ts
|
|
1588
|
-
var types_exports2 = {};
|
|
1589
|
-
var init_types2 = __esm({
|
|
1590
|
-
"src/core/navigators/generators/types.ts"() {
|
|
1591
|
-
"use strict";
|
|
1592
|
-
}
|
|
1593
|
-
});
|
|
1594
|
-
|
|
1595
|
-
// src/core/navigators/hardcodedOrder.ts
|
|
1596
|
-
var hardcodedOrder_exports = {};
|
|
1597
|
-
__export(hardcodedOrder_exports, {
|
|
1598
|
-
default: () => HardcodedOrderNavigator
|
|
1599
|
-
});
|
|
1600
|
-
var HardcodedOrderNavigator;
|
|
1601
|
-
var init_hardcodedOrder = __esm({
|
|
1602
|
-
"src/core/navigators/hardcodedOrder.ts"() {
|
|
1603
|
-
"use strict";
|
|
1604
|
-
init_navigators();
|
|
1605
|
-
init_logger();
|
|
1606
|
-
HardcodedOrderNavigator = class extends ContentNavigator {
|
|
1607
|
-
/** Human-readable name for CardGenerator interface */
|
|
1608
|
-
name;
|
|
1609
|
-
orderedCardIds = [];
|
|
1610
|
-
constructor(user, course, strategyData) {
|
|
1611
|
-
super(user, course, strategyData);
|
|
1612
|
-
this.name = strategyData.name || "Hardcoded Order";
|
|
1613
|
-
if (strategyData.serializedData) {
|
|
1614
|
-
try {
|
|
1615
|
-
this.orderedCardIds = JSON.parse(strategyData.serializedData);
|
|
1616
|
-
} catch (e) {
|
|
1617
|
-
logger.error("Failed to parse serializedData for HardcodedOrderNavigator", e);
|
|
1618
|
-
}
|
|
1619
|
-
}
|
|
1620
|
-
}
|
|
1621
|
-
async getPendingReviews() {
|
|
1622
|
-
const reviews = await this.user.getPendingReviews(this.course.getCourseID());
|
|
1623
|
-
return reviews.map((r) => {
|
|
1624
|
-
return {
|
|
1625
|
-
...r,
|
|
1626
|
-
contentSourceType: "course",
|
|
1627
|
-
contentSourceID: this.course.getCourseID(),
|
|
1628
|
-
cardID: r.cardId,
|
|
1629
|
-
courseID: r.courseId,
|
|
1630
|
-
reviewID: r._id,
|
|
1631
|
-
status: "review"
|
|
1632
|
-
};
|
|
1633
|
-
});
|
|
1634
|
-
}
|
|
1635
|
-
async getNewCards(limit = 99) {
|
|
1636
|
-
const activeCardIds = (await this.user.getActiveCards()).map((c) => c.cardID);
|
|
1637
|
-
const newCardIds = this.orderedCardIds.filter((cardId) => !activeCardIds.includes(cardId));
|
|
1638
|
-
const cardsToReturn = newCardIds.slice(0, limit);
|
|
1639
|
-
return cardsToReturn.map((cardId) => {
|
|
1640
|
-
return {
|
|
1641
|
-
cardID: cardId,
|
|
1642
|
-
courseID: this.course.getCourseID(),
|
|
1643
|
-
contentSourceType: "course",
|
|
1644
|
-
contentSourceID: this.course.getCourseID(),
|
|
1645
|
-
status: "new"
|
|
1646
|
-
};
|
|
1647
|
-
});
|
|
1648
|
-
}
|
|
1649
|
-
/**
|
|
1650
|
-
* Get cards in hardcoded order with scores based on position.
|
|
1651
|
-
*
|
|
1652
|
-
* Earlier cards in the sequence get higher scores.
|
|
1653
|
-
* Score formula: 1.0 - (position / totalCards) * 0.5
|
|
1654
|
-
* This ensures scores range from 1.0 (first card) to 0.5+ (last card).
|
|
1655
|
-
*
|
|
1656
|
-
* This method supports both the legacy signature (limit only) and the
|
|
1657
|
-
* CardGenerator interface signature (limit, context).
|
|
1658
|
-
*
|
|
1659
|
-
* @param limit - Maximum number of cards to return
|
|
1660
|
-
* @param _context - Optional GeneratorContext (currently unused, but required for interface)
|
|
1661
|
-
*/
|
|
1662
|
-
async getWeightedCards(limit, _context) {
|
|
1663
|
-
const activeCardIds = (await this.user.getActiveCards()).map((c) => c.cardID);
|
|
1664
|
-
const reviews = await this.getPendingReviews();
|
|
1665
|
-
const newCardIds = this.orderedCardIds.filter((cardId) => !activeCardIds.includes(cardId));
|
|
1666
|
-
const totalCards = newCardIds.length;
|
|
1667
|
-
const scoredNew = newCardIds.slice(0, limit).map((cardId, index) => {
|
|
1668
|
-
const position = index + 1;
|
|
1669
|
-
const score = Math.max(0.5, 1 - index / totalCards * 0.5);
|
|
1670
|
-
return {
|
|
1671
|
-
cardId,
|
|
1672
|
-
courseId: this.course.getCourseID(),
|
|
1673
|
-
score,
|
|
1674
|
-
provenance: [
|
|
1675
|
-
{
|
|
1676
|
-
strategy: "hardcodedOrder",
|
|
1677
|
-
strategyName: this.strategyName || this.name,
|
|
1678
|
-
strategyId: this.strategyId || "NAVIGATION_STRATEGY-hardcoded",
|
|
1679
|
-
action: "generated",
|
|
1680
|
-
score,
|
|
1681
|
-
reason: `Position ${position} of ${totalCards} in fixed sequence, new card`
|
|
1682
|
-
}
|
|
1683
|
-
]
|
|
1684
|
-
};
|
|
1685
|
-
});
|
|
1686
|
-
const scoredReviews = reviews.map((r) => ({
|
|
1687
|
-
cardId: r.cardID,
|
|
1688
|
-
courseId: r.courseID,
|
|
1689
|
-
score: 1,
|
|
1690
|
-
provenance: [
|
|
1691
|
-
{
|
|
1692
|
-
strategy: "hardcodedOrder",
|
|
1693
|
-
strategyName: this.strategyName || this.name,
|
|
1694
|
-
strategyId: this.strategyId || "NAVIGATION_STRATEGY-hardcoded",
|
|
1695
|
-
action: "generated",
|
|
1696
|
-
score: 1,
|
|
1697
|
-
reason: "Scheduled review, highest priority"
|
|
1698
|
-
}
|
|
1699
|
-
]
|
|
1700
|
-
}));
|
|
1701
|
-
const all = [...scoredReviews, ...scoredNew];
|
|
1702
|
-
all.sort((a, b) => b.score - a.score);
|
|
1703
|
-
return all.slice(0, limit);
|
|
1704
|
-
}
|
|
1705
|
-
};
|
|
1706
|
-
}
|
|
1707
|
-
});
|
|
1708
|
-
|
|
1709
|
-
// src/core/navigators/hierarchyDefinition.ts
|
|
1710
|
-
var hierarchyDefinition_exports = {};
|
|
1711
|
-
__export(hierarchyDefinition_exports, {
|
|
1712
|
-
default: () => HierarchyDefinitionNavigator
|
|
1713
|
-
});
|
|
1714
|
-
var import_common7, DEFAULT_MIN_COUNT, HierarchyDefinitionNavigator;
|
|
1715
|
-
var init_hierarchyDefinition = __esm({
|
|
1716
|
-
"src/core/navigators/hierarchyDefinition.ts"() {
|
|
1717
|
-
"use strict";
|
|
1718
|
-
init_navigators();
|
|
1719
|
-
import_common7 = require("@vue-skuilder/common");
|
|
1720
|
-
DEFAULT_MIN_COUNT = 3;
|
|
1721
|
-
HierarchyDefinitionNavigator = class extends ContentNavigator {
|
|
1722
|
-
config;
|
|
1723
|
-
_strategyData;
|
|
1724
|
-
/** Human-readable name for CardFilter interface */
|
|
1725
|
-
name;
|
|
1726
|
-
constructor(user, course, _strategyData) {
|
|
1727
|
-
super(user, course, _strategyData);
|
|
1728
|
-
this._strategyData = _strategyData;
|
|
1729
|
-
this.config = this.parseConfig(_strategyData.serializedData);
|
|
1730
|
-
this.name = _strategyData.name || "Hierarchy Definition";
|
|
1731
|
-
}
|
|
1732
|
-
parseConfig(serializedData) {
|
|
1733
|
-
try {
|
|
1734
|
-
const parsed = JSON.parse(serializedData);
|
|
1735
|
-
return {
|
|
1736
|
-
prerequisites: parsed.prerequisites || {}
|
|
1737
|
-
};
|
|
1738
|
-
} catch {
|
|
1739
|
-
return {
|
|
1740
|
-
prerequisites: {}
|
|
1741
|
-
};
|
|
1742
|
-
}
|
|
1743
|
-
}
|
|
1744
|
-
/**
|
|
1745
|
-
* Check if a specific prerequisite is satisfied
|
|
1746
|
-
*/
|
|
1747
|
-
isPrerequisiteMet(prereq, userTagElo, userGlobalElo) {
|
|
1748
|
-
if (!userTagElo) return false;
|
|
1749
|
-
const minCount = prereq.masteryThreshold?.minCount ?? DEFAULT_MIN_COUNT;
|
|
1750
|
-
if (userTagElo.count < minCount) return false;
|
|
1751
|
-
if (prereq.masteryThreshold?.minElo !== void 0) {
|
|
1752
|
-
return userTagElo.score >= prereq.masteryThreshold.minElo;
|
|
1753
|
-
} else {
|
|
1754
|
-
return userTagElo.score >= userGlobalElo;
|
|
1755
|
-
}
|
|
1756
|
-
}
|
|
1757
|
-
/**
|
|
1758
|
-
* Get the set of tags the user has mastered.
|
|
1759
|
-
* A tag is "mastered" if it appears as a prerequisite somewhere and meets its threshold.
|
|
1760
|
-
*/
|
|
1761
|
-
async getMasteredTags(context) {
|
|
1762
|
-
const mastered = /* @__PURE__ */ new Set();
|
|
1763
|
-
try {
|
|
1764
|
-
const courseReg = await context.user.getCourseRegDoc(context.course.getCourseID());
|
|
1765
|
-
const userElo = (0, import_common7.toCourseElo)(courseReg.elo);
|
|
1766
|
-
for (const prereqs of Object.values(this.config.prerequisites)) {
|
|
1767
|
-
for (const prereq of prereqs) {
|
|
1768
|
-
const tagElo = userElo.tags[prereq.tag];
|
|
1769
|
-
if (this.isPrerequisiteMet(prereq, tagElo, userElo.global.score)) {
|
|
1770
|
-
mastered.add(prereq.tag);
|
|
1771
|
-
}
|
|
1772
|
-
}
|
|
1773
|
-
}
|
|
1774
|
-
} catch {
|
|
1775
|
-
}
|
|
1776
|
-
return mastered;
|
|
1777
|
-
}
|
|
1778
|
-
/**
|
|
1779
|
-
* Get the set of tags that are unlocked (prerequisites met)
|
|
1780
|
-
*/
|
|
1781
|
-
getUnlockedTags(masteredTags) {
|
|
1782
|
-
const unlocked = /* @__PURE__ */ new Set();
|
|
1783
|
-
for (const [tagId, prereqs] of Object.entries(this.config.prerequisites)) {
|
|
1784
|
-
const allPrereqsMet = prereqs.every((prereq) => masteredTags.has(prereq.tag));
|
|
1785
|
-
if (allPrereqsMet) {
|
|
1786
|
-
unlocked.add(tagId);
|
|
1787
|
-
}
|
|
1788
|
-
}
|
|
1789
|
-
return unlocked;
|
|
1790
|
-
}
|
|
1791
|
-
/**
|
|
1792
|
-
* Check if a tag has prerequisites defined in config
|
|
1793
|
-
*/
|
|
1794
|
-
hasPrerequisites(tagId) {
|
|
1795
|
-
return tagId in this.config.prerequisites;
|
|
1796
|
-
}
|
|
1797
|
-
/**
|
|
1798
|
-
* Check if a card is unlocked and generate reason.
|
|
1799
|
-
*/
|
|
1800
|
-
async checkCardUnlock(cardId, course, unlockedTags, masteredTags) {
|
|
1801
|
-
try {
|
|
1802
|
-
const tagResponse = await course.getAppliedTags(cardId);
|
|
1803
|
-
const cardTags = tagResponse.rows.map((row) => row.value?.name || row.key);
|
|
1804
|
-
const lockedTags = cardTags.filter(
|
|
1805
|
-
(tag) => this.hasPrerequisites(tag) && !unlockedTags.has(tag)
|
|
1806
|
-
);
|
|
1807
|
-
if (lockedTags.length === 0) {
|
|
1808
|
-
const tagList = cardTags.length > 0 ? cardTags.join(", ") : "none";
|
|
1809
|
-
return {
|
|
1810
|
-
isUnlocked: true,
|
|
1811
|
-
reason: `Prerequisites met, tags: ${tagList}`
|
|
1812
|
-
};
|
|
1813
|
-
}
|
|
1814
|
-
const missingPrereqs = lockedTags.flatMap((tag) => {
|
|
1815
|
-
const prereqs = this.config.prerequisites[tag] || [];
|
|
1816
|
-
return prereqs.filter((p) => !masteredTags.has(p.tag)).map((p) => p.tag);
|
|
1817
|
-
});
|
|
1818
|
-
return {
|
|
1819
|
-
isUnlocked: false,
|
|
1820
|
-
reason: `Blocked: missing prerequisites ${missingPrereqs.join(", ")} for tags ${lockedTags.join(", ")}`
|
|
1821
|
-
};
|
|
1822
|
-
} catch {
|
|
1823
|
-
return {
|
|
1824
|
-
isUnlocked: true,
|
|
1825
|
-
reason: "Prerequisites check skipped (tag lookup failed)"
|
|
1826
|
-
};
|
|
1827
|
-
}
|
|
1828
|
-
}
|
|
1829
|
-
/**
|
|
1830
|
-
* CardFilter.transform implementation.
|
|
1831
|
-
*
|
|
1832
|
-
* Apply prerequisite gating to cards. Cards with locked tags receive score: 0.
|
|
1833
|
-
*/
|
|
1834
|
-
async transform(cards, context) {
|
|
1835
|
-
const masteredTags = await this.getMasteredTags(context);
|
|
1836
|
-
const unlockedTags = this.getUnlockedTags(masteredTags);
|
|
1837
|
-
const gated = [];
|
|
1838
|
-
for (const card of cards) {
|
|
1839
|
-
const { isUnlocked, reason } = await this.checkCardUnlock(
|
|
1840
|
-
card.cardId,
|
|
1841
|
-
context.course,
|
|
1842
|
-
unlockedTags,
|
|
1843
|
-
masteredTags
|
|
1844
|
-
);
|
|
1845
|
-
const finalScore = isUnlocked ? card.score : 0;
|
|
1846
|
-
const action = isUnlocked ? "passed" : "penalized";
|
|
1847
|
-
gated.push({
|
|
1848
|
-
...card,
|
|
1849
|
-
score: finalScore,
|
|
1850
|
-
provenance: [
|
|
1851
|
-
...card.provenance,
|
|
1852
|
-
{
|
|
1853
|
-
strategy: "hierarchyDefinition",
|
|
1854
|
-
strategyName: this.strategyName || this.name,
|
|
1855
|
-
strategyId: this.strategyId || "NAVIGATION_STRATEGY-hierarchy",
|
|
1856
|
-
action,
|
|
1857
|
-
score: finalScore,
|
|
1858
|
-
reason
|
|
1859
|
-
}
|
|
1860
|
-
]
|
|
1861
|
-
});
|
|
1862
|
-
}
|
|
1863
|
-
return gated;
|
|
1864
|
-
}
|
|
1865
|
-
/**
|
|
1866
|
-
* Legacy getWeightedCards - now throws as filters should not be used as generators.
|
|
1867
|
-
*
|
|
1868
|
-
* Use transform() via Pipeline instead.
|
|
1869
|
-
*/
|
|
1870
|
-
async getWeightedCards(_limit) {
|
|
1871
|
-
throw new Error(
|
|
1872
|
-
"HierarchyDefinitionNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
|
|
1873
|
-
);
|
|
1874
|
-
}
|
|
1875
|
-
// Legacy methods - stub implementations since filters don't generate cards
|
|
1876
|
-
async getNewCards(_n) {
|
|
1877
|
-
return [];
|
|
1878
|
-
}
|
|
1879
|
-
async getPendingReviews() {
|
|
1880
|
-
return [];
|
|
1881
|
-
}
|
|
1882
|
-
};
|
|
1883
|
-
}
|
|
1884
|
-
});
|
|
1885
|
-
|
|
1886
|
-
// src/core/navigators/interferenceMitigator.ts
|
|
1887
|
-
var interferenceMitigator_exports = {};
|
|
1888
|
-
__export(interferenceMitigator_exports, {
|
|
1889
|
-
default: () => InterferenceMitigatorNavigator
|
|
1890
|
-
});
|
|
1891
|
-
var import_common8, DEFAULT_MIN_COUNT2, DEFAULT_MIN_ELAPSED_DAYS, DEFAULT_INTERFERENCE_DECAY, InterferenceMitigatorNavigator;
|
|
1892
|
-
var init_interferenceMitigator = __esm({
|
|
1893
|
-
"src/core/navigators/interferenceMitigator.ts"() {
|
|
1894
|
-
"use strict";
|
|
1895
|
-
init_navigators();
|
|
1896
|
-
import_common8 = require("@vue-skuilder/common");
|
|
1897
|
-
DEFAULT_MIN_COUNT2 = 10;
|
|
1898
|
-
DEFAULT_MIN_ELAPSED_DAYS = 3;
|
|
1899
|
-
DEFAULT_INTERFERENCE_DECAY = 0.8;
|
|
1900
|
-
InterferenceMitigatorNavigator = class extends ContentNavigator {
|
|
1901
|
-
config;
|
|
1902
|
-
_strategyData;
|
|
1903
|
-
/** Human-readable name for CardFilter interface */
|
|
1904
|
-
name;
|
|
1905
|
-
/** Precomputed map: tag -> set of { partner, decay } it interferes with */
|
|
1906
|
-
interferenceMap;
|
|
1907
|
-
constructor(user, course, _strategyData) {
|
|
1908
|
-
super(user, course, _strategyData);
|
|
1909
|
-
this._strategyData = _strategyData;
|
|
1910
|
-
this.config = this.parseConfig(_strategyData.serializedData);
|
|
1911
|
-
this.interferenceMap = this.buildInterferenceMap();
|
|
1912
|
-
this.name = _strategyData.name || "Interference Mitigator";
|
|
1913
|
-
}
|
|
1914
|
-
parseConfig(serializedData) {
|
|
1915
|
-
try {
|
|
1916
|
-
const parsed = JSON.parse(serializedData);
|
|
1917
|
-
let sets = parsed.interferenceSets || [];
|
|
1918
|
-
if (sets.length > 0 && Array.isArray(sets[0])) {
|
|
1919
|
-
sets = sets.map((tags) => ({ tags }));
|
|
1920
|
-
}
|
|
1921
|
-
return {
|
|
1922
|
-
interferenceSets: sets,
|
|
1923
|
-
maturityThreshold: {
|
|
1924
|
-
minCount: parsed.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT2,
|
|
1925
|
-
minElo: parsed.maturityThreshold?.minElo,
|
|
1926
|
-
minElapsedDays: parsed.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS
|
|
1927
|
-
},
|
|
1928
|
-
defaultDecay: parsed.defaultDecay ?? DEFAULT_INTERFERENCE_DECAY
|
|
1929
|
-
};
|
|
1930
|
-
} catch {
|
|
1931
|
-
return {
|
|
1932
|
-
interferenceSets: [],
|
|
1933
|
-
maturityThreshold: {
|
|
1934
|
-
minCount: DEFAULT_MIN_COUNT2,
|
|
1935
|
-
minElapsedDays: DEFAULT_MIN_ELAPSED_DAYS
|
|
1936
|
-
},
|
|
1937
|
-
defaultDecay: DEFAULT_INTERFERENCE_DECAY
|
|
1938
|
-
};
|
|
1939
|
-
}
|
|
1940
|
-
}
|
|
1941
|
-
/**
|
|
1942
|
-
* Build a map from each tag to its interference partners with decay coefficients.
|
|
1943
|
-
* If tags A, B, C are in an interference group with decay 0.8, then:
|
|
1944
|
-
* - A interferes with B (decay 0.8) and C (decay 0.8)
|
|
1945
|
-
* - B interferes with A (decay 0.8) and C (decay 0.8)
|
|
1946
|
-
* - etc.
|
|
1947
|
-
*/
|
|
1948
|
-
buildInterferenceMap() {
|
|
1949
|
-
const map = /* @__PURE__ */ new Map();
|
|
1950
|
-
for (const group of this.config.interferenceSets) {
|
|
1951
|
-
const decay = group.decay ?? this.config.defaultDecay ?? DEFAULT_INTERFERENCE_DECAY;
|
|
1952
|
-
for (const tag of group.tags) {
|
|
1953
|
-
if (!map.has(tag)) {
|
|
1954
|
-
map.set(tag, []);
|
|
1955
|
-
}
|
|
1956
|
-
const partners = map.get(tag);
|
|
1957
|
-
for (const other of group.tags) {
|
|
1958
|
-
if (other !== tag) {
|
|
1959
|
-
const existing = partners.find((p) => p.partner === other);
|
|
1960
|
-
if (existing) {
|
|
1961
|
-
existing.decay = Math.max(existing.decay, decay);
|
|
1962
|
-
} else {
|
|
1963
|
-
partners.push({ partner: other, decay });
|
|
1964
|
-
}
|
|
1965
|
-
}
|
|
1966
|
-
}
|
|
1967
|
-
}
|
|
1968
|
-
}
|
|
1969
|
-
return map;
|
|
1970
|
-
}
|
|
1971
|
-
/**
|
|
1972
|
-
* Get the set of tags that are currently immature for this user.
|
|
1973
|
-
* A tag is immature if the user has interacted with it but hasn't
|
|
1974
|
-
* reached the maturity threshold.
|
|
1975
|
-
*/
|
|
1976
|
-
async getImmatureTags(context) {
|
|
1977
|
-
const immature = /* @__PURE__ */ new Set();
|
|
1978
|
-
try {
|
|
1979
|
-
const courseReg = await context.user.getCourseRegDoc(context.course.getCourseID());
|
|
1980
|
-
const userElo = (0, import_common8.toCourseElo)(courseReg.elo);
|
|
1981
|
-
const minCount = this.config.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT2;
|
|
1982
|
-
const minElo = this.config.maturityThreshold?.minElo;
|
|
1983
|
-
const minElapsedDays = this.config.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS;
|
|
1984
|
-
const minCountForElapsed = minElapsedDays * 2;
|
|
1985
|
-
for (const [tagId, tagElo] of Object.entries(userElo.tags)) {
|
|
1986
|
-
if (tagElo.count === 0) continue;
|
|
1987
|
-
const belowCount = tagElo.count < minCount;
|
|
1988
|
-
const belowElo = minElo !== void 0 && tagElo.score < minElo;
|
|
1989
|
-
const belowElapsed = tagElo.count < minCountForElapsed;
|
|
1990
|
-
if (belowCount || belowElo || belowElapsed) {
|
|
1991
|
-
immature.add(tagId);
|
|
1992
|
-
}
|
|
1993
|
-
}
|
|
1994
|
-
} catch {
|
|
1995
|
-
}
|
|
1996
|
-
return immature;
|
|
1997
|
-
}
|
|
1998
|
-
/**
|
|
1999
|
-
* Get all tags that interfere with any immature tag, along with their decay coefficients.
|
|
2000
|
-
* These are the tags we want to avoid introducing.
|
|
2001
|
-
*/
|
|
2002
|
-
getTagsToAvoid(immatureTags) {
|
|
2003
|
-
const avoid = /* @__PURE__ */ new Map();
|
|
2004
|
-
for (const immatureTag of immatureTags) {
|
|
2005
|
-
const partners = this.interferenceMap.get(immatureTag);
|
|
2006
|
-
if (partners) {
|
|
2007
|
-
for (const { partner, decay } of partners) {
|
|
2008
|
-
if (!immatureTags.has(partner)) {
|
|
2009
|
-
const existing = avoid.get(partner) ?? 0;
|
|
2010
|
-
avoid.set(partner, Math.max(existing, decay));
|
|
2011
|
-
}
|
|
2012
|
-
}
|
|
2013
|
-
}
|
|
2014
|
-
}
|
|
2015
|
-
return avoid;
|
|
2016
|
-
}
|
|
2017
|
-
/**
|
|
2018
|
-
* Get tags for a single card
|
|
2019
|
-
*/
|
|
2020
|
-
async getCardTags(cardId, course) {
|
|
2021
|
-
try {
|
|
2022
|
-
const tagResponse = await course.getAppliedTags(cardId);
|
|
2023
|
-
return tagResponse.rows.map((row) => row.value?.name || row.key).filter(Boolean);
|
|
2024
|
-
} catch {
|
|
2025
|
-
return [];
|
|
2026
|
-
}
|
|
2027
|
-
}
|
|
2028
|
-
/**
|
|
2029
|
-
* Compute interference score reduction for a card.
|
|
2030
|
-
* Returns: { multiplier, interfering tags, reason }
|
|
2031
|
-
*/
|
|
2032
|
-
computeInterferenceEffect(cardTags, tagsToAvoid, immatureTags) {
|
|
2033
|
-
if (tagsToAvoid.size === 0) {
|
|
2034
|
-
return {
|
|
2035
|
-
multiplier: 1,
|
|
2036
|
-
interferingTags: [],
|
|
2037
|
-
reason: "No interference detected"
|
|
2038
|
-
};
|
|
2039
|
-
}
|
|
2040
|
-
let multiplier = 1;
|
|
2041
|
-
const interferingTags = [];
|
|
2042
|
-
for (const tag of cardTags) {
|
|
2043
|
-
const decay = tagsToAvoid.get(tag);
|
|
2044
|
-
if (decay !== void 0) {
|
|
2045
|
-
interferingTags.push(tag);
|
|
2046
|
-
multiplier *= 1 - decay;
|
|
2047
|
-
}
|
|
2048
|
-
}
|
|
2049
|
-
if (interferingTags.length === 0) {
|
|
2050
|
-
return {
|
|
2051
|
-
multiplier: 1,
|
|
2052
|
-
interferingTags: [],
|
|
2053
|
-
reason: "No interference detected"
|
|
2054
|
-
};
|
|
2055
|
-
}
|
|
2056
|
-
const causingTags = /* @__PURE__ */ new Set();
|
|
2057
|
-
for (const tag of interferingTags) {
|
|
2058
|
-
for (const immatureTag of immatureTags) {
|
|
2059
|
-
const partners = this.interferenceMap.get(immatureTag);
|
|
2060
|
-
if (partners?.some((p) => p.partner === tag)) {
|
|
2061
|
-
causingTags.add(immatureTag);
|
|
2062
|
-
}
|
|
2063
|
-
}
|
|
2064
|
-
}
|
|
2065
|
-
const reason = `Interferes with immature tags ${Array.from(causingTags).join(", ")} (tags: ${interferingTags.join(", ")}, multiplier: ${multiplier.toFixed(2)})`;
|
|
2066
|
-
return { multiplier, interferingTags, reason };
|
|
2067
|
-
}
|
|
2068
|
-
/**
|
|
2069
|
-
* CardFilter.transform implementation.
|
|
2070
|
-
*
|
|
2071
|
-
* Apply interference-aware scoring. Cards with tags that interfere with
|
|
2072
|
-
* immature learnings get reduced scores.
|
|
2073
|
-
*/
|
|
2074
|
-
async transform(cards, context) {
|
|
2075
|
-
const immatureTags = await this.getImmatureTags(context);
|
|
2076
|
-
const tagsToAvoid = this.getTagsToAvoid(immatureTags);
|
|
2077
|
-
const adjusted = [];
|
|
2078
|
-
for (const card of cards) {
|
|
2079
|
-
const cardTags = await this.getCardTags(card.cardId, context.course);
|
|
2080
|
-
const { multiplier, reason } = this.computeInterferenceEffect(
|
|
2081
|
-
cardTags,
|
|
2082
|
-
tagsToAvoid,
|
|
2083
|
-
immatureTags
|
|
2084
|
-
);
|
|
2085
|
-
const finalScore = card.score * multiplier;
|
|
2086
|
-
const action = multiplier < 1 ? "penalized" : multiplier > 1 ? "boosted" : "passed";
|
|
2087
|
-
adjusted.push({
|
|
2088
|
-
...card,
|
|
2089
|
-
score: finalScore,
|
|
2090
|
-
provenance: [
|
|
2091
|
-
...card.provenance,
|
|
2092
|
-
{
|
|
2093
|
-
strategy: "interferenceMitigator",
|
|
2094
|
-
strategyName: this.strategyName || this.name,
|
|
2095
|
-
strategyId: this.strategyId || "NAVIGATION_STRATEGY-interference",
|
|
2096
|
-
action,
|
|
2097
|
-
score: finalScore,
|
|
2098
|
-
reason
|
|
2099
|
-
}
|
|
2100
|
-
]
|
|
2101
|
-
});
|
|
2102
|
-
}
|
|
2103
|
-
return adjusted;
|
|
2104
|
-
}
|
|
2105
|
-
/**
|
|
2106
|
-
* Legacy getWeightedCards - now throws as filters should not be used as generators.
|
|
2107
|
-
*
|
|
2108
|
-
* Use transform() via Pipeline instead.
|
|
2109
|
-
*/
|
|
2110
|
-
async getWeightedCards(_limit) {
|
|
2111
|
-
throw new Error(
|
|
2112
|
-
"InterferenceMitigatorNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
|
|
2113
|
-
);
|
|
2114
|
-
}
|
|
2115
|
-
// Legacy methods - stub implementations since filters don't generate cards
|
|
2116
|
-
async getNewCards(_n) {
|
|
2117
|
-
return [];
|
|
2118
|
-
}
|
|
2119
|
-
async getPendingReviews() {
|
|
2120
|
-
return [];
|
|
2121
|
-
}
|
|
2122
|
-
};
|
|
2123
|
-
}
|
|
2124
|
-
});
|
|
2125
|
-
|
|
2126
|
-
// src/core/navigators/relativePriority.ts
|
|
2127
|
-
var relativePriority_exports = {};
|
|
2128
|
-
__export(relativePriority_exports, {
|
|
2129
|
-
default: () => RelativePriorityNavigator
|
|
2130
|
-
});
|
|
2131
|
-
var DEFAULT_PRIORITY, DEFAULT_PRIORITY_INFLUENCE, DEFAULT_COMBINE_MODE, RelativePriorityNavigator;
|
|
2132
|
-
var init_relativePriority = __esm({
|
|
2133
|
-
"src/core/navigators/relativePriority.ts"() {
|
|
2134
|
-
"use strict";
|
|
2135
|
-
init_navigators();
|
|
2136
|
-
DEFAULT_PRIORITY = 0.5;
|
|
2137
|
-
DEFAULT_PRIORITY_INFLUENCE = 0.5;
|
|
2138
|
-
DEFAULT_COMBINE_MODE = "max";
|
|
2139
|
-
RelativePriorityNavigator = class extends ContentNavigator {
|
|
2140
|
-
config;
|
|
2141
|
-
_strategyData;
|
|
2142
|
-
/** Human-readable name for CardFilter interface */
|
|
2143
|
-
name;
|
|
2144
|
-
constructor(user, course, _strategyData) {
|
|
2145
|
-
super(user, course, _strategyData);
|
|
2146
|
-
this._strategyData = _strategyData;
|
|
2147
|
-
this.config = this.parseConfig(_strategyData.serializedData);
|
|
2148
|
-
this.name = _strategyData.name || "Relative Priority";
|
|
2149
|
-
}
|
|
2150
|
-
parseConfig(serializedData) {
|
|
2151
|
-
try {
|
|
2152
|
-
const parsed = JSON.parse(serializedData);
|
|
2153
|
-
return {
|
|
2154
|
-
tagPriorities: parsed.tagPriorities || {},
|
|
2155
|
-
defaultPriority: parsed.defaultPriority ?? DEFAULT_PRIORITY,
|
|
2156
|
-
combineMode: parsed.combineMode ?? DEFAULT_COMBINE_MODE,
|
|
2157
|
-
priorityInfluence: parsed.priorityInfluence ?? DEFAULT_PRIORITY_INFLUENCE
|
|
2158
|
-
};
|
|
2159
|
-
} catch {
|
|
2160
|
-
return {
|
|
2161
|
-
tagPriorities: {},
|
|
2162
|
-
defaultPriority: DEFAULT_PRIORITY,
|
|
2163
|
-
combineMode: DEFAULT_COMBINE_MODE,
|
|
2164
|
-
priorityInfluence: DEFAULT_PRIORITY_INFLUENCE
|
|
2165
|
-
};
|
|
2166
|
-
}
|
|
2167
|
-
}
|
|
2168
|
-
/**
|
|
2169
|
-
* Look up the priority for a tag.
|
|
2170
|
-
*/
|
|
2171
|
-
getTagPriority(tagId) {
|
|
2172
|
-
return this.config.tagPriorities[tagId] ?? this.config.defaultPriority ?? DEFAULT_PRIORITY;
|
|
2173
|
-
}
|
|
2174
|
-
/**
|
|
2175
|
-
* Compute combined priority for a card based on its tags.
|
|
2176
|
-
*/
|
|
2177
|
-
computeCardPriority(cardTags) {
|
|
2178
|
-
if (cardTags.length === 0) {
|
|
2179
|
-
return this.config.defaultPriority ?? DEFAULT_PRIORITY;
|
|
2180
|
-
}
|
|
2181
|
-
const priorities = cardTags.map((tag) => this.getTagPriority(tag));
|
|
2182
|
-
switch (this.config.combineMode) {
|
|
2183
|
-
case "max":
|
|
2184
|
-
return Math.max(...priorities);
|
|
2185
|
-
case "min":
|
|
2186
|
-
return Math.min(...priorities);
|
|
2187
|
-
case "average":
|
|
2188
|
-
return priorities.reduce((sum, p) => sum + p, 0) / priorities.length;
|
|
2189
|
-
default:
|
|
2190
|
-
return Math.max(...priorities);
|
|
2191
|
-
}
|
|
2192
|
-
}
|
|
2193
|
-
/**
|
|
2194
|
-
* Compute boost factor based on priority.
|
|
2195
|
-
*
|
|
2196
|
-
* The formula: 1 + (priority - 0.5) * priorityInfluence
|
|
2197
|
-
*
|
|
2198
|
-
* This creates a multiplier centered around 1.0:
|
|
2199
|
-
* - Priority 1.0 with influence 0.5 → 1.25 (25% boost)
|
|
2200
|
-
* - Priority 0.5 with any influence → 1.00 (neutral)
|
|
2201
|
-
* - Priority 0.0 with influence 0.5 → 0.75 (25% reduction)
|
|
2202
|
-
*/
|
|
2203
|
-
computeBoostFactor(priority) {
|
|
2204
|
-
const influence = this.config.priorityInfluence ?? DEFAULT_PRIORITY_INFLUENCE;
|
|
2205
|
-
return 1 + (priority - 0.5) * influence;
|
|
2206
|
-
}
|
|
2207
|
-
/**
|
|
2208
|
-
* Build human-readable reason for priority adjustment.
|
|
2209
|
-
*/
|
|
2210
|
-
buildPriorityReason(cardTags, priority, boostFactor, finalScore) {
|
|
2211
|
-
if (cardTags.length === 0) {
|
|
2212
|
-
return `No tags, neutral priority (${priority.toFixed(2)})`;
|
|
2213
|
-
}
|
|
2214
|
-
const tagList = cardTags.slice(0, 3).join(", ");
|
|
2215
|
-
const more = cardTags.length > 3 ? ` (+${cardTags.length - 3} more)` : "";
|
|
2216
|
-
if (boostFactor === 1) {
|
|
2217
|
-
return `Neutral priority (${priority.toFixed(2)}) for tags: ${tagList}${more}`;
|
|
2218
|
-
} else if (boostFactor > 1) {
|
|
2219
|
-
return `High-priority tags: ${tagList}${more} (priority ${priority.toFixed(2)} \u2192 boost ${boostFactor.toFixed(2)}x \u2192 ${finalScore.toFixed(2)})`;
|
|
2220
|
-
} else {
|
|
2221
|
-
return `Low-priority tags: ${tagList}${more} (priority ${priority.toFixed(2)} \u2192 reduce ${boostFactor.toFixed(2)}x \u2192 ${finalScore.toFixed(2)})`;
|
|
2222
|
-
}
|
|
2223
|
-
}
|
|
2224
|
-
/**
|
|
2225
|
-
* Get tags for a single card.
|
|
2226
|
-
*/
|
|
2227
|
-
async getCardTags(cardId, course) {
|
|
2228
|
-
try {
|
|
2229
|
-
const tagResponse = await course.getAppliedTags(cardId);
|
|
2230
|
-
return tagResponse.rows.map((r) => r.doc?.name).filter((x) => !!x);
|
|
2231
|
-
} catch {
|
|
2232
|
-
return [];
|
|
2233
|
-
}
|
|
2234
|
-
}
|
|
2235
|
-
/**
|
|
2236
|
-
* CardFilter.transform implementation.
|
|
2237
|
-
*
|
|
2238
|
-
* Apply priority-adjusted scoring. Cards with high-priority tags get boosted,
|
|
2239
|
-
* cards with low-priority tags get reduced scores.
|
|
2240
|
-
*/
|
|
2241
|
-
async transform(cards, context) {
|
|
2242
|
-
const adjusted = await Promise.all(
|
|
2243
|
-
cards.map(async (card) => {
|
|
2244
|
-
const cardTags = await this.getCardTags(card.cardId, context.course);
|
|
2245
|
-
const priority = this.computeCardPriority(cardTags);
|
|
2246
|
-
const boostFactor = this.computeBoostFactor(priority);
|
|
2247
|
-
const finalScore = Math.max(0, Math.min(1, card.score * boostFactor));
|
|
2248
|
-
const action = boostFactor > 1 ? "boosted" : boostFactor < 1 ? "penalized" : "passed";
|
|
2249
|
-
const reason = this.buildPriorityReason(cardTags, priority, boostFactor, finalScore);
|
|
2250
|
-
return {
|
|
2251
|
-
...card,
|
|
2252
|
-
score: finalScore,
|
|
2253
|
-
provenance: [
|
|
2254
|
-
...card.provenance,
|
|
2255
|
-
{
|
|
2256
|
-
strategy: "relativePriority",
|
|
2257
|
-
strategyName: this.strategyName || this.name,
|
|
2258
|
-
strategyId: this.strategyId || "NAVIGATION_STRATEGY-priority",
|
|
2259
|
-
action,
|
|
2260
|
-
score: finalScore,
|
|
2261
|
-
reason
|
|
2262
|
-
}
|
|
2263
|
-
]
|
|
2264
|
-
};
|
|
2265
|
-
})
|
|
2266
|
-
);
|
|
2267
|
-
return adjusted;
|
|
2268
|
-
}
|
|
2269
|
-
/**
|
|
2270
|
-
* Legacy getWeightedCards - now throws as filters should not be used as generators.
|
|
2271
|
-
*
|
|
2272
|
-
* Use transform() via Pipeline instead.
|
|
2273
|
-
*/
|
|
2274
|
-
async getWeightedCards(_limit) {
|
|
2275
|
-
throw new Error(
|
|
2276
|
-
"RelativePriorityNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
|
|
2277
|
-
);
|
|
2278
|
-
}
|
|
2279
|
-
// Legacy methods - stub implementations since filters don't generate cards
|
|
2280
|
-
async getNewCards(_n) {
|
|
2281
|
-
return [];
|
|
2282
|
-
}
|
|
2283
|
-
async getPendingReviews() {
|
|
2284
|
-
return [];
|
|
2285
|
-
}
|
|
2286
|
-
};
|
|
2287
|
-
}
|
|
2288
|
-
});
|
|
2289
|
-
|
|
2290
|
-
// src/core/navigators/srs.ts
|
|
2291
|
-
var srs_exports = {};
|
|
2292
|
-
__export(srs_exports, {
|
|
2293
|
-
default: () => SRSNavigator
|
|
2294
|
-
});
|
|
2295
|
-
var import_moment3, SRSNavigator;
|
|
2296
|
-
var init_srs = __esm({
|
|
2297
|
-
"src/core/navigators/srs.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"() {
|
|
2298
1538
|
"use strict";
|
|
2299
1539
|
import_moment3 = __toESM(require("moment"), 1);
|
|
2300
1540
|
init_navigators();
|
|
1541
|
+
init_logger();
|
|
2301
1542
|
SRSNavigator = class extends ContentNavigator {
|
|
2302
1543
|
/** Human-readable name for CardGenerator interface */
|
|
2303
1544
|
name;
|
|
@@ -2333,6 +1574,7 @@ var init_srs = __esm({
|
|
|
2333
1574
|
cardId: review.cardId,
|
|
2334
1575
|
courseId: review.courseId,
|
|
2335
1576
|
score,
|
|
1577
|
+
reviewID: review._id,
|
|
2336
1578
|
provenance: [
|
|
2337
1579
|
{
|
|
2338
1580
|
strategy: "srs",
|
|
@@ -2345,6 +1587,7 @@ var init_srs = __esm({
|
|
|
2345
1587
|
]
|
|
2346
1588
|
};
|
|
2347
1589
|
});
|
|
1590
|
+
logger.debug(`[srsNav] got ${scored.length} weighted cards`);
|
|
2348
1591
|
return scored.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
2349
1592
|
}
|
|
2350
1593
|
/**
|
|
@@ -2376,235 +1619,102 @@ var init_srs = __esm({
|
|
|
2376
1619
|
const reason = `${Math.round(hoursOverdue)}h overdue (interval: ${Math.round(intervalHours)}h, relative: ${relativeOverdue.toFixed(2)}), recency: ${recencyFactor.toFixed(2)}, review`;
|
|
2377
1620
|
return { score, reason };
|
|
2378
1621
|
}
|
|
2379
|
-
/**
|
|
2380
|
-
* Get pending reviews in legacy format.
|
|
2381
|
-
*
|
|
2382
|
-
* Returns all pending reviews for the course, enriched with session item fields.
|
|
2383
|
-
*/
|
|
2384
|
-
async getPendingReviews() {
|
|
2385
|
-
if (!this.user || !this.course) {
|
|
2386
|
-
throw new Error("SRSNavigator requires user and course to be set");
|
|
2387
|
-
}
|
|
2388
|
-
const reviews = await this.user.getPendingReviews(this.course.getCourseID());
|
|
2389
|
-
return reviews.map((r) => ({
|
|
2390
|
-
...r,
|
|
2391
|
-
contentSourceType: "course",
|
|
2392
|
-
contentSourceID: this.course.getCourseID(),
|
|
2393
|
-
cardID: r.cardId,
|
|
2394
|
-
courseID: r.courseId,
|
|
2395
|
-
qualifiedID: `${r.courseId}-${r.cardId}`,
|
|
2396
|
-
reviewID: r._id,
|
|
2397
|
-
status: "review"
|
|
2398
|
-
}));
|
|
2399
|
-
}
|
|
2400
|
-
/**
|
|
2401
|
-
* SRS does not generate new cards.
|
|
2402
|
-
* Use ELONavigator or another generator for new cards.
|
|
2403
|
-
*/
|
|
2404
|
-
async getNewCards(_n) {
|
|
2405
|
-
return [];
|
|
2406
|
-
}
|
|
2407
1622
|
};
|
|
2408
1623
|
}
|
|
2409
1624
|
});
|
|
2410
1625
|
|
|
2411
|
-
//
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
"./CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
|
|
2417
|
-
"./Pipeline.ts": () => Promise.resolve().then(() => (init_Pipeline(), Pipeline_exports)),
|
|
2418
|
-
"./PipelineAssembler.ts": () => Promise.resolve().then(() => (init_PipelineAssembler(), PipelineAssembler_exports)),
|
|
2419
|
-
"./elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
|
|
2420
|
-
"./filters/eloDistance.ts": () => Promise.resolve().then(() => (init_eloDistance(), eloDistance_exports)),
|
|
2421
|
-
"./filters/index.ts": () => Promise.resolve().then(() => (init_filters(), filters_exports)),
|
|
2422
|
-
"./filters/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports)),
|
|
2423
|
-
"./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
|
|
2424
|
-
"./generators/types.ts": () => Promise.resolve().then(() => (init_types2(), types_exports2)),
|
|
2425
|
-
"./hardcodedOrder.ts": () => Promise.resolve().then(() => (init_hardcodedOrder(), hardcodedOrder_exports)),
|
|
2426
|
-
"./hierarchyDefinition.ts": () => Promise.resolve().then(() => (init_hierarchyDefinition(), hierarchyDefinition_exports)),
|
|
2427
|
-
"./index.ts": () => Promise.resolve().then(() => (init_navigators(), navigators_exports)),
|
|
2428
|
-
"./interferenceMitigator.ts": () => Promise.resolve().then(() => (init_interferenceMitigator(), interferenceMitigator_exports)),
|
|
2429
|
-
"./relativePriority.ts": () => Promise.resolve().then(() => (init_relativePriority(), relativePriority_exports)),
|
|
2430
|
-
"./srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports))
|
|
2431
|
-
});
|
|
2432
|
-
}
|
|
2433
|
-
});
|
|
2434
|
-
|
|
2435
|
-
// src/core/navigators/index.ts
|
|
2436
|
-
var navigators_exports = {};
|
|
2437
|
-
__export(navigators_exports, {
|
|
2438
|
-
ContentNavigator: () => ContentNavigator,
|
|
2439
|
-
NavigatorRole: () => NavigatorRole,
|
|
2440
|
-
NavigatorRoles: () => NavigatorRoles,
|
|
2441
|
-
Navigators: () => Navigators,
|
|
2442
|
-
getCardOrigin: () => getCardOrigin,
|
|
2443
|
-
isFilter: () => isFilter,
|
|
2444
|
-
isGenerator: () => isGenerator
|
|
2445
|
-
});
|
|
2446
|
-
function getCardOrigin(card) {
|
|
2447
|
-
if (card.provenance.length === 0) {
|
|
2448
|
-
throw new Error("Card has no provenance - cannot determine origin");
|
|
2449
|
-
}
|
|
2450
|
-
const firstEntry = card.provenance[0];
|
|
2451
|
-
const reason = firstEntry.reason.toLowerCase();
|
|
2452
|
-
if (reason.includes("failed")) {
|
|
2453
|
-
return "failed";
|
|
2454
|
-
}
|
|
2455
|
-
if (reason.includes("review")) {
|
|
2456
|
-
return "review";
|
|
2457
|
-
}
|
|
2458
|
-
return "new";
|
|
2459
|
-
}
|
|
2460
|
-
function isGenerator(impl) {
|
|
2461
|
-
return NavigatorRoles[impl] === "generator" /* GENERATOR */;
|
|
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;
|
|
2462
1631
|
}
|
|
2463
|
-
function
|
|
2464
|
-
|
|
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
|
+
};
|
|
2465
1666
|
}
|
|
2466
|
-
var
|
|
2467
|
-
var
|
|
2468
|
-
"src/core/navigators/
|
|
2469
|
-
"use strict";
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
/**
|
|
2518
|
-
* Factory method to create navigator instances dynamically.
|
|
2519
|
-
*
|
|
2520
|
-
* @param user - User interface
|
|
2521
|
-
* @param course - Course interface
|
|
2522
|
-
* @param strategyData - Strategy configuration document
|
|
2523
|
-
* @returns the runtime object used to steer a study session.
|
|
2524
|
-
*/
|
|
2525
|
-
static async create(user, course, strategyData) {
|
|
2526
|
-
const implementingClass = strategyData.implementingClass;
|
|
2527
|
-
let NavigatorImpl;
|
|
2528
|
-
const variations = [".ts", ".js", ""];
|
|
2529
|
-
for (const ext of variations) {
|
|
2530
|
-
try {
|
|
2531
|
-
const module2 = await globImport(`./${implementingClass}${ext}`);
|
|
2532
|
-
NavigatorImpl = module2.default;
|
|
2533
|
-
break;
|
|
2534
|
-
} catch (e) {
|
|
2535
|
-
logger.debug(`Failed to load with extension ${ext}:`, e);
|
|
2536
|
-
}
|
|
2537
|
-
}
|
|
2538
|
-
if (!NavigatorImpl) {
|
|
2539
|
-
throw new Error(`Could not load navigator implementation for: ${implementingClass}`);
|
|
2540
|
-
}
|
|
2541
|
-
return new NavigatorImpl(user, course, strategyData);
|
|
2542
|
-
}
|
|
2543
|
-
/**
|
|
2544
|
-
* Get cards with suitability scores and provenance trails.
|
|
2545
|
-
*
|
|
2546
|
-
* **This is the PRIMARY API for navigation strategies.**
|
|
2547
|
-
*
|
|
2548
|
-
* Returns cards ranked by suitability score (0-1). Higher scores indicate
|
|
2549
|
-
* better candidates for presentation. Each card includes a provenance trail
|
|
2550
|
-
* documenting how strategies contributed to the final score.
|
|
2551
|
-
*
|
|
2552
|
-
* ## For Generators
|
|
2553
|
-
* Override this method to generate candidates and compute scores based on
|
|
2554
|
-
* your strategy's logic (e.g., ELO proximity, review urgency). Create the
|
|
2555
|
-
* initial provenance entry with action='generated'.
|
|
2556
|
-
*
|
|
2557
|
-
* ## Default Implementation
|
|
2558
|
-
* The base class provides a backward-compatible default that:
|
|
2559
|
-
* 1. Calls legacy getNewCards() and getPendingReviews()
|
|
2560
|
-
* 2. Assigns score=1.0 to all cards
|
|
2561
|
-
* 3. Creates minimal provenance from legacy methods
|
|
2562
|
-
* 4. Returns combined results up to limit
|
|
2563
|
-
*
|
|
2564
|
-
* This allows existing strategies to work without modification while
|
|
2565
|
-
* new strategies can override with proper scoring and provenance.
|
|
2566
|
-
*
|
|
2567
|
-
* @param limit - Maximum cards to return
|
|
2568
|
-
* @returns Cards sorted by score descending, with provenance trails
|
|
2569
|
-
*/
|
|
2570
|
-
async getWeightedCards(limit) {
|
|
2571
|
-
const newCards = await this.getNewCards(limit);
|
|
2572
|
-
const reviews = await this.getPendingReviews();
|
|
2573
|
-
const weighted = [
|
|
2574
|
-
...newCards.map((c) => ({
|
|
2575
|
-
cardId: c.cardID,
|
|
2576
|
-
courseId: c.courseID,
|
|
2577
|
-
score: 1,
|
|
2578
|
-
provenance: [
|
|
2579
|
-
{
|
|
2580
|
-
strategy: "legacy",
|
|
2581
|
-
strategyName: this.strategyName || "Legacy API",
|
|
2582
|
-
strategyId: this.strategyId || "legacy-fallback",
|
|
2583
|
-
action: "generated",
|
|
2584
|
-
score: 1,
|
|
2585
|
-
reason: "Generated via legacy getNewCards(), new card"
|
|
2586
|
-
}
|
|
2587
|
-
]
|
|
2588
|
-
})),
|
|
2589
|
-
...reviews.map((r) => ({
|
|
2590
|
-
cardId: r.cardID,
|
|
2591
|
-
courseId: r.courseID,
|
|
2592
|
-
score: 1,
|
|
2593
|
-
provenance: [
|
|
2594
|
-
{
|
|
2595
|
-
strategy: "legacy",
|
|
2596
|
-
strategyName: this.strategyName || "Legacy API",
|
|
2597
|
-
strategyId: this.strategyId || "legacy-fallback",
|
|
2598
|
-
action: "generated",
|
|
2599
|
-
score: 1,
|
|
2600
|
-
reason: "Generated via legacy getPendingReviews(), review"
|
|
2601
|
-
}
|
|
2602
|
-
]
|
|
2603
|
-
}))
|
|
2604
|
-
];
|
|
2605
|
-
return weighted.slice(0, limit);
|
|
2606
|
-
}
|
|
2607
|
-
};
|
|
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();
|
|
2608
1718
|
}
|
|
2609
1719
|
});
|
|
2610
1720
|
|
|
@@ -2684,11 +1794,11 @@ ${JSON.stringify(config)}
|
|
|
2684
1794
|
function isSuccessRow(row) {
|
|
2685
1795
|
return "doc" in row && row.doc !== null && row.doc !== void 0;
|
|
2686
1796
|
}
|
|
2687
|
-
var
|
|
1797
|
+
var import_common7, CoursesDB, CourseDB;
|
|
2688
1798
|
var init_courseDB = __esm({
|
|
2689
1799
|
"src/impl/couch/courseDB.ts"() {
|
|
2690
1800
|
"use strict";
|
|
2691
|
-
|
|
1801
|
+
import_common7 = require("@vue-skuilder/common");
|
|
2692
1802
|
init_couch();
|
|
2693
1803
|
init_updateQueue();
|
|
2694
1804
|
init_types_legacy();
|
|
@@ -2697,12 +1807,8 @@ var init_courseDB = __esm({
|
|
|
2697
1807
|
init_courseAPI();
|
|
2698
1808
|
init_courseLookupDB();
|
|
2699
1809
|
init_navigators();
|
|
2700
|
-
init_Pipeline();
|
|
2701
1810
|
init_PipelineAssembler();
|
|
2702
|
-
|
|
2703
|
-
init_elo();
|
|
2704
|
-
init_srs();
|
|
2705
|
-
init_eloDistance();
|
|
1811
|
+
init_defaults();
|
|
2706
1812
|
CoursesDB = class {
|
|
2707
1813
|
_courseIDs;
|
|
2708
1814
|
constructor(courseIDs) {
|
|
@@ -2814,14 +1920,14 @@ var init_courseDB = __esm({
|
|
|
2814
1920
|
docs.rows.forEach((r) => {
|
|
2815
1921
|
if (isSuccessRow(r)) {
|
|
2816
1922
|
if (r.doc && r.doc.elo) {
|
|
2817
|
-
ret.push((0,
|
|
1923
|
+
ret.push((0, import_common7.toCourseElo)(r.doc.elo));
|
|
2818
1924
|
} else {
|
|
2819
1925
|
logger.warn("no elo data for card: " + r.id);
|
|
2820
|
-
ret.push((0,
|
|
1926
|
+
ret.push((0, import_common7.blankCourseElo)());
|
|
2821
1927
|
}
|
|
2822
1928
|
} else {
|
|
2823
1929
|
logger.warn("no elo data for card: " + JSON.stringify(r));
|
|
2824
|
-
ret.push((0,
|
|
1930
|
+
ret.push((0, import_common7.blankCourseElo)());
|
|
2825
1931
|
}
|
|
2826
1932
|
});
|
|
2827
1933
|
return ret;
|
|
@@ -2883,15 +1989,6 @@ var init_courseDB = __esm({
|
|
|
2883
1989
|
ret[r.id] = r.doc.id_displayable_data;
|
|
2884
1990
|
}
|
|
2885
1991
|
});
|
|
2886
|
-
await Promise.all(
|
|
2887
|
-
cards.rows.map((r) => {
|
|
2888
|
-
return async () => {
|
|
2889
|
-
if (isSuccessRow(r)) {
|
|
2890
|
-
ret[r.id] = r.doc.id_displayable_data;
|
|
2891
|
-
}
|
|
2892
|
-
};
|
|
2893
|
-
})
|
|
2894
|
-
);
|
|
2895
1992
|
return ret;
|
|
2896
1993
|
}
|
|
2897
1994
|
async getCardsByELO(elo, cardLimit) {
|
|
@@ -2976,6 +2073,28 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
2976
2073
|
throw new Error(`Failed to find tags for card ${this.id}-${cardId}`);
|
|
2977
2074
|
}
|
|
2978
2075
|
}
|
|
2076
|
+
async getAppliedTagsBatch(cardIds) {
|
|
2077
|
+
if (cardIds.length === 0) {
|
|
2078
|
+
return /* @__PURE__ */ new Map();
|
|
2079
|
+
}
|
|
2080
|
+
const db = getCourseDB2(this.id);
|
|
2081
|
+
const result = await db.query("getTags", {
|
|
2082
|
+
keys: cardIds,
|
|
2083
|
+
include_docs: false
|
|
2084
|
+
});
|
|
2085
|
+
const tagsByCard = /* @__PURE__ */ new Map();
|
|
2086
|
+
for (const cardId of cardIds) {
|
|
2087
|
+
tagsByCard.set(cardId, []);
|
|
2088
|
+
}
|
|
2089
|
+
for (const row of result.rows) {
|
|
2090
|
+
const cardId = row.key;
|
|
2091
|
+
const tagName = row.value?.name;
|
|
2092
|
+
if (tagName && tagsByCard.has(cardId)) {
|
|
2093
|
+
tagsByCard.get(cardId).push(tagName);
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
return tagsByCard;
|
|
2097
|
+
}
|
|
2979
2098
|
async addTagToCard(cardId, tagId, updateELO) {
|
|
2980
2099
|
return await addTagToCard(
|
|
2981
2100
|
this.id,
|
|
@@ -3003,7 +2122,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3003
2122
|
async getCourseTagStubs() {
|
|
3004
2123
|
return getCourseTagStubs(this.id);
|
|
3005
2124
|
}
|
|
3006
|
-
async addNote(codeCourse, shape, data, author, tags, uploads, elo = (0,
|
|
2125
|
+
async addNote(codeCourse, shape, data, author, tags, uploads, elo = (0, import_common7.blankCourseElo)()) {
|
|
3007
2126
|
try {
|
|
3008
2127
|
const resp = await addNote55(this.id, codeCourse, shape, data, author, tags, uploads, elo);
|
|
3009
2128
|
if (resp.ok) {
|
|
@@ -3012,19 +2131,19 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3012
2131
|
`[courseDB.addNote] Note added but card creation failed: ${resp.cardCreationError}`
|
|
3013
2132
|
);
|
|
3014
2133
|
return {
|
|
3015
|
-
status:
|
|
2134
|
+
status: import_common7.Status.error,
|
|
3016
2135
|
message: `Note was added but no cards were created: ${resp.cardCreationError}`,
|
|
3017
2136
|
id: resp.id
|
|
3018
2137
|
};
|
|
3019
2138
|
}
|
|
3020
2139
|
return {
|
|
3021
|
-
status:
|
|
2140
|
+
status: import_common7.Status.ok,
|
|
3022
2141
|
message: "",
|
|
3023
2142
|
id: resp.id
|
|
3024
2143
|
};
|
|
3025
2144
|
} else {
|
|
3026
2145
|
return {
|
|
3027
|
-
status:
|
|
2146
|
+
status: import_common7.Status.error,
|
|
3028
2147
|
message: "Unexpected error adding note"
|
|
3029
2148
|
};
|
|
3030
2149
|
}
|
|
@@ -3036,7 +2155,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3036
2155
|
message: ${err.message}`
|
|
3037
2156
|
);
|
|
3038
2157
|
return {
|
|
3039
|
-
status:
|
|
2158
|
+
status: import_common7.Status.error,
|
|
3040
2159
|
message: `Error adding note to course. ${e.reason || err.message}`
|
|
3041
2160
|
};
|
|
3042
2161
|
}
|
|
@@ -3103,7 +2222,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3103
2222
|
logger.debug(
|
|
3104
2223
|
"[courseDB] No strategy documents found, using default Pipeline(Composite(ELO, SRS), [eloDistanceFilter])"
|
|
3105
2224
|
);
|
|
3106
|
-
return
|
|
2225
|
+
return createDefaultPipeline(user, this);
|
|
3107
2226
|
}
|
|
3108
2227
|
const assembler = new PipelineAssembler();
|
|
3109
2228
|
const { pipeline, generatorStrategies, filterStrategies, warnings } = await assembler.assemble({
|
|
@@ -3116,7 +2235,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3116
2235
|
}
|
|
3117
2236
|
if (!pipeline) {
|
|
3118
2237
|
logger.debug("[courseDB] Pipeline assembly failed, using default pipeline");
|
|
3119
|
-
return
|
|
2238
|
+
return createDefaultPipeline(user, this);
|
|
3120
2239
|
}
|
|
3121
2240
|
logger.debug(
|
|
3122
2241
|
`[courseDB] Using assembled pipeline with ${generatorStrategies.length} generator(s) and ${filterStrategies.length} filter(s)`
|
|
@@ -3127,69 +2246,12 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3127
2246
|
throw e;
|
|
3128
2247
|
}
|
|
3129
2248
|
}
|
|
3130
|
-
makeDefaultEloStrategy() {
|
|
3131
|
-
return {
|
|
3132
|
-
_id: "NAVIGATION_STRATEGY-ELO-default",
|
|
3133
|
-
docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
|
|
3134
|
-
name: "ELO (default)",
|
|
3135
|
-
description: "Default ELO-based navigation strategy for new cards",
|
|
3136
|
-
implementingClass: "elo" /* ELO */,
|
|
3137
|
-
course: this.id,
|
|
3138
|
-
serializedData: ""
|
|
3139
|
-
};
|
|
3140
|
-
}
|
|
3141
|
-
makeDefaultSrsStrategy() {
|
|
3142
|
-
return {
|
|
3143
|
-
_id: "NAVIGATION_STRATEGY-SRS-default",
|
|
3144
|
-
docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
|
|
3145
|
-
name: "SRS (default)",
|
|
3146
|
-
description: "Default SRS-based navigation strategy for reviews",
|
|
3147
|
-
implementingClass: "srs" /* SRS */,
|
|
3148
|
-
course: this.id,
|
|
3149
|
-
serializedData: ""
|
|
3150
|
-
};
|
|
3151
|
-
}
|
|
3152
|
-
/**
|
|
3153
|
-
* Creates the default navigation pipeline for courses with no configured strategies.
|
|
3154
|
-
*
|
|
3155
|
-
* Default: Pipeline(Composite(ELO, SRS), [eloDistanceFilter])
|
|
3156
|
-
* - ELO generator: scores new cards by skill proximity
|
|
3157
|
-
* - SRS generator: scores reviews by overdueness and interval recency
|
|
3158
|
-
* - ELO distance filter: penalizes cards far from user's current level
|
|
3159
|
-
*/
|
|
3160
|
-
createDefaultPipeline(user) {
|
|
3161
|
-
const eloNavigator = new ELONavigator(user, this, this.makeDefaultEloStrategy());
|
|
3162
|
-
const srsNavigator = new SRSNavigator(user, this, this.makeDefaultSrsStrategy());
|
|
3163
|
-
const compositeGenerator = new CompositeGenerator([eloNavigator, srsNavigator]);
|
|
3164
|
-
const eloDistanceFilter = createEloDistanceFilter();
|
|
3165
|
-
return new Pipeline(compositeGenerator, [eloDistanceFilter], user, this);
|
|
3166
|
-
}
|
|
3167
2249
|
////////////////////////////////////
|
|
3168
2250
|
// END NavigationStrategyManager implementation
|
|
3169
2251
|
////////////////////////////////////
|
|
3170
2252
|
////////////////////////////////////
|
|
3171
2253
|
// StudyContentSource implementation
|
|
3172
2254
|
////////////////////////////////////
|
|
3173
|
-
async getNewCards(limit = 99) {
|
|
3174
|
-
const u = await this._getCurrentUser();
|
|
3175
|
-
try {
|
|
3176
|
-
const navigator = await this.createNavigator(u);
|
|
3177
|
-
return navigator.getNewCards(limit);
|
|
3178
|
-
} catch (e) {
|
|
3179
|
-
logger.error(`[courseDB] Error in getNewCards: ${e}`);
|
|
3180
|
-
throw e;
|
|
3181
|
-
}
|
|
3182
|
-
}
|
|
3183
|
-
async getPendingReviews() {
|
|
3184
|
-
const u = await this._getCurrentUser();
|
|
3185
|
-
try {
|
|
3186
|
-
const navigator = await this.createNavigator(u);
|
|
3187
|
-
return navigator.getPendingReviews();
|
|
3188
|
-
} catch (e) {
|
|
3189
|
-
logger.error(`[courseDB] Error in getPendingReviews: ${e}`);
|
|
3190
|
-
throw e;
|
|
3191
|
-
}
|
|
3192
|
-
}
|
|
3193
2255
|
/**
|
|
3194
2256
|
* Get cards with suitability scores for presentation.
|
|
3195
2257
|
*
|
|
@@ -3221,7 +2283,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3221
2283
|
const courseDoc = (await u.getCourseRegistrationsDoc()).courses.find((c) => {
|
|
3222
2284
|
return c.courseID === this.id;
|
|
3223
2285
|
});
|
|
3224
|
-
targetElo = (0,
|
|
2286
|
+
targetElo = (0, import_common7.EloToNumber)(courseDoc.elo);
|
|
3225
2287
|
} catch {
|
|
3226
2288
|
targetElo = 1e3;
|
|
3227
2289
|
}
|
|
@@ -3429,79 +2491,27 @@ var init_classroomDB2 = __esm({
|
|
|
3429
2491
|
setChangeFcn(f) {
|
|
3430
2492
|
void this.userMessages.on("change", f);
|
|
3431
2493
|
}
|
|
3432
|
-
async getPendingReviews() {
|
|
3433
|
-
const u = this._user;
|
|
3434
|
-
return (await u.getPendingReviews()).filter((r) => r.scheduledFor === "classroom" && r.schedulingAgentId === this._id).map((r) => {
|
|
3435
|
-
return {
|
|
3436
|
-
...r,
|
|
3437
|
-
qualifiedID: `${r.courseId}-${r.cardId}`,
|
|
3438
|
-
courseID: r.courseId,
|
|
3439
|
-
cardID: r.cardId,
|
|
3440
|
-
contentSourceType: "classroom",
|
|
3441
|
-
contentSourceID: this._id,
|
|
3442
|
-
reviewID: r._id,
|
|
3443
|
-
status: "review"
|
|
3444
|
-
};
|
|
3445
|
-
});
|
|
3446
|
-
}
|
|
3447
|
-
async getNewCards() {
|
|
3448
|
-
const activeCards = await this._user.getActiveCards();
|
|
3449
|
-
const now = import_moment4.default.utc();
|
|
3450
|
-
const assigned = await this.getAssignedContent();
|
|
3451
|
-
const due = assigned.filter((c) => now.isAfter(import_moment4.default.utc(c.activeOn, REVIEW_TIME_FORMAT2)));
|
|
3452
|
-
logger.info(`Due content: ${JSON.stringify(due)}`);
|
|
3453
|
-
let ret = [];
|
|
3454
|
-
for (let i = 0; i < due.length; i++) {
|
|
3455
|
-
const content = due[i];
|
|
3456
|
-
if (content.type === "course") {
|
|
3457
|
-
const db = new CourseDB(content.courseID, async () => this._user);
|
|
3458
|
-
ret = ret.concat(await db.getNewCards());
|
|
3459
|
-
} else if (content.type === "tag") {
|
|
3460
|
-
const tagDoc = await getTag(content.courseID, content.tagID);
|
|
3461
|
-
ret = ret.concat(
|
|
3462
|
-
tagDoc.taggedCards.map((c) => {
|
|
3463
|
-
return {
|
|
3464
|
-
courseID: content.courseID,
|
|
3465
|
-
cardID: c,
|
|
3466
|
-
qualifiedID: `${content.courseID}-${c}`,
|
|
3467
|
-
contentSourceType: "classroom",
|
|
3468
|
-
contentSourceID: this._id,
|
|
3469
|
-
status: "new"
|
|
3470
|
-
};
|
|
3471
|
-
})
|
|
3472
|
-
);
|
|
3473
|
-
} else if (content.type === "card") {
|
|
3474
|
-
ret.push(await getCourseDB2(content.courseID).get(content.cardID));
|
|
3475
|
-
}
|
|
3476
|
-
}
|
|
3477
|
-
logger.info(
|
|
3478
|
-
`New Cards from classroom ${this._cfg.name}: ${ret.map((c) => `${c.courseID}-${c.cardID}`)}`
|
|
3479
|
-
);
|
|
3480
|
-
return ret.filter((c) => {
|
|
3481
|
-
if (activeCards.some((ac) => c.cardID === ac.cardID)) {
|
|
3482
|
-
return false;
|
|
3483
|
-
} else {
|
|
3484
|
-
return true;
|
|
3485
|
-
}
|
|
3486
|
-
});
|
|
3487
|
-
}
|
|
3488
2494
|
/**
|
|
3489
2495
|
* Get cards with suitability scores for presentation.
|
|
3490
2496
|
*
|
|
3491
|
-
*
|
|
3492
|
-
*
|
|
3493
|
-
* 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.
|
|
3494
2499
|
*
|
|
3495
2500
|
* @param limit - Maximum number of cards to return
|
|
3496
2501
|
* @returns Cards sorted by score descending (all scores = 1.0)
|
|
3497
2502
|
*/
|
|
3498
2503
|
async getWeightedCards(limit) {
|
|
3499
|
-
const
|
|
3500
|
-
const
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
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,
|
|
3504
2513
|
score: 1,
|
|
2514
|
+
reviewID: r._id,
|
|
3505
2515
|
provenance: [
|
|
3506
2516
|
{
|
|
3507
2517
|
strategy: "classroom",
|
|
@@ -3509,27 +2519,84 @@ var init_classroomDB2 = __esm({
|
|
|
3509
2519
|
strategyId: "CLASSROOM",
|
|
3510
2520
|
action: "generated",
|
|
3511
2521
|
score: 1,
|
|
3512
|
-
reason: "Classroom
|
|
2522
|
+
reason: "Classroom scheduled review"
|
|
3513
2523
|
}
|
|
3514
2524
|
]
|
|
3515
|
-
})
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
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
|
+
});
|
|
3528
2553
|
}
|
|
3529
|
-
|
|
3530
|
-
})
|
|
3531
|
-
|
|
3532
|
-
|
|
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);
|
|
3533
2600
|
}
|
|
3534
2601
|
};
|
|
3535
2602
|
TeacherClassroomDB = class _TeacherClassroomDB extends ClassroomDBBase {
|
|
@@ -3677,8 +2744,7 @@ var init_adminDB2 = __esm({
|
|
|
3677
2744
|
}
|
|
3678
2745
|
}
|
|
3679
2746
|
}
|
|
3680
|
-
|
|
3681
|
-
return dbs.map((db) => {
|
|
2747
|
+
return promisedCRDbs.map((db) => {
|
|
3682
2748
|
return {
|
|
3683
2749
|
...db.getConfig(),
|
|
3684
2750
|
_id: db._id
|
|
@@ -3736,14 +2802,14 @@ var CouchDBSyncStrategy_exports = {};
|
|
|
3736
2802
|
__export(CouchDBSyncStrategy_exports, {
|
|
3737
2803
|
CouchDBSyncStrategy: () => CouchDBSyncStrategy
|
|
3738
2804
|
});
|
|
3739
|
-
var
|
|
2805
|
+
var import_common8, log3, CouchDBSyncStrategy;
|
|
3740
2806
|
var init_CouchDBSyncStrategy = __esm({
|
|
3741
2807
|
"src/impl/couch/CouchDBSyncStrategy.ts"() {
|
|
3742
2808
|
"use strict";
|
|
3743
2809
|
init_factory();
|
|
3744
2810
|
init_types_legacy();
|
|
3745
2811
|
init_logger();
|
|
3746
|
-
|
|
2812
|
+
import_common8 = require("@vue-skuilder/common");
|
|
3747
2813
|
init_common();
|
|
3748
2814
|
init_pouchdb_setup();
|
|
3749
2815
|
init_couch();
|
|
@@ -3814,32 +2880,32 @@ var init_CouchDBSyncStrategy = __esm({
|
|
|
3814
2880
|
}
|
|
3815
2881
|
}
|
|
3816
2882
|
return {
|
|
3817
|
-
status:
|
|
2883
|
+
status: import_common8.Status.ok,
|
|
3818
2884
|
error: void 0
|
|
3819
2885
|
};
|
|
3820
2886
|
} else {
|
|
3821
2887
|
return {
|
|
3822
|
-
status:
|
|
2888
|
+
status: import_common8.Status.error,
|
|
3823
2889
|
error: "Failed to log in after account creation"
|
|
3824
2890
|
};
|
|
3825
2891
|
}
|
|
3826
2892
|
} else {
|
|
3827
2893
|
logger.warn(`Signup not OK: ${JSON.stringify(signupRequest)}`);
|
|
3828
2894
|
return {
|
|
3829
|
-
status:
|
|
2895
|
+
status: import_common8.Status.error,
|
|
3830
2896
|
error: "Account creation failed"
|
|
3831
2897
|
};
|
|
3832
2898
|
}
|
|
3833
2899
|
} catch (e) {
|
|
3834
2900
|
if (e.reason === "Document update conflict.") {
|
|
3835
2901
|
return {
|
|
3836
|
-
status:
|
|
2902
|
+
status: import_common8.Status.error,
|
|
3837
2903
|
error: "This username is taken!"
|
|
3838
2904
|
};
|
|
3839
2905
|
}
|
|
3840
2906
|
logger.error(`Error on signup: ${JSON.stringify(e)}`);
|
|
3841
2907
|
return {
|
|
3842
|
-
status:
|
|
2908
|
+
status: import_common8.Status.error,
|
|
3843
2909
|
error: e.message || "Unknown error during account creation"
|
|
3844
2910
|
};
|
|
3845
2911
|
}
|
|
@@ -3964,8 +3030,8 @@ var init_CouchDBSyncStrategy = __esm({
|
|
|
3964
3030
|
// src/impl/couch/index.ts
|
|
3965
3031
|
function createPouchDBConfig() {
|
|
3966
3032
|
const hasExplicitCredentials = ENV.COUCHDB_USERNAME && ENV.COUCHDB_PASSWORD;
|
|
3967
|
-
const
|
|
3968
|
-
if (hasExplicitCredentials &&
|
|
3033
|
+
const isNodeEnvironment = typeof window === "undefined";
|
|
3034
|
+
if (hasExplicitCredentials && isNodeEnvironment) {
|
|
3969
3035
|
return {
|
|
3970
3036
|
fetch(url, opts = {}) {
|
|
3971
3037
|
const basicAuth = btoa(`${ENV.COUCHDB_USERNAME}:${ENV.COUCHDB_PASSWORD}`);
|
|
@@ -4050,7 +3116,9 @@ var init_couch = __esm({
|
|
|
4050
3116
|
function accomodateGuest() {
|
|
4051
3117
|
logger.log("[funnel] accomodateGuest() called");
|
|
4052
3118
|
if (typeof localStorage === "undefined") {
|
|
4053
|
-
logger.log(
|
|
3119
|
+
logger.log(
|
|
3120
|
+
"[funnel] localStorage not available (Node.js environment), returning default guest"
|
|
3121
|
+
);
|
|
4054
3122
|
return {
|
|
4055
3123
|
username: GuestUsername + "nodejs-test",
|
|
4056
3124
|
firstVisit: true
|
|
@@ -4218,13 +3286,13 @@ async function dropUserFromClassroom(user, classID) {
|
|
|
4218
3286
|
async function getUserClassrooms(user) {
|
|
4219
3287
|
return getOrCreateClassroomRegistrationsDoc(user);
|
|
4220
3288
|
}
|
|
4221
|
-
var
|
|
3289
|
+
var import_common10, import_moment6, log4, BaseUser, userCoursesDoc, userClassroomsDoc;
|
|
4222
3290
|
var init_BaseUserDB = __esm({
|
|
4223
3291
|
"src/impl/common/BaseUserDB.ts"() {
|
|
4224
3292
|
"use strict";
|
|
4225
3293
|
init_core();
|
|
4226
3294
|
init_util();
|
|
4227
|
-
|
|
3295
|
+
import_common10 = require("@vue-skuilder/common");
|
|
4228
3296
|
import_moment6 = __toESM(require("moment"), 1);
|
|
4229
3297
|
init_types_legacy();
|
|
4230
3298
|
init_logger();
|
|
@@ -4274,7 +3342,7 @@ Currently logged-in as ${this._username}.`
|
|
|
4274
3342
|
);
|
|
4275
3343
|
}
|
|
4276
3344
|
const result = await this.syncStrategy.createAccount(username, password);
|
|
4277
|
-
if (result.status ===
|
|
3345
|
+
if (result.status === import_common10.Status.ok) {
|
|
4278
3346
|
log4(`Account created successfully, updating username to ${username}`);
|
|
4279
3347
|
this._username = username;
|
|
4280
3348
|
try {
|
|
@@ -4316,7 +3384,7 @@ Currently logged-in as ${this._username}.`
|
|
|
4316
3384
|
async resetUserData() {
|
|
4317
3385
|
if (this.syncStrategy.canAuthenticate()) {
|
|
4318
3386
|
return {
|
|
4319
|
-
status:
|
|
3387
|
+
status: import_common10.Status.error,
|
|
4320
3388
|
error: "Reset user data is only available for local-only mode. Use logout instead for remote sync."
|
|
4321
3389
|
};
|
|
4322
3390
|
}
|
|
@@ -4335,11 +3403,11 @@ Currently logged-in as ${this._username}.`
|
|
|
4335
3403
|
await localDB.bulkDocs(docsToDelete);
|
|
4336
3404
|
}
|
|
4337
3405
|
await this.init();
|
|
4338
|
-
return { status:
|
|
3406
|
+
return { status: import_common10.Status.ok };
|
|
4339
3407
|
} catch (error) {
|
|
4340
3408
|
logger.error("Failed to reset user data:", error);
|
|
4341
3409
|
return {
|
|
4342
|
-
status:
|
|
3410
|
+
status: import_common10.Status.error,
|
|
4343
3411
|
error: error instanceof Error ? error.message : "Unknown error during reset"
|
|
4344
3412
|
};
|
|
4345
3413
|
}
|
|
@@ -5030,6 +4098,55 @@ Currently logged-in as ${this._username}.`
|
|
|
5030
4098
|
async updateUserElo(courseId, elo) {
|
|
5031
4099
|
return updateUserElo(this._username, courseId, elo);
|
|
5032
4100
|
}
|
|
4101
|
+
async getStrategyState(courseId, strategyKey) {
|
|
4102
|
+
const docId = buildStrategyStateId(courseId, strategyKey);
|
|
4103
|
+
try {
|
|
4104
|
+
const doc = await this.localDB.get(docId);
|
|
4105
|
+
return doc.data;
|
|
4106
|
+
} catch (e) {
|
|
4107
|
+
const err = e;
|
|
4108
|
+
if (err.status === 404) {
|
|
4109
|
+
return null;
|
|
4110
|
+
}
|
|
4111
|
+
throw e;
|
|
4112
|
+
}
|
|
4113
|
+
}
|
|
4114
|
+
async putStrategyState(courseId, strategyKey, data) {
|
|
4115
|
+
const docId = buildStrategyStateId(courseId, strategyKey);
|
|
4116
|
+
let existingRev;
|
|
4117
|
+
try {
|
|
4118
|
+
const existing = await this.localDB.get(docId);
|
|
4119
|
+
existingRev = existing._rev;
|
|
4120
|
+
} catch (e) {
|
|
4121
|
+
const err = e;
|
|
4122
|
+
if (err.status !== 404) {
|
|
4123
|
+
throw e;
|
|
4124
|
+
}
|
|
4125
|
+
}
|
|
4126
|
+
const doc = {
|
|
4127
|
+
_id: docId,
|
|
4128
|
+
_rev: existingRev,
|
|
4129
|
+
docType: "STRATEGY_STATE" /* STRATEGY_STATE */,
|
|
4130
|
+
courseId,
|
|
4131
|
+
strategyKey,
|
|
4132
|
+
data,
|
|
4133
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
4134
|
+
};
|
|
4135
|
+
await this.localDB.put(doc);
|
|
4136
|
+
}
|
|
4137
|
+
async deleteStrategyState(courseId, strategyKey) {
|
|
4138
|
+
const docId = buildStrategyStateId(courseId, strategyKey);
|
|
4139
|
+
try {
|
|
4140
|
+
const doc = await this.localDB.get(docId);
|
|
4141
|
+
await this.localDB.remove(doc);
|
|
4142
|
+
} catch (e) {
|
|
4143
|
+
const err = e;
|
|
4144
|
+
if (err.status === 404) {
|
|
4145
|
+
return;
|
|
4146
|
+
}
|
|
4147
|
+
throw e;
|
|
4148
|
+
}
|
|
4149
|
+
}
|
|
5033
4150
|
};
|
|
5034
4151
|
userCoursesDoc = "CourseRegistrations";
|
|
5035
4152
|
userClassroomsDoc = "ClassroomRegistrations";
|
|
@@ -5077,8 +4194,8 @@ var init_PouchDataLayerProvider = __esm({
|
|
|
5077
4194
|
}
|
|
5078
4195
|
async initialize() {
|
|
5079
4196
|
if (this.initialized) return;
|
|
5080
|
-
const
|
|
5081
|
-
if (
|
|
4197
|
+
const isNodeEnvironment = typeof process !== "undefined" && process.versions != null && process.versions.node != null;
|
|
4198
|
+
if (isNodeEnvironment) {
|
|
5082
4199
|
logger.info(
|
|
5083
4200
|
"CouchDataLayerProvider: Running in Node.js environment, creating guest UserDB for testing."
|
|
5084
4201
|
);
|
|
@@ -5140,11 +4257,11 @@ var init_StaticDataUnpacker = __esm({
|
|
|
5140
4257
|
init_logger();
|
|
5141
4258
|
init_core();
|
|
5142
4259
|
pathUtils = {
|
|
5143
|
-
isAbsolute: (
|
|
5144
|
-
if (/^[a-zA-Z]:[\\/]/.test(
|
|
4260
|
+
isAbsolute: (path2) => {
|
|
4261
|
+
if (/^[a-zA-Z]:[\\/]/.test(path2) || /^\\\\/.test(path2)) {
|
|
5145
4262
|
return true;
|
|
5146
4263
|
}
|
|
5147
|
-
if (
|
|
4264
|
+
if (path2.startsWith("/")) {
|
|
5148
4265
|
return true;
|
|
5149
4266
|
}
|
|
5150
4267
|
return false;
|
|
@@ -5191,6 +4308,36 @@ var init_StaticDataUnpacker = __esm({
|
|
|
5191
4308
|
logger.error(`Document ${id} not found in chunk ${chunk.id}`);
|
|
5192
4309
|
throw new Error(`Document ${id} not found in chunk ${chunk.id}`);
|
|
5193
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
|
+
}
|
|
5194
4341
|
/**
|
|
5195
4342
|
* Query cards by ELO score, returning card IDs sorted by ELO
|
|
5196
4343
|
*/
|
|
@@ -5227,7 +4374,14 @@ var init_StaticDataUnpacker = __esm({
|
|
|
5227
4374
|
* Get all tag names mapped to their card arrays
|
|
5228
4375
|
*/
|
|
5229
4376
|
async getTagsIndex() {
|
|
5230
|
-
|
|
4377
|
+
try {
|
|
4378
|
+
return await this.loadIndex("tags");
|
|
4379
|
+
} catch {
|
|
4380
|
+
return {
|
|
4381
|
+
byCard: {},
|
|
4382
|
+
byTag: {}
|
|
4383
|
+
};
|
|
4384
|
+
}
|
|
5231
4385
|
}
|
|
5232
4386
|
getDocTypeFromId(id) {
|
|
5233
4387
|
for (const docTypeKey in DocTypePrefixes) {
|
|
@@ -5512,14 +4666,15 @@ var init_StaticDataUnpacker = __esm({
|
|
|
5512
4666
|
});
|
|
5513
4667
|
|
|
5514
4668
|
// src/impl/static/courseDB.ts
|
|
5515
|
-
var
|
|
4669
|
+
var import_common12, StaticCourseDB;
|
|
5516
4670
|
var init_courseDB2 = __esm({
|
|
5517
4671
|
"src/impl/static/courseDB.ts"() {
|
|
5518
4672
|
"use strict";
|
|
5519
|
-
|
|
4673
|
+
import_common12 = require("@vue-skuilder/common");
|
|
5520
4674
|
init_types_legacy();
|
|
5521
|
-
init_navigators();
|
|
5522
4675
|
init_logger();
|
|
4676
|
+
init_defaults();
|
|
4677
|
+
init_PipelineAssembler();
|
|
5523
4678
|
StaticCourseDB = class {
|
|
5524
4679
|
constructor(courseId, unpacker, userDB, manifest) {
|
|
5525
4680
|
this.courseId = courseId;
|
|
@@ -5598,21 +4753,6 @@ var init_courseDB2 = __esm({
|
|
|
5598
4753
|
async updateCardElo(cardId, _elo) {
|
|
5599
4754
|
return { ok: true, id: cardId, rev: "1-static" };
|
|
5600
4755
|
}
|
|
5601
|
-
async getNewCards(limit = 99) {
|
|
5602
|
-
const activeCards = await this.userDB.getActiveCards();
|
|
5603
|
-
return (await this.getCardsCenteredAtELO({ limit, elo: "user" }, (c) => {
|
|
5604
|
-
if (activeCards.some((ac) => c.cardID === ac.cardID)) {
|
|
5605
|
-
return false;
|
|
5606
|
-
} else {
|
|
5607
|
-
return true;
|
|
5608
|
-
}
|
|
5609
|
-
})).map((c) => {
|
|
5610
|
-
return {
|
|
5611
|
-
...c,
|
|
5612
|
-
status: "new"
|
|
5613
|
-
};
|
|
5614
|
-
});
|
|
5615
|
-
}
|
|
5616
4756
|
async getCardsCenteredAtELO(options, filter) {
|
|
5617
4757
|
let targetElo = typeof options.elo === "number" ? options.elo : 1e3;
|
|
5618
4758
|
if (options.elo === "user") {
|
|
@@ -5697,6 +4837,14 @@ var init_courseDB2 = __esm({
|
|
|
5697
4837
|
};
|
|
5698
4838
|
}
|
|
5699
4839
|
}
|
|
4840
|
+
async getAppliedTagsBatch(cardIds) {
|
|
4841
|
+
const tagsIndex = await this.unpacker.getTagsIndex();
|
|
4842
|
+
const tagsByCard = /* @__PURE__ */ new Map();
|
|
4843
|
+
for (const cardId of cardIds) {
|
|
4844
|
+
tagsByCard.set(cardId, tagsIndex.byCard[cardId] || []);
|
|
4845
|
+
}
|
|
4846
|
+
return tagsByCard;
|
|
4847
|
+
}
|
|
5700
4848
|
async addTagToCard(_cardId, _tagId) {
|
|
5701
4849
|
throw new Error("Cannot modify tags in static mode");
|
|
5702
4850
|
}
|
|
@@ -5779,7 +4927,7 @@ var init_courseDB2 = __esm({
|
|
|
5779
4927
|
}
|
|
5780
4928
|
async addNote(_codeCourse, _shape, _data, _author, _tags, _uploads, _elo) {
|
|
5781
4929
|
return {
|
|
5782
|
-
status:
|
|
4930
|
+
status: import_common12.Status.error,
|
|
5783
4931
|
message: "Cannot add notes in static mode"
|
|
5784
4932
|
};
|
|
5785
4933
|
}
|
|
@@ -5790,19 +4938,23 @@ var init_courseDB2 = __esm({
|
|
|
5790
4938
|
return [];
|
|
5791
4939
|
}
|
|
5792
4940
|
// Navigation Strategy Manager implementation
|
|
5793
|
-
async getNavigationStrategy(
|
|
5794
|
-
|
|
5795
|
-
|
|
5796
|
-
|
|
5797
|
-
|
|
5798
|
-
|
|
5799
|
-
|
|
5800
|
-
course: this.courseId,
|
|
5801
|
-
serializedData: ""
|
|
5802
|
-
};
|
|
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
|
+
}
|
|
5803
4948
|
}
|
|
5804
4949
|
async getAllNavigationStrategies() {
|
|
5805
|
-
|
|
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
|
+
}
|
|
5806
4958
|
}
|
|
5807
4959
|
async addNavigationStrategy(_data) {
|
|
5808
4960
|
throw new Error("Cannot add navigation strategies in static mode");
|
|
@@ -5810,9 +4962,52 @@ var init_courseDB2 = __esm({
|
|
|
5810
4962
|
async updateNavigationStrategy(_id, _data) {
|
|
5811
4963
|
throw new Error("Cannot update navigation strategies in static mode");
|
|
5812
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
|
+
}
|
|
5813
5002
|
// Study Content Source implementation
|
|
5814
|
-
async
|
|
5815
|
-
|
|
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
|
+
}
|
|
5816
5011
|
}
|
|
5817
5012
|
// Attachment helper methods (internal use, not part of interface)
|
|
5818
5013
|
/**
|
|
@@ -6131,11 +5326,11 @@ var init_factory = __esm({
|
|
|
6131
5326
|
});
|
|
6132
5327
|
|
|
6133
5328
|
// src/study/TagFilteredContentSource.ts
|
|
6134
|
-
var
|
|
5329
|
+
var import_common16, TagFilteredContentSource;
|
|
6135
5330
|
var init_TagFilteredContentSource = __esm({
|
|
6136
5331
|
"src/study/TagFilteredContentSource.ts"() {
|
|
6137
5332
|
"use strict";
|
|
6138
|
-
|
|
5333
|
+
import_common16 = require("@vue-skuilder/common");
|
|
6139
5334
|
init_courseDB();
|
|
6140
5335
|
init_logger();
|
|
6141
5336
|
TagFilteredContentSource = class {
|
|
@@ -6211,108 +5406,71 @@ var init_TagFilteredContentSource = __esm({
|
|
|
6211
5406
|
return finalCardIds;
|
|
6212
5407
|
}
|
|
6213
5408
|
/**
|
|
6214
|
-
*
|
|
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)
|
|
6215
5417
|
*/
|
|
6216
|
-
async
|
|
6217
|
-
if (!(0,
|
|
6218
|
-
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");
|
|
6219
5421
|
return [];
|
|
6220
5422
|
}
|
|
6221
5423
|
const eligibleCardIds = await this.resolveFilteredCardIds();
|
|
6222
5424
|
const activeCards = await this.user.getActiveCards();
|
|
6223
5425
|
const activeCardIds = new Set(activeCards.map((c) => c.cardID));
|
|
6224
|
-
const
|
|
5426
|
+
const newCardWeighted = [];
|
|
6225
5427
|
for (const cardId of eligibleCardIds) {
|
|
6226
5428
|
if (!activeCardIds.has(cardId)) {
|
|
6227
|
-
|
|
6228
|
-
|
|
6229
|
-
|
|
6230
|
-
|
|
6231
|
-
|
|
6232
|
-
|
|
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
|
+
]
|
|
6233
5443
|
});
|
|
6234
5444
|
}
|
|
6235
|
-
if (
|
|
5445
|
+
if (newCardWeighted.length >= limit) {
|
|
6236
5446
|
break;
|
|
6237
5447
|
}
|
|
6238
5448
|
}
|
|
6239
|
-
logger.info(
|
|
6240
|
-
|
|
6241
|
-
|
|
6242
|
-
/**
|
|
6243
|
-
* Gets pending reviews, filtered to only include cards that match the tag filter.
|
|
6244
|
-
*/
|
|
6245
|
-
async getPendingReviews() {
|
|
6246
|
-
if (!(0, import_common18.hasActiveFilter)(this.filter)) {
|
|
6247
|
-
logger.warn("[TagFilteredContentSource] getPendingReviews called with no active filter");
|
|
6248
|
-
return [];
|
|
6249
|
-
}
|
|
6250
|
-
const eligibleCardIds = await this.resolveFilteredCardIds();
|
|
5449
|
+
logger.info(
|
|
5450
|
+
`[TagFilteredContentSource] Found ${newCardWeighted.length} new cards matching filter`
|
|
5451
|
+
);
|
|
6251
5452
|
const allReviews = await this.user.getPendingReviews(this.courseId);
|
|
6252
|
-
const filteredReviews = allReviews.filter((review) =>
|
|
6253
|
-
return eligibleCardIds.has(review.cardId);
|
|
6254
|
-
});
|
|
5453
|
+
const filteredReviews = allReviews.filter((review) => eligibleCardIds.has(review.cardId));
|
|
6255
5454
|
logger.info(
|
|
6256
5455
|
`[TagFilteredContentSource] Found ${filteredReviews.length} pending reviews matching filter (of ${allReviews.length} total)`
|
|
6257
5456
|
);
|
|
6258
|
-
|
|
6259
|
-
|
|
6260
|
-
|
|
6261
|
-
|
|
6262
|
-
contentSourceType: "course",
|
|
6263
|
-
contentSourceID: this.courseId,
|
|
5457
|
+
const reviewWeighted = filteredReviews.map((r) => ({
|
|
5458
|
+
cardId: r.cardId,
|
|
5459
|
+
courseId: r.courseId,
|
|
5460
|
+
score: 1,
|
|
6264
5461
|
reviewID: r._id,
|
|
6265
|
-
|
|
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
|
+
]
|
|
6266
5472
|
}));
|
|
6267
|
-
|
|
6268
|
-
/**
|
|
6269
|
-
* Get cards with suitability scores for presentation.
|
|
6270
|
-
*
|
|
6271
|
-
* This implementation wraps the legacy getNewCards/getPendingReviews methods,
|
|
6272
|
-
* assigning score=1.0 to all cards. TagFilteredContentSource does not currently
|
|
6273
|
-
* support pluggable navigation strategies - it returns flat-scored candidates.
|
|
6274
|
-
*
|
|
6275
|
-
* @param limit - Maximum number of cards to return
|
|
6276
|
-
* @returns Cards sorted by score descending (all scores = 1.0)
|
|
6277
|
-
*/
|
|
6278
|
-
async getWeightedCards(limit) {
|
|
6279
|
-
const [newCards, reviews] = await Promise.all([
|
|
6280
|
-
this.getNewCards(limit),
|
|
6281
|
-
this.getPendingReviews()
|
|
6282
|
-
]);
|
|
6283
|
-
const weighted = [
|
|
6284
|
-
...reviews.map((r) => ({
|
|
6285
|
-
cardId: r.cardID,
|
|
6286
|
-
courseId: r.courseID,
|
|
6287
|
-
score: 1,
|
|
6288
|
-
provenance: [
|
|
6289
|
-
{
|
|
6290
|
-
strategy: "tagFilter",
|
|
6291
|
-
strategyName: "Tag Filter",
|
|
6292
|
-
strategyId: "TAG_FILTER",
|
|
6293
|
-
action: "generated",
|
|
6294
|
-
score: 1,
|
|
6295
|
-
reason: `Tag-filtered review (tags: ${this.filter.include.join(", ")})`
|
|
6296
|
-
}
|
|
6297
|
-
]
|
|
6298
|
-
})),
|
|
6299
|
-
...newCards.map((c) => ({
|
|
6300
|
-
cardId: c.cardID,
|
|
6301
|
-
courseId: c.courseID,
|
|
6302
|
-
score: 1,
|
|
6303
|
-
provenance: [
|
|
6304
|
-
{
|
|
6305
|
-
strategy: "tagFilter",
|
|
6306
|
-
strategyName: "Tag Filter",
|
|
6307
|
-
strategyId: "TAG_FILTER",
|
|
6308
|
-
action: "generated",
|
|
6309
|
-
score: 1,
|
|
6310
|
-
reason: `Tag-filtered new card (tags: ${this.filter.include.join(", ")})`
|
|
6311
|
-
}
|
|
6312
|
-
]
|
|
6313
|
-
}))
|
|
6314
|
-
];
|
|
6315
|
-
return weighted.slice(0, limit);
|
|
5473
|
+
return [...reviewWeighted, ...newCardWeighted].slice(0, limit);
|
|
6316
5474
|
}
|
|
6317
5475
|
/**
|
|
6318
5476
|
* Clears the cached resolved card IDs.
|
|
@@ -6346,19 +5504,19 @@ async function getStudySource(source, user) {
|
|
|
6346
5504
|
if (source.type === "classroom") {
|
|
6347
5505
|
return await StudentClassroomDB.factory(source.id, user);
|
|
6348
5506
|
} else {
|
|
6349
|
-
if ((0,
|
|
5507
|
+
if ((0, import_common17.hasActiveFilter)(source.tagFilter)) {
|
|
6350
5508
|
return new TagFilteredContentSource(source.id, source.tagFilter, user);
|
|
6351
5509
|
}
|
|
6352
5510
|
return getDataLayer().getCourseDB(source.id);
|
|
6353
5511
|
}
|
|
6354
5512
|
}
|
|
6355
|
-
var
|
|
5513
|
+
var import_common17;
|
|
6356
5514
|
var init_contentSource = __esm({
|
|
6357
5515
|
"src/core/interfaces/contentSource.ts"() {
|
|
6358
5516
|
"use strict";
|
|
6359
5517
|
init_factory();
|
|
6360
5518
|
init_classroomDB2();
|
|
6361
|
-
|
|
5519
|
+
import_common17 = require("@vue-skuilder/common");
|
|
6362
5520
|
init_TagFilteredContentSource();
|
|
6363
5521
|
}
|
|
6364
5522
|
});
|
|
@@ -6404,6 +5562,16 @@ var init_user = __esm({
|
|
|
6404
5562
|
}
|
|
6405
5563
|
});
|
|
6406
5564
|
|
|
5565
|
+
// src/core/types/strategyState.ts
|
|
5566
|
+
function buildStrategyStateId(courseId, strategyKey) {
|
|
5567
|
+
return `STRATEGY_STATE::${courseId}::${strategyKey}`;
|
|
5568
|
+
}
|
|
5569
|
+
var init_strategyState = __esm({
|
|
5570
|
+
"src/core/types/strategyState.ts"() {
|
|
5571
|
+
"use strict";
|
|
5572
|
+
}
|
|
5573
|
+
});
|
|
5574
|
+
|
|
6407
5575
|
// src/core/bulkImport/cardProcessor.ts
|
|
6408
5576
|
async function importParsedCards(parsedCards, courseDB, config) {
|
|
6409
5577
|
const results = [];
|
|
@@ -6472,7 +5640,7 @@ elo: ${elo}`;
|
|
|
6472
5640
|
misc: {}
|
|
6473
5641
|
} : void 0
|
|
6474
5642
|
);
|
|
6475
|
-
if (result.status ===
|
|
5643
|
+
if (result.status === import_common18.Status.ok) {
|
|
6476
5644
|
return {
|
|
6477
5645
|
originalText,
|
|
6478
5646
|
status: "success",
|
|
@@ -6516,17 +5684,17 @@ function validateProcessorConfig(config) {
|
|
|
6516
5684
|
}
|
|
6517
5685
|
return { isValid: true };
|
|
6518
5686
|
}
|
|
6519
|
-
var
|
|
5687
|
+
var import_common18;
|
|
6520
5688
|
var init_cardProcessor = __esm({
|
|
6521
5689
|
"src/core/bulkImport/cardProcessor.ts"() {
|
|
6522
5690
|
"use strict";
|
|
6523
|
-
|
|
5691
|
+
import_common18 = require("@vue-skuilder/common");
|
|
6524
5692
|
init_logger();
|
|
6525
5693
|
}
|
|
6526
5694
|
});
|
|
6527
5695
|
|
|
6528
5696
|
// src/core/bulkImport/types.ts
|
|
6529
|
-
var
|
|
5697
|
+
var init_types = __esm({
|
|
6530
5698
|
"src/core/bulkImport/types.ts"() {
|
|
6531
5699
|
"use strict";
|
|
6532
5700
|
}
|
|
@@ -6537,7 +5705,7 @@ var init_bulkImport = __esm({
|
|
|
6537
5705
|
"src/core/bulkImport/index.ts"() {
|
|
6538
5706
|
"use strict";
|
|
6539
5707
|
init_cardProcessor();
|
|
6540
|
-
|
|
5708
|
+
init_types();
|
|
6541
5709
|
}
|
|
6542
5710
|
});
|
|
6543
5711
|
|
|
@@ -6548,6 +5716,7 @@ var init_core = __esm({
|
|
|
6548
5716
|
init_interfaces();
|
|
6549
5717
|
init_types_legacy();
|
|
6550
5718
|
init_user();
|
|
5719
|
+
init_strategyState();
|
|
6551
5720
|
init_Loggable();
|
|
6552
5721
|
init_util();
|
|
6553
5722
|
init_navigators();
|
|
@@ -6571,11 +5740,13 @@ __export(index_exports, {
|
|
|
6571
5740
|
NavigatorRole: () => NavigatorRole,
|
|
6572
5741
|
NavigatorRoles: () => NavigatorRoles,
|
|
6573
5742
|
Navigators: () => Navigators,
|
|
5743
|
+
QuotaRoundRobinMixer: () => QuotaRoundRobinMixer,
|
|
6574
5744
|
SessionController: () => SessionController,
|
|
6575
5745
|
StaticToCouchDBMigrator: () => StaticToCouchDBMigrator,
|
|
6576
5746
|
TagFilteredContentSource: () => TagFilteredContentSource,
|
|
6577
5747
|
_resetDataLayer: () => _resetDataLayer,
|
|
6578
5748
|
areQuestionRecords: () => areQuestionRecords,
|
|
5749
|
+
buildStrategyStateId: () => buildStrategyStateId,
|
|
6579
5750
|
docIsDeleted: () => docIsDeleted,
|
|
6580
5751
|
ensureAppDataDirectory: () => ensureAppDataDirectory,
|
|
6581
5752
|
getAppDataDirectory: () => getAppDataDirectory,
|
|
@@ -6583,22 +5754,17 @@ __export(index_exports, {
|
|
|
6583
5754
|
getCardOrigin: () => getCardOrigin,
|
|
6584
5755
|
getDataLayer: () => getDataLayer,
|
|
6585
5756
|
getDbPath: () => getDbPath,
|
|
6586
|
-
getLogFilePath: () => getLogFilePath,
|
|
6587
5757
|
getStudySource: () => getStudySource,
|
|
6588
5758
|
importParsedCards: () => importParsedCards,
|
|
6589
5759
|
initializeDataDirectory: () => initializeDataDirectory,
|
|
6590
5760
|
initializeDataLayer: () => initializeDataLayer,
|
|
6591
|
-
initializeTuiLogging: () => initializeTuiLogging,
|
|
6592
5761
|
isFilter: () => isFilter,
|
|
6593
5762
|
isGenerator: () => isGenerator,
|
|
6594
5763
|
isQuestionRecord: () => isQuestionRecord,
|
|
6595
5764
|
isReview: () => isReview,
|
|
6596
5765
|
log: () => log,
|
|
6597
|
-
logger: () => logger2,
|
|
6598
5766
|
newInterval: () => newInterval,
|
|
6599
5767
|
parseCardHistoryID: () => parseCardHistoryID,
|
|
6600
|
-
showUserError: () => showUserError,
|
|
6601
|
-
showUserMessage: () => showUserMessage,
|
|
6602
5768
|
validateMigration: () => validateMigration,
|
|
6603
5769
|
validateProcessorConfig: () => validateProcessorConfig,
|
|
6604
5770
|
validateStaticCourse: () => validateStaticCourse
|
|
@@ -6717,7 +5883,7 @@ var SrsService = class {
|
|
|
6717
5883
|
};
|
|
6718
5884
|
|
|
6719
5885
|
// src/study/services/EloService.ts
|
|
6720
|
-
var
|
|
5886
|
+
var import_common19 = require("@vue-skuilder/common");
|
|
6721
5887
|
init_logger();
|
|
6722
5888
|
var EloService = class {
|
|
6723
5889
|
dataLayer;
|
|
@@ -6740,10 +5906,10 @@ var EloService = class {
|
|
|
6740
5906
|
logger.warn(`k value interpretation not currently implemented`);
|
|
6741
5907
|
}
|
|
6742
5908
|
const courseDB = this.dataLayer.getCourseDB(currentCard.card.course_id);
|
|
6743
|
-
const userElo = (0,
|
|
5909
|
+
const userElo = (0, import_common19.toCourseElo)(userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo);
|
|
6744
5910
|
const cardElo = (await courseDB.getCardEloData([currentCard.card.card_id]))[0];
|
|
6745
5911
|
if (cardElo && userElo) {
|
|
6746
|
-
const eloUpdate = (0,
|
|
5912
|
+
const eloUpdate = (0, import_common19.adjustCourseScores)(userElo, cardElo, userScore);
|
|
6747
5913
|
userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo = eloUpdate.userElo;
|
|
6748
5914
|
const results = await Promise.allSettled([
|
|
6749
5915
|
this.user.updateUserElo(course_id, eloUpdate.userElo),
|
|
@@ -6945,156 +6111,124 @@ var ResponseProcessor = class {
|
|
|
6945
6111
|
};
|
|
6946
6112
|
|
|
6947
6113
|
// src/study/services/CardHydrationService.ts
|
|
6948
|
-
var
|
|
6114
|
+
var import_common20 = require("@vue-skuilder/common");
|
|
6949
6115
|
init_logger();
|
|
6950
|
-
|
|
6951
|
-
|
|
6952
|
-
|
|
6953
|
-
|
|
6954
|
-
|
|
6955
|
-
|
|
6956
|
-
|
|
6957
|
-
|
|
6958
|
-
|
|
6959
|
-
|
|
6960
|
-
|
|
6961
|
-
|
|
6962
|
-
}
|
|
6963
|
-
|
|
6964
|
-
|
|
6965
|
-
|
|
6966
|
-
|
|
6967
|
-
|
|
6968
|
-
|
|
6969
|
-
|
|
6970
|
-
|
|
6971
|
-
|
|
6972
|
-
|
|
6973
|
-
|
|
6974
|
-
|
|
6975
|
-
dequeue(cardIdExtractor) {
|
|
6976
|
-
if (this.q.length !== 0) {
|
|
6977
|
-
this._dequeueCount++;
|
|
6978
|
-
const item = this.q.splice(0, 1)[0];
|
|
6979
|
-
if (cardIdExtractor) {
|
|
6980
|
-
const cardId = cardIdExtractor(item);
|
|
6981
|
-
const index = this.seenCardIds.indexOf(cardId);
|
|
6982
|
-
if (index > -1) {
|
|
6983
|
-
this.seenCardIds.splice(index, 1);
|
|
6984
|
-
}
|
|
6985
|
-
}
|
|
6986
|
-
return item;
|
|
6987
|
-
} else {
|
|
6988
|
-
return null;
|
|
6989
|
-
}
|
|
6990
|
-
}
|
|
6991
|
-
get toString() {
|
|
6992
|
-
return `${typeof this.q[0]}:
|
|
6993
|
-
` + this.q.map((i) => ` ${i.courseID}+${i.cardID}: ${i.status}`).join("\n");
|
|
6994
|
-
}
|
|
6995
|
-
};
|
|
6996
|
-
|
|
6997
|
-
// 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
|
+
}
|
|
6998
6141
|
var CardHydrationService = class {
|
|
6999
|
-
constructor(getViewComponent, getCourseDB3,
|
|
6142
|
+
constructor(getViewComponent, getCourseDB3, getItemsToHydrate) {
|
|
7000
6143
|
this.getViewComponent = getViewComponent;
|
|
7001
6144
|
this.getCourseDB = getCourseDB3;
|
|
7002
|
-
this.
|
|
7003
|
-
this.removeItemFromQueue = removeItemFromQueue;
|
|
7004
|
-
this.hasAvailableCards = hasAvailableCards;
|
|
6145
|
+
this.getItemsToHydrate = getItemsToHydrate;
|
|
7005
6146
|
}
|
|
7006
|
-
|
|
7007
|
-
|
|
6147
|
+
hydratedCards = /* @__PURE__ */ new Map();
|
|
6148
|
+
hydrationInFlight = /* @__PURE__ */ new Set();
|
|
7008
6149
|
hydrationInProgress = false;
|
|
7009
|
-
BUFFER_SIZE = 5;
|
|
7010
6150
|
/**
|
|
7011
|
-
* Get
|
|
7012
|
-
* @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.
|
|
7013
6159
|
*/
|
|
7014
|
-
|
|
7015
|
-
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);
|
|
7016
6168
|
}
|
|
7017
6169
|
/**
|
|
7018
6170
|
* Check if hydration should be triggered and start background hydration if needed.
|
|
7019
6171
|
*/
|
|
7020
6172
|
async ensureHydratedCards() {
|
|
7021
|
-
|
|
7022
|
-
void this.fillHydratedQueue();
|
|
7023
|
-
}
|
|
6173
|
+
void this.fillHydratedCards();
|
|
7024
6174
|
}
|
|
7025
6175
|
/**
|
|
7026
|
-
* Wait for a
|
|
6176
|
+
* Wait for a specific card to become hydrated.
|
|
7027
6177
|
* @returns Promise that resolves to a hydrated card or null
|
|
7028
6178
|
*/
|
|
7029
|
-
async
|
|
7030
|
-
if (this.
|
|
7031
|
-
|
|
6179
|
+
async waitForCard(cardId) {
|
|
6180
|
+
if (this.hydratedCards.has(cardId)) {
|
|
6181
|
+
return this.hydratedCards.get(cardId);
|
|
7032
6182
|
}
|
|
7033
|
-
|
|
7034
|
-
|
|
6183
|
+
if (!this.hydrationInProgress) {
|
|
6184
|
+
void this.fillHydratedCards();
|
|
7035
6185
|
}
|
|
7036
|
-
|
|
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;
|
|
7037
6200
|
}
|
|
7038
6201
|
/**
|
|
7039
|
-
* Get current hydrated
|
|
6202
|
+
* Get current hydrated cache size.
|
|
7040
6203
|
*/
|
|
7041
6204
|
get hydratedCount() {
|
|
7042
|
-
return this.
|
|
6205
|
+
return this.hydratedCards.size;
|
|
7043
6206
|
}
|
|
7044
6207
|
/**
|
|
7045
|
-
* Get
|
|
6208
|
+
* Get list of currently hydrated card IDs (for debugging).
|
|
7046
6209
|
*/
|
|
7047
|
-
|
|
7048
|
-
return this.
|
|
6210
|
+
getHydratedCardIds() {
|
|
6211
|
+
return Array.from(this.hydratedCards.keys());
|
|
7049
6212
|
}
|
|
7050
6213
|
/**
|
|
7051
|
-
* Fill the hydrated
|
|
6214
|
+
* Fill the hydrated cache by hydrating items from getItemsToHydrate().
|
|
6215
|
+
* This is a pure cache-warming operation - no queue mutation.
|
|
7052
6216
|
*/
|
|
7053
|
-
async
|
|
6217
|
+
async fillHydratedCards() {
|
|
7054
6218
|
if (this.hydrationInProgress) {
|
|
7055
6219
|
return;
|
|
7056
6220
|
}
|
|
7057
6221
|
this.hydrationInProgress = true;
|
|
7058
6222
|
try {
|
|
7059
|
-
|
|
7060
|
-
|
|
7061
|
-
if (
|
|
7062
|
-
|
|
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;
|
|
7063
6227
|
}
|
|
7064
6228
|
try {
|
|
7065
|
-
|
|
7066
|
-
const cachedCard = this.failedCardCache.get(nextItem.cardID);
|
|
7067
|
-
this.hydratedQ.add(cachedCard, cachedCard.item.cardID);
|
|
7068
|
-
this.failedCardCache.delete(nextItem.cardID);
|
|
7069
|
-
} else {
|
|
7070
|
-
const courseDB = this.getCourseDB(nextItem.courseID);
|
|
7071
|
-
const cardData = await courseDB.getCourseDoc(nextItem.cardID);
|
|
7072
|
-
if (!(0, import_common22.isCourseElo)(cardData.elo)) {
|
|
7073
|
-
cardData.elo = (0, import_common22.toCourseElo)(cardData.elo);
|
|
7074
|
-
}
|
|
7075
|
-
const view = this.getViewComponent(cardData.id_view);
|
|
7076
|
-
const dataDocs = await Promise.all(
|
|
7077
|
-
cardData.id_displayable_data.map(
|
|
7078
|
-
(id) => courseDB.getCourseDoc(id, {
|
|
7079
|
-
attachments: true,
|
|
7080
|
-
binary: true
|
|
7081
|
-
})
|
|
7082
|
-
)
|
|
7083
|
-
);
|
|
7084
|
-
const data = dataDocs.map(import_common22.displayableDataToViewData).reverse();
|
|
7085
|
-
this.hydratedQ.add(
|
|
7086
|
-
{
|
|
7087
|
-
item: nextItem,
|
|
7088
|
-
view,
|
|
7089
|
-
data
|
|
7090
|
-
},
|
|
7091
|
-
nextItem.cardID
|
|
7092
|
-
);
|
|
7093
|
-
}
|
|
6229
|
+
await this.hydrateCard(item);
|
|
7094
6230
|
} catch (e) {
|
|
7095
|
-
logger.error(`Error hydrating card ${
|
|
7096
|
-
} finally {
|
|
7097
|
-
this.removeItemFromQueue(nextItem);
|
|
6231
|
+
logger.error(`[CardHydrationService] Error hydrating card ${item.cardID}:`, e);
|
|
7098
6232
|
}
|
|
7099
6233
|
}
|
|
7100
6234
|
} finally {
|
|
@@ -7102,10 +6236,97 @@ var CardHydrationService = class {
|
|
|
7102
6236
|
}
|
|
7103
6237
|
}
|
|
7104
6238
|
/**
|
|
7105
|
-
*
|
|
6239
|
+
* Hydrate a single card and add to cache.
|
|
7106
6240
|
*/
|
|
7107
|
-
|
|
7108
|
-
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");
|
|
7109
6330
|
}
|
|
7110
6331
|
};
|
|
7111
6332
|
|
|
@@ -7647,7 +6868,7 @@ try {
|
|
|
7647
6868
|
}
|
|
7648
6869
|
} catch {
|
|
7649
6870
|
}
|
|
7650
|
-
async function validateStaticCourse(staticPath,
|
|
6871
|
+
async function validateStaticCourse(staticPath, fs2) {
|
|
7651
6872
|
const validation = {
|
|
7652
6873
|
valid: true,
|
|
7653
6874
|
manifestExists: false,
|
|
@@ -7657,8 +6878,8 @@ async function validateStaticCourse(staticPath, fs3) {
|
|
|
7657
6878
|
warnings: []
|
|
7658
6879
|
};
|
|
7659
6880
|
try {
|
|
7660
|
-
if (
|
|
7661
|
-
const stats = await
|
|
6881
|
+
if (fs2) {
|
|
6882
|
+
const stats = await fs2.stat(staticPath);
|
|
7662
6883
|
if (!stats.isDirectory()) {
|
|
7663
6884
|
validation.errors.push(`Path is not a directory: ${staticPath}`);
|
|
7664
6885
|
validation.valid = false;
|
|
@@ -7678,11 +6899,11 @@ async function validateStaticCourse(staticPath, fs3) {
|
|
|
7678
6899
|
}
|
|
7679
6900
|
let manifestPath = `${staticPath}/manifest.json`;
|
|
7680
6901
|
try {
|
|
7681
|
-
if (
|
|
7682
|
-
manifestPath =
|
|
7683
|
-
if (await
|
|
6902
|
+
if (fs2) {
|
|
6903
|
+
manifestPath = fs2.joinPath(staticPath, "manifest.json");
|
|
6904
|
+
if (await fs2.exists(manifestPath)) {
|
|
7684
6905
|
validation.manifestExists = true;
|
|
7685
|
-
const manifestContent = await
|
|
6906
|
+
const manifestContent = await fs2.readFile(manifestPath);
|
|
7686
6907
|
const manifest = JSON.parse(manifestContent);
|
|
7687
6908
|
validation.courseId = manifest.courseId;
|
|
7688
6909
|
validation.courseName = manifest.courseName;
|
|
@@ -7714,10 +6935,10 @@ async function validateStaticCourse(staticPath, fs3) {
|
|
|
7714
6935
|
}
|
|
7715
6936
|
let chunksPath = `${staticPath}/chunks`;
|
|
7716
6937
|
try {
|
|
7717
|
-
if (
|
|
7718
|
-
chunksPath =
|
|
7719
|
-
if (await
|
|
7720
|
-
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);
|
|
7721
6942
|
if (chunksStats.isDirectory()) {
|
|
7722
6943
|
validation.chunksExist = true;
|
|
7723
6944
|
} else {
|
|
@@ -7745,10 +6966,10 @@ async function validateStaticCourse(staticPath, fs3) {
|
|
|
7745
6966
|
}
|
|
7746
6967
|
let attachmentsPath;
|
|
7747
6968
|
try {
|
|
7748
|
-
if (
|
|
7749
|
-
attachmentsPath =
|
|
7750
|
-
if (await
|
|
7751
|
-
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);
|
|
7752
6973
|
if (attachmentsStats.isDirectory()) {
|
|
7753
6974
|
validation.attachmentsExist = true;
|
|
7754
6975
|
}
|
|
@@ -8526,26 +7747,43 @@ var StaticToCouchDBMigrator = class {
|
|
|
8526
7747
|
/**
|
|
8527
7748
|
* Check if a path is a local file path (vs URL)
|
|
8528
7749
|
*/
|
|
8529
|
-
isLocalPath(
|
|
8530
|
-
return !
|
|
7750
|
+
isLocalPath(path2) {
|
|
7751
|
+
return !path2.startsWith("http://") && !path2.startsWith("https://");
|
|
8531
7752
|
}
|
|
8532
7753
|
};
|
|
8533
7754
|
|
|
8534
7755
|
// src/util/index.ts
|
|
8535
7756
|
init_dataDirectory();
|
|
8536
|
-
init_tuiLogger();
|
|
8537
7757
|
|
|
8538
7758
|
// src/study/SessionController.ts
|
|
8539
7759
|
init_navigators();
|
|
8540
|
-
|
|
8541
|
-
|
|
8542
|
-
|
|
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();
|
|
8543
7780
|
var SessionController = class extends Loggable {
|
|
8544
7781
|
_className = "SessionController";
|
|
8545
7782
|
services;
|
|
8546
7783
|
srsService;
|
|
8547
7784
|
eloService;
|
|
8548
7785
|
hydrationService;
|
|
7786
|
+
mixer;
|
|
8549
7787
|
sources;
|
|
8550
7788
|
// dataLayer and getViewComponent now injected into CardHydrationService
|
|
8551
7789
|
_sessionRecord = [];
|
|
@@ -8573,18 +7811,21 @@ var SessionController = class extends Loggable {
|
|
|
8573
7811
|
// @ts-expect-error NodeJS.Timeout type not available in browser context
|
|
8574
7812
|
_intervalHandle;
|
|
8575
7813
|
/**
|
|
8576
|
-
*
|
|
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)
|
|
8577
7819
|
*/
|
|
8578
|
-
constructor(sources, time, dataLayer, getViewComponent) {
|
|
7820
|
+
constructor(sources, time, dataLayer, getViewComponent, mixer) {
|
|
8579
7821
|
super();
|
|
7822
|
+
this.mixer = mixer || new QuotaRoundRobinMixer();
|
|
8580
7823
|
this.srsService = new SrsService(dataLayer.getUserDB());
|
|
8581
7824
|
this.eloService = new EloService(dataLayer, dataLayer.getUserDB());
|
|
8582
7825
|
this.hydrationService = new CardHydrationService(
|
|
8583
7826
|
getViewComponent,
|
|
8584
7827
|
(courseId) => dataLayer.getCourseDB(courseId),
|
|
8585
|
-
() => this.
|
|
8586
|
-
(item) => this.removeItemFromQueue(item),
|
|
8587
|
-
() => this.hasAvailableCards()
|
|
7828
|
+
() => this._getItemsToHydrate()
|
|
8588
7829
|
);
|
|
8589
7830
|
this.services = {
|
|
8590
7831
|
response: new ResponseProcessor(this.srsService, this.eloService)
|
|
@@ -8638,16 +7879,12 @@ var SessionController = class extends Loggable {
|
|
|
8638
7879
|
return ret;
|
|
8639
7880
|
}
|
|
8640
7881
|
async prepareSession() {
|
|
8641
|
-
|
|
8642
|
-
|
|
8643
|
-
|
|
8644
|
-
|
|
8645
|
-
} else {
|
|
8646
|
-
await Promise.all([this.getScheduledReviews(), this.getNewCards()]);
|
|
8647
|
-
}
|
|
8648
|
-
} catch (e) {
|
|
8649
|
-
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
|
+
);
|
|
8650
7886
|
}
|
|
7887
|
+
await this.getWeightedContent();
|
|
8651
7888
|
await this.hydrationService.ensureHydratedCards();
|
|
8652
7889
|
this._intervalHandle = setInterval(() => {
|
|
8653
7890
|
this.tick();
|
|
@@ -8685,14 +7922,10 @@ var SessionController = class extends Loggable {
|
|
|
8685
7922
|
}
|
|
8686
7923
|
return items;
|
|
8687
7924
|
};
|
|
8688
|
-
const extractHydratedItems = () => {
|
|
8689
|
-
const items = [];
|
|
8690
|
-
return items;
|
|
8691
|
-
};
|
|
8692
7925
|
return {
|
|
8693
7926
|
api: {
|
|
8694
7927
|
mode: supportsWeightedCards ? "weighted" : "legacy",
|
|
8695
|
-
description: supportsWeightedCards ? "Using getWeightedCards() API with scored candidates" : "
|
|
7928
|
+
description: supportsWeightedCards ? "Using getWeightedCards() API with scored candidates" : "ERROR: getWeightedCards() not a function."
|
|
8696
7929
|
},
|
|
8697
7930
|
reviewQueue: {
|
|
8698
7931
|
length: this.reviewQ.length,
|
|
@@ -8711,162 +7944,97 @@ var SessionController = class extends Loggable {
|
|
|
8711
7944
|
},
|
|
8712
7945
|
hydratedCache: {
|
|
8713
7946
|
count: this.hydrationService.hydratedCount,
|
|
8714
|
-
|
|
8715
|
-
items: extractHydratedItems()
|
|
7947
|
+
cardIds: this.hydrationService.getHydratedCardIds()
|
|
8716
7948
|
}
|
|
8717
7949
|
};
|
|
8718
7950
|
}
|
|
8719
7951
|
/**
|
|
8720
|
-
* Fetch content using the
|
|
7952
|
+
* Fetch content using the getWeightedCards API and mix across sources.
|
|
8721
7953
|
*
|
|
8722
|
-
* This method
|
|
8723
|
-
*
|
|
8724
|
-
*
|
|
8725
|
-
*
|
|
8726
|
-
*
|
|
8727
|
-
* 1. Fetch weighted cards to get scoring/ordering information
|
|
8728
|
-
* 2. Fetch full review data via legacy getPendingReviews()
|
|
8729
|
-
* 3. Order reviews by their weighted scores
|
|
8730
|
-
* 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
|
|
8731
7959
|
*/
|
|
8732
7960
|
async getWeightedContent() {
|
|
8733
7961
|
const limit = 20;
|
|
8734
|
-
const
|
|
8735
|
-
|
|
8736
|
-
|
|
8737
|
-
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];
|
|
8738
7965
|
try {
|
|
8739
|
-
const
|
|
8740
|
-
|
|
8741
|
-
|
|
7966
|
+
const weighted = await source.getWeightedCards(limit);
|
|
7967
|
+
batches.push({
|
|
7968
|
+
sourceIndex: i,
|
|
7969
|
+
weighted
|
|
8742
7970
|
});
|
|
8743
|
-
allReviews.push(...reviews);
|
|
8744
|
-
if (typeof source.getWeightedCards === "function") {
|
|
8745
|
-
const weighted = await source.getWeightedCards(limit);
|
|
8746
|
-
allWeighted.push(...weighted);
|
|
8747
|
-
} else {
|
|
8748
|
-
const newCards = await source.getNewCards(limit);
|
|
8749
|
-
allNewCards.push(...newCards);
|
|
8750
|
-
allWeighted.push(
|
|
8751
|
-
...newCards.map((c) => ({
|
|
8752
|
-
cardId: c.cardID,
|
|
8753
|
-
courseId: c.courseID,
|
|
8754
|
-
score: 1,
|
|
8755
|
-
provenance: [
|
|
8756
|
-
{
|
|
8757
|
-
strategy: "legacy",
|
|
8758
|
-
strategyName: "Legacy Fallback",
|
|
8759
|
-
strategyId: "legacy-fallback",
|
|
8760
|
-
action: "generated",
|
|
8761
|
-
score: 1,
|
|
8762
|
-
reason: "Fallback to legacy getNewCards(), new card"
|
|
8763
|
-
}
|
|
8764
|
-
]
|
|
8765
|
-
})),
|
|
8766
|
-
...reviews.map((r) => ({
|
|
8767
|
-
cardId: r.cardID,
|
|
8768
|
-
courseId: r.courseID,
|
|
8769
|
-
score: 1,
|
|
8770
|
-
provenance: [
|
|
8771
|
-
{
|
|
8772
|
-
strategy: "legacy",
|
|
8773
|
-
strategyName: "Legacy Fallback",
|
|
8774
|
-
strategyId: "legacy-fallback",
|
|
8775
|
-
action: "generated",
|
|
8776
|
-
score: 1,
|
|
8777
|
-
reason: "Fallback to legacy getPendingReviews(), review"
|
|
8778
|
-
}
|
|
8779
|
-
]
|
|
8780
|
-
}))
|
|
8781
|
-
);
|
|
8782
|
-
}
|
|
8783
7971
|
} catch (error) {
|
|
8784
|
-
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
|
+
}
|
|
8785
7976
|
}
|
|
8786
7977
|
}
|
|
8787
|
-
|
|
8788
|
-
|
|
8789
|
-
|
|
8790
|
-
|
|
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
|
+
);
|
|
8791
7982
|
}
|
|
8792
|
-
const
|
|
8793
|
-
|
|
8794
|
-
|
|
8795
|
-
})
|
|
8796
|
-
|
|
8797
|
-
|
|
8798
|
-
|
|
8799
|
-
|
|
8800
|
-
|
|
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)})
|
|
8801
7999
|
`;
|
|
8802
8000
|
}
|
|
8803
|
-
|
|
8804
|
-
for (const card of newCardWeighted) {
|
|
8001
|
+
for (const w of newWeighted) {
|
|
8805
8002
|
const newItem = {
|
|
8806
|
-
cardID:
|
|
8807
|
-
courseID:
|
|
8003
|
+
cardID: w.cardId,
|
|
8004
|
+
courseID: w.courseId,
|
|
8808
8005
|
contentSourceType: "course",
|
|
8809
|
-
contentSourceID:
|
|
8006
|
+
contentSourceID: w.courseId,
|
|
8810
8007
|
status: "new"
|
|
8811
8008
|
};
|
|
8812
|
-
this.newQ.add(newItem,
|
|
8813
|
-
report += `New: ${
|
|
8009
|
+
this.newQ.add(newItem, newItem.cardID);
|
|
8010
|
+
report += `New: ${w.courseId}::${w.cardId} (score: ${w.score.toFixed(2)})
|
|
8814
8011
|
`;
|
|
8815
8012
|
}
|
|
8816
8013
|
this.log(report);
|
|
8817
8014
|
}
|
|
8818
8015
|
/**
|
|
8819
|
-
*
|
|
8820
|
-
*
|
|
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).
|
|
8821
8019
|
*/
|
|
8822
|
-
|
|
8823
|
-
const
|
|
8824
|
-
|
|
8825
|
-
|
|
8826
|
-
|
|
8827
|
-
return [];
|
|
8828
|
-
})
|
|
8829
|
-
)
|
|
8830
|
-
);
|
|
8831
|
-
const dueCards = [];
|
|
8832
|
-
while (reviews.length != 0 && reviews.some((r) => r.length > 0)) {
|
|
8833
|
-
const index = randomInt(0, reviews.length - 1);
|
|
8834
|
-
const source = reviews[index];
|
|
8835
|
-
if (source.length === 0) {
|
|
8836
|
-
reviews.splice(index, 1);
|
|
8837
|
-
continue;
|
|
8838
|
-
} else {
|
|
8839
|
-
dueCards.push(source.shift());
|
|
8840
|
-
}
|
|
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));
|
|
8841
8025
|
}
|
|
8842
|
-
let
|
|
8843
|
-
|
|
8844
|
-
|
|
8845
|
-
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;
|
|
8846
8033
|
}
|
|
8847
8034
|
/**
|
|
8848
|
-
*
|
|
8849
|
-
*
|
|
8035
|
+
* Selects the next item to present to the user.
|
|
8036
|
+
* Nondeterministic: uses probability to balance between queues based on session state.
|
|
8850
8037
|
*/
|
|
8851
|
-
async getNewCards(n = 10) {
|
|
8852
|
-
const perCourse = Math.ceil(n / this.sources.length);
|
|
8853
|
-
const newContent = await Promise.all(this.sources.map((c) => c.getNewCards(perCourse)));
|
|
8854
|
-
newContent.forEach((newContentFromSource) => {
|
|
8855
|
-
newContentFromSource.filter((c) => {
|
|
8856
|
-
return this._sessionRecord.find((record) => record.card.card_id === c.cardID) === void 0;
|
|
8857
|
-
});
|
|
8858
|
-
});
|
|
8859
|
-
while (n > 0 && newContent.some((nc) => nc.length > 0)) {
|
|
8860
|
-
for (let i = 0; i < newContent.length; i++) {
|
|
8861
|
-
if (newContent[i].length > 0) {
|
|
8862
|
-
const item = newContent[i].splice(0, 1)[0];
|
|
8863
|
-
this.log(`Adding new card: ${item.courseID}::${item.cardID}`);
|
|
8864
|
-
this.newQ.add(item, item.cardID);
|
|
8865
|
-
n--;
|
|
8866
|
-
}
|
|
8867
|
-
}
|
|
8868
|
-
}
|
|
8869
|
-
}
|
|
8870
8038
|
_selectNextItemToHydrate() {
|
|
8871
8039
|
const choice = Math.random();
|
|
8872
8040
|
let newBound = 0.1;
|
|
@@ -8923,16 +8091,18 @@ var SessionController = class extends Loggable {
|
|
|
8923
8091
|
this._currentCard = null;
|
|
8924
8092
|
return null;
|
|
8925
8093
|
}
|
|
8926
|
-
|
|
8927
|
-
if (!
|
|
8928
|
-
card = await this.hydrationService.waitForHydratedCard();
|
|
8929
|
-
}
|
|
8930
|
-
await this.hydrationService.ensureHydratedCards();
|
|
8931
|
-
if (card) {
|
|
8932
|
-
this._currentCard = card;
|
|
8933
|
-
} else {
|
|
8094
|
+
const nextItem = this._selectNextItemToHydrate();
|
|
8095
|
+
if (!nextItem) {
|
|
8934
8096
|
this._currentCard = null;
|
|
8097
|
+
return null;
|
|
8935
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;
|
|
8936
8106
|
return card;
|
|
8937
8107
|
}
|
|
8938
8108
|
/**
|
|
@@ -8968,8 +8138,8 @@ var SessionController = class extends Loggable {
|
|
|
8968
8138
|
dismissCurrentCard(action = "dismiss-success") {
|
|
8969
8139
|
if (this._currentCard) {
|
|
8970
8140
|
if (action === "dismiss-success") {
|
|
8141
|
+
this.hydrationService.removeCard(this._currentCard.item.cardID);
|
|
8971
8142
|
} else if (action === "marked-failed") {
|
|
8972
|
-
this.hydrationService.cacheFailedCard(this._currentCard);
|
|
8973
8143
|
let failedItem;
|
|
8974
8144
|
if (isReview(this._currentCard.item)) {
|
|
8975
8145
|
failedItem = {
|
|
@@ -8991,22 +8161,21 @@ var SessionController = class extends Loggable {
|
|
|
8991
8161
|
}
|
|
8992
8162
|
this.failedQ.add(failedItem, failedItem.cardID);
|
|
8993
8163
|
} else if (action === "dismiss-error") {
|
|
8164
|
+
this.hydrationService.removeCard(this._currentCard.item.cardID);
|
|
8994
8165
|
} else if (action === "dismiss-failed") {
|
|
8166
|
+
this.hydrationService.removeCard(this._currentCard.item.cardID);
|
|
8995
8167
|
}
|
|
8996
8168
|
}
|
|
8997
8169
|
}
|
|
8998
|
-
hasAvailableCards() {
|
|
8999
|
-
return this.reviewQ.length > 0 || this.newQ.length > 0 || this.failedQ.length > 0;
|
|
9000
|
-
}
|
|
9001
8170
|
/**
|
|
9002
|
-
*
|
|
8171
|
+
* Remove an item from its source queue after consumption by nextCard().
|
|
9003
8172
|
*/
|
|
9004
8173
|
removeItemFromQueue(item) {
|
|
9005
|
-
if (this.reviewQ.peek(0) === item) {
|
|
8174
|
+
if (this.reviewQ.peek(0)?.cardID === item.cardID) {
|
|
9006
8175
|
this.reviewQ.dequeue((queueItem) => queueItem.cardID);
|
|
9007
|
-
} else if (this.newQ.peek(0) === item) {
|
|
8176
|
+
} else if (this.newQ.peek(0)?.cardID === item.cardID) {
|
|
9008
8177
|
this.newQ.dequeue((queueItem) => queueItem.cardID);
|
|
9009
|
-
} else {
|
|
8178
|
+
} else if (this.failedQ.peek(0)?.cardID === item.cardID) {
|
|
9010
8179
|
this.failedQ.dequeue((queueItem) => queueItem.cardID);
|
|
9011
8180
|
}
|
|
9012
8181
|
}
|
|
@@ -9032,11 +8201,13 @@ init_factory();
|
|
|
9032
8201
|
NavigatorRole,
|
|
9033
8202
|
NavigatorRoles,
|
|
9034
8203
|
Navigators,
|
|
8204
|
+
QuotaRoundRobinMixer,
|
|
9035
8205
|
SessionController,
|
|
9036
8206
|
StaticToCouchDBMigrator,
|
|
9037
8207
|
TagFilteredContentSource,
|
|
9038
8208
|
_resetDataLayer,
|
|
9039
8209
|
areQuestionRecords,
|
|
8210
|
+
buildStrategyStateId,
|
|
9040
8211
|
docIsDeleted,
|
|
9041
8212
|
ensureAppDataDirectory,
|
|
9042
8213
|
getAppDataDirectory,
|
|
@@ -9044,22 +8215,17 @@ init_factory();
|
|
|
9044
8215
|
getCardOrigin,
|
|
9045
8216
|
getDataLayer,
|
|
9046
8217
|
getDbPath,
|
|
9047
|
-
getLogFilePath,
|
|
9048
8218
|
getStudySource,
|
|
9049
8219
|
importParsedCards,
|
|
9050
8220
|
initializeDataDirectory,
|
|
9051
8221
|
initializeDataLayer,
|
|
9052
|
-
initializeTuiLogging,
|
|
9053
8222
|
isFilter,
|
|
9054
8223
|
isGenerator,
|
|
9055
8224
|
isQuestionRecord,
|
|
9056
8225
|
isReview,
|
|
9057
8226
|
log,
|
|
9058
|
-
logger,
|
|
9059
8227
|
newInterval,
|
|
9060
8228
|
parseCardHistoryID,
|
|
9061
|
-
showUserError,
|
|
9062
|
-
showUserMessage,
|
|
9063
8229
|
validateMigration,
|
|
9064
8230
|
validateProcessorConfig,
|
|
9065
8231
|
validateStaticCourse
|