@vue-skuilder/db 0.1.20 → 0.1.21

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