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