@vue-skuilder/db 0.1.18 → 0.1.21

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