@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.mjs
CHANGED
|
@@ -1,10 +1,5 @@
|
|
|
1
1
|
var __defProp = Object.defineProperty;
|
|
2
2
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
3
|
-
var __glob = (map) => (path3) => {
|
|
4
|
-
var fn = map[path3];
|
|
5
|
-
if (fn) return fn();
|
|
6
|
-
throw new Error("Module not found in bundle: " + path3);
|
|
7
|
-
};
|
|
8
3
|
var __esm = (fn, res) => function __init() {
|
|
9
4
|
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
10
5
|
};
|
|
@@ -100,6 +95,7 @@ var init_types_legacy = __esm({
|
|
|
100
95
|
DocType3["SCHEDULED_CARD"] = "SCHEDULED_CARD";
|
|
101
96
|
DocType3["TAG"] = "TAG";
|
|
102
97
|
DocType3["NAVIGATION_STRATEGY"] = "NAVIGATION_STRATEGY";
|
|
98
|
+
DocType3["STRATEGY_STATE"] = "STRATEGY_STATE";
|
|
103
99
|
return DocType3;
|
|
104
100
|
})(DocType || {});
|
|
105
101
|
DocTypePrefixes = {
|
|
@@ -113,7 +109,8 @@ var init_types_legacy = __esm({
|
|
|
113
109
|
["QUESTION" /* QUESTIONTYPE */]: "QUESTION",
|
|
114
110
|
["VIEW" /* VIEW */]: "VIEW",
|
|
115
111
|
["PEDAGOGY" /* PEDAGOGY */]: "PEDAGOGY",
|
|
116
|
-
["NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */]: "NAVIGATION_STRATEGY"
|
|
112
|
+
["NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */]: "NAVIGATION_STRATEGY",
|
|
113
|
+
["STRATEGY_STATE" /* STRATEGY_STATE */]: "STRATEGY_STATE"
|
|
117
114
|
};
|
|
118
115
|
}
|
|
119
116
|
});
|
|
@@ -164,6 +161,9 @@ var init_pouchdb_setup = __esm({
|
|
|
164
161
|
"use strict";
|
|
165
162
|
PouchDB.plugin(PouchDBFind);
|
|
166
163
|
PouchDB.plugin(PouchDBAuth);
|
|
164
|
+
if (typeof PouchDB.debug !== "undefined") {
|
|
165
|
+
PouchDB.debug.disable();
|
|
166
|
+
}
|
|
167
167
|
PouchDB.defaults({
|
|
168
168
|
// ajax: {
|
|
169
169
|
// timeout: 60000,
|
|
@@ -173,112 +173,21 @@ var init_pouchdb_setup = __esm({
|
|
|
173
173
|
}
|
|
174
174
|
});
|
|
175
175
|
|
|
176
|
-
// src/util/
|
|
176
|
+
// src/util/dataDirectory.ts
|
|
177
177
|
import * as fs from "fs";
|
|
178
178
|
import * as path from "path";
|
|
179
|
-
function initializeTuiLogging() {
|
|
180
|
-
isNodeEnvironment = typeof window === "undefined" && typeof process !== "undefined";
|
|
181
|
-
if (!isNodeEnvironment) {
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
try {
|
|
185
|
-
logFile = path.join(getAppDataDirectory(), "lastrun.log");
|
|
186
|
-
if (fs.existsSync(logFile)) {
|
|
187
|
-
fs.unlinkSync(logFile);
|
|
188
|
-
}
|
|
189
|
-
const startTime = (/* @__PURE__ */ new Date()).toISOString();
|
|
190
|
-
fs.writeFileSync(logFile, `=== TUI Session Started: ${startTime} ===
|
|
191
|
-
`);
|
|
192
|
-
const originalConsole = {
|
|
193
|
-
// eslint-disable-next-line no-console
|
|
194
|
-
log: console.log,
|
|
195
|
-
// eslint-disable-next-line no-console
|
|
196
|
-
error: console.error,
|
|
197
|
-
// eslint-disable-next-line no-console
|
|
198
|
-
warn: console.warn,
|
|
199
|
-
// eslint-disable-next-line no-console
|
|
200
|
-
info: console.info
|
|
201
|
-
};
|
|
202
|
-
const writeToLog = (level, args) => {
|
|
203
|
-
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
204
|
-
const message = args.map(
|
|
205
|
-
(arg) => typeof arg === "object" ? JSON.stringify(arg, null, 2) : String(arg)
|
|
206
|
-
).join(" ");
|
|
207
|
-
const logEntry = `[${timestamp}] ${level}: ${message}
|
|
208
|
-
`;
|
|
209
|
-
try {
|
|
210
|
-
fs.appendFileSync(logFile, logEntry);
|
|
211
|
-
} catch (err) {
|
|
212
|
-
originalConsole.error("Failed to write to log file:", err);
|
|
213
|
-
originalConsole[level.toLowerCase()](...args);
|
|
214
|
-
}
|
|
215
|
-
};
|
|
216
|
-
console.log = (...args) => writeToLog("INFO", args);
|
|
217
|
-
console.info = (...args) => writeToLog("INFO", args);
|
|
218
|
-
console.warn = (...args) => writeToLog("WARN", args);
|
|
219
|
-
console.error = (...args) => writeToLog("ERROR", args);
|
|
220
|
-
console._originalMethods = originalConsole;
|
|
221
|
-
console.log("TUI logging initialized - logs redirected to", logFile);
|
|
222
|
-
} catch (err) {
|
|
223
|
-
console.error("Failed to initialize TUI logging:", err);
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
function getLogFilePath() {
|
|
227
|
-
return logFile;
|
|
228
|
-
}
|
|
229
|
-
function showUserMessage(message) {
|
|
230
|
-
if (isNodeEnvironment) {
|
|
231
|
-
process.stdout.write(message + "\n");
|
|
232
|
-
} else {
|
|
233
|
-
console.log(message);
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
function showUserError(message) {
|
|
237
|
-
if (isNodeEnvironment) {
|
|
238
|
-
process.stderr.write("Error: " + message + "\n");
|
|
239
|
-
} else {
|
|
240
|
-
console.error(message);
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
var logFile, isNodeEnvironment, logger2;
|
|
244
|
-
var init_tuiLogger = __esm({
|
|
245
|
-
"src/util/tuiLogger.ts"() {
|
|
246
|
-
"use strict";
|
|
247
|
-
init_dataDirectory();
|
|
248
|
-
logFile = null;
|
|
249
|
-
isNodeEnvironment = false;
|
|
250
|
-
logger2 = {
|
|
251
|
-
debug: (message, ...args) => {
|
|
252
|
-
console.log(`[DEBUG] ${message}`, ...args);
|
|
253
|
-
},
|
|
254
|
-
info: (message, ...args) => {
|
|
255
|
-
console.info(`[INFO] ${message}`, ...args);
|
|
256
|
-
},
|
|
257
|
-
warn: (message, ...args) => {
|
|
258
|
-
console.warn(`[WARN] ${message}`, ...args);
|
|
259
|
-
},
|
|
260
|
-
error: (message, ...args) => {
|
|
261
|
-
console.error(`[ERROR] ${message}`, ...args);
|
|
262
|
-
}
|
|
263
|
-
};
|
|
264
|
-
}
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
// src/util/dataDirectory.ts
|
|
268
|
-
import * as fs2 from "fs";
|
|
269
|
-
import * as path2 from "path";
|
|
270
179
|
import * as os from "os";
|
|
271
180
|
function getAppDataDirectory() {
|
|
272
181
|
if (ENV.LOCAL_STORAGE_PREFIX) {
|
|
273
|
-
return
|
|
182
|
+
return path.join(os.homedir(), `.tuilder`, ENV.LOCAL_STORAGE_PREFIX);
|
|
274
183
|
} else {
|
|
275
|
-
return
|
|
184
|
+
return path.join(os.homedir(), ".tuilder");
|
|
276
185
|
}
|
|
277
186
|
}
|
|
278
187
|
async function ensureAppDataDirectory() {
|
|
279
188
|
const appDataDir = getAppDataDirectory();
|
|
280
189
|
try {
|
|
281
|
-
await
|
|
190
|
+
await fs.promises.mkdir(appDataDir, { recursive: true });
|
|
282
191
|
} catch (err) {
|
|
283
192
|
if (err.code !== "EEXIST") {
|
|
284
193
|
throw new Error(`Failed to create app data directory ${appDataDir}: ${err.message}`);
|
|
@@ -287,16 +196,16 @@ async function ensureAppDataDirectory() {
|
|
|
287
196
|
return appDataDir;
|
|
288
197
|
}
|
|
289
198
|
function getDbPath(dbName) {
|
|
290
|
-
return
|
|
199
|
+
return path.join(getAppDataDirectory(), dbName);
|
|
291
200
|
}
|
|
292
201
|
async function initializeDataDirectory() {
|
|
293
202
|
await ensureAppDataDirectory();
|
|
294
|
-
|
|
203
|
+
logger.info(`PouchDB data directory initialized: ${getAppDataDirectory()}`);
|
|
295
204
|
}
|
|
296
205
|
var init_dataDirectory = __esm({
|
|
297
206
|
"src/util/dataDirectory.ts"() {
|
|
298
207
|
"use strict";
|
|
299
|
-
|
|
208
|
+
init_logger();
|
|
300
209
|
init_factory();
|
|
301
210
|
}
|
|
302
211
|
});
|
|
@@ -922,196 +831,223 @@ var init_courseLookupDB = __esm({
|
|
|
922
831
|
}
|
|
923
832
|
});
|
|
924
833
|
|
|
925
|
-
// src/core/navigators/
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
834
|
+
// src/core/navigators/index.ts
|
|
835
|
+
function getCardOrigin(card) {
|
|
836
|
+
if (card.provenance.length === 0) {
|
|
837
|
+
throw new Error("Card has no provenance - cannot determine origin");
|
|
838
|
+
}
|
|
839
|
+
const firstEntry = card.provenance[0];
|
|
840
|
+
const reason = firstEntry.reason.toLowerCase();
|
|
841
|
+
if (reason.includes("failed")) {
|
|
842
|
+
return "failed";
|
|
843
|
+
}
|
|
844
|
+
if (reason.includes("review")) {
|
|
845
|
+
return "review";
|
|
846
|
+
}
|
|
847
|
+
return "new";
|
|
848
|
+
}
|
|
849
|
+
function isGenerator(impl) {
|
|
850
|
+
return NavigatorRoles[impl] === "generator" /* GENERATOR */;
|
|
851
|
+
}
|
|
852
|
+
function isFilter(impl) {
|
|
853
|
+
return NavigatorRoles[impl] === "filter" /* FILTER */;
|
|
854
|
+
}
|
|
855
|
+
var Navigators, NavigatorRole, NavigatorRoles, ContentNavigator;
|
|
856
|
+
var init_navigators = __esm({
|
|
857
|
+
"src/core/navigators/index.ts"() {
|
|
934
858
|
"use strict";
|
|
935
|
-
init_navigators();
|
|
936
859
|
init_logger();
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
860
|
+
Navigators = /* @__PURE__ */ ((Navigators2) => {
|
|
861
|
+
Navigators2["ELO"] = "elo";
|
|
862
|
+
Navigators2["SRS"] = "srs";
|
|
863
|
+
Navigators2["HIERARCHY"] = "hierarchyDefinition";
|
|
864
|
+
Navigators2["INTERFERENCE"] = "interferenceMitigator";
|
|
865
|
+
Navigators2["RELATIVE_PRIORITY"] = "relativePriority";
|
|
866
|
+
Navigators2["USER_TAG_PREFERENCE"] = "userTagPreference";
|
|
867
|
+
return Navigators2;
|
|
868
|
+
})(Navigators || {});
|
|
869
|
+
NavigatorRole = /* @__PURE__ */ ((NavigatorRole2) => {
|
|
870
|
+
NavigatorRole2["GENERATOR"] = "generator";
|
|
871
|
+
NavigatorRole2["FILTER"] = "filter";
|
|
872
|
+
return NavigatorRole2;
|
|
873
|
+
})(NavigatorRole || {});
|
|
874
|
+
NavigatorRoles = {
|
|
875
|
+
["elo" /* ELO */]: "generator" /* GENERATOR */,
|
|
876
|
+
["srs" /* SRS */]: "generator" /* GENERATOR */,
|
|
877
|
+
["hierarchyDefinition" /* HIERARCHY */]: "filter" /* FILTER */,
|
|
878
|
+
["interferenceMitigator" /* INTERFERENCE */]: "filter" /* FILTER */,
|
|
879
|
+
["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */,
|
|
880
|
+
["userTagPreference" /* USER_TAG_PREFERENCE */]: "filter" /* FILTER */
|
|
881
|
+
};
|
|
882
|
+
ContentNavigator = class {
|
|
883
|
+
/** User interface for this navigation session */
|
|
884
|
+
user;
|
|
885
|
+
/** Course interface for this navigation session */
|
|
886
|
+
course;
|
|
887
|
+
/** Human-readable name for this strategy instance (from ContentNavigationStrategyData.name) */
|
|
888
|
+
strategyName;
|
|
889
|
+
/** Unique document ID for this strategy instance (from ContentNavigationStrategyData._id) */
|
|
890
|
+
strategyId;
|
|
961
891
|
/**
|
|
962
|
-
*
|
|
892
|
+
* Constructor for standard navigators.
|
|
893
|
+
* Call this from subclass constructors to initialize common fields.
|
|
963
894
|
*
|
|
964
|
-
*
|
|
895
|
+
* Note: CompositeGenerator and Pipeline call super() without args, then set
|
|
896
|
+
* user/course fields directly if needed.
|
|
965
897
|
*/
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
)
|
|
970
|
-
|
|
898
|
+
constructor(user, course, strategyData) {
|
|
899
|
+
this.user = user;
|
|
900
|
+
this.course = course;
|
|
901
|
+
if (strategyData) {
|
|
902
|
+
this.strategyName = strategyData.name;
|
|
903
|
+
this.strategyId = strategyData._id;
|
|
904
|
+
}
|
|
971
905
|
}
|
|
906
|
+
// ============================================================================
|
|
907
|
+
// STRATEGY STATE HELPERS
|
|
908
|
+
// ============================================================================
|
|
909
|
+
//
|
|
910
|
+
// These methods allow strategies to persist their own state (user preferences,
|
|
911
|
+
// learned patterns, temporal tracking) in the user database.
|
|
912
|
+
//
|
|
913
|
+
// ============================================================================
|
|
972
914
|
/**
|
|
973
|
-
*
|
|
974
|
-
*
|
|
975
|
-
* Cards appearing in multiple generators receive a score boost.
|
|
976
|
-
* Provenance tracks which generators produced each card and how scores were aggregated.
|
|
915
|
+
* Unique key identifying this strategy for state storage.
|
|
977
916
|
*
|
|
978
|
-
*
|
|
979
|
-
*
|
|
980
|
-
*
|
|
981
|
-
* @param limit - Maximum number of cards to return
|
|
982
|
-
* @param context - Optional GeneratorContext passed to child generators
|
|
917
|
+
* Defaults to the constructor name (e.g., "UserTagPreferenceFilter").
|
|
918
|
+
* Override in subclasses if multiple instances of the same strategy type
|
|
919
|
+
* need separate state storage.
|
|
983
920
|
*/
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
this.generators.map((g) => g.getWeightedCards(limit, context))
|
|
987
|
-
);
|
|
988
|
-
const byCardId = /* @__PURE__ */ new Map();
|
|
989
|
-
for (const cards of results) {
|
|
990
|
-
for (const card of cards) {
|
|
991
|
-
const existing = byCardId.get(card.cardId) || [];
|
|
992
|
-
existing.push(card);
|
|
993
|
-
byCardId.set(card.cardId, existing);
|
|
994
|
-
}
|
|
995
|
-
}
|
|
996
|
-
const merged = [];
|
|
997
|
-
for (const [, cards] of byCardId) {
|
|
998
|
-
const aggregatedScore = this.aggregateScores(cards);
|
|
999
|
-
const finalScore = Math.min(1, aggregatedScore);
|
|
1000
|
-
const mergedProvenance = cards.flatMap((c) => c.provenance);
|
|
1001
|
-
const initialScore = cards[0].score;
|
|
1002
|
-
const action = finalScore > initialScore ? "boosted" : finalScore < initialScore ? "penalized" : "passed";
|
|
1003
|
-
const reason = this.buildAggregationReason(cards, finalScore);
|
|
1004
|
-
merged.push({
|
|
1005
|
-
...cards[0],
|
|
1006
|
-
score: finalScore,
|
|
1007
|
-
provenance: [
|
|
1008
|
-
...mergedProvenance,
|
|
1009
|
-
{
|
|
1010
|
-
strategy: "composite",
|
|
1011
|
-
strategyName: "Composite Generator",
|
|
1012
|
-
strategyId: "COMPOSITE_GENERATOR",
|
|
1013
|
-
action,
|
|
1014
|
-
score: finalScore,
|
|
1015
|
-
reason
|
|
1016
|
-
}
|
|
1017
|
-
]
|
|
1018
|
-
});
|
|
1019
|
-
}
|
|
1020
|
-
return merged.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
921
|
+
get strategyKey() {
|
|
922
|
+
return this.constructor.name;
|
|
1021
923
|
}
|
|
1022
924
|
/**
|
|
1023
|
-
*
|
|
925
|
+
* Get this strategy's persisted state for the current course.
|
|
926
|
+
*
|
|
927
|
+
* @returns The strategy's data payload, or null if no state exists
|
|
928
|
+
* @throws Error if user or course is not initialized
|
|
1024
929
|
*/
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
}
|
|
1031
|
-
const strategies = cards.map((c) => c.provenance[0]?.strategy || "unknown").join(", ");
|
|
1032
|
-
switch (this.aggregationMode) {
|
|
1033
|
-
case "max" /* MAX */:
|
|
1034
|
-
return `Max of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
|
|
1035
|
-
case "average" /* AVERAGE */:
|
|
1036
|
-
return `Average of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
|
|
1037
|
-
case "frequencyBoost" /* FREQUENCY_BOOST */: {
|
|
1038
|
-
const avg = cards.reduce((sum, c) => sum + c.score, 0) / count;
|
|
1039
|
-
const boost = 1 + FREQUENCY_BOOST_FACTOR * (count - 1);
|
|
1040
|
-
return `Frequency boost from ${count} generators (${strategies}): avg ${avg.toFixed(2)} \xD7 ${boost.toFixed(2)} \u2192 ${finalScore.toFixed(2)}`;
|
|
1041
|
-
}
|
|
1042
|
-
default:
|
|
1043
|
-
return `Aggregated from ${count} generators: ${finalScore.toFixed(2)}`;
|
|
930
|
+
async getStrategyState() {
|
|
931
|
+
if (!this.user || !this.course) {
|
|
932
|
+
throw new Error(
|
|
933
|
+
`Cannot get strategy state: navigator not properly initialized. Ensure user and course are provided to constructor.`
|
|
934
|
+
);
|
|
1044
935
|
}
|
|
936
|
+
return this.user.getStrategyState(this.course.getCourseID(), this.strategyKey);
|
|
1045
937
|
}
|
|
1046
938
|
/**
|
|
1047
|
-
*
|
|
939
|
+
* Persist this strategy's state for the current course.
|
|
940
|
+
*
|
|
941
|
+
* @param data - The strategy's data payload to store
|
|
942
|
+
* @throws Error if user or course is not initialized
|
|
1048
943
|
*/
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
case "average" /* AVERAGE */:
|
|
1055
|
-
return scores.reduce((sum, s) => sum + s, 0) / scores.length;
|
|
1056
|
-
case "frequencyBoost" /* FREQUENCY_BOOST */: {
|
|
1057
|
-
const avg = scores.reduce((sum, s) => sum + s, 0) / scores.length;
|
|
1058
|
-
const frequencyBoost = 1 + FREQUENCY_BOOST_FACTOR * (cards.length - 1);
|
|
1059
|
-
return avg * frequencyBoost;
|
|
1060
|
-
}
|
|
1061
|
-
default:
|
|
1062
|
-
return scores[0];
|
|
944
|
+
async putStrategyState(data) {
|
|
945
|
+
if (!this.user || !this.course) {
|
|
946
|
+
throw new Error(
|
|
947
|
+
`Cannot put strategy state: navigator not properly initialized. Ensure user and course are provided to constructor.`
|
|
948
|
+
);
|
|
1063
949
|
}
|
|
950
|
+
return this.user.putStrategyState(this.course.getCourseID(), this.strategyKey, data);
|
|
1064
951
|
}
|
|
1065
952
|
/**
|
|
1066
|
-
*
|
|
953
|
+
* Factory method to create navigator instances dynamically.
|
|
954
|
+
*
|
|
955
|
+
* @param user - User interface
|
|
956
|
+
* @param course - Course interface
|
|
957
|
+
* @param strategyData - Strategy configuration document
|
|
958
|
+
* @returns the runtime object used to steer a study session.
|
|
1067
959
|
*/
|
|
1068
|
-
async
|
|
1069
|
-
const
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
const
|
|
1073
|
-
const
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
960
|
+
static async create(user, course, strategyData) {
|
|
961
|
+
const implementingClass = strategyData.implementingClass;
|
|
962
|
+
let NavigatorImpl;
|
|
963
|
+
const variations = [".ts", ".js", ""];
|
|
964
|
+
const dirs = ["filters", "generators"];
|
|
965
|
+
for (const ext of variations) {
|
|
966
|
+
for (const dir of dirs) {
|
|
967
|
+
const loadFrom = `./${dir}/${implementingClass}${ext}`;
|
|
968
|
+
try {
|
|
969
|
+
const module = await import(loadFrom);
|
|
970
|
+
NavigatorImpl = module.default;
|
|
971
|
+
break;
|
|
972
|
+
} catch (e) {
|
|
973
|
+
logger.debug(`Failed to load extension from ${loadFrom}:`, e);
|
|
1080
974
|
}
|
|
1081
975
|
}
|
|
1082
976
|
}
|
|
1083
|
-
|
|
977
|
+
if (!NavigatorImpl) {
|
|
978
|
+
throw new Error(`Could not load navigator implementation for: ${implementingClass}`);
|
|
979
|
+
}
|
|
980
|
+
return new NavigatorImpl(user, course, strategyData);
|
|
1084
981
|
}
|
|
1085
982
|
/**
|
|
1086
|
-
* Get
|
|
983
|
+
* Get cards with suitability scores and provenance trails.
|
|
984
|
+
*
|
|
985
|
+
* **This is the PRIMARY API for navigation strategies.**
|
|
986
|
+
*
|
|
987
|
+
* Returns cards ranked by suitability score (0-1). Higher scores indicate
|
|
988
|
+
* better candidates for presentation. Each card includes a provenance trail
|
|
989
|
+
* documenting how strategies contributed to the final score.
|
|
990
|
+
*
|
|
991
|
+
* ## Implementation Required
|
|
992
|
+
* All navigation strategies MUST override this method. The base class does
|
|
993
|
+
* not provide a default implementation.
|
|
994
|
+
*
|
|
995
|
+
* ## For Generators
|
|
996
|
+
* Override this method to generate candidates and compute scores based on
|
|
997
|
+
* your strategy's logic (e.g., ELO proximity, review urgency). Create the
|
|
998
|
+
* initial provenance entry with action='generated'.
|
|
999
|
+
*
|
|
1000
|
+
* ## For Filters
|
|
1001
|
+
* Filters should implement the CardFilter interface instead and be composed
|
|
1002
|
+
* via Pipeline. Filters do not directly implement getWeightedCards().
|
|
1003
|
+
*
|
|
1004
|
+
* @param limit - Maximum cards to return
|
|
1005
|
+
* @returns Cards sorted by score descending, with provenance trails
|
|
1087
1006
|
*/
|
|
1088
|
-
async
|
|
1089
|
-
|
|
1090
|
-
(g) => g instanceof ContentNavigator
|
|
1091
|
-
);
|
|
1092
|
-
const results = await Promise.all(legacyGenerators.map((g) => g.getPendingReviews()));
|
|
1093
|
-
const seen = /* @__PURE__ */ new Set();
|
|
1094
|
-
const merged = [];
|
|
1095
|
-
for (const reviews of results) {
|
|
1096
|
-
for (const review of reviews) {
|
|
1097
|
-
if (!seen.has(review.cardID)) {
|
|
1098
|
-
seen.add(review.cardID);
|
|
1099
|
-
merged.push(review);
|
|
1100
|
-
}
|
|
1101
|
-
}
|
|
1102
|
-
}
|
|
1103
|
-
return merged;
|
|
1007
|
+
async getWeightedCards(_limit) {
|
|
1008
|
+
throw new Error(`${this.constructor.name} must implement getWeightedCards(). `);
|
|
1104
1009
|
}
|
|
1105
1010
|
};
|
|
1106
1011
|
}
|
|
1107
1012
|
});
|
|
1108
1013
|
|
|
1109
1014
|
// src/core/navigators/Pipeline.ts
|
|
1110
|
-
var Pipeline_exports = {};
|
|
1111
|
-
__export(Pipeline_exports, {
|
|
1112
|
-
Pipeline: () => Pipeline
|
|
1113
|
-
});
|
|
1114
1015
|
import { toCourseElo as toCourseElo2 } from "@vue-skuilder/common";
|
|
1016
|
+
function logPipelineConfig(generator, filters) {
|
|
1017
|
+
const filterList = filters.length > 0 ? "\n - " + filters.map((f) => f.name).join("\n - ") : " none";
|
|
1018
|
+
logger.info(
|
|
1019
|
+
`[Pipeline] Configuration:
|
|
1020
|
+
Generator: ${generator.name}
|
|
1021
|
+
Filters:${filterList}`
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
1024
|
+
function logTagHydration(cards, tagsByCard) {
|
|
1025
|
+
const totalTags = Array.from(tagsByCard.values()).reduce((sum, tags) => sum + tags.length, 0);
|
|
1026
|
+
const cardsWithTags = Array.from(tagsByCard.values()).filter((tags) => tags.length > 0).length;
|
|
1027
|
+
logger.debug(
|
|
1028
|
+
`[Pipeline] Tag hydration: ${cards.length} cards, ${cardsWithTags} have tags (${totalTags} total tags) - single batch query`
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
1031
|
+
function logExecutionSummary(generatorName, generatedCount, filterCount, finalCount, topScores) {
|
|
1032
|
+
const scoreDisplay = topScores.length > 0 ? topScores.map((s) => s.toFixed(2)).join(", ") : "none";
|
|
1033
|
+
logger.info(
|
|
1034
|
+
`[Pipeline] Execution: ${generatorName} produced ${generatedCount} \u2192 ${filterCount} filters \u2192 ${finalCount} results (top scores: ${scoreDisplay})`
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
1037
|
+
function logCardProvenance(cards, maxCards = 3) {
|
|
1038
|
+
const cardsToLog = cards.slice(0, maxCards);
|
|
1039
|
+
logger.debug(`[Pipeline] Provenance for top ${cardsToLog.length} cards:`);
|
|
1040
|
+
for (const card of cardsToLog) {
|
|
1041
|
+
logger.debug(`[Pipeline] ${card.cardId} (final score: ${card.score.toFixed(3)}):`);
|
|
1042
|
+
for (const entry of card.provenance) {
|
|
1043
|
+
const scoreChange = entry.score.toFixed(3);
|
|
1044
|
+
const action = entry.action.padEnd(9);
|
|
1045
|
+
logger.debug(
|
|
1046
|
+
`[Pipeline] ${action} ${scoreChange} - ${entry.strategyName}: ${entry.reason}`
|
|
1047
|
+
);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1115
1051
|
var Pipeline;
|
|
1116
1052
|
var init_Pipeline = __esm({
|
|
1117
1053
|
"src/core/navigators/Pipeline.ts"() {
|
|
@@ -1135,19 +1071,23 @@ var init_Pipeline = __esm({
|
|
|
1135
1071
|
this.filters = filters;
|
|
1136
1072
|
this.user = user;
|
|
1137
1073
|
this.course = course;
|
|
1138
|
-
|
|
1139
|
-
`[
|
|
1140
|
-
)
|
|
1074
|
+
course.getCourseConfig().then((cfg) => {
|
|
1075
|
+
logger.debug(`[pipeline] Crated pipeline for ${cfg.name}`);
|
|
1076
|
+
}).catch((e) => {
|
|
1077
|
+
logger.error(`[pipeline] Failed to lookup courseCfg: ${e}`);
|
|
1078
|
+
});
|
|
1079
|
+
logPipelineConfig(generator, filters);
|
|
1141
1080
|
}
|
|
1142
1081
|
/**
|
|
1143
1082
|
* Get weighted cards by running generator and applying filters.
|
|
1144
1083
|
*
|
|
1145
1084
|
* 1. Build shared context (user ELO, etc.)
|
|
1146
1085
|
* 2. Get candidates from generator (passing context)
|
|
1147
|
-
* 3.
|
|
1148
|
-
* 4.
|
|
1149
|
-
* 5.
|
|
1150
|
-
* 6.
|
|
1086
|
+
* 3. Batch hydrate tags for all candidates
|
|
1087
|
+
* 4. Apply each filter sequentially
|
|
1088
|
+
* 5. Remove zero-score cards
|
|
1089
|
+
* 6. Sort by score descending
|
|
1090
|
+
* 7. Return top N
|
|
1151
1091
|
*
|
|
1152
1092
|
* @param limit - Maximum number of cards to return
|
|
1153
1093
|
* @returns Cards sorted by score descending
|
|
@@ -1160,7 +1100,9 @@ var init_Pipeline = __esm({
|
|
|
1160
1100
|
`[Pipeline] Fetching ${fetchLimit} candidates from generator '${this.generator.name}'`
|
|
1161
1101
|
);
|
|
1162
1102
|
let cards = await this.generator.getWeightedCards(fetchLimit, context);
|
|
1163
|
-
|
|
1103
|
+
const generatedCount = cards.length;
|
|
1104
|
+
logger.debug(`[Pipeline] Generator returned ${generatedCount} candidates`);
|
|
1105
|
+
cards = await this.hydrateTags(cards);
|
|
1164
1106
|
for (const filter of this.filters) {
|
|
1165
1107
|
const beforeCount = cards.length;
|
|
1166
1108
|
cards = await filter.transform(cards, context);
|
|
@@ -1169,11 +1111,39 @@ var init_Pipeline = __esm({
|
|
|
1169
1111
|
cards = cards.filter((c) => c.score > 0);
|
|
1170
1112
|
cards.sort((a, b) => b.score - a.score);
|
|
1171
1113
|
const result = cards.slice(0, limit);
|
|
1172
|
-
|
|
1173
|
-
|
|
1114
|
+
const topScores = result.slice(0, 3).map((c) => c.score);
|
|
1115
|
+
logExecutionSummary(
|
|
1116
|
+
this.generator.name,
|
|
1117
|
+
generatedCount,
|
|
1118
|
+
this.filters.length,
|
|
1119
|
+
result.length,
|
|
1120
|
+
topScores
|
|
1174
1121
|
);
|
|
1122
|
+
logCardProvenance(result, 3);
|
|
1175
1123
|
return result;
|
|
1176
1124
|
}
|
|
1125
|
+
/**
|
|
1126
|
+
* Batch hydrate tags for all cards.
|
|
1127
|
+
*
|
|
1128
|
+
* Fetches tags for all cards in a single database query and attaches them
|
|
1129
|
+
* to the WeightedCard objects. Filters can then use card.tags instead of
|
|
1130
|
+
* making individual getAppliedTags() calls.
|
|
1131
|
+
*
|
|
1132
|
+
* @param cards - Cards to hydrate
|
|
1133
|
+
* @returns Cards with tags populated
|
|
1134
|
+
*/
|
|
1135
|
+
async hydrateTags(cards) {
|
|
1136
|
+
if (cards.length === 0) {
|
|
1137
|
+
return cards;
|
|
1138
|
+
}
|
|
1139
|
+
const cardIds = cards.map((c) => c.cardId);
|
|
1140
|
+
const tagsByCard = await this.course.getAppliedTagsBatch(cardIds);
|
|
1141
|
+
logTagHydration(cards, tagsByCard);
|
|
1142
|
+
return cards.map((card) => ({
|
|
1143
|
+
...card,
|
|
1144
|
+
tags: tagsByCard.get(card.cardId) ?? []
|
|
1145
|
+
}));
|
|
1146
|
+
}
|
|
1177
1147
|
/**
|
|
1178
1148
|
* Build shared context for generator and filters.
|
|
1179
1149
|
*
|
|
@@ -1197,48 +1167,155 @@ var init_Pipeline = __esm({
|
|
|
1197
1167
|
userElo
|
|
1198
1168
|
};
|
|
1199
1169
|
}
|
|
1200
|
-
// ===========================================================================
|
|
1201
|
-
// Legacy StudyContentSource methods
|
|
1202
|
-
// ===========================================================================
|
|
1203
|
-
//
|
|
1204
|
-
// These delegate to the generator for backward compatibility.
|
|
1205
|
-
// Eventually SessionController will use getWeightedCards() exclusively.
|
|
1206
|
-
//
|
|
1207
1170
|
/**
|
|
1208
|
-
* Get
|
|
1209
|
-
* Delegates to the generator if it supports the legacy interface.
|
|
1171
|
+
* Get the course ID for this pipeline.
|
|
1210
1172
|
*/
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1173
|
+
getCourseID() {
|
|
1174
|
+
return this.course.getCourseID();
|
|
1175
|
+
}
|
|
1176
|
+
};
|
|
1177
|
+
}
|
|
1178
|
+
});
|
|
1179
|
+
|
|
1180
|
+
// src/core/navigators/generators/CompositeGenerator.ts
|
|
1181
|
+
var DEFAULT_AGGREGATION_MODE, FREQUENCY_BOOST_FACTOR, CompositeGenerator;
|
|
1182
|
+
var init_CompositeGenerator = __esm({
|
|
1183
|
+
"src/core/navigators/generators/CompositeGenerator.ts"() {
|
|
1184
|
+
"use strict";
|
|
1185
|
+
init_navigators();
|
|
1186
|
+
init_logger();
|
|
1187
|
+
DEFAULT_AGGREGATION_MODE = "frequencyBoost" /* FREQUENCY_BOOST */;
|
|
1188
|
+
FREQUENCY_BOOST_FACTOR = 0.1;
|
|
1189
|
+
CompositeGenerator = class _CompositeGenerator extends ContentNavigator {
|
|
1190
|
+
/** Human-readable name for CardGenerator interface */
|
|
1191
|
+
name = "Composite Generator";
|
|
1192
|
+
generators;
|
|
1193
|
+
aggregationMode;
|
|
1194
|
+
constructor(generators, aggregationMode = DEFAULT_AGGREGATION_MODE) {
|
|
1195
|
+
super();
|
|
1196
|
+
this.generators = generators;
|
|
1197
|
+
this.aggregationMode = aggregationMode;
|
|
1198
|
+
if (generators.length === 0) {
|
|
1199
|
+
throw new Error("CompositeGenerator requires at least one generator");
|
|
1214
1200
|
}
|
|
1215
|
-
|
|
1201
|
+
logger.debug(
|
|
1202
|
+
`[CompositeGenerator] Created with ${generators.length} generators, mode: ${aggregationMode}`
|
|
1203
|
+
);
|
|
1216
1204
|
}
|
|
1217
1205
|
/**
|
|
1218
|
-
*
|
|
1219
|
-
*
|
|
1206
|
+
* Creates a CompositeGenerator from strategy data.
|
|
1207
|
+
*
|
|
1208
|
+
* This is a convenience factory for use by PipelineAssembler.
|
|
1220
1209
|
*/
|
|
1221
|
-
async
|
|
1222
|
-
|
|
1223
|
-
|
|
1210
|
+
static async fromStrategies(user, course, strategies, aggregationMode = DEFAULT_AGGREGATION_MODE) {
|
|
1211
|
+
const generators = await Promise.all(
|
|
1212
|
+
strategies.map((s) => ContentNavigator.create(user, course, s))
|
|
1213
|
+
);
|
|
1214
|
+
return new _CompositeGenerator(generators, aggregationMode);
|
|
1215
|
+
}
|
|
1216
|
+
/**
|
|
1217
|
+
* Get weighted cards from all generators, merge and deduplicate.
|
|
1218
|
+
*
|
|
1219
|
+
* Cards appearing in multiple generators receive a score boost.
|
|
1220
|
+
* Provenance tracks which generators produced each card and how scores were aggregated.
|
|
1221
|
+
*
|
|
1222
|
+
* This method supports both the legacy signature (limit only) and the
|
|
1223
|
+
* CardGenerator interface signature (limit, context).
|
|
1224
|
+
*
|
|
1225
|
+
* @param limit - Maximum number of cards to return
|
|
1226
|
+
* @param context - GeneratorContext passed to child generators (required when called via Pipeline)
|
|
1227
|
+
*/
|
|
1228
|
+
async getWeightedCards(limit, context) {
|
|
1229
|
+
if (!context) {
|
|
1230
|
+
throw new Error(
|
|
1231
|
+
"CompositeGenerator.getWeightedCards requires a GeneratorContext. It should be called via Pipeline, not directly."
|
|
1232
|
+
);
|
|
1224
1233
|
}
|
|
1225
|
-
|
|
1234
|
+
const results = await Promise.all(
|
|
1235
|
+
this.generators.map((g) => g.getWeightedCards(limit, context))
|
|
1236
|
+
);
|
|
1237
|
+
const byCardId = /* @__PURE__ */ new Map();
|
|
1238
|
+
for (const cards of results) {
|
|
1239
|
+
for (const card of cards) {
|
|
1240
|
+
const existing = byCardId.get(card.cardId) || [];
|
|
1241
|
+
existing.push(card);
|
|
1242
|
+
byCardId.set(card.cardId, existing);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
const merged = [];
|
|
1246
|
+
for (const [, cards] of byCardId) {
|
|
1247
|
+
const aggregatedScore = this.aggregateScores(cards);
|
|
1248
|
+
const finalScore = Math.min(1, aggregatedScore);
|
|
1249
|
+
const mergedProvenance = cards.flatMap((c) => c.provenance);
|
|
1250
|
+
const initialScore = cards[0].score;
|
|
1251
|
+
const action = finalScore > initialScore ? "boosted" : finalScore < initialScore ? "penalized" : "passed";
|
|
1252
|
+
const reason = this.buildAggregationReason(cards, finalScore);
|
|
1253
|
+
merged.push({
|
|
1254
|
+
...cards[0],
|
|
1255
|
+
score: finalScore,
|
|
1256
|
+
provenance: [
|
|
1257
|
+
...mergedProvenance,
|
|
1258
|
+
{
|
|
1259
|
+
strategy: "composite",
|
|
1260
|
+
strategyName: "Composite Generator",
|
|
1261
|
+
strategyId: "COMPOSITE_GENERATOR",
|
|
1262
|
+
action,
|
|
1263
|
+
score: finalScore,
|
|
1264
|
+
reason
|
|
1265
|
+
}
|
|
1266
|
+
]
|
|
1267
|
+
});
|
|
1268
|
+
}
|
|
1269
|
+
return merged.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
1226
1270
|
}
|
|
1227
1271
|
/**
|
|
1228
|
-
*
|
|
1272
|
+
* Build human-readable reason for score aggregation.
|
|
1229
1273
|
*/
|
|
1230
|
-
|
|
1231
|
-
|
|
1274
|
+
buildAggregationReason(cards, finalScore) {
|
|
1275
|
+
const count = cards.length;
|
|
1276
|
+
const scores = cards.map((c) => c.score.toFixed(2)).join(", ");
|
|
1277
|
+
if (count === 1) {
|
|
1278
|
+
return `Single generator, score ${finalScore.toFixed(2)}`;
|
|
1279
|
+
}
|
|
1280
|
+
const strategies = cards.map((c) => c.provenance[0]?.strategy || "unknown").join(", ");
|
|
1281
|
+
switch (this.aggregationMode) {
|
|
1282
|
+
case "max" /* MAX */:
|
|
1283
|
+
return `Max of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
|
|
1284
|
+
case "average" /* AVERAGE */:
|
|
1285
|
+
return `Average of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
|
|
1286
|
+
case "frequencyBoost" /* FREQUENCY_BOOST */: {
|
|
1287
|
+
const avg = cards.reduce((sum, c) => sum + c.score, 0) / count;
|
|
1288
|
+
const boost = 1 + FREQUENCY_BOOST_FACTOR * (count - 1);
|
|
1289
|
+
return `Frequency boost from ${count} generators (${strategies}): avg ${avg.toFixed(2)} \xD7 ${boost.toFixed(2)} \u2192 ${finalScore.toFixed(2)}`;
|
|
1290
|
+
}
|
|
1291
|
+
default:
|
|
1292
|
+
return `Aggregated from ${count} generators: ${finalScore.toFixed(2)}`;
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
/**
|
|
1296
|
+
* Aggregate scores from multiple generators for the same card.
|
|
1297
|
+
*/
|
|
1298
|
+
aggregateScores(cards) {
|
|
1299
|
+
const scores = cards.map((c) => c.score);
|
|
1300
|
+
switch (this.aggregationMode) {
|
|
1301
|
+
case "max" /* MAX */:
|
|
1302
|
+
return Math.max(...scores);
|
|
1303
|
+
case "average" /* AVERAGE */:
|
|
1304
|
+
return scores.reduce((sum, s) => sum + s, 0) / scores.length;
|
|
1305
|
+
case "frequencyBoost" /* FREQUENCY_BOOST */: {
|
|
1306
|
+
const avg = scores.reduce((sum, s) => sum + s, 0) / scores.length;
|
|
1307
|
+
const frequencyBoost = 1 + FREQUENCY_BOOST_FACTOR * (cards.length - 1);
|
|
1308
|
+
return avg * frequencyBoost;
|
|
1309
|
+
}
|
|
1310
|
+
default:
|
|
1311
|
+
return scores[0];
|
|
1312
|
+
}
|
|
1232
1313
|
}
|
|
1233
1314
|
};
|
|
1234
1315
|
}
|
|
1235
1316
|
});
|
|
1236
1317
|
|
|
1237
1318
|
// src/core/navigators/PipelineAssembler.ts
|
|
1238
|
-
var PipelineAssembler_exports = {};
|
|
1239
|
-
__export(PipelineAssembler_exports, {
|
|
1240
|
-
PipelineAssembler: () => PipelineAssembler
|
|
1241
|
-
});
|
|
1242
1319
|
var PipelineAssembler;
|
|
1243
1320
|
var init_PipelineAssembler = __esm({
|
|
1244
1321
|
"src/core/navigators/PipelineAssembler.ts"() {
|
|
@@ -1359,15 +1436,11 @@ var init_PipelineAssembler = __esm({
|
|
|
1359
1436
|
}
|
|
1360
1437
|
});
|
|
1361
1438
|
|
|
1362
|
-
// src/core/navigators/elo.ts
|
|
1363
|
-
var elo_exports = {};
|
|
1364
|
-
__export(elo_exports, {
|
|
1365
|
-
default: () => ELONavigator
|
|
1366
|
-
});
|
|
1439
|
+
// src/core/navigators/generators/elo.ts
|
|
1367
1440
|
import { toCourseElo as toCourseElo3 } from "@vue-skuilder/common";
|
|
1368
1441
|
var ELONavigator;
|
|
1369
1442
|
var init_elo = __esm({
|
|
1370
|
-
"src/core/navigators/elo.ts"() {
|
|
1443
|
+
"src/core/navigators/generators/elo.ts"() {
|
|
1371
1444
|
"use strict";
|
|
1372
1445
|
init_navigators();
|
|
1373
1446
|
ELONavigator = class extends ContentNavigator {
|
|
@@ -1377,50 +1450,6 @@ var init_elo = __esm({
|
|
|
1377
1450
|
super(user, course, strategyData);
|
|
1378
1451
|
this.name = strategyData?.name || "ELO";
|
|
1379
1452
|
}
|
|
1380
|
-
async getPendingReviews() {
|
|
1381
|
-
const reviews = await this.user.getPendingReviews(this.course.getCourseID());
|
|
1382
|
-
const elo = await this.course.getCardEloData(reviews.map((r) => r.cardId));
|
|
1383
|
-
const ratedReviews = reviews.map((r, i) => {
|
|
1384
|
-
const ratedR = {
|
|
1385
|
-
...r,
|
|
1386
|
-
...elo[i]
|
|
1387
|
-
};
|
|
1388
|
-
return ratedR;
|
|
1389
|
-
});
|
|
1390
|
-
ratedReviews.sort((a, b) => {
|
|
1391
|
-
return a.global.score - b.global.score;
|
|
1392
|
-
});
|
|
1393
|
-
return ratedReviews.map((r) => {
|
|
1394
|
-
return {
|
|
1395
|
-
...r,
|
|
1396
|
-
contentSourceType: "course",
|
|
1397
|
-
contentSourceID: this.course.getCourseID(),
|
|
1398
|
-
cardID: r.cardId,
|
|
1399
|
-
courseID: r.courseId,
|
|
1400
|
-
qualifiedID: `${r.courseId}-${r.cardId}`,
|
|
1401
|
-
reviewID: r._id,
|
|
1402
|
-
status: "review"
|
|
1403
|
-
};
|
|
1404
|
-
});
|
|
1405
|
-
}
|
|
1406
|
-
async getNewCards(limit = 99) {
|
|
1407
|
-
const activeCards = await this.user.getActiveCards();
|
|
1408
|
-
return (await this.course.getCardsCenteredAtELO(
|
|
1409
|
-
{ limit, elo: "user" },
|
|
1410
|
-
(c) => {
|
|
1411
|
-
if (activeCards.some((ac) => c.cardID === ac.cardID)) {
|
|
1412
|
-
return false;
|
|
1413
|
-
} else {
|
|
1414
|
-
return true;
|
|
1415
|
-
}
|
|
1416
|
-
}
|
|
1417
|
-
)).map((c) => {
|
|
1418
|
-
return {
|
|
1419
|
-
...c,
|
|
1420
|
-
status: "new"
|
|
1421
|
-
};
|
|
1422
|
-
});
|
|
1423
|
-
}
|
|
1424
1453
|
/**
|
|
1425
1454
|
* Get new cards with suitability scores based on ELO distance.
|
|
1426
1455
|
*
|
|
@@ -1445,7 +1474,11 @@ var init_elo = __esm({
|
|
|
1445
1474
|
const userElo = toCourseElo3(courseReg.elo);
|
|
1446
1475
|
userGlobalElo = userElo.global.score;
|
|
1447
1476
|
}
|
|
1448
|
-
const
|
|
1477
|
+
const activeCards = await this.user.getActiveCards();
|
|
1478
|
+
const newCards = (await this.course.getCardsCenteredAtELO(
|
|
1479
|
+
{ limit, elo: "user" },
|
|
1480
|
+
(c) => !activeCards.some((ac) => c.cardID === ac.cardID)
|
|
1481
|
+
)).map((c) => ({ ...c, status: "new" }));
|
|
1449
1482
|
const cardIds = newCards.map((c) => c.cardID);
|
|
1450
1483
|
const cardEloData = await this.course.getCardEloData(cardIds);
|
|
1451
1484
|
const scored = newCards.map((c, i) => {
|
|
@@ -1475,806 +1508,14 @@ var init_elo = __esm({
|
|
|
1475
1508
|
}
|
|
1476
1509
|
});
|
|
1477
1510
|
|
|
1478
|
-
// src/core/navigators/
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
DEFAULT_MIN_MULTIPLIER: () => DEFAULT_MIN_MULTIPLIER,
|
|
1484
|
-
createEloDistanceFilter: () => createEloDistanceFilter
|
|
1485
|
-
});
|
|
1486
|
-
function computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier) {
|
|
1487
|
-
const normalizedDistance = distance / halfLife;
|
|
1488
|
-
const decay = Math.exp(-(normalizedDistance * normalizedDistance));
|
|
1489
|
-
return minMultiplier + (maxMultiplier - minMultiplier) * decay;
|
|
1490
|
-
}
|
|
1491
|
-
function createEloDistanceFilter(config) {
|
|
1492
|
-
const halfLife = config?.halfLife ?? DEFAULT_HALF_LIFE;
|
|
1493
|
-
const minMultiplier = config?.minMultiplier ?? DEFAULT_MIN_MULTIPLIER;
|
|
1494
|
-
const maxMultiplier = config?.maxMultiplier ?? DEFAULT_MAX_MULTIPLIER;
|
|
1495
|
-
return {
|
|
1496
|
-
name: "ELO Distance Filter",
|
|
1497
|
-
async transform(cards, context) {
|
|
1498
|
-
const { course, userElo } = context;
|
|
1499
|
-
const cardIds = cards.map((c) => c.cardId);
|
|
1500
|
-
const cardElos = await course.getCardEloData(cardIds);
|
|
1501
|
-
return cards.map((card, i) => {
|
|
1502
|
-
const cardElo = cardElos[i]?.global?.score ?? 1e3;
|
|
1503
|
-
const distance = Math.abs(cardElo - userElo);
|
|
1504
|
-
const multiplier = computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier);
|
|
1505
|
-
const newScore = card.score * multiplier;
|
|
1506
|
-
const action = multiplier < maxMultiplier - 0.01 ? "penalized" : "passed";
|
|
1507
|
-
return {
|
|
1508
|
-
...card,
|
|
1509
|
-
score: newScore,
|
|
1510
|
-
provenance: [
|
|
1511
|
-
...card.provenance,
|
|
1512
|
-
{
|
|
1513
|
-
strategy: "eloDistance",
|
|
1514
|
-
strategyName: "ELO Distance Filter",
|
|
1515
|
-
strategyId: "ELO_DISTANCE_FILTER",
|
|
1516
|
-
action,
|
|
1517
|
-
score: newScore,
|
|
1518
|
-
reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userElo)}) \u2192 ${multiplier.toFixed(2)}x`
|
|
1519
|
-
}
|
|
1520
|
-
]
|
|
1521
|
-
};
|
|
1522
|
-
});
|
|
1523
|
-
}
|
|
1524
|
-
};
|
|
1525
|
-
}
|
|
1526
|
-
var DEFAULT_HALF_LIFE, DEFAULT_MIN_MULTIPLIER, DEFAULT_MAX_MULTIPLIER;
|
|
1527
|
-
var init_eloDistance = __esm({
|
|
1528
|
-
"src/core/navigators/filters/eloDistance.ts"() {
|
|
1529
|
-
"use strict";
|
|
1530
|
-
DEFAULT_HALF_LIFE = 200;
|
|
1531
|
-
DEFAULT_MIN_MULTIPLIER = 0.3;
|
|
1532
|
-
DEFAULT_MAX_MULTIPLIER = 1;
|
|
1533
|
-
}
|
|
1534
|
-
});
|
|
1535
|
-
|
|
1536
|
-
// src/core/navigators/filters/index.ts
|
|
1537
|
-
var filters_exports = {};
|
|
1538
|
-
__export(filters_exports, {
|
|
1539
|
-
createEloDistanceFilter: () => createEloDistanceFilter
|
|
1540
|
-
});
|
|
1541
|
-
var init_filters = __esm({
|
|
1542
|
-
"src/core/navigators/filters/index.ts"() {
|
|
1543
|
-
"use strict";
|
|
1544
|
-
init_eloDistance();
|
|
1545
|
-
}
|
|
1546
|
-
});
|
|
1547
|
-
|
|
1548
|
-
// src/core/navigators/filters/types.ts
|
|
1549
|
-
var types_exports = {};
|
|
1550
|
-
var init_types = __esm({
|
|
1551
|
-
"src/core/navigators/filters/types.ts"() {
|
|
1552
|
-
"use strict";
|
|
1553
|
-
}
|
|
1554
|
-
});
|
|
1555
|
-
|
|
1556
|
-
// src/core/navigators/generators/index.ts
|
|
1557
|
-
var generators_exports = {};
|
|
1558
|
-
var init_generators = __esm({
|
|
1559
|
-
"src/core/navigators/generators/index.ts"() {
|
|
1560
|
-
"use strict";
|
|
1561
|
-
}
|
|
1562
|
-
});
|
|
1563
|
-
|
|
1564
|
-
// src/core/navigators/generators/types.ts
|
|
1565
|
-
var types_exports2 = {};
|
|
1566
|
-
var init_types2 = __esm({
|
|
1567
|
-
"src/core/navigators/generators/types.ts"() {
|
|
1568
|
-
"use strict";
|
|
1569
|
-
}
|
|
1570
|
-
});
|
|
1571
|
-
|
|
1572
|
-
// src/core/navigators/hardcodedOrder.ts
|
|
1573
|
-
var hardcodedOrder_exports = {};
|
|
1574
|
-
__export(hardcodedOrder_exports, {
|
|
1575
|
-
default: () => HardcodedOrderNavigator
|
|
1576
|
-
});
|
|
1577
|
-
var HardcodedOrderNavigator;
|
|
1578
|
-
var init_hardcodedOrder = __esm({
|
|
1579
|
-
"src/core/navigators/hardcodedOrder.ts"() {
|
|
1511
|
+
// src/core/navigators/generators/srs.ts
|
|
1512
|
+
import moment3 from "moment";
|
|
1513
|
+
var SRSNavigator;
|
|
1514
|
+
var init_srs = __esm({
|
|
1515
|
+
"src/core/navigators/generators/srs.ts"() {
|
|
1580
1516
|
"use strict";
|
|
1581
1517
|
init_navigators();
|
|
1582
1518
|
init_logger();
|
|
1583
|
-
HardcodedOrderNavigator = class extends ContentNavigator {
|
|
1584
|
-
/** Human-readable name for CardGenerator interface */
|
|
1585
|
-
name;
|
|
1586
|
-
orderedCardIds = [];
|
|
1587
|
-
constructor(user, course, strategyData) {
|
|
1588
|
-
super(user, course, strategyData);
|
|
1589
|
-
this.name = strategyData.name || "Hardcoded Order";
|
|
1590
|
-
if (strategyData.serializedData) {
|
|
1591
|
-
try {
|
|
1592
|
-
this.orderedCardIds = JSON.parse(strategyData.serializedData);
|
|
1593
|
-
} catch (e) {
|
|
1594
|
-
logger.error("Failed to parse serializedData for HardcodedOrderNavigator", e);
|
|
1595
|
-
}
|
|
1596
|
-
}
|
|
1597
|
-
}
|
|
1598
|
-
async getPendingReviews() {
|
|
1599
|
-
const reviews = await this.user.getPendingReviews(this.course.getCourseID());
|
|
1600
|
-
return reviews.map((r) => {
|
|
1601
|
-
return {
|
|
1602
|
-
...r,
|
|
1603
|
-
contentSourceType: "course",
|
|
1604
|
-
contentSourceID: this.course.getCourseID(),
|
|
1605
|
-
cardID: r.cardId,
|
|
1606
|
-
courseID: r.courseId,
|
|
1607
|
-
reviewID: r._id,
|
|
1608
|
-
status: "review"
|
|
1609
|
-
};
|
|
1610
|
-
});
|
|
1611
|
-
}
|
|
1612
|
-
async getNewCards(limit = 99) {
|
|
1613
|
-
const activeCardIds = (await this.user.getActiveCards()).map((c) => c.cardID);
|
|
1614
|
-
const newCardIds = this.orderedCardIds.filter((cardId) => !activeCardIds.includes(cardId));
|
|
1615
|
-
const cardsToReturn = newCardIds.slice(0, limit);
|
|
1616
|
-
return cardsToReturn.map((cardId) => {
|
|
1617
|
-
return {
|
|
1618
|
-
cardID: cardId,
|
|
1619
|
-
courseID: this.course.getCourseID(),
|
|
1620
|
-
contentSourceType: "course",
|
|
1621
|
-
contentSourceID: this.course.getCourseID(),
|
|
1622
|
-
status: "new"
|
|
1623
|
-
};
|
|
1624
|
-
});
|
|
1625
|
-
}
|
|
1626
|
-
/**
|
|
1627
|
-
* Get cards in hardcoded order with scores based on position.
|
|
1628
|
-
*
|
|
1629
|
-
* Earlier cards in the sequence get higher scores.
|
|
1630
|
-
* Score formula: 1.0 - (position / totalCards) * 0.5
|
|
1631
|
-
* This ensures scores range from 1.0 (first card) to 0.5+ (last card).
|
|
1632
|
-
*
|
|
1633
|
-
* This method supports both the legacy signature (limit only) and the
|
|
1634
|
-
* CardGenerator interface signature (limit, context).
|
|
1635
|
-
*
|
|
1636
|
-
* @param limit - Maximum number of cards to return
|
|
1637
|
-
* @param _context - Optional GeneratorContext (currently unused, but required for interface)
|
|
1638
|
-
*/
|
|
1639
|
-
async getWeightedCards(limit, _context) {
|
|
1640
|
-
const activeCardIds = (await this.user.getActiveCards()).map((c) => c.cardID);
|
|
1641
|
-
const reviews = await this.getPendingReviews();
|
|
1642
|
-
const newCardIds = this.orderedCardIds.filter((cardId) => !activeCardIds.includes(cardId));
|
|
1643
|
-
const totalCards = newCardIds.length;
|
|
1644
|
-
const scoredNew = newCardIds.slice(0, limit).map((cardId, index) => {
|
|
1645
|
-
const position = index + 1;
|
|
1646
|
-
const score = Math.max(0.5, 1 - index / totalCards * 0.5);
|
|
1647
|
-
return {
|
|
1648
|
-
cardId,
|
|
1649
|
-
courseId: this.course.getCourseID(),
|
|
1650
|
-
score,
|
|
1651
|
-
provenance: [
|
|
1652
|
-
{
|
|
1653
|
-
strategy: "hardcodedOrder",
|
|
1654
|
-
strategyName: this.strategyName || this.name,
|
|
1655
|
-
strategyId: this.strategyId || "NAVIGATION_STRATEGY-hardcoded",
|
|
1656
|
-
action: "generated",
|
|
1657
|
-
score,
|
|
1658
|
-
reason: `Position ${position} of ${totalCards} in fixed sequence, new card`
|
|
1659
|
-
}
|
|
1660
|
-
]
|
|
1661
|
-
};
|
|
1662
|
-
});
|
|
1663
|
-
const scoredReviews = reviews.map((r) => ({
|
|
1664
|
-
cardId: r.cardID,
|
|
1665
|
-
courseId: r.courseID,
|
|
1666
|
-
score: 1,
|
|
1667
|
-
provenance: [
|
|
1668
|
-
{
|
|
1669
|
-
strategy: "hardcodedOrder",
|
|
1670
|
-
strategyName: this.strategyName || this.name,
|
|
1671
|
-
strategyId: this.strategyId || "NAVIGATION_STRATEGY-hardcoded",
|
|
1672
|
-
action: "generated",
|
|
1673
|
-
score: 1,
|
|
1674
|
-
reason: "Scheduled review, highest priority"
|
|
1675
|
-
}
|
|
1676
|
-
]
|
|
1677
|
-
}));
|
|
1678
|
-
const all = [...scoredReviews, ...scoredNew];
|
|
1679
|
-
all.sort((a, b) => b.score - a.score);
|
|
1680
|
-
return all.slice(0, limit);
|
|
1681
|
-
}
|
|
1682
|
-
};
|
|
1683
|
-
}
|
|
1684
|
-
});
|
|
1685
|
-
|
|
1686
|
-
// src/core/navigators/hierarchyDefinition.ts
|
|
1687
|
-
var hierarchyDefinition_exports = {};
|
|
1688
|
-
__export(hierarchyDefinition_exports, {
|
|
1689
|
-
default: () => HierarchyDefinitionNavigator
|
|
1690
|
-
});
|
|
1691
|
-
import { toCourseElo as toCourseElo4 } from "@vue-skuilder/common";
|
|
1692
|
-
var DEFAULT_MIN_COUNT, HierarchyDefinitionNavigator;
|
|
1693
|
-
var init_hierarchyDefinition = __esm({
|
|
1694
|
-
"src/core/navigators/hierarchyDefinition.ts"() {
|
|
1695
|
-
"use strict";
|
|
1696
|
-
init_navigators();
|
|
1697
|
-
DEFAULT_MIN_COUNT = 3;
|
|
1698
|
-
HierarchyDefinitionNavigator = class extends ContentNavigator {
|
|
1699
|
-
config;
|
|
1700
|
-
_strategyData;
|
|
1701
|
-
/** Human-readable name for CardFilter interface */
|
|
1702
|
-
name;
|
|
1703
|
-
constructor(user, course, _strategyData) {
|
|
1704
|
-
super(user, course, _strategyData);
|
|
1705
|
-
this._strategyData = _strategyData;
|
|
1706
|
-
this.config = this.parseConfig(_strategyData.serializedData);
|
|
1707
|
-
this.name = _strategyData.name || "Hierarchy Definition";
|
|
1708
|
-
}
|
|
1709
|
-
parseConfig(serializedData) {
|
|
1710
|
-
try {
|
|
1711
|
-
const parsed = JSON.parse(serializedData);
|
|
1712
|
-
return {
|
|
1713
|
-
prerequisites: parsed.prerequisites || {}
|
|
1714
|
-
};
|
|
1715
|
-
} catch {
|
|
1716
|
-
return {
|
|
1717
|
-
prerequisites: {}
|
|
1718
|
-
};
|
|
1719
|
-
}
|
|
1720
|
-
}
|
|
1721
|
-
/**
|
|
1722
|
-
* Check if a specific prerequisite is satisfied
|
|
1723
|
-
*/
|
|
1724
|
-
isPrerequisiteMet(prereq, userTagElo, userGlobalElo) {
|
|
1725
|
-
if (!userTagElo) return false;
|
|
1726
|
-
const minCount = prereq.masteryThreshold?.minCount ?? DEFAULT_MIN_COUNT;
|
|
1727
|
-
if (userTagElo.count < minCount) return false;
|
|
1728
|
-
if (prereq.masteryThreshold?.minElo !== void 0) {
|
|
1729
|
-
return userTagElo.score >= prereq.masteryThreshold.minElo;
|
|
1730
|
-
} else {
|
|
1731
|
-
return userTagElo.score >= userGlobalElo;
|
|
1732
|
-
}
|
|
1733
|
-
}
|
|
1734
|
-
/**
|
|
1735
|
-
* Get the set of tags the user has mastered.
|
|
1736
|
-
* A tag is "mastered" if it appears as a prerequisite somewhere and meets its threshold.
|
|
1737
|
-
*/
|
|
1738
|
-
async getMasteredTags(context) {
|
|
1739
|
-
const mastered = /* @__PURE__ */ new Set();
|
|
1740
|
-
try {
|
|
1741
|
-
const courseReg = await context.user.getCourseRegDoc(context.course.getCourseID());
|
|
1742
|
-
const userElo = toCourseElo4(courseReg.elo);
|
|
1743
|
-
for (const prereqs of Object.values(this.config.prerequisites)) {
|
|
1744
|
-
for (const prereq of prereqs) {
|
|
1745
|
-
const tagElo = userElo.tags[prereq.tag];
|
|
1746
|
-
if (this.isPrerequisiteMet(prereq, tagElo, userElo.global.score)) {
|
|
1747
|
-
mastered.add(prereq.tag);
|
|
1748
|
-
}
|
|
1749
|
-
}
|
|
1750
|
-
}
|
|
1751
|
-
} catch {
|
|
1752
|
-
}
|
|
1753
|
-
return mastered;
|
|
1754
|
-
}
|
|
1755
|
-
/**
|
|
1756
|
-
* Get the set of tags that are unlocked (prerequisites met)
|
|
1757
|
-
*/
|
|
1758
|
-
getUnlockedTags(masteredTags) {
|
|
1759
|
-
const unlocked = /* @__PURE__ */ new Set();
|
|
1760
|
-
for (const [tagId, prereqs] of Object.entries(this.config.prerequisites)) {
|
|
1761
|
-
const allPrereqsMet = prereqs.every((prereq) => masteredTags.has(prereq.tag));
|
|
1762
|
-
if (allPrereqsMet) {
|
|
1763
|
-
unlocked.add(tagId);
|
|
1764
|
-
}
|
|
1765
|
-
}
|
|
1766
|
-
return unlocked;
|
|
1767
|
-
}
|
|
1768
|
-
/**
|
|
1769
|
-
* Check if a tag has prerequisites defined in config
|
|
1770
|
-
*/
|
|
1771
|
-
hasPrerequisites(tagId) {
|
|
1772
|
-
return tagId in this.config.prerequisites;
|
|
1773
|
-
}
|
|
1774
|
-
/**
|
|
1775
|
-
* Check if a card is unlocked and generate reason.
|
|
1776
|
-
*/
|
|
1777
|
-
async checkCardUnlock(cardId, course, unlockedTags, masteredTags) {
|
|
1778
|
-
try {
|
|
1779
|
-
const tagResponse = await course.getAppliedTags(cardId);
|
|
1780
|
-
const cardTags = tagResponse.rows.map((row) => row.value?.name || row.key);
|
|
1781
|
-
const lockedTags = cardTags.filter(
|
|
1782
|
-
(tag) => this.hasPrerequisites(tag) && !unlockedTags.has(tag)
|
|
1783
|
-
);
|
|
1784
|
-
if (lockedTags.length === 0) {
|
|
1785
|
-
const tagList = cardTags.length > 0 ? cardTags.join(", ") : "none";
|
|
1786
|
-
return {
|
|
1787
|
-
isUnlocked: true,
|
|
1788
|
-
reason: `Prerequisites met, tags: ${tagList}`
|
|
1789
|
-
};
|
|
1790
|
-
}
|
|
1791
|
-
const missingPrereqs = lockedTags.flatMap((tag) => {
|
|
1792
|
-
const prereqs = this.config.prerequisites[tag] || [];
|
|
1793
|
-
return prereqs.filter((p) => !masteredTags.has(p.tag)).map((p) => p.tag);
|
|
1794
|
-
});
|
|
1795
|
-
return {
|
|
1796
|
-
isUnlocked: false,
|
|
1797
|
-
reason: `Blocked: missing prerequisites ${missingPrereqs.join(", ")} for tags ${lockedTags.join(", ")}`
|
|
1798
|
-
};
|
|
1799
|
-
} catch {
|
|
1800
|
-
return {
|
|
1801
|
-
isUnlocked: true,
|
|
1802
|
-
reason: "Prerequisites check skipped (tag lookup failed)"
|
|
1803
|
-
};
|
|
1804
|
-
}
|
|
1805
|
-
}
|
|
1806
|
-
/**
|
|
1807
|
-
* CardFilter.transform implementation.
|
|
1808
|
-
*
|
|
1809
|
-
* Apply prerequisite gating to cards. Cards with locked tags receive score: 0.
|
|
1810
|
-
*/
|
|
1811
|
-
async transform(cards, context) {
|
|
1812
|
-
const masteredTags = await this.getMasteredTags(context);
|
|
1813
|
-
const unlockedTags = this.getUnlockedTags(masteredTags);
|
|
1814
|
-
const gated = [];
|
|
1815
|
-
for (const card of cards) {
|
|
1816
|
-
const { isUnlocked, reason } = await this.checkCardUnlock(
|
|
1817
|
-
card.cardId,
|
|
1818
|
-
context.course,
|
|
1819
|
-
unlockedTags,
|
|
1820
|
-
masteredTags
|
|
1821
|
-
);
|
|
1822
|
-
const finalScore = isUnlocked ? card.score : 0;
|
|
1823
|
-
const action = isUnlocked ? "passed" : "penalized";
|
|
1824
|
-
gated.push({
|
|
1825
|
-
...card,
|
|
1826
|
-
score: finalScore,
|
|
1827
|
-
provenance: [
|
|
1828
|
-
...card.provenance,
|
|
1829
|
-
{
|
|
1830
|
-
strategy: "hierarchyDefinition",
|
|
1831
|
-
strategyName: this.strategyName || this.name,
|
|
1832
|
-
strategyId: this.strategyId || "NAVIGATION_STRATEGY-hierarchy",
|
|
1833
|
-
action,
|
|
1834
|
-
score: finalScore,
|
|
1835
|
-
reason
|
|
1836
|
-
}
|
|
1837
|
-
]
|
|
1838
|
-
});
|
|
1839
|
-
}
|
|
1840
|
-
return gated;
|
|
1841
|
-
}
|
|
1842
|
-
/**
|
|
1843
|
-
* Legacy getWeightedCards - now throws as filters should not be used as generators.
|
|
1844
|
-
*
|
|
1845
|
-
* Use transform() via Pipeline instead.
|
|
1846
|
-
*/
|
|
1847
|
-
async getWeightedCards(_limit) {
|
|
1848
|
-
throw new Error(
|
|
1849
|
-
"HierarchyDefinitionNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
|
|
1850
|
-
);
|
|
1851
|
-
}
|
|
1852
|
-
// Legacy methods - stub implementations since filters don't generate cards
|
|
1853
|
-
async getNewCards(_n) {
|
|
1854
|
-
return [];
|
|
1855
|
-
}
|
|
1856
|
-
async getPendingReviews() {
|
|
1857
|
-
return [];
|
|
1858
|
-
}
|
|
1859
|
-
};
|
|
1860
|
-
}
|
|
1861
|
-
});
|
|
1862
|
-
|
|
1863
|
-
// src/core/navigators/interferenceMitigator.ts
|
|
1864
|
-
var interferenceMitigator_exports = {};
|
|
1865
|
-
__export(interferenceMitigator_exports, {
|
|
1866
|
-
default: () => InterferenceMitigatorNavigator
|
|
1867
|
-
});
|
|
1868
|
-
import { toCourseElo as toCourseElo5 } from "@vue-skuilder/common";
|
|
1869
|
-
var DEFAULT_MIN_COUNT2, DEFAULT_MIN_ELAPSED_DAYS, DEFAULT_INTERFERENCE_DECAY, InterferenceMitigatorNavigator;
|
|
1870
|
-
var init_interferenceMitigator = __esm({
|
|
1871
|
-
"src/core/navigators/interferenceMitigator.ts"() {
|
|
1872
|
-
"use strict";
|
|
1873
|
-
init_navigators();
|
|
1874
|
-
DEFAULT_MIN_COUNT2 = 10;
|
|
1875
|
-
DEFAULT_MIN_ELAPSED_DAYS = 3;
|
|
1876
|
-
DEFAULT_INTERFERENCE_DECAY = 0.8;
|
|
1877
|
-
InterferenceMitigatorNavigator = class extends ContentNavigator {
|
|
1878
|
-
config;
|
|
1879
|
-
_strategyData;
|
|
1880
|
-
/** Human-readable name for CardFilter interface */
|
|
1881
|
-
name;
|
|
1882
|
-
/** Precomputed map: tag -> set of { partner, decay } it interferes with */
|
|
1883
|
-
interferenceMap;
|
|
1884
|
-
constructor(user, course, _strategyData) {
|
|
1885
|
-
super(user, course, _strategyData);
|
|
1886
|
-
this._strategyData = _strategyData;
|
|
1887
|
-
this.config = this.parseConfig(_strategyData.serializedData);
|
|
1888
|
-
this.interferenceMap = this.buildInterferenceMap();
|
|
1889
|
-
this.name = _strategyData.name || "Interference Mitigator";
|
|
1890
|
-
}
|
|
1891
|
-
parseConfig(serializedData) {
|
|
1892
|
-
try {
|
|
1893
|
-
const parsed = JSON.parse(serializedData);
|
|
1894
|
-
let sets = parsed.interferenceSets || [];
|
|
1895
|
-
if (sets.length > 0 && Array.isArray(sets[0])) {
|
|
1896
|
-
sets = sets.map((tags) => ({ tags }));
|
|
1897
|
-
}
|
|
1898
|
-
return {
|
|
1899
|
-
interferenceSets: sets,
|
|
1900
|
-
maturityThreshold: {
|
|
1901
|
-
minCount: parsed.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT2,
|
|
1902
|
-
minElo: parsed.maturityThreshold?.minElo,
|
|
1903
|
-
minElapsedDays: parsed.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS
|
|
1904
|
-
},
|
|
1905
|
-
defaultDecay: parsed.defaultDecay ?? DEFAULT_INTERFERENCE_DECAY
|
|
1906
|
-
};
|
|
1907
|
-
} catch {
|
|
1908
|
-
return {
|
|
1909
|
-
interferenceSets: [],
|
|
1910
|
-
maturityThreshold: {
|
|
1911
|
-
minCount: DEFAULT_MIN_COUNT2,
|
|
1912
|
-
minElapsedDays: DEFAULT_MIN_ELAPSED_DAYS
|
|
1913
|
-
},
|
|
1914
|
-
defaultDecay: DEFAULT_INTERFERENCE_DECAY
|
|
1915
|
-
};
|
|
1916
|
-
}
|
|
1917
|
-
}
|
|
1918
|
-
/**
|
|
1919
|
-
* Build a map from each tag to its interference partners with decay coefficients.
|
|
1920
|
-
* If tags A, B, C are in an interference group with decay 0.8, then:
|
|
1921
|
-
* - A interferes with B (decay 0.8) and C (decay 0.8)
|
|
1922
|
-
* - B interferes with A (decay 0.8) and C (decay 0.8)
|
|
1923
|
-
* - etc.
|
|
1924
|
-
*/
|
|
1925
|
-
buildInterferenceMap() {
|
|
1926
|
-
const map = /* @__PURE__ */ new Map();
|
|
1927
|
-
for (const group of this.config.interferenceSets) {
|
|
1928
|
-
const decay = group.decay ?? this.config.defaultDecay ?? DEFAULT_INTERFERENCE_DECAY;
|
|
1929
|
-
for (const tag of group.tags) {
|
|
1930
|
-
if (!map.has(tag)) {
|
|
1931
|
-
map.set(tag, []);
|
|
1932
|
-
}
|
|
1933
|
-
const partners = map.get(tag);
|
|
1934
|
-
for (const other of group.tags) {
|
|
1935
|
-
if (other !== tag) {
|
|
1936
|
-
const existing = partners.find((p) => p.partner === other);
|
|
1937
|
-
if (existing) {
|
|
1938
|
-
existing.decay = Math.max(existing.decay, decay);
|
|
1939
|
-
} else {
|
|
1940
|
-
partners.push({ partner: other, decay });
|
|
1941
|
-
}
|
|
1942
|
-
}
|
|
1943
|
-
}
|
|
1944
|
-
}
|
|
1945
|
-
}
|
|
1946
|
-
return map;
|
|
1947
|
-
}
|
|
1948
|
-
/**
|
|
1949
|
-
* Get the set of tags that are currently immature for this user.
|
|
1950
|
-
* A tag is immature if the user has interacted with it but hasn't
|
|
1951
|
-
* reached the maturity threshold.
|
|
1952
|
-
*/
|
|
1953
|
-
async getImmatureTags(context) {
|
|
1954
|
-
const immature = /* @__PURE__ */ new Set();
|
|
1955
|
-
try {
|
|
1956
|
-
const courseReg = await context.user.getCourseRegDoc(context.course.getCourseID());
|
|
1957
|
-
const userElo = toCourseElo5(courseReg.elo);
|
|
1958
|
-
const minCount = this.config.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT2;
|
|
1959
|
-
const minElo = this.config.maturityThreshold?.minElo;
|
|
1960
|
-
const minElapsedDays = this.config.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS;
|
|
1961
|
-
const minCountForElapsed = minElapsedDays * 2;
|
|
1962
|
-
for (const [tagId, tagElo] of Object.entries(userElo.tags)) {
|
|
1963
|
-
if (tagElo.count === 0) continue;
|
|
1964
|
-
const belowCount = tagElo.count < minCount;
|
|
1965
|
-
const belowElo = minElo !== void 0 && tagElo.score < minElo;
|
|
1966
|
-
const belowElapsed = tagElo.count < minCountForElapsed;
|
|
1967
|
-
if (belowCount || belowElo || belowElapsed) {
|
|
1968
|
-
immature.add(tagId);
|
|
1969
|
-
}
|
|
1970
|
-
}
|
|
1971
|
-
} catch {
|
|
1972
|
-
}
|
|
1973
|
-
return immature;
|
|
1974
|
-
}
|
|
1975
|
-
/**
|
|
1976
|
-
* Get all tags that interfere with any immature tag, along with their decay coefficients.
|
|
1977
|
-
* These are the tags we want to avoid introducing.
|
|
1978
|
-
*/
|
|
1979
|
-
getTagsToAvoid(immatureTags) {
|
|
1980
|
-
const avoid = /* @__PURE__ */ new Map();
|
|
1981
|
-
for (const immatureTag of immatureTags) {
|
|
1982
|
-
const partners = this.interferenceMap.get(immatureTag);
|
|
1983
|
-
if (partners) {
|
|
1984
|
-
for (const { partner, decay } of partners) {
|
|
1985
|
-
if (!immatureTags.has(partner)) {
|
|
1986
|
-
const existing = avoid.get(partner) ?? 0;
|
|
1987
|
-
avoid.set(partner, Math.max(existing, decay));
|
|
1988
|
-
}
|
|
1989
|
-
}
|
|
1990
|
-
}
|
|
1991
|
-
}
|
|
1992
|
-
return avoid;
|
|
1993
|
-
}
|
|
1994
|
-
/**
|
|
1995
|
-
* Get tags for a single card
|
|
1996
|
-
*/
|
|
1997
|
-
async getCardTags(cardId, course) {
|
|
1998
|
-
try {
|
|
1999
|
-
const tagResponse = await course.getAppliedTags(cardId);
|
|
2000
|
-
return tagResponse.rows.map((row) => row.value?.name || row.key).filter(Boolean);
|
|
2001
|
-
} catch {
|
|
2002
|
-
return [];
|
|
2003
|
-
}
|
|
2004
|
-
}
|
|
2005
|
-
/**
|
|
2006
|
-
* Compute interference score reduction for a card.
|
|
2007
|
-
* Returns: { multiplier, interfering tags, reason }
|
|
2008
|
-
*/
|
|
2009
|
-
computeInterferenceEffect(cardTags, tagsToAvoid, immatureTags) {
|
|
2010
|
-
if (tagsToAvoid.size === 0) {
|
|
2011
|
-
return {
|
|
2012
|
-
multiplier: 1,
|
|
2013
|
-
interferingTags: [],
|
|
2014
|
-
reason: "No interference detected"
|
|
2015
|
-
};
|
|
2016
|
-
}
|
|
2017
|
-
let multiplier = 1;
|
|
2018
|
-
const interferingTags = [];
|
|
2019
|
-
for (const tag of cardTags) {
|
|
2020
|
-
const decay = tagsToAvoid.get(tag);
|
|
2021
|
-
if (decay !== void 0) {
|
|
2022
|
-
interferingTags.push(tag);
|
|
2023
|
-
multiplier *= 1 - decay;
|
|
2024
|
-
}
|
|
2025
|
-
}
|
|
2026
|
-
if (interferingTags.length === 0) {
|
|
2027
|
-
return {
|
|
2028
|
-
multiplier: 1,
|
|
2029
|
-
interferingTags: [],
|
|
2030
|
-
reason: "No interference detected"
|
|
2031
|
-
};
|
|
2032
|
-
}
|
|
2033
|
-
const causingTags = /* @__PURE__ */ new Set();
|
|
2034
|
-
for (const tag of interferingTags) {
|
|
2035
|
-
for (const immatureTag of immatureTags) {
|
|
2036
|
-
const partners = this.interferenceMap.get(immatureTag);
|
|
2037
|
-
if (partners?.some((p) => p.partner === tag)) {
|
|
2038
|
-
causingTags.add(immatureTag);
|
|
2039
|
-
}
|
|
2040
|
-
}
|
|
2041
|
-
}
|
|
2042
|
-
const reason = `Interferes with immature tags ${Array.from(causingTags).join(", ")} (tags: ${interferingTags.join(", ")}, multiplier: ${multiplier.toFixed(2)})`;
|
|
2043
|
-
return { multiplier, interferingTags, reason };
|
|
2044
|
-
}
|
|
2045
|
-
/**
|
|
2046
|
-
* CardFilter.transform implementation.
|
|
2047
|
-
*
|
|
2048
|
-
* Apply interference-aware scoring. Cards with tags that interfere with
|
|
2049
|
-
* immature learnings get reduced scores.
|
|
2050
|
-
*/
|
|
2051
|
-
async transform(cards, context) {
|
|
2052
|
-
const immatureTags = await this.getImmatureTags(context);
|
|
2053
|
-
const tagsToAvoid = this.getTagsToAvoid(immatureTags);
|
|
2054
|
-
const adjusted = [];
|
|
2055
|
-
for (const card of cards) {
|
|
2056
|
-
const cardTags = await this.getCardTags(card.cardId, context.course);
|
|
2057
|
-
const { multiplier, reason } = this.computeInterferenceEffect(
|
|
2058
|
-
cardTags,
|
|
2059
|
-
tagsToAvoid,
|
|
2060
|
-
immatureTags
|
|
2061
|
-
);
|
|
2062
|
-
const finalScore = card.score * multiplier;
|
|
2063
|
-
const action = multiplier < 1 ? "penalized" : multiplier > 1 ? "boosted" : "passed";
|
|
2064
|
-
adjusted.push({
|
|
2065
|
-
...card,
|
|
2066
|
-
score: finalScore,
|
|
2067
|
-
provenance: [
|
|
2068
|
-
...card.provenance,
|
|
2069
|
-
{
|
|
2070
|
-
strategy: "interferenceMitigator",
|
|
2071
|
-
strategyName: this.strategyName || this.name,
|
|
2072
|
-
strategyId: this.strategyId || "NAVIGATION_STRATEGY-interference",
|
|
2073
|
-
action,
|
|
2074
|
-
score: finalScore,
|
|
2075
|
-
reason
|
|
2076
|
-
}
|
|
2077
|
-
]
|
|
2078
|
-
});
|
|
2079
|
-
}
|
|
2080
|
-
return adjusted;
|
|
2081
|
-
}
|
|
2082
|
-
/**
|
|
2083
|
-
* Legacy getWeightedCards - now throws as filters should not be used as generators.
|
|
2084
|
-
*
|
|
2085
|
-
* Use transform() via Pipeline instead.
|
|
2086
|
-
*/
|
|
2087
|
-
async getWeightedCards(_limit) {
|
|
2088
|
-
throw new Error(
|
|
2089
|
-
"InterferenceMitigatorNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
|
|
2090
|
-
);
|
|
2091
|
-
}
|
|
2092
|
-
// Legacy methods - stub implementations since filters don't generate cards
|
|
2093
|
-
async getNewCards(_n) {
|
|
2094
|
-
return [];
|
|
2095
|
-
}
|
|
2096
|
-
async getPendingReviews() {
|
|
2097
|
-
return [];
|
|
2098
|
-
}
|
|
2099
|
-
};
|
|
2100
|
-
}
|
|
2101
|
-
});
|
|
2102
|
-
|
|
2103
|
-
// src/core/navigators/relativePriority.ts
|
|
2104
|
-
var relativePriority_exports = {};
|
|
2105
|
-
__export(relativePriority_exports, {
|
|
2106
|
-
default: () => RelativePriorityNavigator
|
|
2107
|
-
});
|
|
2108
|
-
var DEFAULT_PRIORITY, DEFAULT_PRIORITY_INFLUENCE, DEFAULT_COMBINE_MODE, RelativePriorityNavigator;
|
|
2109
|
-
var init_relativePriority = __esm({
|
|
2110
|
-
"src/core/navigators/relativePriority.ts"() {
|
|
2111
|
-
"use strict";
|
|
2112
|
-
init_navigators();
|
|
2113
|
-
DEFAULT_PRIORITY = 0.5;
|
|
2114
|
-
DEFAULT_PRIORITY_INFLUENCE = 0.5;
|
|
2115
|
-
DEFAULT_COMBINE_MODE = "max";
|
|
2116
|
-
RelativePriorityNavigator = class extends ContentNavigator {
|
|
2117
|
-
config;
|
|
2118
|
-
_strategyData;
|
|
2119
|
-
/** Human-readable name for CardFilter interface */
|
|
2120
|
-
name;
|
|
2121
|
-
constructor(user, course, _strategyData) {
|
|
2122
|
-
super(user, course, _strategyData);
|
|
2123
|
-
this._strategyData = _strategyData;
|
|
2124
|
-
this.config = this.parseConfig(_strategyData.serializedData);
|
|
2125
|
-
this.name = _strategyData.name || "Relative Priority";
|
|
2126
|
-
}
|
|
2127
|
-
parseConfig(serializedData) {
|
|
2128
|
-
try {
|
|
2129
|
-
const parsed = JSON.parse(serializedData);
|
|
2130
|
-
return {
|
|
2131
|
-
tagPriorities: parsed.tagPriorities || {},
|
|
2132
|
-
defaultPriority: parsed.defaultPriority ?? DEFAULT_PRIORITY,
|
|
2133
|
-
combineMode: parsed.combineMode ?? DEFAULT_COMBINE_MODE,
|
|
2134
|
-
priorityInfluence: parsed.priorityInfluence ?? DEFAULT_PRIORITY_INFLUENCE
|
|
2135
|
-
};
|
|
2136
|
-
} catch {
|
|
2137
|
-
return {
|
|
2138
|
-
tagPriorities: {},
|
|
2139
|
-
defaultPriority: DEFAULT_PRIORITY,
|
|
2140
|
-
combineMode: DEFAULT_COMBINE_MODE,
|
|
2141
|
-
priorityInfluence: DEFAULT_PRIORITY_INFLUENCE
|
|
2142
|
-
};
|
|
2143
|
-
}
|
|
2144
|
-
}
|
|
2145
|
-
/**
|
|
2146
|
-
* Look up the priority for a tag.
|
|
2147
|
-
*/
|
|
2148
|
-
getTagPriority(tagId) {
|
|
2149
|
-
return this.config.tagPriorities[tagId] ?? this.config.defaultPriority ?? DEFAULT_PRIORITY;
|
|
2150
|
-
}
|
|
2151
|
-
/**
|
|
2152
|
-
* Compute combined priority for a card based on its tags.
|
|
2153
|
-
*/
|
|
2154
|
-
computeCardPriority(cardTags) {
|
|
2155
|
-
if (cardTags.length === 0) {
|
|
2156
|
-
return this.config.defaultPriority ?? DEFAULT_PRIORITY;
|
|
2157
|
-
}
|
|
2158
|
-
const priorities = cardTags.map((tag) => this.getTagPriority(tag));
|
|
2159
|
-
switch (this.config.combineMode) {
|
|
2160
|
-
case "max":
|
|
2161
|
-
return Math.max(...priorities);
|
|
2162
|
-
case "min":
|
|
2163
|
-
return Math.min(...priorities);
|
|
2164
|
-
case "average":
|
|
2165
|
-
return priorities.reduce((sum, p) => sum + p, 0) / priorities.length;
|
|
2166
|
-
default:
|
|
2167
|
-
return Math.max(...priorities);
|
|
2168
|
-
}
|
|
2169
|
-
}
|
|
2170
|
-
/**
|
|
2171
|
-
* Compute boost factor based on priority.
|
|
2172
|
-
*
|
|
2173
|
-
* The formula: 1 + (priority - 0.5) * priorityInfluence
|
|
2174
|
-
*
|
|
2175
|
-
* This creates a multiplier centered around 1.0:
|
|
2176
|
-
* - Priority 1.0 with influence 0.5 → 1.25 (25% boost)
|
|
2177
|
-
* - Priority 0.5 with any influence → 1.00 (neutral)
|
|
2178
|
-
* - Priority 0.0 with influence 0.5 → 0.75 (25% reduction)
|
|
2179
|
-
*/
|
|
2180
|
-
computeBoostFactor(priority) {
|
|
2181
|
-
const influence = this.config.priorityInfluence ?? DEFAULT_PRIORITY_INFLUENCE;
|
|
2182
|
-
return 1 + (priority - 0.5) * influence;
|
|
2183
|
-
}
|
|
2184
|
-
/**
|
|
2185
|
-
* Build human-readable reason for priority adjustment.
|
|
2186
|
-
*/
|
|
2187
|
-
buildPriorityReason(cardTags, priority, boostFactor, finalScore) {
|
|
2188
|
-
if (cardTags.length === 0) {
|
|
2189
|
-
return `No tags, neutral priority (${priority.toFixed(2)})`;
|
|
2190
|
-
}
|
|
2191
|
-
const tagList = cardTags.slice(0, 3).join(", ");
|
|
2192
|
-
const more = cardTags.length > 3 ? ` (+${cardTags.length - 3} more)` : "";
|
|
2193
|
-
if (boostFactor === 1) {
|
|
2194
|
-
return `Neutral priority (${priority.toFixed(2)}) for tags: ${tagList}${more}`;
|
|
2195
|
-
} else if (boostFactor > 1) {
|
|
2196
|
-
return `High-priority tags: ${tagList}${more} (priority ${priority.toFixed(2)} \u2192 boost ${boostFactor.toFixed(2)}x \u2192 ${finalScore.toFixed(2)})`;
|
|
2197
|
-
} else {
|
|
2198
|
-
return `Low-priority tags: ${tagList}${more} (priority ${priority.toFixed(2)} \u2192 reduce ${boostFactor.toFixed(2)}x \u2192 ${finalScore.toFixed(2)})`;
|
|
2199
|
-
}
|
|
2200
|
-
}
|
|
2201
|
-
/**
|
|
2202
|
-
* Get tags for a single card.
|
|
2203
|
-
*/
|
|
2204
|
-
async getCardTags(cardId, course) {
|
|
2205
|
-
try {
|
|
2206
|
-
const tagResponse = await course.getAppliedTags(cardId);
|
|
2207
|
-
return tagResponse.rows.map((r) => r.doc?.name).filter((x) => !!x);
|
|
2208
|
-
} catch {
|
|
2209
|
-
return [];
|
|
2210
|
-
}
|
|
2211
|
-
}
|
|
2212
|
-
/**
|
|
2213
|
-
* CardFilter.transform implementation.
|
|
2214
|
-
*
|
|
2215
|
-
* Apply priority-adjusted scoring. Cards with high-priority tags get boosted,
|
|
2216
|
-
* cards with low-priority tags get reduced scores.
|
|
2217
|
-
*/
|
|
2218
|
-
async transform(cards, context) {
|
|
2219
|
-
const adjusted = await Promise.all(
|
|
2220
|
-
cards.map(async (card) => {
|
|
2221
|
-
const cardTags = await this.getCardTags(card.cardId, context.course);
|
|
2222
|
-
const priority = this.computeCardPriority(cardTags);
|
|
2223
|
-
const boostFactor = this.computeBoostFactor(priority);
|
|
2224
|
-
const finalScore = Math.max(0, Math.min(1, card.score * boostFactor));
|
|
2225
|
-
const action = boostFactor > 1 ? "boosted" : boostFactor < 1 ? "penalized" : "passed";
|
|
2226
|
-
const reason = this.buildPriorityReason(cardTags, priority, boostFactor, finalScore);
|
|
2227
|
-
return {
|
|
2228
|
-
...card,
|
|
2229
|
-
score: finalScore,
|
|
2230
|
-
provenance: [
|
|
2231
|
-
...card.provenance,
|
|
2232
|
-
{
|
|
2233
|
-
strategy: "relativePriority",
|
|
2234
|
-
strategyName: this.strategyName || this.name,
|
|
2235
|
-
strategyId: this.strategyId || "NAVIGATION_STRATEGY-priority",
|
|
2236
|
-
action,
|
|
2237
|
-
score: finalScore,
|
|
2238
|
-
reason
|
|
2239
|
-
}
|
|
2240
|
-
]
|
|
2241
|
-
};
|
|
2242
|
-
})
|
|
2243
|
-
);
|
|
2244
|
-
return adjusted;
|
|
2245
|
-
}
|
|
2246
|
-
/**
|
|
2247
|
-
* Legacy getWeightedCards - now throws as filters should not be used as generators.
|
|
2248
|
-
*
|
|
2249
|
-
* Use transform() via Pipeline instead.
|
|
2250
|
-
*/
|
|
2251
|
-
async getWeightedCards(_limit) {
|
|
2252
|
-
throw new Error(
|
|
2253
|
-
"RelativePriorityNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
|
|
2254
|
-
);
|
|
2255
|
-
}
|
|
2256
|
-
// Legacy methods - stub implementations since filters don't generate cards
|
|
2257
|
-
async getNewCards(_n) {
|
|
2258
|
-
return [];
|
|
2259
|
-
}
|
|
2260
|
-
async getPendingReviews() {
|
|
2261
|
-
return [];
|
|
2262
|
-
}
|
|
2263
|
-
};
|
|
2264
|
-
}
|
|
2265
|
-
});
|
|
2266
|
-
|
|
2267
|
-
// src/core/navigators/srs.ts
|
|
2268
|
-
var srs_exports = {};
|
|
2269
|
-
__export(srs_exports, {
|
|
2270
|
-
default: () => SRSNavigator
|
|
2271
|
-
});
|
|
2272
|
-
import moment3 from "moment";
|
|
2273
|
-
var SRSNavigator;
|
|
2274
|
-
var init_srs = __esm({
|
|
2275
|
-
"src/core/navigators/srs.ts"() {
|
|
2276
|
-
"use strict";
|
|
2277
|
-
init_navigators();
|
|
2278
1519
|
SRSNavigator = class extends ContentNavigator {
|
|
2279
1520
|
/** Human-readable name for CardGenerator interface */
|
|
2280
1521
|
name;
|
|
@@ -2310,6 +1551,7 @@ var init_srs = __esm({
|
|
|
2310
1551
|
cardId: review.cardId,
|
|
2311
1552
|
courseId: review.courseId,
|
|
2312
1553
|
score,
|
|
1554
|
+
reviewID: review._id,
|
|
2313
1555
|
provenance: [
|
|
2314
1556
|
{
|
|
2315
1557
|
strategy: "srs",
|
|
@@ -2322,6 +1564,7 @@ var init_srs = __esm({
|
|
|
2322
1564
|
]
|
|
2323
1565
|
};
|
|
2324
1566
|
});
|
|
1567
|
+
logger.debug(`[srsNav] got ${scored.length} weighted cards`);
|
|
2325
1568
|
return scored.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
2326
1569
|
}
|
|
2327
1570
|
/**
|
|
@@ -2353,235 +1596,102 @@ var init_srs = __esm({
|
|
|
2353
1596
|
const reason = `${Math.round(hoursOverdue)}h overdue (interval: ${Math.round(intervalHours)}h, relative: ${relativeOverdue.toFixed(2)}), recency: ${recencyFactor.toFixed(2)}, review`;
|
|
2354
1597
|
return { score, reason };
|
|
2355
1598
|
}
|
|
2356
|
-
/**
|
|
2357
|
-
* Get pending reviews in legacy format.
|
|
2358
|
-
*
|
|
2359
|
-
* Returns all pending reviews for the course, enriched with session item fields.
|
|
2360
|
-
*/
|
|
2361
|
-
async getPendingReviews() {
|
|
2362
|
-
if (!this.user || !this.course) {
|
|
2363
|
-
throw new Error("SRSNavigator requires user and course to be set");
|
|
2364
|
-
}
|
|
2365
|
-
const reviews = await this.user.getPendingReviews(this.course.getCourseID());
|
|
2366
|
-
return reviews.map((r) => ({
|
|
2367
|
-
...r,
|
|
2368
|
-
contentSourceType: "course",
|
|
2369
|
-
contentSourceID: this.course.getCourseID(),
|
|
2370
|
-
cardID: r.cardId,
|
|
2371
|
-
courseID: r.courseId,
|
|
2372
|
-
qualifiedID: `${r.courseId}-${r.cardId}`,
|
|
2373
|
-
reviewID: r._id,
|
|
2374
|
-
status: "review"
|
|
2375
|
-
}));
|
|
2376
|
-
}
|
|
2377
|
-
/**
|
|
2378
|
-
* SRS does not generate new cards.
|
|
2379
|
-
* Use ELONavigator or another generator for new cards.
|
|
2380
|
-
*/
|
|
2381
|
-
async getNewCards(_n) {
|
|
2382
|
-
return [];
|
|
2383
|
-
}
|
|
2384
1599
|
};
|
|
2385
1600
|
}
|
|
2386
1601
|
});
|
|
2387
1602
|
|
|
2388
|
-
//
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
"./CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
|
|
2394
|
-
"./Pipeline.ts": () => Promise.resolve().then(() => (init_Pipeline(), Pipeline_exports)),
|
|
2395
|
-
"./PipelineAssembler.ts": () => Promise.resolve().then(() => (init_PipelineAssembler(), PipelineAssembler_exports)),
|
|
2396
|
-
"./elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
|
|
2397
|
-
"./filters/eloDistance.ts": () => Promise.resolve().then(() => (init_eloDistance(), eloDistance_exports)),
|
|
2398
|
-
"./filters/index.ts": () => Promise.resolve().then(() => (init_filters(), filters_exports)),
|
|
2399
|
-
"./filters/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports)),
|
|
2400
|
-
"./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
|
|
2401
|
-
"./generators/types.ts": () => Promise.resolve().then(() => (init_types2(), types_exports2)),
|
|
2402
|
-
"./hardcodedOrder.ts": () => Promise.resolve().then(() => (init_hardcodedOrder(), hardcodedOrder_exports)),
|
|
2403
|
-
"./hierarchyDefinition.ts": () => Promise.resolve().then(() => (init_hierarchyDefinition(), hierarchyDefinition_exports)),
|
|
2404
|
-
"./index.ts": () => Promise.resolve().then(() => (init_navigators(), navigators_exports)),
|
|
2405
|
-
"./interferenceMitigator.ts": () => Promise.resolve().then(() => (init_interferenceMitigator(), interferenceMitigator_exports)),
|
|
2406
|
-
"./relativePriority.ts": () => Promise.resolve().then(() => (init_relativePriority(), relativePriority_exports)),
|
|
2407
|
-
"./srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports))
|
|
2408
|
-
});
|
|
2409
|
-
}
|
|
2410
|
-
});
|
|
2411
|
-
|
|
2412
|
-
// src/core/navigators/index.ts
|
|
2413
|
-
var navigators_exports = {};
|
|
2414
|
-
__export(navigators_exports, {
|
|
2415
|
-
ContentNavigator: () => ContentNavigator,
|
|
2416
|
-
NavigatorRole: () => NavigatorRole,
|
|
2417
|
-
NavigatorRoles: () => NavigatorRoles,
|
|
2418
|
-
Navigators: () => Navigators,
|
|
2419
|
-
getCardOrigin: () => getCardOrigin,
|
|
2420
|
-
isFilter: () => isFilter,
|
|
2421
|
-
isGenerator: () => isGenerator
|
|
2422
|
-
});
|
|
2423
|
-
function getCardOrigin(card) {
|
|
2424
|
-
if (card.provenance.length === 0) {
|
|
2425
|
-
throw new Error("Card has no provenance - cannot determine origin");
|
|
2426
|
-
}
|
|
2427
|
-
const firstEntry = card.provenance[0];
|
|
2428
|
-
const reason = firstEntry.reason.toLowerCase();
|
|
2429
|
-
if (reason.includes("failed")) {
|
|
2430
|
-
return "failed";
|
|
2431
|
-
}
|
|
2432
|
-
if (reason.includes("review")) {
|
|
2433
|
-
return "review";
|
|
2434
|
-
}
|
|
2435
|
-
return "new";
|
|
2436
|
-
}
|
|
2437
|
-
function isGenerator(impl) {
|
|
2438
|
-
return NavigatorRoles[impl] === "generator" /* GENERATOR */;
|
|
2439
|
-
}
|
|
2440
|
-
function isFilter(impl) {
|
|
2441
|
-
return NavigatorRoles[impl] === "filter" /* FILTER */;
|
|
1603
|
+
// src/core/navigators/filters/eloDistance.ts
|
|
1604
|
+
function computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier) {
|
|
1605
|
+
const normalizedDistance = distance / halfLife;
|
|
1606
|
+
const decay = Math.exp(-(normalizedDistance * normalizedDistance));
|
|
1607
|
+
return minMultiplier + (maxMultiplier - minMultiplier) * decay;
|
|
2442
1608
|
}
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
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
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
* ## For Generators
|
|
2530
|
-
* Override this method to generate candidates and compute scores based on
|
|
2531
|
-
* your strategy's logic (e.g., ELO proximity, review urgency). Create the
|
|
2532
|
-
* initial provenance entry with action='generated'.
|
|
2533
|
-
*
|
|
2534
|
-
* ## Default Implementation
|
|
2535
|
-
* The base class provides a backward-compatible default that:
|
|
2536
|
-
* 1. Calls legacy getNewCards() and getPendingReviews()
|
|
2537
|
-
* 2. Assigns score=1.0 to all cards
|
|
2538
|
-
* 3. Creates minimal provenance from legacy methods
|
|
2539
|
-
* 4. Returns combined results up to limit
|
|
2540
|
-
*
|
|
2541
|
-
* This allows existing strategies to work without modification while
|
|
2542
|
-
* new strategies can override with proper scoring and provenance.
|
|
2543
|
-
*
|
|
2544
|
-
* @param limit - Maximum cards to return
|
|
2545
|
-
* @returns Cards sorted by score descending, with provenance trails
|
|
2546
|
-
*/
|
|
2547
|
-
async getWeightedCards(limit) {
|
|
2548
|
-
const newCards = await this.getNewCards(limit);
|
|
2549
|
-
const reviews = await this.getPendingReviews();
|
|
2550
|
-
const weighted = [
|
|
2551
|
-
...newCards.map((c) => ({
|
|
2552
|
-
cardId: c.cardID,
|
|
2553
|
-
courseId: c.courseID,
|
|
2554
|
-
score: 1,
|
|
2555
|
-
provenance: [
|
|
2556
|
-
{
|
|
2557
|
-
strategy: "legacy",
|
|
2558
|
-
strategyName: this.strategyName || "Legacy API",
|
|
2559
|
-
strategyId: this.strategyId || "legacy-fallback",
|
|
2560
|
-
action: "generated",
|
|
2561
|
-
score: 1,
|
|
2562
|
-
reason: "Generated via legacy getNewCards(), new card"
|
|
2563
|
-
}
|
|
2564
|
-
]
|
|
2565
|
-
})),
|
|
2566
|
-
...reviews.map((r) => ({
|
|
2567
|
-
cardId: r.cardID,
|
|
2568
|
-
courseId: r.courseID,
|
|
2569
|
-
score: 1,
|
|
2570
|
-
provenance: [
|
|
2571
|
-
{
|
|
2572
|
-
strategy: "legacy",
|
|
2573
|
-
strategyName: this.strategyName || "Legacy API",
|
|
2574
|
-
strategyId: this.strategyId || "legacy-fallback",
|
|
2575
|
-
action: "generated",
|
|
2576
|
-
score: 1,
|
|
2577
|
-
reason: "Generated via legacy getPendingReviews(), review"
|
|
2578
|
-
}
|
|
2579
|
-
]
|
|
2580
|
-
}))
|
|
2581
|
-
];
|
|
2582
|
-
return weighted.slice(0, limit);
|
|
2583
|
-
}
|
|
2584
|
-
};
|
|
1609
|
+
function createEloDistanceFilter(config) {
|
|
1610
|
+
const halfLife = config?.halfLife ?? DEFAULT_HALF_LIFE;
|
|
1611
|
+
const minMultiplier = config?.minMultiplier ?? DEFAULT_MIN_MULTIPLIER;
|
|
1612
|
+
const maxMultiplier = config?.maxMultiplier ?? DEFAULT_MAX_MULTIPLIER;
|
|
1613
|
+
return {
|
|
1614
|
+
name: "ELO Distance Filter",
|
|
1615
|
+
async transform(cards, context) {
|
|
1616
|
+
const { course, userElo } = context;
|
|
1617
|
+
const cardIds = cards.map((c) => c.cardId);
|
|
1618
|
+
const cardElos = await course.getCardEloData(cardIds);
|
|
1619
|
+
return cards.map((card, i) => {
|
|
1620
|
+
const cardElo = cardElos[i]?.global?.score ?? 1e3;
|
|
1621
|
+
const distance = Math.abs(cardElo - userElo);
|
|
1622
|
+
const multiplier = computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier);
|
|
1623
|
+
const newScore = card.score * multiplier;
|
|
1624
|
+
const action = multiplier < maxMultiplier - 0.01 ? "penalized" : "passed";
|
|
1625
|
+
return {
|
|
1626
|
+
...card,
|
|
1627
|
+
score: newScore,
|
|
1628
|
+
provenance: [
|
|
1629
|
+
...card.provenance,
|
|
1630
|
+
{
|
|
1631
|
+
strategy: "eloDistance",
|
|
1632
|
+
strategyName: "ELO Distance Filter",
|
|
1633
|
+
strategyId: "ELO_DISTANCE_FILTER",
|
|
1634
|
+
action,
|
|
1635
|
+
score: newScore,
|
|
1636
|
+
reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userElo)}) \u2192 ${multiplier.toFixed(2)}x`
|
|
1637
|
+
}
|
|
1638
|
+
]
|
|
1639
|
+
};
|
|
1640
|
+
});
|
|
1641
|
+
}
|
|
1642
|
+
};
|
|
1643
|
+
}
|
|
1644
|
+
var DEFAULT_HALF_LIFE, DEFAULT_MIN_MULTIPLIER, DEFAULT_MAX_MULTIPLIER;
|
|
1645
|
+
var init_eloDistance = __esm({
|
|
1646
|
+
"src/core/navigators/filters/eloDistance.ts"() {
|
|
1647
|
+
"use strict";
|
|
1648
|
+
DEFAULT_HALF_LIFE = 200;
|
|
1649
|
+
DEFAULT_MIN_MULTIPLIER = 0.3;
|
|
1650
|
+
DEFAULT_MAX_MULTIPLIER = 1;
|
|
1651
|
+
}
|
|
1652
|
+
});
|
|
1653
|
+
|
|
1654
|
+
// src/core/navigators/defaults.ts
|
|
1655
|
+
function createDefaultEloStrategy(courseId) {
|
|
1656
|
+
return {
|
|
1657
|
+
_id: "NAVIGATION_STRATEGY-ELO-default",
|
|
1658
|
+
docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
|
|
1659
|
+
name: "ELO (default)",
|
|
1660
|
+
description: "Default ELO-based navigation strategy for new cards",
|
|
1661
|
+
implementingClass: "elo" /* ELO */,
|
|
1662
|
+
course: courseId,
|
|
1663
|
+
serializedData: ""
|
|
1664
|
+
};
|
|
1665
|
+
}
|
|
1666
|
+
function createDefaultSrsStrategy(courseId) {
|
|
1667
|
+
return {
|
|
1668
|
+
_id: "NAVIGATION_STRATEGY-SRS-default",
|
|
1669
|
+
docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
|
|
1670
|
+
name: "SRS (default)",
|
|
1671
|
+
description: "Default SRS-based navigation strategy for reviews",
|
|
1672
|
+
implementingClass: "srs" /* SRS */,
|
|
1673
|
+
course: courseId,
|
|
1674
|
+
serializedData: ""
|
|
1675
|
+
};
|
|
1676
|
+
}
|
|
1677
|
+
function createDefaultPipeline(user, course) {
|
|
1678
|
+
const courseId = course.getCourseID();
|
|
1679
|
+
const eloNavigator = new ELONavigator(user, course, createDefaultEloStrategy(courseId));
|
|
1680
|
+
const srsNavigator = new SRSNavigator(user, course, createDefaultSrsStrategy(courseId));
|
|
1681
|
+
const compositeGenerator = new CompositeGenerator([eloNavigator, srsNavigator]);
|
|
1682
|
+
const eloDistanceFilter = createEloDistanceFilter();
|
|
1683
|
+
return new Pipeline(compositeGenerator, [eloDistanceFilter], user, course);
|
|
1684
|
+
}
|
|
1685
|
+
var init_defaults = __esm({
|
|
1686
|
+
"src/core/navigators/defaults.ts"() {
|
|
1687
|
+
"use strict";
|
|
1688
|
+
init_navigators();
|
|
1689
|
+
init_Pipeline();
|
|
1690
|
+
init_CompositeGenerator();
|
|
1691
|
+
init_elo();
|
|
1692
|
+
init_srs();
|
|
1693
|
+
init_eloDistance();
|
|
1694
|
+
init_types_legacy();
|
|
2585
1695
|
}
|
|
2586
1696
|
});
|
|
2587
1697
|
|
|
@@ -2590,7 +1700,7 @@ import {
|
|
|
2590
1700
|
EloToNumber,
|
|
2591
1701
|
Status,
|
|
2592
1702
|
blankCourseElo as blankCourseElo2,
|
|
2593
|
-
toCourseElo as
|
|
1703
|
+
toCourseElo as toCourseElo4
|
|
2594
1704
|
} from "@vue-skuilder/common";
|
|
2595
1705
|
function randIntWeightedTowardZero(n) {
|
|
2596
1706
|
return Math.floor(Math.random() * Math.random() * Math.random() * n);
|
|
@@ -2679,12 +1789,8 @@ var init_courseDB = __esm({
|
|
|
2679
1789
|
init_courseAPI();
|
|
2680
1790
|
init_courseLookupDB();
|
|
2681
1791
|
init_navigators();
|
|
2682
|
-
init_Pipeline();
|
|
2683
1792
|
init_PipelineAssembler();
|
|
2684
|
-
|
|
2685
|
-
init_elo();
|
|
2686
|
-
init_srs();
|
|
2687
|
-
init_eloDistance();
|
|
1793
|
+
init_defaults();
|
|
2688
1794
|
CoursesDB = class {
|
|
2689
1795
|
_courseIDs;
|
|
2690
1796
|
constructor(courseIDs) {
|
|
@@ -2796,7 +1902,7 @@ var init_courseDB = __esm({
|
|
|
2796
1902
|
docs.rows.forEach((r) => {
|
|
2797
1903
|
if (isSuccessRow(r)) {
|
|
2798
1904
|
if (r.doc && r.doc.elo) {
|
|
2799
|
-
ret.push(
|
|
1905
|
+
ret.push(toCourseElo4(r.doc.elo));
|
|
2800
1906
|
} else {
|
|
2801
1907
|
logger.warn("no elo data for card: " + r.id);
|
|
2802
1908
|
ret.push(blankCourseElo2());
|
|
@@ -2865,15 +1971,6 @@ var init_courseDB = __esm({
|
|
|
2865
1971
|
ret[r.id] = r.doc.id_displayable_data;
|
|
2866
1972
|
}
|
|
2867
1973
|
});
|
|
2868
|
-
await Promise.all(
|
|
2869
|
-
cards.rows.map((r) => {
|
|
2870
|
-
return async () => {
|
|
2871
|
-
if (isSuccessRow(r)) {
|
|
2872
|
-
ret[r.id] = r.doc.id_displayable_data;
|
|
2873
|
-
}
|
|
2874
|
-
};
|
|
2875
|
-
})
|
|
2876
|
-
);
|
|
2877
1974
|
return ret;
|
|
2878
1975
|
}
|
|
2879
1976
|
async getCardsByELO(elo, cardLimit) {
|
|
@@ -2958,6 +2055,28 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
2958
2055
|
throw new Error(`Failed to find tags for card ${this.id}-${cardId}`);
|
|
2959
2056
|
}
|
|
2960
2057
|
}
|
|
2058
|
+
async getAppliedTagsBatch(cardIds) {
|
|
2059
|
+
if (cardIds.length === 0) {
|
|
2060
|
+
return /* @__PURE__ */ new Map();
|
|
2061
|
+
}
|
|
2062
|
+
const db = getCourseDB2(this.id);
|
|
2063
|
+
const result = await db.query("getTags", {
|
|
2064
|
+
keys: cardIds,
|
|
2065
|
+
include_docs: false
|
|
2066
|
+
});
|
|
2067
|
+
const tagsByCard = /* @__PURE__ */ new Map();
|
|
2068
|
+
for (const cardId of cardIds) {
|
|
2069
|
+
tagsByCard.set(cardId, []);
|
|
2070
|
+
}
|
|
2071
|
+
for (const row of result.rows) {
|
|
2072
|
+
const cardId = row.key;
|
|
2073
|
+
const tagName = row.value?.name;
|
|
2074
|
+
if (tagName && tagsByCard.has(cardId)) {
|
|
2075
|
+
tagsByCard.get(cardId).push(tagName);
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
return tagsByCard;
|
|
2079
|
+
}
|
|
2961
2080
|
async addTagToCard(cardId, tagId, updateELO) {
|
|
2962
2081
|
return await addTagToCard(
|
|
2963
2082
|
this.id,
|
|
@@ -3085,7 +2204,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3085
2204
|
logger.debug(
|
|
3086
2205
|
"[courseDB] No strategy documents found, using default Pipeline(Composite(ELO, SRS), [eloDistanceFilter])"
|
|
3087
2206
|
);
|
|
3088
|
-
return
|
|
2207
|
+
return createDefaultPipeline(user, this);
|
|
3089
2208
|
}
|
|
3090
2209
|
const assembler = new PipelineAssembler();
|
|
3091
2210
|
const { pipeline, generatorStrategies, filterStrategies, warnings } = await assembler.assemble({
|
|
@@ -3098,7 +2217,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3098
2217
|
}
|
|
3099
2218
|
if (!pipeline) {
|
|
3100
2219
|
logger.debug("[courseDB] Pipeline assembly failed, using default pipeline");
|
|
3101
|
-
return
|
|
2220
|
+
return createDefaultPipeline(user, this);
|
|
3102
2221
|
}
|
|
3103
2222
|
logger.debug(
|
|
3104
2223
|
`[courseDB] Using assembled pipeline with ${generatorStrategies.length} generator(s) and ${filterStrategies.length} filter(s)`
|
|
@@ -3109,69 +2228,12 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3109
2228
|
throw e;
|
|
3110
2229
|
}
|
|
3111
2230
|
}
|
|
3112
|
-
makeDefaultEloStrategy() {
|
|
3113
|
-
return {
|
|
3114
|
-
_id: "NAVIGATION_STRATEGY-ELO-default",
|
|
3115
|
-
docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
|
|
3116
|
-
name: "ELO (default)",
|
|
3117
|
-
description: "Default ELO-based navigation strategy for new cards",
|
|
3118
|
-
implementingClass: "elo" /* ELO */,
|
|
3119
|
-
course: this.id,
|
|
3120
|
-
serializedData: ""
|
|
3121
|
-
};
|
|
3122
|
-
}
|
|
3123
|
-
makeDefaultSrsStrategy() {
|
|
3124
|
-
return {
|
|
3125
|
-
_id: "NAVIGATION_STRATEGY-SRS-default",
|
|
3126
|
-
docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
|
|
3127
|
-
name: "SRS (default)",
|
|
3128
|
-
description: "Default SRS-based navigation strategy for reviews",
|
|
3129
|
-
implementingClass: "srs" /* SRS */,
|
|
3130
|
-
course: this.id,
|
|
3131
|
-
serializedData: ""
|
|
3132
|
-
};
|
|
3133
|
-
}
|
|
3134
|
-
/**
|
|
3135
|
-
* Creates the default navigation pipeline for courses with no configured strategies.
|
|
3136
|
-
*
|
|
3137
|
-
* Default: Pipeline(Composite(ELO, SRS), [eloDistanceFilter])
|
|
3138
|
-
* - ELO generator: scores new cards by skill proximity
|
|
3139
|
-
* - SRS generator: scores reviews by overdueness and interval recency
|
|
3140
|
-
* - ELO distance filter: penalizes cards far from user's current level
|
|
3141
|
-
*/
|
|
3142
|
-
createDefaultPipeline(user) {
|
|
3143
|
-
const eloNavigator = new ELONavigator(user, this, this.makeDefaultEloStrategy());
|
|
3144
|
-
const srsNavigator = new SRSNavigator(user, this, this.makeDefaultSrsStrategy());
|
|
3145
|
-
const compositeGenerator = new CompositeGenerator([eloNavigator, srsNavigator]);
|
|
3146
|
-
const eloDistanceFilter = createEloDistanceFilter();
|
|
3147
|
-
return new Pipeline(compositeGenerator, [eloDistanceFilter], user, this);
|
|
3148
|
-
}
|
|
3149
2231
|
////////////////////////////////////
|
|
3150
2232
|
// END NavigationStrategyManager implementation
|
|
3151
2233
|
////////////////////////////////////
|
|
3152
2234
|
////////////////////////////////////
|
|
3153
2235
|
// StudyContentSource implementation
|
|
3154
2236
|
////////////////////////////////////
|
|
3155
|
-
async getNewCards(limit = 99) {
|
|
3156
|
-
const u = await this._getCurrentUser();
|
|
3157
|
-
try {
|
|
3158
|
-
const navigator = await this.createNavigator(u);
|
|
3159
|
-
return navigator.getNewCards(limit);
|
|
3160
|
-
} catch (e) {
|
|
3161
|
-
logger.error(`[courseDB] Error in getNewCards: ${e}`);
|
|
3162
|
-
throw e;
|
|
3163
|
-
}
|
|
3164
|
-
}
|
|
3165
|
-
async getPendingReviews() {
|
|
3166
|
-
const u = await this._getCurrentUser();
|
|
3167
|
-
try {
|
|
3168
|
-
const navigator = await this.createNavigator(u);
|
|
3169
|
-
return navigator.getPendingReviews();
|
|
3170
|
-
} catch (e) {
|
|
3171
|
-
logger.error(`[courseDB] Error in getPendingReviews: ${e}`);
|
|
3172
|
-
throw e;
|
|
3173
|
-
}
|
|
3174
|
-
}
|
|
3175
2237
|
/**
|
|
3176
2238
|
* Get cards with suitability scores for presentation.
|
|
3177
2239
|
*
|
|
@@ -3411,79 +2473,27 @@ var init_classroomDB2 = __esm({
|
|
|
3411
2473
|
setChangeFcn(f) {
|
|
3412
2474
|
void this.userMessages.on("change", f);
|
|
3413
2475
|
}
|
|
3414
|
-
async getPendingReviews() {
|
|
3415
|
-
const u = this._user;
|
|
3416
|
-
return (await u.getPendingReviews()).filter((r) => r.scheduledFor === "classroom" && r.schedulingAgentId === this._id).map((r) => {
|
|
3417
|
-
return {
|
|
3418
|
-
...r,
|
|
3419
|
-
qualifiedID: `${r.courseId}-${r.cardId}`,
|
|
3420
|
-
courseID: r.courseId,
|
|
3421
|
-
cardID: r.cardId,
|
|
3422
|
-
contentSourceType: "classroom",
|
|
3423
|
-
contentSourceID: this._id,
|
|
3424
|
-
reviewID: r._id,
|
|
3425
|
-
status: "review"
|
|
3426
|
-
};
|
|
3427
|
-
});
|
|
3428
|
-
}
|
|
3429
|
-
async getNewCards() {
|
|
3430
|
-
const activeCards = await this._user.getActiveCards();
|
|
3431
|
-
const now = moment4.utc();
|
|
3432
|
-
const assigned = await this.getAssignedContent();
|
|
3433
|
-
const due = assigned.filter((c) => now.isAfter(moment4.utc(c.activeOn, REVIEW_TIME_FORMAT2)));
|
|
3434
|
-
logger.info(`Due content: ${JSON.stringify(due)}`);
|
|
3435
|
-
let ret = [];
|
|
3436
|
-
for (let i = 0; i < due.length; i++) {
|
|
3437
|
-
const content = due[i];
|
|
3438
|
-
if (content.type === "course") {
|
|
3439
|
-
const db = new CourseDB(content.courseID, async () => this._user);
|
|
3440
|
-
ret = ret.concat(await db.getNewCards());
|
|
3441
|
-
} else if (content.type === "tag") {
|
|
3442
|
-
const tagDoc = await getTag(content.courseID, content.tagID);
|
|
3443
|
-
ret = ret.concat(
|
|
3444
|
-
tagDoc.taggedCards.map((c) => {
|
|
3445
|
-
return {
|
|
3446
|
-
courseID: content.courseID,
|
|
3447
|
-
cardID: c,
|
|
3448
|
-
qualifiedID: `${content.courseID}-${c}`,
|
|
3449
|
-
contentSourceType: "classroom",
|
|
3450
|
-
contentSourceID: this._id,
|
|
3451
|
-
status: "new"
|
|
3452
|
-
};
|
|
3453
|
-
})
|
|
3454
|
-
);
|
|
3455
|
-
} else if (content.type === "card") {
|
|
3456
|
-
ret.push(await getCourseDB2(content.courseID).get(content.cardID));
|
|
3457
|
-
}
|
|
3458
|
-
}
|
|
3459
|
-
logger.info(
|
|
3460
|
-
`New Cards from classroom ${this._cfg.name}: ${ret.map((c) => `${c.courseID}-${c.cardID}`)}`
|
|
3461
|
-
);
|
|
3462
|
-
return ret.filter((c) => {
|
|
3463
|
-
if (activeCards.some((ac) => c.cardID === ac.cardID)) {
|
|
3464
|
-
return false;
|
|
3465
|
-
} else {
|
|
3466
|
-
return true;
|
|
3467
|
-
}
|
|
3468
|
-
});
|
|
3469
|
-
}
|
|
3470
2476
|
/**
|
|
3471
2477
|
* Get cards with suitability scores for presentation.
|
|
3472
2478
|
*
|
|
3473
|
-
*
|
|
3474
|
-
*
|
|
3475
|
-
* support pluggable navigation strategies.
|
|
2479
|
+
* Gathers new cards from assigned content (courses, tags, cards) and
|
|
2480
|
+
* pending reviews scheduled for this classroom. Assigns score=1.0 to all.
|
|
3476
2481
|
*
|
|
3477
2482
|
* @param limit - Maximum number of cards to return
|
|
3478
2483
|
* @returns Cards sorted by score descending (all scores = 1.0)
|
|
3479
2484
|
*/
|
|
3480
2485
|
async getWeightedCards(limit) {
|
|
3481
|
-
const
|
|
3482
|
-
const
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
2486
|
+
const weighted = [];
|
|
2487
|
+
const allUserReviews = await this._user.getPendingReviews();
|
|
2488
|
+
const classroomReviews = allUserReviews.filter(
|
|
2489
|
+
(r) => r.scheduledFor === "classroom" && r.schedulingAgentId === this._id
|
|
2490
|
+
);
|
|
2491
|
+
for (const r of classroomReviews) {
|
|
2492
|
+
weighted.push({
|
|
2493
|
+
cardId: r.cardId,
|
|
2494
|
+
courseId: r.courseId,
|
|
3486
2495
|
score: 1,
|
|
2496
|
+
reviewID: r._id,
|
|
3487
2497
|
provenance: [
|
|
3488
2498
|
{
|
|
3489
2499
|
strategy: "classroom",
|
|
@@ -3491,27 +2501,84 @@ var init_classroomDB2 = __esm({
|
|
|
3491
2501
|
strategyId: "CLASSROOM",
|
|
3492
2502
|
action: "generated",
|
|
3493
2503
|
score: 1,
|
|
3494
|
-
reason: "Classroom
|
|
2504
|
+
reason: "Classroom scheduled review"
|
|
3495
2505
|
}
|
|
3496
2506
|
]
|
|
3497
|
-
})
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
2507
|
+
});
|
|
2508
|
+
}
|
|
2509
|
+
const activeCards = await this._user.getActiveCards();
|
|
2510
|
+
const activeCardIds = new Set(activeCards.map((ac) => ac.cardID));
|
|
2511
|
+
const now = moment4.utc();
|
|
2512
|
+
const assigned = await this.getAssignedContent();
|
|
2513
|
+
const due = assigned.filter((c) => now.isAfter(moment4.utc(c.activeOn, REVIEW_TIME_FORMAT2)));
|
|
2514
|
+
logger.info(`[StudentClassroomDB] Due content: ${JSON.stringify(due)}`);
|
|
2515
|
+
for (const content of due) {
|
|
2516
|
+
if (content.type === "course") {
|
|
2517
|
+
const db = new CourseDB(content.courseID, async () => this._user);
|
|
2518
|
+
const courseCards = await db.getWeightedCards(limit);
|
|
2519
|
+
for (const card of courseCards) {
|
|
2520
|
+
if (!activeCardIds.has(card.cardId)) {
|
|
2521
|
+
weighted.push({
|
|
2522
|
+
...card,
|
|
2523
|
+
provenance: [
|
|
2524
|
+
...card.provenance,
|
|
2525
|
+
{
|
|
2526
|
+
strategy: "classroom",
|
|
2527
|
+
strategyName: "Classroom",
|
|
2528
|
+
strategyId: "CLASSROOM",
|
|
2529
|
+
action: "passed",
|
|
2530
|
+
score: card.score,
|
|
2531
|
+
reason: `Assigned via classroom from course ${content.courseID}`
|
|
2532
|
+
}
|
|
2533
|
+
]
|
|
2534
|
+
});
|
|
3510
2535
|
}
|
|
3511
|
-
|
|
3512
|
-
})
|
|
3513
|
-
|
|
3514
|
-
|
|
2536
|
+
}
|
|
2537
|
+
} else if (content.type === "tag") {
|
|
2538
|
+
const tagDoc = await getTag(content.courseID, content.tagID);
|
|
2539
|
+
for (const cardId of tagDoc.taggedCards) {
|
|
2540
|
+
if (!activeCardIds.has(cardId)) {
|
|
2541
|
+
weighted.push({
|
|
2542
|
+
cardId,
|
|
2543
|
+
courseId: content.courseID,
|
|
2544
|
+
score: 1,
|
|
2545
|
+
provenance: [
|
|
2546
|
+
{
|
|
2547
|
+
strategy: "classroom",
|
|
2548
|
+
strategyName: "Classroom",
|
|
2549
|
+
strategyId: "CLASSROOM",
|
|
2550
|
+
action: "generated",
|
|
2551
|
+
score: 1,
|
|
2552
|
+
reason: `Classroom assigned tag: ${content.tagID}, new card`
|
|
2553
|
+
}
|
|
2554
|
+
]
|
|
2555
|
+
});
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
} else if (content.type === "card") {
|
|
2559
|
+
if (!activeCardIds.has(content.cardID)) {
|
|
2560
|
+
weighted.push({
|
|
2561
|
+
cardId: content.cardID,
|
|
2562
|
+
courseId: content.courseID,
|
|
2563
|
+
score: 1,
|
|
2564
|
+
provenance: [
|
|
2565
|
+
{
|
|
2566
|
+
strategy: "classroom",
|
|
2567
|
+
strategyName: "Classroom",
|
|
2568
|
+
strategyId: "CLASSROOM",
|
|
2569
|
+
action: "generated",
|
|
2570
|
+
score: 1,
|
|
2571
|
+
reason: "Classroom assigned card, new card"
|
|
2572
|
+
}
|
|
2573
|
+
]
|
|
2574
|
+
});
|
|
2575
|
+
}
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2578
|
+
logger.info(
|
|
2579
|
+
`[StudentClassroomDB] New cards from classroom ${this._cfg.name}: ${weighted.length} total (reviews + new)`
|
|
2580
|
+
);
|
|
2581
|
+
return weighted.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
3515
2582
|
}
|
|
3516
2583
|
};
|
|
3517
2584
|
TeacherClassroomDB = class _TeacherClassroomDB extends ClassroomDBBase {
|
|
@@ -3659,8 +2726,7 @@ var init_adminDB2 = __esm({
|
|
|
3659
2726
|
}
|
|
3660
2727
|
}
|
|
3661
2728
|
}
|
|
3662
|
-
|
|
3663
|
-
return dbs.map((db) => {
|
|
2729
|
+
return promisedCRDbs.map((db) => {
|
|
3664
2730
|
return {
|
|
3665
2731
|
...db.getConfig(),
|
|
3666
2732
|
_id: db._id
|
|
@@ -3948,8 +3014,8 @@ import moment5 from "moment";
|
|
|
3948
3014
|
import process2 from "process";
|
|
3949
3015
|
function createPouchDBConfig() {
|
|
3950
3016
|
const hasExplicitCredentials = ENV.COUCHDB_USERNAME && ENV.COUCHDB_PASSWORD;
|
|
3951
|
-
const
|
|
3952
|
-
if (hasExplicitCredentials &&
|
|
3017
|
+
const isNodeEnvironment = typeof window === "undefined";
|
|
3018
|
+
if (hasExplicitCredentials && isNodeEnvironment) {
|
|
3953
3019
|
return {
|
|
3954
3020
|
fetch(url, opts = {}) {
|
|
3955
3021
|
const basicAuth = btoa(`${ENV.COUCHDB_USERNAME}:${ENV.COUCHDB_PASSWORD}`);
|
|
@@ -4033,7 +3099,9 @@ import moment6 from "moment";
|
|
|
4033
3099
|
function accomodateGuest() {
|
|
4034
3100
|
logger.log("[funnel] accomodateGuest() called");
|
|
4035
3101
|
if (typeof localStorage === "undefined") {
|
|
4036
|
-
logger.log(
|
|
3102
|
+
logger.log(
|
|
3103
|
+
"[funnel] localStorage not available (Node.js environment), returning default guest"
|
|
3104
|
+
);
|
|
4037
3105
|
return {
|
|
4038
3106
|
username: GuestUsername + "nodejs-test",
|
|
4039
3107
|
firstVisit: true
|
|
@@ -5011,6 +4079,55 @@ Currently logged-in as ${this._username}.`
|
|
|
5011
4079
|
async updateUserElo(courseId, elo) {
|
|
5012
4080
|
return updateUserElo(this._username, courseId, elo);
|
|
5013
4081
|
}
|
|
4082
|
+
async getStrategyState(courseId, strategyKey) {
|
|
4083
|
+
const docId = buildStrategyStateId(courseId, strategyKey);
|
|
4084
|
+
try {
|
|
4085
|
+
const doc = await this.localDB.get(docId);
|
|
4086
|
+
return doc.data;
|
|
4087
|
+
} catch (e) {
|
|
4088
|
+
const err = e;
|
|
4089
|
+
if (err.status === 404) {
|
|
4090
|
+
return null;
|
|
4091
|
+
}
|
|
4092
|
+
throw e;
|
|
4093
|
+
}
|
|
4094
|
+
}
|
|
4095
|
+
async putStrategyState(courseId, strategyKey, data) {
|
|
4096
|
+
const docId = buildStrategyStateId(courseId, strategyKey);
|
|
4097
|
+
let existingRev;
|
|
4098
|
+
try {
|
|
4099
|
+
const existing = await this.localDB.get(docId);
|
|
4100
|
+
existingRev = existing._rev;
|
|
4101
|
+
} catch (e) {
|
|
4102
|
+
const err = e;
|
|
4103
|
+
if (err.status !== 404) {
|
|
4104
|
+
throw e;
|
|
4105
|
+
}
|
|
4106
|
+
}
|
|
4107
|
+
const doc = {
|
|
4108
|
+
_id: docId,
|
|
4109
|
+
_rev: existingRev,
|
|
4110
|
+
docType: "STRATEGY_STATE" /* STRATEGY_STATE */,
|
|
4111
|
+
courseId,
|
|
4112
|
+
strategyKey,
|
|
4113
|
+
data,
|
|
4114
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
4115
|
+
};
|
|
4116
|
+
await this.localDB.put(doc);
|
|
4117
|
+
}
|
|
4118
|
+
async deleteStrategyState(courseId, strategyKey) {
|
|
4119
|
+
const docId = buildStrategyStateId(courseId, strategyKey);
|
|
4120
|
+
try {
|
|
4121
|
+
const doc = await this.localDB.get(docId);
|
|
4122
|
+
await this.localDB.remove(doc);
|
|
4123
|
+
} catch (e) {
|
|
4124
|
+
const err = e;
|
|
4125
|
+
if (err.status === 404) {
|
|
4126
|
+
return;
|
|
4127
|
+
}
|
|
4128
|
+
throw e;
|
|
4129
|
+
}
|
|
4130
|
+
}
|
|
5014
4131
|
};
|
|
5015
4132
|
userCoursesDoc = "CourseRegistrations";
|
|
5016
4133
|
userClassroomsDoc = "ClassroomRegistrations";
|
|
@@ -5058,8 +4175,8 @@ var init_PouchDataLayerProvider = __esm({
|
|
|
5058
4175
|
}
|
|
5059
4176
|
async initialize() {
|
|
5060
4177
|
if (this.initialized) return;
|
|
5061
|
-
const
|
|
5062
|
-
if (
|
|
4178
|
+
const isNodeEnvironment = typeof process !== "undefined" && process.versions != null && process.versions.node != null;
|
|
4179
|
+
if (isNodeEnvironment) {
|
|
5063
4180
|
logger.info(
|
|
5064
4181
|
"CouchDataLayerProvider: Running in Node.js environment, creating guest UserDB for testing."
|
|
5065
4182
|
);
|
|
@@ -5121,11 +4238,11 @@ var init_StaticDataUnpacker = __esm({
|
|
|
5121
4238
|
init_logger();
|
|
5122
4239
|
init_core();
|
|
5123
4240
|
pathUtils = {
|
|
5124
|
-
isAbsolute: (
|
|
5125
|
-
if (/^[a-zA-Z]:[\\/]/.test(
|
|
4241
|
+
isAbsolute: (path2) => {
|
|
4242
|
+
if (/^[a-zA-Z]:[\\/]/.test(path2) || /^\\\\/.test(path2)) {
|
|
5126
4243
|
return true;
|
|
5127
4244
|
}
|
|
5128
|
-
if (
|
|
4245
|
+
if (path2.startsWith("/")) {
|
|
5129
4246
|
return true;
|
|
5130
4247
|
}
|
|
5131
4248
|
return false;
|
|
@@ -5172,6 +4289,36 @@ var init_StaticDataUnpacker = __esm({
|
|
|
5172
4289
|
logger.error(`Document ${id} not found in chunk ${chunk.id}`);
|
|
5173
4290
|
throw new Error(`Document ${id} not found in chunk ${chunk.id}`);
|
|
5174
4291
|
}
|
|
4292
|
+
/**
|
|
4293
|
+
* Get all documents with IDs starting with a specific prefix.
|
|
4294
|
+
*
|
|
4295
|
+
* This method loads the relevant chunk(s) and returns all matching documents.
|
|
4296
|
+
* Useful for querying documents by type (e.g., all NAVIGATION_STRATEGY documents).
|
|
4297
|
+
*
|
|
4298
|
+
* @param prefix - Document ID prefix to match (e.g., "NAVIGATION_STRATEGY")
|
|
4299
|
+
* @returns Array of all documents with IDs starting with the prefix
|
|
4300
|
+
*/
|
|
4301
|
+
async getAllDocumentsByPrefix(prefix) {
|
|
4302
|
+
const relevantChunks = this.manifest.chunks.filter((chunk) => {
|
|
4303
|
+
const prefixEnd = prefix + "\uFFF0";
|
|
4304
|
+
return chunk.startKey <= prefixEnd && chunk.endKey >= prefix;
|
|
4305
|
+
});
|
|
4306
|
+
if (relevantChunks.length === 0) {
|
|
4307
|
+
logger.debug(`[StaticDataUnpacker] No chunks found for prefix: ${prefix}`);
|
|
4308
|
+
return [];
|
|
4309
|
+
}
|
|
4310
|
+
await Promise.all(relevantChunks.map((chunk) => this.loadChunk(chunk.id)));
|
|
4311
|
+
const matchingDocs = [];
|
|
4312
|
+
for (const [docId, doc] of this.documentCache.entries()) {
|
|
4313
|
+
if (docId.startsWith(prefix)) {
|
|
4314
|
+
matchingDocs.push(await this.hydrateAttachments(doc));
|
|
4315
|
+
}
|
|
4316
|
+
}
|
|
4317
|
+
logger.debug(
|
|
4318
|
+
`[StaticDataUnpacker] Found ${matchingDocs.length} documents with prefix: ${prefix}`
|
|
4319
|
+
);
|
|
4320
|
+
return matchingDocs;
|
|
4321
|
+
}
|
|
5175
4322
|
/**
|
|
5176
4323
|
* Query cards by ELO score, returning card IDs sorted by ELO
|
|
5177
4324
|
*/
|
|
@@ -5208,7 +4355,14 @@ var init_StaticDataUnpacker = __esm({
|
|
|
5208
4355
|
* Get all tag names mapped to their card arrays
|
|
5209
4356
|
*/
|
|
5210
4357
|
async getTagsIndex() {
|
|
5211
|
-
|
|
4358
|
+
try {
|
|
4359
|
+
return await this.loadIndex("tags");
|
|
4360
|
+
} catch {
|
|
4361
|
+
return {
|
|
4362
|
+
byCard: {},
|
|
4363
|
+
byTag: {}
|
|
4364
|
+
};
|
|
4365
|
+
}
|
|
5212
4366
|
}
|
|
5213
4367
|
getDocTypeFromId(id) {
|
|
5214
4368
|
for (const docTypeKey in DocTypePrefixes) {
|
|
@@ -5499,8 +4653,9 @@ var init_courseDB2 = __esm({
|
|
|
5499
4653
|
"src/impl/static/courseDB.ts"() {
|
|
5500
4654
|
"use strict";
|
|
5501
4655
|
init_types_legacy();
|
|
5502
|
-
init_navigators();
|
|
5503
4656
|
init_logger();
|
|
4657
|
+
init_defaults();
|
|
4658
|
+
init_PipelineAssembler();
|
|
5504
4659
|
StaticCourseDB = class {
|
|
5505
4660
|
constructor(courseId, unpacker, userDB, manifest) {
|
|
5506
4661
|
this.courseId = courseId;
|
|
@@ -5579,21 +4734,6 @@ var init_courseDB2 = __esm({
|
|
|
5579
4734
|
async updateCardElo(cardId, _elo) {
|
|
5580
4735
|
return { ok: true, id: cardId, rev: "1-static" };
|
|
5581
4736
|
}
|
|
5582
|
-
async getNewCards(limit = 99) {
|
|
5583
|
-
const activeCards = await this.userDB.getActiveCards();
|
|
5584
|
-
return (await this.getCardsCenteredAtELO({ limit, elo: "user" }, (c) => {
|
|
5585
|
-
if (activeCards.some((ac) => c.cardID === ac.cardID)) {
|
|
5586
|
-
return false;
|
|
5587
|
-
} else {
|
|
5588
|
-
return true;
|
|
5589
|
-
}
|
|
5590
|
-
})).map((c) => {
|
|
5591
|
-
return {
|
|
5592
|
-
...c,
|
|
5593
|
-
status: "new"
|
|
5594
|
-
};
|
|
5595
|
-
});
|
|
5596
|
-
}
|
|
5597
4737
|
async getCardsCenteredAtELO(options, filter) {
|
|
5598
4738
|
let targetElo = typeof options.elo === "number" ? options.elo : 1e3;
|
|
5599
4739
|
if (options.elo === "user") {
|
|
@@ -5678,6 +4818,14 @@ var init_courseDB2 = __esm({
|
|
|
5678
4818
|
};
|
|
5679
4819
|
}
|
|
5680
4820
|
}
|
|
4821
|
+
async getAppliedTagsBatch(cardIds) {
|
|
4822
|
+
const tagsIndex = await this.unpacker.getTagsIndex();
|
|
4823
|
+
const tagsByCard = /* @__PURE__ */ new Map();
|
|
4824
|
+
for (const cardId of cardIds) {
|
|
4825
|
+
tagsByCard.set(cardId, tagsIndex.byCard[cardId] || []);
|
|
4826
|
+
}
|
|
4827
|
+
return tagsByCard;
|
|
4828
|
+
}
|
|
5681
4829
|
async addTagToCard(_cardId, _tagId) {
|
|
5682
4830
|
throw new Error("Cannot modify tags in static mode");
|
|
5683
4831
|
}
|
|
@@ -5771,19 +4919,23 @@ var init_courseDB2 = __esm({
|
|
|
5771
4919
|
return [];
|
|
5772
4920
|
}
|
|
5773
4921
|
// Navigation Strategy Manager implementation
|
|
5774
|
-
async getNavigationStrategy(
|
|
5775
|
-
|
|
5776
|
-
|
|
5777
|
-
|
|
5778
|
-
|
|
5779
|
-
|
|
5780
|
-
|
|
5781
|
-
course: this.courseId,
|
|
5782
|
-
serializedData: ""
|
|
5783
|
-
};
|
|
4922
|
+
async getNavigationStrategy(id) {
|
|
4923
|
+
try {
|
|
4924
|
+
return await this.unpacker.getDocument(id);
|
|
4925
|
+
} catch (error) {
|
|
4926
|
+
logger.error(`[static/courseDB] Strategy ${id} not found: ${error}`);
|
|
4927
|
+
throw error;
|
|
4928
|
+
}
|
|
5784
4929
|
}
|
|
5785
4930
|
async getAllNavigationStrategies() {
|
|
5786
|
-
|
|
4931
|
+
const prefix = DocTypePrefixes["NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */];
|
|
4932
|
+
try {
|
|
4933
|
+
const docs = await this.unpacker.getAllDocumentsByPrefix(prefix);
|
|
4934
|
+
return docs;
|
|
4935
|
+
} catch (error) {
|
|
4936
|
+
logger.warn(`[static/courseDB] Error loading navigation strategies: ${error}`);
|
|
4937
|
+
return [];
|
|
4938
|
+
}
|
|
5787
4939
|
}
|
|
5788
4940
|
async addNavigationStrategy(_data) {
|
|
5789
4941
|
throw new Error("Cannot add navigation strategies in static mode");
|
|
@@ -5791,9 +4943,52 @@ var init_courseDB2 = __esm({
|
|
|
5791
4943
|
async updateNavigationStrategy(_id, _data) {
|
|
5792
4944
|
throw new Error("Cannot update navigation strategies in static mode");
|
|
5793
4945
|
}
|
|
4946
|
+
/**
|
|
4947
|
+
* Create a ContentNavigator for this course.
|
|
4948
|
+
*
|
|
4949
|
+
* Loads navigation strategy documents from static data and uses PipelineAssembler
|
|
4950
|
+
* to build a Pipeline. Falls back to default pipeline if no strategies found.
|
|
4951
|
+
*/
|
|
4952
|
+
async createNavigator(user) {
|
|
4953
|
+
try {
|
|
4954
|
+
const allStrategies = await this.getAllNavigationStrategies();
|
|
4955
|
+
if (allStrategies.length === 0) {
|
|
4956
|
+
logger.debug(
|
|
4957
|
+
"[static/courseDB] No strategy documents found, using default Pipeline(Composite(ELO, SRS), [eloDistanceFilter])"
|
|
4958
|
+
);
|
|
4959
|
+
return createDefaultPipeline(user, this);
|
|
4960
|
+
}
|
|
4961
|
+
const assembler = new PipelineAssembler();
|
|
4962
|
+
const { pipeline, generatorStrategies, filterStrategies, warnings } = await assembler.assemble({
|
|
4963
|
+
strategies: allStrategies,
|
|
4964
|
+
user,
|
|
4965
|
+
course: this
|
|
4966
|
+
});
|
|
4967
|
+
for (const warning of warnings) {
|
|
4968
|
+
logger.warn(`[PipelineAssembler] ${warning}`);
|
|
4969
|
+
}
|
|
4970
|
+
if (!pipeline) {
|
|
4971
|
+
logger.debug("[static/courseDB] Pipeline assembly failed, using default pipeline");
|
|
4972
|
+
return createDefaultPipeline(user, this);
|
|
4973
|
+
}
|
|
4974
|
+
logger.debug(
|
|
4975
|
+
`[static/courseDB] Using assembled pipeline with ${generatorStrategies.length} generator(s) and ${filterStrategies.length} filter(s)`
|
|
4976
|
+
);
|
|
4977
|
+
return pipeline;
|
|
4978
|
+
} catch (e) {
|
|
4979
|
+
logger.error(`[static/courseDB] Error creating navigator: ${e}`);
|
|
4980
|
+
throw e;
|
|
4981
|
+
}
|
|
4982
|
+
}
|
|
5794
4983
|
// Study Content Source implementation
|
|
5795
|
-
async
|
|
5796
|
-
|
|
4984
|
+
async getWeightedCards(limit) {
|
|
4985
|
+
try {
|
|
4986
|
+
const navigator = await this.createNavigator(this.userDB);
|
|
4987
|
+
return navigator.getWeightedCards(limit);
|
|
4988
|
+
} catch (e) {
|
|
4989
|
+
logger.error(`[static/courseDB] Error getting weighted cards: ${e}`);
|
|
4990
|
+
throw e;
|
|
4991
|
+
}
|
|
5797
4992
|
}
|
|
5798
4993
|
// Attachment helper methods (internal use, not part of interface)
|
|
5799
4994
|
/**
|
|
@@ -6192,108 +5387,71 @@ var init_TagFilteredContentSource = __esm({
|
|
|
6192
5387
|
return finalCardIds;
|
|
6193
5388
|
}
|
|
6194
5389
|
/**
|
|
6195
|
-
*
|
|
5390
|
+
* Get cards with suitability scores for presentation.
|
|
5391
|
+
*
|
|
5392
|
+
* Filters cards by tag inclusion/exclusion and assigns score=1.0 to all.
|
|
5393
|
+
* TagFilteredContentSource does not currently support pluggable navigation
|
|
5394
|
+
* strategies - it returns flat-scored candidates.
|
|
5395
|
+
*
|
|
5396
|
+
* @param limit - Maximum number of cards to return
|
|
5397
|
+
* @returns Cards sorted by score descending (all scores = 1.0)
|
|
6196
5398
|
*/
|
|
6197
|
-
async
|
|
5399
|
+
async getWeightedCards(limit) {
|
|
6198
5400
|
if (!hasActiveFilter(this.filter)) {
|
|
6199
|
-
logger.warn("[TagFilteredContentSource]
|
|
5401
|
+
logger.warn("[TagFilteredContentSource] getWeightedCards called with no active filter");
|
|
6200
5402
|
return [];
|
|
6201
5403
|
}
|
|
6202
5404
|
const eligibleCardIds = await this.resolveFilteredCardIds();
|
|
6203
5405
|
const activeCards = await this.user.getActiveCards();
|
|
6204
5406
|
const activeCardIds = new Set(activeCards.map((c) => c.cardID));
|
|
6205
|
-
const
|
|
5407
|
+
const newCardWeighted = [];
|
|
6206
5408
|
for (const cardId of eligibleCardIds) {
|
|
6207
5409
|
if (!activeCardIds.has(cardId)) {
|
|
6208
|
-
|
|
6209
|
-
|
|
6210
|
-
|
|
6211
|
-
|
|
6212
|
-
|
|
6213
|
-
|
|
5410
|
+
newCardWeighted.push({
|
|
5411
|
+
cardId,
|
|
5412
|
+
courseId: this.courseId,
|
|
5413
|
+
score: 1,
|
|
5414
|
+
provenance: [
|
|
5415
|
+
{
|
|
5416
|
+
strategy: "tagFilter",
|
|
5417
|
+
strategyName: "Tag Filter",
|
|
5418
|
+
strategyId: "TAG_FILTER",
|
|
5419
|
+
action: "generated",
|
|
5420
|
+
score: 1,
|
|
5421
|
+
reason: `Tag-filtered new card (tags: ${this.filter.include.join(", ")})`
|
|
5422
|
+
}
|
|
5423
|
+
]
|
|
6214
5424
|
});
|
|
6215
5425
|
}
|
|
6216
|
-
if (
|
|
5426
|
+
if (newCardWeighted.length >= limit) {
|
|
6217
5427
|
break;
|
|
6218
5428
|
}
|
|
6219
5429
|
}
|
|
6220
|
-
logger.info(
|
|
6221
|
-
|
|
6222
|
-
|
|
6223
|
-
/**
|
|
6224
|
-
* Gets pending reviews, filtered to only include cards that match the tag filter.
|
|
6225
|
-
*/
|
|
6226
|
-
async getPendingReviews() {
|
|
6227
|
-
if (!hasActiveFilter(this.filter)) {
|
|
6228
|
-
logger.warn("[TagFilteredContentSource] getPendingReviews called with no active filter");
|
|
6229
|
-
return [];
|
|
6230
|
-
}
|
|
6231
|
-
const eligibleCardIds = await this.resolveFilteredCardIds();
|
|
5430
|
+
logger.info(
|
|
5431
|
+
`[TagFilteredContentSource] Found ${newCardWeighted.length} new cards matching filter`
|
|
5432
|
+
);
|
|
6232
5433
|
const allReviews = await this.user.getPendingReviews(this.courseId);
|
|
6233
|
-
const filteredReviews = allReviews.filter((review) =>
|
|
6234
|
-
return eligibleCardIds.has(review.cardId);
|
|
6235
|
-
});
|
|
5434
|
+
const filteredReviews = allReviews.filter((review) => eligibleCardIds.has(review.cardId));
|
|
6236
5435
|
logger.info(
|
|
6237
5436
|
`[TagFilteredContentSource] Found ${filteredReviews.length} pending reviews matching filter (of ${allReviews.length} total)`
|
|
6238
5437
|
);
|
|
6239
|
-
|
|
6240
|
-
|
|
6241
|
-
|
|
6242
|
-
|
|
6243
|
-
contentSourceType: "course",
|
|
6244
|
-
contentSourceID: this.courseId,
|
|
5438
|
+
const reviewWeighted = filteredReviews.map((r) => ({
|
|
5439
|
+
cardId: r.cardId,
|
|
5440
|
+
courseId: r.courseId,
|
|
5441
|
+
score: 1,
|
|
6245
5442
|
reviewID: r._id,
|
|
6246
|
-
|
|
5443
|
+
provenance: [
|
|
5444
|
+
{
|
|
5445
|
+
strategy: "tagFilter",
|
|
5446
|
+
strategyName: "Tag Filter",
|
|
5447
|
+
strategyId: "TAG_FILTER",
|
|
5448
|
+
action: "generated",
|
|
5449
|
+
score: 1,
|
|
5450
|
+
reason: `Tag-filtered review (tags: ${this.filter.include.join(", ")})`
|
|
5451
|
+
}
|
|
5452
|
+
]
|
|
6247
5453
|
}));
|
|
6248
|
-
|
|
6249
|
-
/**
|
|
6250
|
-
* Get cards with suitability scores for presentation.
|
|
6251
|
-
*
|
|
6252
|
-
* This implementation wraps the legacy getNewCards/getPendingReviews methods,
|
|
6253
|
-
* assigning score=1.0 to all cards. TagFilteredContentSource does not currently
|
|
6254
|
-
* support pluggable navigation strategies - it returns flat-scored candidates.
|
|
6255
|
-
*
|
|
6256
|
-
* @param limit - Maximum number of cards to return
|
|
6257
|
-
* @returns Cards sorted by score descending (all scores = 1.0)
|
|
6258
|
-
*/
|
|
6259
|
-
async getWeightedCards(limit) {
|
|
6260
|
-
const [newCards, reviews] = await Promise.all([
|
|
6261
|
-
this.getNewCards(limit),
|
|
6262
|
-
this.getPendingReviews()
|
|
6263
|
-
]);
|
|
6264
|
-
const weighted = [
|
|
6265
|
-
...reviews.map((r) => ({
|
|
6266
|
-
cardId: r.cardID,
|
|
6267
|
-
courseId: r.courseID,
|
|
6268
|
-
score: 1,
|
|
6269
|
-
provenance: [
|
|
6270
|
-
{
|
|
6271
|
-
strategy: "tagFilter",
|
|
6272
|
-
strategyName: "Tag Filter",
|
|
6273
|
-
strategyId: "TAG_FILTER",
|
|
6274
|
-
action: "generated",
|
|
6275
|
-
score: 1,
|
|
6276
|
-
reason: `Tag-filtered review (tags: ${this.filter.include.join(", ")})`
|
|
6277
|
-
}
|
|
6278
|
-
]
|
|
6279
|
-
})),
|
|
6280
|
-
...newCards.map((c) => ({
|
|
6281
|
-
cardId: c.cardID,
|
|
6282
|
-
courseId: c.courseID,
|
|
6283
|
-
score: 1,
|
|
6284
|
-
provenance: [
|
|
6285
|
-
{
|
|
6286
|
-
strategy: "tagFilter",
|
|
6287
|
-
strategyName: "Tag Filter",
|
|
6288
|
-
strategyId: "TAG_FILTER",
|
|
6289
|
-
action: "generated",
|
|
6290
|
-
score: 1,
|
|
6291
|
-
reason: `Tag-filtered new card (tags: ${this.filter.include.join(", ")})`
|
|
6292
|
-
}
|
|
6293
|
-
]
|
|
6294
|
-
}))
|
|
6295
|
-
];
|
|
6296
|
-
return weighted.slice(0, limit);
|
|
5454
|
+
return [...reviewWeighted, ...newCardWeighted].slice(0, limit);
|
|
6297
5455
|
}
|
|
6298
5456
|
/**
|
|
6299
5457
|
* Clears the cached resolved card IDs.
|
|
@@ -6384,6 +5542,16 @@ var init_user = __esm({
|
|
|
6384
5542
|
}
|
|
6385
5543
|
});
|
|
6386
5544
|
|
|
5545
|
+
// src/core/types/strategyState.ts
|
|
5546
|
+
function buildStrategyStateId(courseId, strategyKey) {
|
|
5547
|
+
return `STRATEGY_STATE::${courseId}::${strategyKey}`;
|
|
5548
|
+
}
|
|
5549
|
+
var init_strategyState = __esm({
|
|
5550
|
+
"src/core/types/strategyState.ts"() {
|
|
5551
|
+
"use strict";
|
|
5552
|
+
}
|
|
5553
|
+
});
|
|
5554
|
+
|
|
6387
5555
|
// src/core/bulkImport/cardProcessor.ts
|
|
6388
5556
|
import { Status as Status5 } from "@vue-skuilder/common";
|
|
6389
5557
|
async function importParsedCards(parsedCards, courseDB, config) {
|
|
@@ -6505,7 +5673,7 @@ var init_cardProcessor = __esm({
|
|
|
6505
5673
|
});
|
|
6506
5674
|
|
|
6507
5675
|
// src/core/bulkImport/types.ts
|
|
6508
|
-
var
|
|
5676
|
+
var init_types = __esm({
|
|
6509
5677
|
"src/core/bulkImport/types.ts"() {
|
|
6510
5678
|
"use strict";
|
|
6511
5679
|
}
|
|
@@ -6516,7 +5684,7 @@ var init_bulkImport = __esm({
|
|
|
6516
5684
|
"src/core/bulkImport/index.ts"() {
|
|
6517
5685
|
"use strict";
|
|
6518
5686
|
init_cardProcessor();
|
|
6519
|
-
|
|
5687
|
+
init_types();
|
|
6520
5688
|
}
|
|
6521
5689
|
});
|
|
6522
5690
|
|
|
@@ -6527,6 +5695,7 @@ var init_core = __esm({
|
|
|
6527
5695
|
init_interfaces();
|
|
6528
5696
|
init_types_legacy();
|
|
6529
5697
|
init_user();
|
|
5698
|
+
init_strategyState();
|
|
6530
5699
|
init_Loggable();
|
|
6531
5700
|
init_util();
|
|
6532
5701
|
init_navigators();
|
|
@@ -6649,7 +5818,7 @@ var SrsService = class {
|
|
|
6649
5818
|
|
|
6650
5819
|
// src/study/services/EloService.ts
|
|
6651
5820
|
init_logger();
|
|
6652
|
-
import { adjustCourseScores, toCourseElo as
|
|
5821
|
+
import { adjustCourseScores, toCourseElo as toCourseElo5 } from "@vue-skuilder/common";
|
|
6653
5822
|
var EloService = class {
|
|
6654
5823
|
dataLayer;
|
|
6655
5824
|
user;
|
|
@@ -6671,7 +5840,7 @@ var EloService = class {
|
|
|
6671
5840
|
logger.warn(`k value interpretation not currently implemented`);
|
|
6672
5841
|
}
|
|
6673
5842
|
const courseDB = this.dataLayer.getCourseDB(currentCard.card.course_id);
|
|
6674
|
-
const userElo =
|
|
5843
|
+
const userElo = toCourseElo5(userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo);
|
|
6675
5844
|
const cardElo = (await courseDB.getCardEloData([currentCard.card.card_id]))[0];
|
|
6676
5845
|
if (cardElo && userElo) {
|
|
6677
5846
|
const eloUpdate = adjustCourseScores(userElo, cardElo, userScore);
|
|
@@ -6880,156 +6049,124 @@ init_logger();
|
|
|
6880
6049
|
import {
|
|
6881
6050
|
displayableDataToViewData,
|
|
6882
6051
|
isCourseElo,
|
|
6883
|
-
toCourseElo as
|
|
6052
|
+
toCourseElo as toCourseElo6
|
|
6884
6053
|
} from "@vue-skuilder/common";
|
|
6885
|
-
|
|
6886
|
-
|
|
6887
|
-
|
|
6888
|
-
|
|
6889
|
-
|
|
6890
|
-
|
|
6891
|
-
|
|
6892
|
-
|
|
6893
|
-
|
|
6894
|
-
|
|
6895
|
-
|
|
6896
|
-
|
|
6897
|
-
}
|
|
6898
|
-
|
|
6899
|
-
|
|
6900
|
-
|
|
6901
|
-
|
|
6902
|
-
|
|
6903
|
-
|
|
6904
|
-
|
|
6905
|
-
|
|
6906
|
-
|
|
6907
|
-
|
|
6908
|
-
|
|
6909
|
-
|
|
6910
|
-
dequeue(cardIdExtractor) {
|
|
6911
|
-
if (this.q.length !== 0) {
|
|
6912
|
-
this._dequeueCount++;
|
|
6913
|
-
const item = this.q.splice(0, 1)[0];
|
|
6914
|
-
if (cardIdExtractor) {
|
|
6915
|
-
const cardId = cardIdExtractor(item);
|
|
6916
|
-
const index = this.seenCardIds.indexOf(cardId);
|
|
6917
|
-
if (index > -1) {
|
|
6918
|
-
this.seenCardIds.splice(index, 1);
|
|
6919
|
-
}
|
|
6920
|
-
}
|
|
6921
|
-
return item;
|
|
6922
|
-
} else {
|
|
6923
|
-
return null;
|
|
6924
|
-
}
|
|
6925
|
-
}
|
|
6926
|
-
get toString() {
|
|
6927
|
-
return `${typeof this.q[0]}:
|
|
6928
|
-
` + this.q.map((i) => ` ${i.courseID}+${i.cardID}: ${i.status}`).join("\n");
|
|
6929
|
-
}
|
|
6930
|
-
};
|
|
6931
|
-
|
|
6932
|
-
// src/study/services/CardHydrationService.ts
|
|
6054
|
+
function parseAudioURIs(data) {
|
|
6055
|
+
if (typeof data !== "string") return [];
|
|
6056
|
+
const audioPattern = /https?:\/\/[^\s"'<>]+\.(wav|mp3|ogg|m4a|aac|webm)/gi;
|
|
6057
|
+
return data.match(audioPattern) ?? [];
|
|
6058
|
+
}
|
|
6059
|
+
function prefetchAudio(url) {
|
|
6060
|
+
return new Promise((resolve) => {
|
|
6061
|
+
const audio = new Audio();
|
|
6062
|
+
audio.preload = "auto";
|
|
6063
|
+
const cleanup = () => {
|
|
6064
|
+
audio.oncanplaythrough = null;
|
|
6065
|
+
audio.onerror = null;
|
|
6066
|
+
};
|
|
6067
|
+
audio.oncanplaythrough = () => {
|
|
6068
|
+
cleanup();
|
|
6069
|
+
resolve();
|
|
6070
|
+
};
|
|
6071
|
+
audio.onerror = () => {
|
|
6072
|
+
cleanup();
|
|
6073
|
+
logger.warn(`[CardHydrationService] Failed to prefetch audio: ${url}`);
|
|
6074
|
+
resolve();
|
|
6075
|
+
};
|
|
6076
|
+
audio.src = url;
|
|
6077
|
+
});
|
|
6078
|
+
}
|
|
6933
6079
|
var CardHydrationService = class {
|
|
6934
|
-
constructor(getViewComponent, getCourseDB3,
|
|
6080
|
+
constructor(getViewComponent, getCourseDB3, getItemsToHydrate) {
|
|
6935
6081
|
this.getViewComponent = getViewComponent;
|
|
6936
6082
|
this.getCourseDB = getCourseDB3;
|
|
6937
|
-
this.
|
|
6938
|
-
this.removeItemFromQueue = removeItemFromQueue;
|
|
6939
|
-
this.hasAvailableCards = hasAvailableCards;
|
|
6083
|
+
this.getItemsToHydrate = getItemsToHydrate;
|
|
6940
6084
|
}
|
|
6941
|
-
|
|
6942
|
-
|
|
6085
|
+
hydratedCards = /* @__PURE__ */ new Map();
|
|
6086
|
+
hydrationInFlight = /* @__PURE__ */ new Set();
|
|
6943
6087
|
hydrationInProgress = false;
|
|
6944
|
-
BUFFER_SIZE = 5;
|
|
6945
6088
|
/**
|
|
6946
|
-
* Get
|
|
6947
|
-
* @returns Hydrated card or null if
|
|
6089
|
+
* Get a hydrated card by ID.
|
|
6090
|
+
* @returns Hydrated card or null if not in cache
|
|
6091
|
+
*/
|
|
6092
|
+
getHydratedCard(cardId) {
|
|
6093
|
+
return this.hydratedCards.get(cardId) ?? null;
|
|
6094
|
+
}
|
|
6095
|
+
/**
|
|
6096
|
+
* Check if a card is hydrated.
|
|
6948
6097
|
*/
|
|
6949
|
-
|
|
6950
|
-
return this.
|
|
6098
|
+
hasHydratedCard(cardId) {
|
|
6099
|
+
return this.hydratedCards.has(cardId);
|
|
6100
|
+
}
|
|
6101
|
+
/**
|
|
6102
|
+
* Remove a card from the cache (call on successful dismiss to free memory).
|
|
6103
|
+
*/
|
|
6104
|
+
removeCard(cardId) {
|
|
6105
|
+
this.hydratedCards.delete(cardId);
|
|
6951
6106
|
}
|
|
6952
6107
|
/**
|
|
6953
6108
|
* Check if hydration should be triggered and start background hydration if needed.
|
|
6954
6109
|
*/
|
|
6955
6110
|
async ensureHydratedCards() {
|
|
6956
|
-
|
|
6957
|
-
void this.fillHydratedQueue();
|
|
6958
|
-
}
|
|
6111
|
+
void this.fillHydratedCards();
|
|
6959
6112
|
}
|
|
6960
6113
|
/**
|
|
6961
|
-
* Wait for a
|
|
6114
|
+
* Wait for a specific card to become hydrated.
|
|
6962
6115
|
* @returns Promise that resolves to a hydrated card or null
|
|
6963
6116
|
*/
|
|
6964
|
-
async
|
|
6965
|
-
if (this.
|
|
6966
|
-
|
|
6117
|
+
async waitForCard(cardId) {
|
|
6118
|
+
if (this.hydratedCards.has(cardId)) {
|
|
6119
|
+
return this.hydratedCards.get(cardId);
|
|
6967
6120
|
}
|
|
6968
|
-
|
|
6969
|
-
|
|
6121
|
+
if (!this.hydrationInProgress) {
|
|
6122
|
+
void this.fillHydratedCards();
|
|
6970
6123
|
}
|
|
6971
|
-
|
|
6124
|
+
const maxWaitMs = 1e4;
|
|
6125
|
+
const pollIntervalMs = 25;
|
|
6126
|
+
let elapsed = 0;
|
|
6127
|
+
while (elapsed < maxWaitMs) {
|
|
6128
|
+
if (this.hydratedCards.has(cardId)) {
|
|
6129
|
+
return this.hydratedCards.get(cardId);
|
|
6130
|
+
}
|
|
6131
|
+
if (!this.hydrationInFlight.has(cardId) && !this.hydrationInProgress) {
|
|
6132
|
+
break;
|
|
6133
|
+
}
|
|
6134
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
6135
|
+
elapsed += pollIntervalMs;
|
|
6136
|
+
}
|
|
6137
|
+
return this.hydratedCards.get(cardId) ?? null;
|
|
6972
6138
|
}
|
|
6973
6139
|
/**
|
|
6974
|
-
* Get current hydrated
|
|
6140
|
+
* Get current hydrated cache size.
|
|
6975
6141
|
*/
|
|
6976
6142
|
get hydratedCount() {
|
|
6977
|
-
return this.
|
|
6143
|
+
return this.hydratedCards.size;
|
|
6978
6144
|
}
|
|
6979
6145
|
/**
|
|
6980
|
-
* Get
|
|
6146
|
+
* Get list of currently hydrated card IDs (for debugging).
|
|
6981
6147
|
*/
|
|
6982
|
-
|
|
6983
|
-
return this.
|
|
6148
|
+
getHydratedCardIds() {
|
|
6149
|
+
return Array.from(this.hydratedCards.keys());
|
|
6984
6150
|
}
|
|
6985
6151
|
/**
|
|
6986
|
-
* Fill the hydrated
|
|
6152
|
+
* Fill the hydrated cache by hydrating items from getItemsToHydrate().
|
|
6153
|
+
* This is a pure cache-warming operation - no queue mutation.
|
|
6987
6154
|
*/
|
|
6988
|
-
async
|
|
6155
|
+
async fillHydratedCards() {
|
|
6989
6156
|
if (this.hydrationInProgress) {
|
|
6990
6157
|
return;
|
|
6991
6158
|
}
|
|
6992
6159
|
this.hydrationInProgress = true;
|
|
6993
6160
|
try {
|
|
6994
|
-
|
|
6995
|
-
|
|
6996
|
-
if (
|
|
6997
|
-
|
|
6161
|
+
const itemsToHydrate = this.getItemsToHydrate();
|
|
6162
|
+
for (const item of itemsToHydrate) {
|
|
6163
|
+
if (this.hydratedCards.has(item.cardID) || this.hydrationInFlight.has(item.cardID)) {
|
|
6164
|
+
continue;
|
|
6998
6165
|
}
|
|
6999
6166
|
try {
|
|
7000
|
-
|
|
7001
|
-
const cachedCard = this.failedCardCache.get(nextItem.cardID);
|
|
7002
|
-
this.hydratedQ.add(cachedCard, cachedCard.item.cardID);
|
|
7003
|
-
this.failedCardCache.delete(nextItem.cardID);
|
|
7004
|
-
} else {
|
|
7005
|
-
const courseDB = this.getCourseDB(nextItem.courseID);
|
|
7006
|
-
const cardData = await courseDB.getCourseDoc(nextItem.cardID);
|
|
7007
|
-
if (!isCourseElo(cardData.elo)) {
|
|
7008
|
-
cardData.elo = toCourseElo8(cardData.elo);
|
|
7009
|
-
}
|
|
7010
|
-
const view = this.getViewComponent(cardData.id_view);
|
|
7011
|
-
const dataDocs = await Promise.all(
|
|
7012
|
-
cardData.id_displayable_data.map(
|
|
7013
|
-
(id) => courseDB.getCourseDoc(id, {
|
|
7014
|
-
attachments: true,
|
|
7015
|
-
binary: true
|
|
7016
|
-
})
|
|
7017
|
-
)
|
|
7018
|
-
);
|
|
7019
|
-
const data = dataDocs.map(displayableDataToViewData).reverse();
|
|
7020
|
-
this.hydratedQ.add(
|
|
7021
|
-
{
|
|
7022
|
-
item: nextItem,
|
|
7023
|
-
view,
|
|
7024
|
-
data
|
|
7025
|
-
},
|
|
7026
|
-
nextItem.cardID
|
|
7027
|
-
);
|
|
7028
|
-
}
|
|
6167
|
+
await this.hydrateCard(item);
|
|
7029
6168
|
} catch (e) {
|
|
7030
|
-
logger.error(`Error hydrating card ${
|
|
7031
|
-
} finally {
|
|
7032
|
-
this.removeItemFromQueue(nextItem);
|
|
6169
|
+
logger.error(`[CardHydrationService] Error hydrating card ${item.cardID}:`, e);
|
|
7033
6170
|
}
|
|
7034
6171
|
}
|
|
7035
6172
|
} finally {
|
|
@@ -7037,10 +6174,97 @@ var CardHydrationService = class {
|
|
|
7037
6174
|
}
|
|
7038
6175
|
}
|
|
7039
6176
|
/**
|
|
7040
|
-
*
|
|
6177
|
+
* Hydrate a single card and add to cache.
|
|
7041
6178
|
*/
|
|
7042
|
-
|
|
7043
|
-
this.
|
|
6179
|
+
async hydrateCard(item) {
|
|
6180
|
+
if (this.hydratedCards.has(item.cardID) || this.hydrationInFlight.has(item.cardID)) {
|
|
6181
|
+
return;
|
|
6182
|
+
}
|
|
6183
|
+
this.hydrationInFlight.add(item.cardID);
|
|
6184
|
+
try {
|
|
6185
|
+
const courseDB = this.getCourseDB(item.courseID);
|
|
6186
|
+
const cardData = await courseDB.getCourseDoc(item.cardID);
|
|
6187
|
+
if (!isCourseElo(cardData.elo)) {
|
|
6188
|
+
cardData.elo = toCourseElo6(cardData.elo);
|
|
6189
|
+
}
|
|
6190
|
+
const view = this.getViewComponent(cardData.id_view);
|
|
6191
|
+
const dataDocs = await Promise.all(
|
|
6192
|
+
cardData.id_displayable_data.map(
|
|
6193
|
+
(id) => courseDB.getCourseDoc(id, {
|
|
6194
|
+
attachments: true,
|
|
6195
|
+
binary: true
|
|
6196
|
+
})
|
|
6197
|
+
)
|
|
6198
|
+
);
|
|
6199
|
+
const audioToPrefetch = [];
|
|
6200
|
+
dataDocs.forEach((dd) => {
|
|
6201
|
+
dd.data.forEach((f) => {
|
|
6202
|
+
audioToPrefetch.push(...parseAudioURIs(f.data));
|
|
6203
|
+
});
|
|
6204
|
+
});
|
|
6205
|
+
const uniqueAudioUrls = [...new Set(audioToPrefetch)];
|
|
6206
|
+
if (uniqueAudioUrls.length > 0) {
|
|
6207
|
+
logger.debug(
|
|
6208
|
+
`[CardHydrationService] Prefetching ${uniqueAudioUrls.length} audio files for card ${item.cardID}`
|
|
6209
|
+
);
|
|
6210
|
+
await Promise.allSettled(uniqueAudioUrls.map(prefetchAudio));
|
|
6211
|
+
}
|
|
6212
|
+
const data = dataDocs.map(displayableDataToViewData).reverse();
|
|
6213
|
+
this.hydratedCards.set(item.cardID, {
|
|
6214
|
+
item,
|
|
6215
|
+
view,
|
|
6216
|
+
data
|
|
6217
|
+
});
|
|
6218
|
+
logger.debug(`[CardHydrationService] Hydrated card ${item.cardID}`);
|
|
6219
|
+
} finally {
|
|
6220
|
+
this.hydrationInFlight.delete(item.cardID);
|
|
6221
|
+
}
|
|
6222
|
+
}
|
|
6223
|
+
};
|
|
6224
|
+
|
|
6225
|
+
// src/study/ItemQueue.ts
|
|
6226
|
+
var ItemQueue = class {
|
|
6227
|
+
q = [];
|
|
6228
|
+
seenCardIds = [];
|
|
6229
|
+
_dequeueCount = 0;
|
|
6230
|
+
get dequeueCount() {
|
|
6231
|
+
return this._dequeueCount;
|
|
6232
|
+
}
|
|
6233
|
+
add(item, cardId) {
|
|
6234
|
+
if (this.seenCardIds.find((d) => d === cardId)) {
|
|
6235
|
+
return;
|
|
6236
|
+
}
|
|
6237
|
+
this.seenCardIds.push(cardId);
|
|
6238
|
+
this.q.push(item);
|
|
6239
|
+
}
|
|
6240
|
+
addAll(items, cardIdExtractor) {
|
|
6241
|
+
items.forEach((i) => this.add(i, cardIdExtractor(i)));
|
|
6242
|
+
}
|
|
6243
|
+
get length() {
|
|
6244
|
+
return this.q.length;
|
|
6245
|
+
}
|
|
6246
|
+
peek(index) {
|
|
6247
|
+
return this.q[index];
|
|
6248
|
+
}
|
|
6249
|
+
dequeue(cardIdExtractor) {
|
|
6250
|
+
if (this.q.length !== 0) {
|
|
6251
|
+
this._dequeueCount++;
|
|
6252
|
+
const item = this.q.splice(0, 1)[0];
|
|
6253
|
+
if (cardIdExtractor) {
|
|
6254
|
+
const cardId = cardIdExtractor(item);
|
|
6255
|
+
const index = this.seenCardIds.indexOf(cardId);
|
|
6256
|
+
if (index > -1) {
|
|
6257
|
+
this.seenCardIds.splice(index, 1);
|
|
6258
|
+
}
|
|
6259
|
+
}
|
|
6260
|
+
return item;
|
|
6261
|
+
} else {
|
|
6262
|
+
return null;
|
|
6263
|
+
}
|
|
6264
|
+
}
|
|
6265
|
+
get toString() {
|
|
6266
|
+
return `${typeof this.q[0]}:
|
|
6267
|
+
` + this.q.map((i) => ` ${i.courseID}+${i.cardID}: ${i.status}`).join("\n");
|
|
7044
6268
|
}
|
|
7045
6269
|
};
|
|
7046
6270
|
|
|
@@ -7582,7 +6806,7 @@ try {
|
|
|
7582
6806
|
}
|
|
7583
6807
|
} catch {
|
|
7584
6808
|
}
|
|
7585
|
-
async function validateStaticCourse(staticPath,
|
|
6809
|
+
async function validateStaticCourse(staticPath, fs2) {
|
|
7586
6810
|
const validation = {
|
|
7587
6811
|
valid: true,
|
|
7588
6812
|
manifestExists: false,
|
|
@@ -7592,8 +6816,8 @@ async function validateStaticCourse(staticPath, fs3) {
|
|
|
7592
6816
|
warnings: []
|
|
7593
6817
|
};
|
|
7594
6818
|
try {
|
|
7595
|
-
if (
|
|
7596
|
-
const stats = await
|
|
6819
|
+
if (fs2) {
|
|
6820
|
+
const stats = await fs2.stat(staticPath);
|
|
7597
6821
|
if (!stats.isDirectory()) {
|
|
7598
6822
|
validation.errors.push(`Path is not a directory: ${staticPath}`);
|
|
7599
6823
|
validation.valid = false;
|
|
@@ -7613,11 +6837,11 @@ async function validateStaticCourse(staticPath, fs3) {
|
|
|
7613
6837
|
}
|
|
7614
6838
|
let manifestPath = `${staticPath}/manifest.json`;
|
|
7615
6839
|
try {
|
|
7616
|
-
if (
|
|
7617
|
-
manifestPath =
|
|
7618
|
-
if (await
|
|
6840
|
+
if (fs2) {
|
|
6841
|
+
manifestPath = fs2.joinPath(staticPath, "manifest.json");
|
|
6842
|
+
if (await fs2.exists(manifestPath)) {
|
|
7619
6843
|
validation.manifestExists = true;
|
|
7620
|
-
const manifestContent = await
|
|
6844
|
+
const manifestContent = await fs2.readFile(manifestPath);
|
|
7621
6845
|
const manifest = JSON.parse(manifestContent);
|
|
7622
6846
|
validation.courseId = manifest.courseId;
|
|
7623
6847
|
validation.courseName = manifest.courseName;
|
|
@@ -7649,10 +6873,10 @@ async function validateStaticCourse(staticPath, fs3) {
|
|
|
7649
6873
|
}
|
|
7650
6874
|
let chunksPath = `${staticPath}/chunks`;
|
|
7651
6875
|
try {
|
|
7652
|
-
if (
|
|
7653
|
-
chunksPath =
|
|
7654
|
-
if (await
|
|
7655
|
-
const chunksStats = await
|
|
6876
|
+
if (fs2) {
|
|
6877
|
+
chunksPath = fs2.joinPath(staticPath, "chunks");
|
|
6878
|
+
if (await fs2.exists(chunksPath)) {
|
|
6879
|
+
const chunksStats = await fs2.stat(chunksPath);
|
|
7656
6880
|
if (chunksStats.isDirectory()) {
|
|
7657
6881
|
validation.chunksExist = true;
|
|
7658
6882
|
} else {
|
|
@@ -7680,10 +6904,10 @@ async function validateStaticCourse(staticPath, fs3) {
|
|
|
7680
6904
|
}
|
|
7681
6905
|
let attachmentsPath;
|
|
7682
6906
|
try {
|
|
7683
|
-
if (
|
|
7684
|
-
attachmentsPath =
|
|
7685
|
-
if (await
|
|
7686
|
-
const attachmentsStats = await
|
|
6907
|
+
if (fs2) {
|
|
6908
|
+
attachmentsPath = fs2.joinPath(staticPath, "attachments");
|
|
6909
|
+
if (await fs2.exists(attachmentsPath)) {
|
|
6910
|
+
const attachmentsStats = await fs2.stat(attachmentsPath);
|
|
7687
6911
|
if (attachmentsStats.isDirectory()) {
|
|
7688
6912
|
validation.attachmentsExist = true;
|
|
7689
6913
|
}
|
|
@@ -8461,26 +7685,43 @@ var StaticToCouchDBMigrator = class {
|
|
|
8461
7685
|
/**
|
|
8462
7686
|
* Check if a path is a local file path (vs URL)
|
|
8463
7687
|
*/
|
|
8464
|
-
isLocalPath(
|
|
8465
|
-
return !
|
|
7688
|
+
isLocalPath(path2) {
|
|
7689
|
+
return !path2.startsWith("http://") && !path2.startsWith("https://");
|
|
8466
7690
|
}
|
|
8467
7691
|
};
|
|
8468
7692
|
|
|
8469
7693
|
// src/util/index.ts
|
|
8470
7694
|
init_dataDirectory();
|
|
8471
|
-
init_tuiLogger();
|
|
8472
7695
|
|
|
8473
7696
|
// src/study/SessionController.ts
|
|
8474
7697
|
init_navigators();
|
|
8475
|
-
|
|
8476
|
-
|
|
8477
|
-
|
|
7698
|
+
|
|
7699
|
+
// src/study/SourceMixer.ts
|
|
7700
|
+
var QuotaRoundRobinMixer = class {
|
|
7701
|
+
mix(batches, limit) {
|
|
7702
|
+
if (batches.length === 0) {
|
|
7703
|
+
return [];
|
|
7704
|
+
}
|
|
7705
|
+
const quotaPerSource = Math.ceil(limit / batches.length);
|
|
7706
|
+
const mixed = [];
|
|
7707
|
+
for (const batch of batches) {
|
|
7708
|
+
const sortedBatch = [...batch.weighted].sort((a, b) => b.score - a.score);
|
|
7709
|
+
const topFromSource = sortedBatch.slice(0, quotaPerSource);
|
|
7710
|
+
mixed.push(...topFromSource);
|
|
7711
|
+
}
|
|
7712
|
+
return mixed.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
7713
|
+
}
|
|
7714
|
+
};
|
|
7715
|
+
|
|
7716
|
+
// src/study/SessionController.ts
|
|
7717
|
+
init_logger();
|
|
8478
7718
|
var SessionController = class extends Loggable {
|
|
8479
7719
|
_className = "SessionController";
|
|
8480
7720
|
services;
|
|
8481
7721
|
srsService;
|
|
8482
7722
|
eloService;
|
|
8483
7723
|
hydrationService;
|
|
7724
|
+
mixer;
|
|
8484
7725
|
sources;
|
|
8485
7726
|
// dataLayer and getViewComponent now injected into CardHydrationService
|
|
8486
7727
|
_sessionRecord = [];
|
|
@@ -8508,18 +7749,21 @@ var SessionController = class extends Loggable {
|
|
|
8508
7749
|
// @ts-expect-error NodeJS.Timeout type not available in browser context
|
|
8509
7750
|
_intervalHandle;
|
|
8510
7751
|
/**
|
|
8511
|
-
*
|
|
7752
|
+
* @param sources - Array of content sources to mix for the session
|
|
7753
|
+
* @param time - Session duration in seconds
|
|
7754
|
+
* @param dataLayer - Data layer provider
|
|
7755
|
+
* @param getViewComponent - Function to resolve view components
|
|
7756
|
+
* @param mixer - Optional source mixer strategy (defaults to QuotaRoundRobinMixer)
|
|
8512
7757
|
*/
|
|
8513
|
-
constructor(sources, time, dataLayer, getViewComponent) {
|
|
7758
|
+
constructor(sources, time, dataLayer, getViewComponent, mixer) {
|
|
8514
7759
|
super();
|
|
7760
|
+
this.mixer = mixer || new QuotaRoundRobinMixer();
|
|
8515
7761
|
this.srsService = new SrsService(dataLayer.getUserDB());
|
|
8516
7762
|
this.eloService = new EloService(dataLayer, dataLayer.getUserDB());
|
|
8517
7763
|
this.hydrationService = new CardHydrationService(
|
|
8518
7764
|
getViewComponent,
|
|
8519
7765
|
(courseId) => dataLayer.getCourseDB(courseId),
|
|
8520
|
-
() => this.
|
|
8521
|
-
(item) => this.removeItemFromQueue(item),
|
|
8522
|
-
() => this.hasAvailableCards()
|
|
7766
|
+
() => this._getItemsToHydrate()
|
|
8523
7767
|
);
|
|
8524
7768
|
this.services = {
|
|
8525
7769
|
response: new ResponseProcessor(this.srsService, this.eloService)
|
|
@@ -8573,16 +7817,12 @@ var SessionController = class extends Loggable {
|
|
|
8573
7817
|
return ret;
|
|
8574
7818
|
}
|
|
8575
7819
|
async prepareSession() {
|
|
8576
|
-
|
|
8577
|
-
|
|
8578
|
-
|
|
8579
|
-
|
|
8580
|
-
} else {
|
|
8581
|
-
await Promise.all([this.getScheduledReviews(), this.getNewCards()]);
|
|
8582
|
-
}
|
|
8583
|
-
} catch (e) {
|
|
8584
|
-
this.error("Error preparing study session:", e);
|
|
7820
|
+
if (this.sources.some((s) => typeof s.getWeightedCards !== "function")) {
|
|
7821
|
+
throw new Error(
|
|
7822
|
+
"[SessionController] All content sources must implement getWeightedCards()."
|
|
7823
|
+
);
|
|
8585
7824
|
}
|
|
7825
|
+
await this.getWeightedContent();
|
|
8586
7826
|
await this.hydrationService.ensureHydratedCards();
|
|
8587
7827
|
this._intervalHandle = setInterval(() => {
|
|
8588
7828
|
this.tick();
|
|
@@ -8620,14 +7860,10 @@ var SessionController = class extends Loggable {
|
|
|
8620
7860
|
}
|
|
8621
7861
|
return items;
|
|
8622
7862
|
};
|
|
8623
|
-
const extractHydratedItems = () => {
|
|
8624
|
-
const items = [];
|
|
8625
|
-
return items;
|
|
8626
|
-
};
|
|
8627
7863
|
return {
|
|
8628
7864
|
api: {
|
|
8629
7865
|
mode: supportsWeightedCards ? "weighted" : "legacy",
|
|
8630
|
-
description: supportsWeightedCards ? "Using getWeightedCards() API with scored candidates" : "
|
|
7866
|
+
description: supportsWeightedCards ? "Using getWeightedCards() API with scored candidates" : "ERROR: getWeightedCards() not a function."
|
|
8631
7867
|
},
|
|
8632
7868
|
reviewQueue: {
|
|
8633
7869
|
length: this.reviewQ.length,
|
|
@@ -8646,162 +7882,97 @@ var SessionController = class extends Loggable {
|
|
|
8646
7882
|
},
|
|
8647
7883
|
hydratedCache: {
|
|
8648
7884
|
count: this.hydrationService.hydratedCount,
|
|
8649
|
-
|
|
8650
|
-
items: extractHydratedItems()
|
|
7885
|
+
cardIds: this.hydrationService.getHydratedCardIds()
|
|
8651
7886
|
}
|
|
8652
7887
|
};
|
|
8653
7888
|
}
|
|
8654
7889
|
/**
|
|
8655
|
-
* Fetch content using the
|
|
7890
|
+
* Fetch content using the getWeightedCards API and mix across sources.
|
|
8656
7891
|
*
|
|
8657
|
-
* This method
|
|
8658
|
-
*
|
|
8659
|
-
*
|
|
8660
|
-
*
|
|
8661
|
-
*
|
|
8662
|
-
* 1. Fetch weighted cards to get scoring/ordering information
|
|
8663
|
-
* 2. Fetch full review data via legacy getPendingReviews()
|
|
8664
|
-
* 3. Order reviews by their weighted scores
|
|
8665
|
-
* 4. Add new cards ordered by their weighted scores
|
|
7892
|
+
* This method:
|
|
7893
|
+
* 1. Fetches weighted cards from each source
|
|
7894
|
+
* 2. Fetches full review data (we need ScheduledCard fields for queue)
|
|
7895
|
+
* 3. Uses SourceMixer to balance content across sources
|
|
7896
|
+
* 4. Populates review and new card queues with mixed results
|
|
8666
7897
|
*/
|
|
8667
7898
|
async getWeightedContent() {
|
|
8668
7899
|
const limit = 20;
|
|
8669
|
-
const
|
|
8670
|
-
|
|
8671
|
-
|
|
8672
|
-
for (const source of this.sources) {
|
|
7900
|
+
const batches = [];
|
|
7901
|
+
for (let i = 0; i < this.sources.length; i++) {
|
|
7902
|
+
const source = this.sources[i];
|
|
8673
7903
|
try {
|
|
8674
|
-
const
|
|
8675
|
-
|
|
8676
|
-
|
|
7904
|
+
const weighted = await source.getWeightedCards(limit);
|
|
7905
|
+
batches.push({
|
|
7906
|
+
sourceIndex: i,
|
|
7907
|
+
weighted
|
|
8677
7908
|
});
|
|
8678
|
-
allReviews.push(...reviews);
|
|
8679
|
-
if (typeof source.getWeightedCards === "function") {
|
|
8680
|
-
const weighted = await source.getWeightedCards(limit);
|
|
8681
|
-
allWeighted.push(...weighted);
|
|
8682
|
-
} else {
|
|
8683
|
-
const newCards = await source.getNewCards(limit);
|
|
8684
|
-
allNewCards.push(...newCards);
|
|
8685
|
-
allWeighted.push(
|
|
8686
|
-
...newCards.map((c) => ({
|
|
8687
|
-
cardId: c.cardID,
|
|
8688
|
-
courseId: c.courseID,
|
|
8689
|
-
score: 1,
|
|
8690
|
-
provenance: [
|
|
8691
|
-
{
|
|
8692
|
-
strategy: "legacy",
|
|
8693
|
-
strategyName: "Legacy Fallback",
|
|
8694
|
-
strategyId: "legacy-fallback",
|
|
8695
|
-
action: "generated",
|
|
8696
|
-
score: 1,
|
|
8697
|
-
reason: "Fallback to legacy getNewCards(), new card"
|
|
8698
|
-
}
|
|
8699
|
-
]
|
|
8700
|
-
})),
|
|
8701
|
-
...reviews.map((r) => ({
|
|
8702
|
-
cardId: r.cardID,
|
|
8703
|
-
courseId: r.courseID,
|
|
8704
|
-
score: 1,
|
|
8705
|
-
provenance: [
|
|
8706
|
-
{
|
|
8707
|
-
strategy: "legacy",
|
|
8708
|
-
strategyName: "Legacy Fallback",
|
|
8709
|
-
strategyId: "legacy-fallback",
|
|
8710
|
-
action: "generated",
|
|
8711
|
-
score: 1,
|
|
8712
|
-
reason: "Fallback to legacy getPendingReviews(), review"
|
|
8713
|
-
}
|
|
8714
|
-
]
|
|
8715
|
-
}))
|
|
8716
|
-
);
|
|
8717
|
-
}
|
|
8718
7909
|
} catch (error) {
|
|
8719
|
-
this.error(`Failed to get content from source:`, error);
|
|
7910
|
+
this.error(`Failed to get content from source ${i}:`, error);
|
|
7911
|
+
if (this.sources.length === 1) {
|
|
7912
|
+
throw new Error(`Cannot start session: failed to load content from source ${i}`);
|
|
7913
|
+
}
|
|
8720
7914
|
}
|
|
8721
7915
|
}
|
|
8722
|
-
|
|
8723
|
-
|
|
8724
|
-
|
|
8725
|
-
|
|
7916
|
+
if (batches.length === 0) {
|
|
7917
|
+
throw new Error(
|
|
7918
|
+
`Cannot start session: failed to load content from all ${this.sources.length} source(s). Check logs for details.`
|
|
7919
|
+
);
|
|
8726
7920
|
}
|
|
8727
|
-
const
|
|
8728
|
-
|
|
8729
|
-
|
|
8730
|
-
})
|
|
8731
|
-
|
|
8732
|
-
|
|
8733
|
-
|
|
8734
|
-
|
|
8735
|
-
|
|
7921
|
+
const mixedWeighted = this.mixer.mix(batches, limit * this.sources.length);
|
|
7922
|
+
const reviewWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "review");
|
|
7923
|
+
const newWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "new");
|
|
7924
|
+
logger.debug(`[reviews] got ${reviewWeighted.length} reviews from mixer`);
|
|
7925
|
+
let report = "Mixed content session created with:\n";
|
|
7926
|
+
for (const w of reviewWeighted) {
|
|
7927
|
+
const reviewItem = {
|
|
7928
|
+
cardID: w.cardId,
|
|
7929
|
+
courseID: w.courseId,
|
|
7930
|
+
contentSourceType: "course",
|
|
7931
|
+
contentSourceID: w.courseId,
|
|
7932
|
+
reviewID: w.reviewID,
|
|
7933
|
+
status: "review"
|
|
7934
|
+
};
|
|
7935
|
+
this.reviewQ.add(reviewItem, reviewItem.cardID);
|
|
7936
|
+
report += `Review: ${w.courseId}::${w.cardId} (score: ${w.score.toFixed(2)})
|
|
8736
7937
|
`;
|
|
8737
7938
|
}
|
|
8738
|
-
|
|
8739
|
-
for (const card of newCardWeighted) {
|
|
7939
|
+
for (const w of newWeighted) {
|
|
8740
7940
|
const newItem = {
|
|
8741
|
-
cardID:
|
|
8742
|
-
courseID:
|
|
7941
|
+
cardID: w.cardId,
|
|
7942
|
+
courseID: w.courseId,
|
|
8743
7943
|
contentSourceType: "course",
|
|
8744
|
-
contentSourceID:
|
|
7944
|
+
contentSourceID: w.courseId,
|
|
8745
7945
|
status: "new"
|
|
8746
7946
|
};
|
|
8747
|
-
this.newQ.add(newItem,
|
|
8748
|
-
report += `New: ${
|
|
7947
|
+
this.newQ.add(newItem, newItem.cardID);
|
|
7948
|
+
report += `New: ${w.courseId}::${w.cardId} (score: ${w.score.toFixed(2)})
|
|
8749
7949
|
`;
|
|
8750
7950
|
}
|
|
8751
7951
|
this.log(report);
|
|
8752
7952
|
}
|
|
8753
7953
|
/**
|
|
8754
|
-
*
|
|
8755
|
-
*
|
|
7954
|
+
* Returns items that should be pre-hydrated.
|
|
7955
|
+
* Deterministic: top N items from each queue to ensure coverage.
|
|
7956
|
+
* Failed queue items will typically already be hydrated (from initial render).
|
|
8756
7957
|
*/
|
|
8757
|
-
|
|
8758
|
-
const
|
|
8759
|
-
|
|
8760
|
-
|
|
8761
|
-
|
|
8762
|
-
return [];
|
|
8763
|
-
})
|
|
8764
|
-
)
|
|
8765
|
-
);
|
|
8766
|
-
const dueCards = [];
|
|
8767
|
-
while (reviews.length != 0 && reviews.some((r) => r.length > 0)) {
|
|
8768
|
-
const index = randomInt(0, reviews.length - 1);
|
|
8769
|
-
const source = reviews[index];
|
|
8770
|
-
if (source.length === 0) {
|
|
8771
|
-
reviews.splice(index, 1);
|
|
8772
|
-
continue;
|
|
8773
|
-
} else {
|
|
8774
|
-
dueCards.push(source.shift());
|
|
8775
|
-
}
|
|
7958
|
+
_getItemsToHydrate() {
|
|
7959
|
+
const items = [];
|
|
7960
|
+
const ITEMS_PER_QUEUE = 2;
|
|
7961
|
+
for (let i = 0; i < Math.min(ITEMS_PER_QUEUE, this.reviewQ.length); i++) {
|
|
7962
|
+
items.push(this.reviewQ.peek(i));
|
|
8776
7963
|
}
|
|
8777
|
-
let
|
|
8778
|
-
|
|
8779
|
-
|
|
8780
|
-
this.
|
|
7964
|
+
for (let i = 0; i < Math.min(ITEMS_PER_QUEUE, this.newQ.length); i++) {
|
|
7965
|
+
items.push(this.newQ.peek(i));
|
|
7966
|
+
}
|
|
7967
|
+
for (let i = 0; i < Math.min(ITEMS_PER_QUEUE, this.failedQ.length); i++) {
|
|
7968
|
+
items.push(this.failedQ.peek(i));
|
|
7969
|
+
}
|
|
7970
|
+
return items;
|
|
8781
7971
|
}
|
|
8782
7972
|
/**
|
|
8783
|
-
*
|
|
8784
|
-
*
|
|
7973
|
+
* Selects the next item to present to the user.
|
|
7974
|
+
* Nondeterministic: uses probability to balance between queues based on session state.
|
|
8785
7975
|
*/
|
|
8786
|
-
async getNewCards(n = 10) {
|
|
8787
|
-
const perCourse = Math.ceil(n / this.sources.length);
|
|
8788
|
-
const newContent = await Promise.all(this.sources.map((c) => c.getNewCards(perCourse)));
|
|
8789
|
-
newContent.forEach((newContentFromSource) => {
|
|
8790
|
-
newContentFromSource.filter((c) => {
|
|
8791
|
-
return this._sessionRecord.find((record) => record.card.card_id === c.cardID) === void 0;
|
|
8792
|
-
});
|
|
8793
|
-
});
|
|
8794
|
-
while (n > 0 && newContent.some((nc) => nc.length > 0)) {
|
|
8795
|
-
for (let i = 0; i < newContent.length; i++) {
|
|
8796
|
-
if (newContent[i].length > 0) {
|
|
8797
|
-
const item = newContent[i].splice(0, 1)[0];
|
|
8798
|
-
this.log(`Adding new card: ${item.courseID}::${item.cardID}`);
|
|
8799
|
-
this.newQ.add(item, item.cardID);
|
|
8800
|
-
n--;
|
|
8801
|
-
}
|
|
8802
|
-
}
|
|
8803
|
-
}
|
|
8804
|
-
}
|
|
8805
7976
|
_selectNextItemToHydrate() {
|
|
8806
7977
|
const choice = Math.random();
|
|
8807
7978
|
let newBound = 0.1;
|
|
@@ -8858,16 +8029,18 @@ var SessionController = class extends Loggable {
|
|
|
8858
8029
|
this._currentCard = null;
|
|
8859
8030
|
return null;
|
|
8860
8031
|
}
|
|
8861
|
-
|
|
8862
|
-
if (!
|
|
8863
|
-
card = await this.hydrationService.waitForHydratedCard();
|
|
8864
|
-
}
|
|
8865
|
-
await this.hydrationService.ensureHydratedCards();
|
|
8866
|
-
if (card) {
|
|
8867
|
-
this._currentCard = card;
|
|
8868
|
-
} else {
|
|
8032
|
+
const nextItem = this._selectNextItemToHydrate();
|
|
8033
|
+
if (!nextItem) {
|
|
8869
8034
|
this._currentCard = null;
|
|
8035
|
+
return null;
|
|
8870
8036
|
}
|
|
8037
|
+
let card = this.hydrationService.getHydratedCard(nextItem.cardID);
|
|
8038
|
+
if (!card) {
|
|
8039
|
+
card = await this.hydrationService.waitForCard(nextItem.cardID);
|
|
8040
|
+
}
|
|
8041
|
+
this.removeItemFromQueue(nextItem);
|
|
8042
|
+
await this.hydrationService.ensureHydratedCards();
|
|
8043
|
+
this._currentCard = card;
|
|
8871
8044
|
return card;
|
|
8872
8045
|
}
|
|
8873
8046
|
/**
|
|
@@ -8903,8 +8076,8 @@ var SessionController = class extends Loggable {
|
|
|
8903
8076
|
dismissCurrentCard(action = "dismiss-success") {
|
|
8904
8077
|
if (this._currentCard) {
|
|
8905
8078
|
if (action === "dismiss-success") {
|
|
8079
|
+
this.hydrationService.removeCard(this._currentCard.item.cardID);
|
|
8906
8080
|
} else if (action === "marked-failed") {
|
|
8907
|
-
this.hydrationService.cacheFailedCard(this._currentCard);
|
|
8908
8081
|
let failedItem;
|
|
8909
8082
|
if (isReview(this._currentCard.item)) {
|
|
8910
8083
|
failedItem = {
|
|
@@ -8926,22 +8099,21 @@ var SessionController = class extends Loggable {
|
|
|
8926
8099
|
}
|
|
8927
8100
|
this.failedQ.add(failedItem, failedItem.cardID);
|
|
8928
8101
|
} else if (action === "dismiss-error") {
|
|
8102
|
+
this.hydrationService.removeCard(this._currentCard.item.cardID);
|
|
8929
8103
|
} else if (action === "dismiss-failed") {
|
|
8104
|
+
this.hydrationService.removeCard(this._currentCard.item.cardID);
|
|
8930
8105
|
}
|
|
8931
8106
|
}
|
|
8932
8107
|
}
|
|
8933
|
-
hasAvailableCards() {
|
|
8934
|
-
return this.reviewQ.length > 0 || this.newQ.length > 0 || this.failedQ.length > 0;
|
|
8935
|
-
}
|
|
8936
8108
|
/**
|
|
8937
|
-
*
|
|
8109
|
+
* Remove an item from its source queue after consumption by nextCard().
|
|
8938
8110
|
*/
|
|
8939
8111
|
removeItemFromQueue(item) {
|
|
8940
|
-
if (this.reviewQ.peek(0) === item) {
|
|
8112
|
+
if (this.reviewQ.peek(0)?.cardID === item.cardID) {
|
|
8941
8113
|
this.reviewQ.dequeue((queueItem) => queueItem.cardID);
|
|
8942
|
-
} else if (this.newQ.peek(0) === item) {
|
|
8114
|
+
} else if (this.newQ.peek(0)?.cardID === item.cardID) {
|
|
8943
8115
|
this.newQ.dequeue((queueItem) => queueItem.cardID);
|
|
8944
|
-
} else {
|
|
8116
|
+
} else if (this.failedQ.peek(0)?.cardID === item.cardID) {
|
|
8945
8117
|
this.failedQ.dequeue((queueItem) => queueItem.cardID);
|
|
8946
8118
|
}
|
|
8947
8119
|
}
|
|
@@ -8966,11 +8138,13 @@ export {
|
|
|
8966
8138
|
NavigatorRole,
|
|
8967
8139
|
NavigatorRoles,
|
|
8968
8140
|
Navigators,
|
|
8141
|
+
QuotaRoundRobinMixer,
|
|
8969
8142
|
SessionController,
|
|
8970
8143
|
StaticToCouchDBMigrator,
|
|
8971
8144
|
TagFilteredContentSource,
|
|
8972
8145
|
_resetDataLayer,
|
|
8973
8146
|
areQuestionRecords,
|
|
8147
|
+
buildStrategyStateId,
|
|
8974
8148
|
docIsDeleted,
|
|
8975
8149
|
ensureAppDataDirectory,
|
|
8976
8150
|
getAppDataDirectory,
|
|
@@ -8978,22 +8152,17 @@ export {
|
|
|
8978
8152
|
getCardOrigin,
|
|
8979
8153
|
getDataLayer,
|
|
8980
8154
|
getDbPath,
|
|
8981
|
-
getLogFilePath,
|
|
8982
8155
|
getStudySource,
|
|
8983
8156
|
importParsedCards,
|
|
8984
8157
|
initializeDataDirectory,
|
|
8985
8158
|
initializeDataLayer,
|
|
8986
|
-
initializeTuiLogging,
|
|
8987
8159
|
isFilter,
|
|
8988
8160
|
isGenerator,
|
|
8989
8161
|
isQuestionRecord,
|
|
8990
8162
|
isReview,
|
|
8991
8163
|
log,
|
|
8992
|
-
logger2 as logger,
|
|
8993
8164
|
newInterval,
|
|
8994
8165
|
parseCardHistoryID,
|
|
8995
|
-
showUserError,
|
|
8996
|
-
showUserMessage,
|
|
8997
8166
|
validateMigration,
|
|
8998
8167
|
validateProcessorConfig,
|
|
8999
8168
|
validateStaticCourse
|