@vue-skuilder/db 0.1.20 → 0.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CLAUDE.md +2 -2
  2. package/dist/{classroomDB-CZdMBiTU.d.ts → contentSource-BP9hznNV.d.ts} +150 -196
  3. package/dist/{classroomDB-PxDZTky3.d.cts → contentSource-DsJadoBU.d.cts} +150 -196
  4. package/dist/core/index.d.cts +3 -3
  5. package/dist/core/index.d.ts +3 -3
  6. package/dist/core/index.js +615 -1758
  7. package/dist/core/index.js.map +1 -1
  8. package/dist/core/index.mjs +579 -1727
  9. package/dist/core/index.mjs.map +1 -1
  10. package/dist/{dataLayerProvider-D8o6ZnKW.d.ts → dataLayerProvider-CHYrQ5pB.d.cts} +1 -1
  11. package/dist/{dataLayerProvider-D0MoZMjH.d.cts → dataLayerProvider-MDTxXq2l.d.ts} +1 -1
  12. package/dist/impl/couch/index.d.cts +6 -22
  13. package/dist/impl/couch/index.d.ts +6 -22
  14. package/dist/impl/couch/index.js +598 -1769
  15. package/dist/impl/couch/index.js.map +1 -1
  16. package/dist/impl/couch/index.mjs +579 -1755
  17. package/dist/impl/couch/index.mjs.map +1 -1
  18. package/dist/impl/static/index.d.cts +22 -6
  19. package/dist/impl/static/index.d.ts +22 -6
  20. package/dist/impl/static/index.js +617 -1629
  21. package/dist/impl/static/index.js.map +1 -1
  22. package/dist/impl/static/index.mjs +607 -1624
  23. package/dist/impl/static/index.mjs.map +1 -1
  24. package/dist/index.d.cts +64 -56
  25. package/dist/index.d.ts +64 -56
  26. package/dist/index.js +1000 -2161
  27. package/dist/index.js.map +1 -1
  28. package/dist/index.mjs +970 -2127
  29. package/dist/index.mjs.map +1 -1
  30. package/dist/pouch/index.js +3 -0
  31. package/dist/pouch/index.js.map +1 -1
  32. package/dist/pouch/index.mjs +3 -0
  33. package/dist/pouch/index.mjs.map +1 -1
  34. package/docs/navigators-architecture.md +2 -9
  35. package/package.json +3 -3
  36. package/src/core/interfaces/classroomDB.ts +5 -13
  37. package/src/core/interfaces/contentSource.ts +6 -66
  38. package/src/core/interfaces/courseDB.ts +2 -7
  39. package/src/core/navigators/Pipeline.ts +24 -53
  40. package/src/core/navigators/PipelineAssembler.ts +1 -1
  41. package/src/core/navigators/defaults.ts +84 -0
  42. package/src/core/navigators/{hierarchyDefinition.ts → filters/hierarchyDefinition.ts} +11 -25
  43. package/src/core/navigators/{interferenceMitigator.ts → filters/interferenceMitigator.ts} +10 -24
  44. package/src/core/navigators/{relativePriority.ts → filters/relativePriority.ts} +10 -24
  45. package/src/core/navigators/filters/userTagPreference.ts +1 -16
  46. package/src/core/navigators/{CompositeGenerator.ts → generators/CompositeGenerator.ts} +15 -64
  47. package/src/core/navigators/{elo.ts → generators/elo.ts} +13 -63
  48. package/src/core/navigators/{srs.ts → generators/srs.ts} +11 -40
  49. package/src/core/navigators/generators/types.ts +1 -1
  50. package/src/core/navigators/index.ts +36 -91
  51. package/src/impl/couch/classroomDB.ts +100 -103
  52. package/src/impl/couch/courseDB.ts +5 -81
  53. package/src/impl/couch/pouchdb-setup.ts +7 -0
  54. package/src/impl/static/StaticDataUnpacker.ts +50 -1
  55. package/src/impl/static/courseDB.ts +76 -37
  56. package/src/study/SessionController.ts +122 -202
  57. package/src/study/SourceMixer.ts +65 -0
  58. package/src/study/TagFilteredContentSource.ts +49 -92
  59. package/src/study/index.ts +1 -0
  60. package/src/study/services/CardHydrationService.ts +165 -81
  61. package/src/util/dataDirectory.ts +1 -1
  62. package/src/util/index.ts +0 -1
  63. package/tests/core/navigators/CompositeGenerator.test.ts +44 -168
  64. package/tests/core/navigators/Pipeline.test.ts +5 -72
  65. package/tests/core/navigators/PipelineAssembler.test.ts +8 -58
  66. package/tests/core/navigators/navigators.test.ts +118 -151
  67. package/src/core/navigators/hardcodedOrder.ts +0 -163
  68. package/src/util/tuiLogger.ts +0 -139
  69. /package/src/core/navigators/{inferredPreference.ts → filters/inferredPreferenceStub.ts} +0 -0
  70. /package/src/core/navigators/{userGoal.ts → filters/userGoalStub.ts} +0 -0
package/dist/index.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
  };
@@ -166,6 +161,9 @@ var init_pouchdb_setup = __esm({
166
161
  "use strict";
167
162
  PouchDB.plugin(PouchDBFind);
168
163
  PouchDB.plugin(PouchDBAuth);
164
+ if (typeof PouchDB.debug !== "undefined") {
165
+ PouchDB.debug.disable();
166
+ }
169
167
  PouchDB.defaults({
170
168
  // ajax: {
171
169
  // timeout: 60000,
@@ -175,112 +173,21 @@ var init_pouchdb_setup = __esm({
175
173
  }
176
174
  });
177
175
 
178
- // src/util/tuiLogger.ts
176
+ // src/util/dataDirectory.ts
179
177
  import * as fs from "fs";
180
178
  import * as path from "path";
181
- function initializeTuiLogging() {
182
- isNodeEnvironment = typeof window === "undefined" && typeof process !== "undefined";
183
- if (!isNodeEnvironment) {
184
- return;
185
- }
186
- try {
187
- logFile = path.join(getAppDataDirectory(), "lastrun.log");
188
- if (fs.existsSync(logFile)) {
189
- fs.unlinkSync(logFile);
190
- }
191
- const startTime = (/* @__PURE__ */ new Date()).toISOString();
192
- fs.writeFileSync(logFile, `=== TUI Session Started: ${startTime} ===
193
- `);
194
- const originalConsole = {
195
- // eslint-disable-next-line no-console
196
- log: console.log,
197
- // eslint-disable-next-line no-console
198
- error: console.error,
199
- // eslint-disable-next-line no-console
200
- warn: console.warn,
201
- // eslint-disable-next-line no-console
202
- info: console.info
203
- };
204
- const writeToLog = (level, args) => {
205
- const timestamp = (/* @__PURE__ */ new Date()).toISOString();
206
- const message = args.map(
207
- (arg) => typeof arg === "object" ? JSON.stringify(arg, null, 2) : String(arg)
208
- ).join(" ");
209
- const logEntry = `[${timestamp}] ${level}: ${message}
210
- `;
211
- try {
212
- fs.appendFileSync(logFile, logEntry);
213
- } catch (err) {
214
- originalConsole.error("Failed to write to log file:", err);
215
- originalConsole[level.toLowerCase()](...args);
216
- }
217
- };
218
- console.log = (...args) => writeToLog("INFO", args);
219
- console.info = (...args) => writeToLog("INFO", args);
220
- console.warn = (...args) => writeToLog("WARN", args);
221
- console.error = (...args) => writeToLog("ERROR", args);
222
- console._originalMethods = originalConsole;
223
- console.log("TUI logging initialized - logs redirected to", logFile);
224
- } catch (err) {
225
- console.error("Failed to initialize TUI logging:", err);
226
- }
227
- }
228
- function getLogFilePath() {
229
- return logFile;
230
- }
231
- function showUserMessage(message) {
232
- if (isNodeEnvironment) {
233
- process.stdout.write(message + "\n");
234
- } else {
235
- console.log(message);
236
- }
237
- }
238
- function showUserError(message) {
239
- if (isNodeEnvironment) {
240
- process.stderr.write("Error: " + message + "\n");
241
- } else {
242
- console.error(message);
243
- }
244
- }
245
- var logFile, isNodeEnvironment, logger2;
246
- var init_tuiLogger = __esm({
247
- "src/util/tuiLogger.ts"() {
248
- "use strict";
249
- init_dataDirectory();
250
- logFile = null;
251
- isNodeEnvironment = false;
252
- logger2 = {
253
- debug: (message, ...args) => {
254
- console.log(`[DEBUG] ${message}`, ...args);
255
- },
256
- info: (message, ...args) => {
257
- console.info(`[INFO] ${message}`, ...args);
258
- },
259
- warn: (message, ...args) => {
260
- console.warn(`[WARN] ${message}`, ...args);
261
- },
262
- error: (message, ...args) => {
263
- console.error(`[ERROR] ${message}`, ...args);
264
- }
265
- };
266
- }
267
- });
268
-
269
- // src/util/dataDirectory.ts
270
- import * as fs2 from "fs";
271
- import * as path2 from "path";
272
179
  import * as os from "os";
273
180
  function getAppDataDirectory() {
274
181
  if (ENV.LOCAL_STORAGE_PREFIX) {
275
- return path2.join(os.homedir(), `.tuilder`, ENV.LOCAL_STORAGE_PREFIX);
182
+ return path.join(os.homedir(), `.tuilder`, ENV.LOCAL_STORAGE_PREFIX);
276
183
  } else {
277
- return path2.join(os.homedir(), ".tuilder");
184
+ return path.join(os.homedir(), ".tuilder");
278
185
  }
279
186
  }
280
187
  async function ensureAppDataDirectory() {
281
188
  const appDataDir = getAppDataDirectory();
282
189
  try {
283
- await fs2.promises.mkdir(appDataDir, { recursive: true });
190
+ await fs.promises.mkdir(appDataDir, { recursive: true });
284
191
  } catch (err) {
285
192
  if (err.code !== "EEXIST") {
286
193
  throw new Error(`Failed to create app data directory ${appDataDir}: ${err.message}`);
@@ -289,16 +196,16 @@ async function ensureAppDataDirectory() {
289
196
  return appDataDir;
290
197
  }
291
198
  function getDbPath(dbName) {
292
- return path2.join(getAppDataDirectory(), dbName);
199
+ return path.join(getAppDataDirectory(), dbName);
293
200
  }
294
201
  async function initializeDataDirectory() {
295
202
  await ensureAppDataDirectory();
296
- logger2.info(`PouchDB data directory initialized: ${getAppDataDirectory()}`);
203
+ logger.info(`PouchDB data directory initialized: ${getAppDataDirectory()}`);
297
204
  }
298
205
  var init_dataDirectory = __esm({
299
206
  "src/util/dataDirectory.ts"() {
300
207
  "use strict";
301
- init_tuiLogger();
208
+ init_logger();
302
209
  init_factory();
303
210
  }
304
211
  });
@@ -924,195 +831,187 @@ var init_courseLookupDB = __esm({
924
831
  }
925
832
  });
926
833
 
927
- // src/core/navigators/CompositeGenerator.ts
928
- var CompositeGenerator_exports = {};
929
- __export(CompositeGenerator_exports, {
930
- AggregationMode: () => AggregationMode,
931
- default: () => CompositeGenerator
932
- });
933
- var AggregationMode, DEFAULT_AGGREGATION_MODE, FREQUENCY_BOOST_FACTOR, CompositeGenerator;
934
- var init_CompositeGenerator = __esm({
935
- "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"() {
936
858
  "use strict";
937
- init_navigators();
938
859
  init_logger();
939
- AggregationMode = /* @__PURE__ */ ((AggregationMode2) => {
940
- AggregationMode2["MAX"] = "max";
941
- AggregationMode2["AVERAGE"] = "average";
942
- AggregationMode2["FREQUENCY_BOOST"] = "frequencyBoost";
943
- return AggregationMode2;
944
- })(AggregationMode || {});
945
- DEFAULT_AGGREGATION_MODE = "frequencyBoost" /* FREQUENCY_BOOST */;
946
- FREQUENCY_BOOST_FACTOR = 0.1;
947
- CompositeGenerator = class _CompositeGenerator extends ContentNavigator {
948
- /** Human-readable name for CardGenerator interface */
949
- name = "Composite Generator";
950
- generators;
951
- aggregationMode;
952
- constructor(generators, aggregationMode = DEFAULT_AGGREGATION_MODE) {
953
- super();
954
- this.generators = generators;
955
- this.aggregationMode = aggregationMode;
956
- if (generators.length === 0) {
957
- throw new Error("CompositeGenerator requires at least one generator");
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;
891
+ /**
892
+ * Constructor for standard navigators.
893
+ * Call this from subclass constructors to initialize common fields.
894
+ *
895
+ * Note: CompositeGenerator and Pipeline call super() without args, then set
896
+ * user/course fields directly if needed.
897
+ */
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;
958
904
  }
959
- logger.debug(
960
- `[CompositeGenerator] Created with ${generators.length} generators, mode: ${aggregationMode}`
961
- );
962
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
+ // ============================================================================
963
914
  /**
964
- * Creates a CompositeGenerator from strategy data.
915
+ * Unique key identifying this strategy for state storage.
965
916
  *
966
- * This is a convenience factory for use by PipelineAssembler.
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.
967
920
  */
968
- static async fromStrategies(user, course, strategies, aggregationMode = DEFAULT_AGGREGATION_MODE) {
969
- const generators = await Promise.all(
970
- strategies.map((s) => ContentNavigator.create(user, course, s))
971
- );
972
- return new _CompositeGenerator(generators, aggregationMode);
921
+ get strategyKey() {
922
+ return this.constructor.name;
973
923
  }
974
924
  /**
975
- * Get weighted cards from all generators, merge and deduplicate.
976
- *
977
- * Cards appearing in multiple generators receive a score boost.
978
- * Provenance tracks which generators produced each card and how scores were aggregated.
979
- *
980
- * This method supports both the legacy signature (limit only) and the
981
- * CardGenerator interface signature (limit, context).
925
+ * Get this strategy's persisted state for the current course.
982
926
  *
983
- * @param limit - Maximum number of cards to return
984
- * @param context - Optional GeneratorContext passed to child generators
927
+ * @returns The strategy's data payload, or null if no state exists
928
+ * @throws Error if user or course is not initialized
985
929
  */
986
- async getWeightedCards(limit, context) {
987
- const results = await Promise.all(
988
- this.generators.map((g) => g.getWeightedCards(limit, context))
989
- );
990
- const byCardId = /* @__PURE__ */ new Map();
991
- for (const cards of results) {
992
- for (const card of cards) {
993
- const existing = byCardId.get(card.cardId) || [];
994
- existing.push(card);
995
- byCardId.set(card.cardId, existing);
996
- }
997
- }
998
- const merged = [];
999
- for (const [, cards] of byCardId) {
1000
- const aggregatedScore = this.aggregateScores(cards);
1001
- const finalScore = Math.min(1, aggregatedScore);
1002
- const mergedProvenance = cards.flatMap((c) => c.provenance);
1003
- const initialScore = cards[0].score;
1004
- const action = finalScore > initialScore ? "boosted" : finalScore < initialScore ? "penalized" : "passed";
1005
- const reason = this.buildAggregationReason(cards, finalScore);
1006
- merged.push({
1007
- ...cards[0],
1008
- score: finalScore,
1009
- provenance: [
1010
- ...mergedProvenance,
1011
- {
1012
- strategy: "composite",
1013
- strategyName: "Composite Generator",
1014
- strategyId: "COMPOSITE_GENERATOR",
1015
- action,
1016
- score: finalScore,
1017
- reason
1018
- }
1019
- ]
1020
- });
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
+ );
1021
935
  }
1022
- return merged.sort((a, b) => b.score - a.score).slice(0, limit);
936
+ return this.user.getStrategyState(this.course.getCourseID(), this.strategyKey);
1023
937
  }
1024
938
  /**
1025
- * Build human-readable reason for score aggregation.
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
1026
943
  */
1027
- buildAggregationReason(cards, finalScore) {
1028
- const count = cards.length;
1029
- const scores = cards.map((c) => c.score.toFixed(2)).join(", ");
1030
- if (count === 1) {
1031
- return `Single generator, score ${finalScore.toFixed(2)}`;
1032
- }
1033
- const strategies = cards.map((c) => c.provenance[0]?.strategy || "unknown").join(", ");
1034
- switch (this.aggregationMode) {
1035
- case "max" /* MAX */:
1036
- return `Max of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
1037
- case "average" /* AVERAGE */:
1038
- return `Average of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
1039
- case "frequencyBoost" /* FREQUENCY_BOOST */: {
1040
- const avg = cards.reduce((sum, c) => sum + c.score, 0) / count;
1041
- const boost = 1 + FREQUENCY_BOOST_FACTOR * (count - 1);
1042
- return `Frequency boost from ${count} generators (${strategies}): avg ${avg.toFixed(2)} \xD7 ${boost.toFixed(2)} \u2192 ${finalScore.toFixed(2)}`;
1043
- }
1044
- default:
1045
- return `Aggregated from ${count} generators: ${finalScore.toFixed(2)}`;
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
+ );
1046
949
  }
950
+ return this.user.putStrategyState(this.course.getCourseID(), this.strategyKey, data);
1047
951
  }
1048
952
  /**
1049
- * Aggregate scores from multiple generators for the same card.
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.
1050
959
  */
1051
- aggregateScores(cards) {
1052
- const scores = cards.map((c) => c.score);
1053
- switch (this.aggregationMode) {
1054
- case "max" /* MAX */:
1055
- return Math.max(...scores);
1056
- case "average" /* AVERAGE */:
1057
- return scores.reduce((sum, s) => sum + s, 0) / scores.length;
1058
- case "frequencyBoost" /* FREQUENCY_BOOST */: {
1059
- const avg = scores.reduce((sum, s) => sum + s, 0) / scores.length;
1060
- const frequencyBoost = 1 + FREQUENCY_BOOST_FACTOR * (cards.length - 1);
1061
- return avg * frequencyBoost;
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);
974
+ }
1062
975
  }
1063
- default:
1064
- return scores[0];
1065
976
  }
1066
- }
1067
- /**
1068
- * Get new cards from all generators, merged and deduplicated.
1069
- */
1070
- async getNewCards(n) {
1071
- const legacyGenerators = this.generators.filter(
1072
- (g) => g instanceof ContentNavigator
1073
- );
1074
- const results = await Promise.all(legacyGenerators.map((g) => g.getNewCards(n)));
1075
- const seen = /* @__PURE__ */ new Set();
1076
- const merged = [];
1077
- for (const cards of results) {
1078
- for (const card of cards) {
1079
- if (!seen.has(card.cardID)) {
1080
- seen.add(card.cardID);
1081
- merged.push(card);
1082
- }
1083
- }
977
+ if (!NavigatorImpl) {
978
+ throw new Error(`Could not load navigator implementation for: ${implementingClass}`);
1084
979
  }
1085
- return n ? merged.slice(0, n) : merged;
980
+ return new NavigatorImpl(user, course, strategyData);
1086
981
  }
1087
982
  /**
1088
- * 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
1089
1006
  */
1090
- async getPendingReviews() {
1091
- const legacyGenerators = this.generators.filter(
1092
- (g) => g instanceof ContentNavigator
1093
- );
1094
- const results = await Promise.all(legacyGenerators.map((g) => g.getPendingReviews()));
1095
- const seen = /* @__PURE__ */ new Set();
1096
- const merged = [];
1097
- for (const reviews of results) {
1098
- for (const review of reviews) {
1099
- if (!seen.has(review.cardID)) {
1100
- seen.add(review.cardID);
1101
- merged.push(review);
1102
- }
1103
- }
1104
- }
1105
- return merged;
1007
+ async getWeightedCards(_limit) {
1008
+ throw new Error(`${this.constructor.name} must implement getWeightedCards(). `);
1106
1009
  }
1107
1010
  };
1108
1011
  }
1109
1012
  });
1110
1013
 
1111
1014
  // src/core/navigators/Pipeline.ts
1112
- var Pipeline_exports = {};
1113
- __export(Pipeline_exports, {
1114
- Pipeline: () => Pipeline
1115
- });
1116
1015
  import { toCourseElo as toCourseElo2 } from "@vue-skuilder/common";
1117
1016
  function logPipelineConfig(generator, filters) {
1118
1017
  const filterList = filters.length > 0 ? "\n - " + filters.map((f) => f.name).join("\n - ") : " none";
@@ -1172,6 +1071,11 @@ var init_Pipeline = __esm({
1172
1071
  this.filters = filters;
1173
1072
  this.user = user;
1174
1073
  this.course = course;
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
+ });
1175
1079
  logPipelineConfig(generator, filters);
1176
1080
  }
1177
1081
  /**
@@ -1208,7 +1112,13 @@ var init_Pipeline = __esm({
1208
1112
  cards.sort((a, b) => b.score - a.score);
1209
1113
  const result = cards.slice(0, limit);
1210
1114
  const topScores = result.slice(0, 3).map((c) => c.score);
1211
- logExecutionSummary(this.generator.name, generatedCount, this.filters.length, result.length, topScores);
1115
+ logExecutionSummary(
1116
+ this.generator.name,
1117
+ generatedCount,
1118
+ this.filters.length,
1119
+ result.length,
1120
+ topScores
1121
+ );
1212
1122
  logCardProvenance(result, 3);
1213
1123
  return result;
1214
1124
  }
@@ -1257,48 +1167,155 @@ var init_Pipeline = __esm({
1257
1167
  userElo
1258
1168
  };
1259
1169
  }
1260
- // ===========================================================================
1261
- // Legacy StudyContentSource methods
1262
- // ===========================================================================
1263
- //
1264
- // These delegate to the generator for backward compatibility.
1265
- // Eventually SessionController will use getWeightedCards() exclusively.
1266
- //
1267
1170
  /**
1268
- * Get new cards via legacy API.
1269
- * Delegates to the generator if it supports the legacy interface.
1171
+ * Get the course ID for this pipeline.
1270
1172
  */
1271
- async getNewCards(n) {
1272
- if ("getNewCards" in this.generator && typeof this.generator.getNewCards === "function") {
1273
- 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");
1274
1200
  }
1275
- return [];
1201
+ logger.debug(
1202
+ `[CompositeGenerator] Created with ${generators.length} generators, mode: ${aggregationMode}`
1203
+ );
1276
1204
  }
1277
1205
  /**
1278
- * Get pending reviews via legacy API.
1279
- * 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.
1280
1209
  */
1281
- async getPendingReviews() {
1282
- if ("getPendingReviews" in this.generator && typeof this.generator.getPendingReviews === "function") {
1283
- 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
+ );
1284
1233
  }
1285
- 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);
1286
1270
  }
1287
1271
  /**
1288
- * Get the course ID for this pipeline.
1272
+ * Build human-readable reason for score aggregation.
1289
1273
  */
1290
- getCourseID() {
1291
- 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
+ }
1292
1313
  }
1293
1314
  };
1294
1315
  }
1295
1316
  });
1296
1317
 
1297
1318
  // src/core/navigators/PipelineAssembler.ts
1298
- var PipelineAssembler_exports = {};
1299
- __export(PipelineAssembler_exports, {
1300
- PipelineAssembler: () => PipelineAssembler
1301
- });
1302
1319
  var PipelineAssembler;
1303
1320
  var init_PipelineAssembler = __esm({
1304
1321
  "src/core/navigators/PipelineAssembler.ts"() {
@@ -1419,15 +1436,11 @@ var init_PipelineAssembler = __esm({
1419
1436
  }
1420
1437
  });
1421
1438
 
1422
- // src/core/navigators/elo.ts
1423
- var elo_exports = {};
1424
- __export(elo_exports, {
1425
- default: () => ELONavigator
1426
- });
1439
+ // src/core/navigators/generators/elo.ts
1427
1440
  import { toCourseElo as toCourseElo3 } from "@vue-skuilder/common";
1428
1441
  var ELONavigator;
1429
1442
  var init_elo = __esm({
1430
- "src/core/navigators/elo.ts"() {
1443
+ "src/core/navigators/generators/elo.ts"() {
1431
1444
  "use strict";
1432
1445
  init_navigators();
1433
1446
  ELONavigator = class extends ContentNavigator {
@@ -1437,50 +1450,6 @@ var init_elo = __esm({
1437
1450
  super(user, course, strategyData);
1438
1451
  this.name = strategyData?.name || "ELO";
1439
1452
  }
1440
- async getPendingReviews() {
1441
- const reviews = await this.user.getPendingReviews(this.course.getCourseID());
1442
- const elo = await this.course.getCardEloData(reviews.map((r) => r.cardId));
1443
- const ratedReviews = reviews.map((r, i) => {
1444
- const ratedR = {
1445
- ...r,
1446
- ...elo[i]
1447
- };
1448
- return ratedR;
1449
- });
1450
- ratedReviews.sort((a, b) => {
1451
- return a.global.score - b.global.score;
1452
- });
1453
- return ratedReviews.map((r) => {
1454
- return {
1455
- ...r,
1456
- contentSourceType: "course",
1457
- contentSourceID: this.course.getCourseID(),
1458
- cardID: r.cardId,
1459
- courseID: r.courseId,
1460
- qualifiedID: `${r.courseId}-${r.cardId}`,
1461
- reviewID: r._id,
1462
- status: "review"
1463
- };
1464
- });
1465
- }
1466
- async getNewCards(limit = 99) {
1467
- const activeCards = await this.user.getActiveCards();
1468
- return (await this.course.getCardsCenteredAtELO(
1469
- { limit, elo: "user" },
1470
- (c) => {
1471
- if (activeCards.some((ac) => c.cardID === ac.cardID)) {
1472
- return false;
1473
- } else {
1474
- return true;
1475
- }
1476
- }
1477
- )).map((c) => {
1478
- return {
1479
- ...c,
1480
- status: "new"
1481
- };
1482
- });
1483
- }
1484
1453
  /**
1485
1454
  * Get new cards with suitability scores based on ELO distance.
1486
1455
  *
@@ -1505,7 +1474,11 @@ var init_elo = __esm({
1505
1474
  const userElo = toCourseElo3(courseReg.elo);
1506
1475
  userGlobalElo = userElo.global.score;
1507
1476
  }
1508
- 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" }));
1509
1482
  const cardIds = newCards.map((c) => c.cardID);
1510
1483
  const cardEloData = await this.course.getCardEloData(cardIds);
1511
1484
  const scored = newCards.map((c, i) => {
@@ -1535,925 +1508,14 @@ var init_elo = __esm({
1535
1508
  }
1536
1509
  });
1537
1510
 
1538
- // src/core/navigators/filters/eloDistance.ts
1539
- var eloDistance_exports = {};
1540
- __export(eloDistance_exports, {
1541
- DEFAULT_HALF_LIFE: () => DEFAULT_HALF_LIFE,
1542
- DEFAULT_MAX_MULTIPLIER: () => DEFAULT_MAX_MULTIPLIER,
1543
- DEFAULT_MIN_MULTIPLIER: () => DEFAULT_MIN_MULTIPLIER,
1544
- createEloDistanceFilter: () => createEloDistanceFilter
1545
- });
1546
- function computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier) {
1547
- const normalizedDistance = distance / halfLife;
1548
- const decay = Math.exp(-(normalizedDistance * normalizedDistance));
1549
- return minMultiplier + (maxMultiplier - minMultiplier) * decay;
1550
- }
1551
- function createEloDistanceFilter(config) {
1552
- const halfLife = config?.halfLife ?? DEFAULT_HALF_LIFE;
1553
- const minMultiplier = config?.minMultiplier ?? DEFAULT_MIN_MULTIPLIER;
1554
- const maxMultiplier = config?.maxMultiplier ?? DEFAULT_MAX_MULTIPLIER;
1555
- return {
1556
- name: "ELO Distance Filter",
1557
- async transform(cards, context) {
1558
- const { course, userElo } = context;
1559
- const cardIds = cards.map((c) => c.cardId);
1560
- const cardElos = await course.getCardEloData(cardIds);
1561
- return cards.map((card, i) => {
1562
- const cardElo = cardElos[i]?.global?.score ?? 1e3;
1563
- const distance = Math.abs(cardElo - userElo);
1564
- const multiplier = computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier);
1565
- const newScore = card.score * multiplier;
1566
- const action = multiplier < maxMultiplier - 0.01 ? "penalized" : "passed";
1567
- return {
1568
- ...card,
1569
- score: newScore,
1570
- provenance: [
1571
- ...card.provenance,
1572
- {
1573
- strategy: "eloDistance",
1574
- strategyName: "ELO Distance Filter",
1575
- strategyId: "ELO_DISTANCE_FILTER",
1576
- action,
1577
- score: newScore,
1578
- reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userElo)}) \u2192 ${multiplier.toFixed(2)}x`
1579
- }
1580
- ]
1581
- };
1582
- });
1583
- }
1584
- };
1585
- }
1586
- var DEFAULT_HALF_LIFE, DEFAULT_MIN_MULTIPLIER, DEFAULT_MAX_MULTIPLIER;
1587
- var init_eloDistance = __esm({
1588
- "src/core/navigators/filters/eloDistance.ts"() {
1589
- "use strict";
1590
- DEFAULT_HALF_LIFE = 200;
1591
- DEFAULT_MIN_MULTIPLIER = 0.3;
1592
- DEFAULT_MAX_MULTIPLIER = 1;
1593
- }
1594
- });
1595
-
1596
- // src/core/navigators/filters/userTagPreference.ts
1597
- var userTagPreference_exports = {};
1598
- __export(userTagPreference_exports, {
1599
- default: () => UserTagPreferenceFilter
1600
- });
1601
- var UserTagPreferenceFilter;
1602
- var init_userTagPreference = __esm({
1603
- "src/core/navigators/filters/userTagPreference.ts"() {
1604
- "use strict";
1605
- init_navigators();
1606
- UserTagPreferenceFilter = class extends ContentNavigator {
1607
- _strategyData;
1608
- /** Human-readable name for CardFilter interface */
1609
- name;
1610
- constructor(user, course, strategyData) {
1611
- super(user, course, strategyData);
1612
- this._strategyData = strategyData;
1613
- this.name = strategyData.name || "User Tag Preferences";
1614
- }
1615
- /**
1616
- * Compute multiplier for a card based on its tags and user preferences.
1617
- * Returns the maximum multiplier among all matching tags, or 1.0 if no matches.
1618
- */
1619
- computeMultiplier(cardTags, boostMap) {
1620
- const multipliers = cardTags.map((tag) => boostMap[tag]).filter((val) => val !== void 0);
1621
- if (multipliers.length === 0) {
1622
- return 1;
1623
- }
1624
- return Math.max(...multipliers);
1625
- }
1626
- /**
1627
- * Build human-readable reason for the filter's decision.
1628
- */
1629
- buildReason(cardTags, boostMap, multiplier) {
1630
- const matchingTags = cardTags.filter((tag) => boostMap[tag] === multiplier);
1631
- if (multiplier === 0) {
1632
- return `Excluded by user preference: ${matchingTags.join(", ")} (${multiplier}x)`;
1633
- }
1634
- if (multiplier < 1) {
1635
- return `Penalized by user preference: ${matchingTags.join(", ")} (${multiplier.toFixed(2)}x)`;
1636
- }
1637
- if (multiplier > 1) {
1638
- return `Boosted by user preference: ${matchingTags.join(", ")} (${multiplier.toFixed(2)}x)`;
1639
- }
1640
- return "No matching user preferences";
1641
- }
1642
- /**
1643
- * CardFilter.transform implementation.
1644
- *
1645
- * Apply user tag preferences:
1646
- * 1. Read preferences from strategy state
1647
- * 2. If no preferences, pass through unchanged
1648
- * 3. For each card:
1649
- * - Look up tag in boost record
1650
- * - If tag found: apply multiplier (0 = exclude, 1 = neutral, >1 = boost)
1651
- * - If multiple tags match: use max multiplier
1652
- * - Append provenance with clear reason
1653
- */
1654
- async transform(cards, _context) {
1655
- const prefs = await this.getStrategyState();
1656
- if (!prefs || Object.keys(prefs.boost).length === 0) {
1657
- return cards.map((card) => ({
1658
- ...card,
1659
- provenance: [
1660
- ...card.provenance,
1661
- {
1662
- strategy: "userTagPreference",
1663
- strategyName: this.strategyName || this.name,
1664
- strategyId: this.strategyId || this._strategyData._id,
1665
- action: "passed",
1666
- score: card.score,
1667
- reason: "No user tag preferences configured"
1668
- }
1669
- ]
1670
- }));
1671
- }
1672
- const adjusted = await Promise.all(
1673
- cards.map(async (card) => {
1674
- const cardTags = card.tags ?? [];
1675
- const multiplier = this.computeMultiplier(cardTags, prefs.boost);
1676
- const finalScore = Math.min(1, card.score * multiplier);
1677
- let action;
1678
- if (multiplier === 0 || multiplier < 1) {
1679
- action = "penalized";
1680
- } else if (multiplier > 1) {
1681
- action = "boosted";
1682
- } else {
1683
- action = "passed";
1684
- }
1685
- return {
1686
- ...card,
1687
- score: finalScore,
1688
- provenance: [
1689
- ...card.provenance,
1690
- {
1691
- strategy: "userTagPreference",
1692
- strategyName: this.strategyName || this.name,
1693
- strategyId: this.strategyId || this._strategyData._id,
1694
- action,
1695
- score: finalScore,
1696
- reason: this.buildReason(cardTags, prefs.boost, multiplier)
1697
- }
1698
- ]
1699
- };
1700
- })
1701
- );
1702
- return adjusted;
1703
- }
1704
- /**
1705
- * Legacy getWeightedCards - throws as filters should not be used as generators.
1706
- */
1707
- async getWeightedCards(_limit) {
1708
- throw new Error(
1709
- "UserTagPreferenceFilter is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
1710
- );
1711
- }
1712
- // Legacy methods - stub implementations since filters don't generate cards
1713
- async getNewCards(_n) {
1714
- return [];
1715
- }
1716
- async getPendingReviews() {
1717
- return [];
1718
- }
1719
- };
1720
- }
1721
- });
1722
-
1723
- // src/core/navigators/filters/index.ts
1724
- var filters_exports = {};
1725
- __export(filters_exports, {
1726
- UserTagPreferenceFilter: () => UserTagPreferenceFilter,
1727
- createEloDistanceFilter: () => createEloDistanceFilter
1728
- });
1729
- var init_filters = __esm({
1730
- "src/core/navigators/filters/index.ts"() {
1731
- "use strict";
1732
- init_eloDistance();
1733
- init_userTagPreference();
1734
- }
1735
- });
1736
-
1737
- // src/core/navigators/filters/types.ts
1738
- var types_exports = {};
1739
- var init_types = __esm({
1740
- "src/core/navigators/filters/types.ts"() {
1741
- "use strict";
1742
- }
1743
- });
1744
-
1745
- // src/core/navigators/generators/index.ts
1746
- var generators_exports = {};
1747
- var init_generators = __esm({
1748
- "src/core/navigators/generators/index.ts"() {
1749
- "use strict";
1750
- }
1751
- });
1752
-
1753
- // src/core/navigators/generators/types.ts
1754
- var types_exports2 = {};
1755
- var init_types2 = __esm({
1756
- "src/core/navigators/generators/types.ts"() {
1757
- "use strict";
1758
- }
1759
- });
1760
-
1761
- // src/core/navigators/hardcodedOrder.ts
1762
- var hardcodedOrder_exports = {};
1763
- __export(hardcodedOrder_exports, {
1764
- default: () => HardcodedOrderNavigator
1765
- });
1766
- var HardcodedOrderNavigator;
1767
- var init_hardcodedOrder = __esm({
1768
- "src/core/navigators/hardcodedOrder.ts"() {
1769
- "use strict";
1770
- init_navigators();
1771
- init_logger();
1772
- HardcodedOrderNavigator = class extends ContentNavigator {
1773
- /** Human-readable name for CardGenerator interface */
1774
- name;
1775
- orderedCardIds = [];
1776
- constructor(user, course, strategyData) {
1777
- super(user, course, strategyData);
1778
- this.name = strategyData.name || "Hardcoded Order";
1779
- if (strategyData.serializedData) {
1780
- try {
1781
- this.orderedCardIds = JSON.parse(strategyData.serializedData);
1782
- } catch (e) {
1783
- logger.error("Failed to parse serializedData for HardcodedOrderNavigator", e);
1784
- }
1785
- }
1786
- }
1787
- async getPendingReviews() {
1788
- const reviews = await this.user.getPendingReviews(this.course.getCourseID());
1789
- return reviews.map((r) => {
1790
- return {
1791
- ...r,
1792
- contentSourceType: "course",
1793
- contentSourceID: this.course.getCourseID(),
1794
- cardID: r.cardId,
1795
- courseID: r.courseId,
1796
- reviewID: r._id,
1797
- status: "review"
1798
- };
1799
- });
1800
- }
1801
- async getNewCards(limit = 99) {
1802
- const activeCardIds = (await this.user.getActiveCards()).map((c) => c.cardID);
1803
- const newCardIds = this.orderedCardIds.filter((cardId) => !activeCardIds.includes(cardId));
1804
- const cardsToReturn = newCardIds.slice(0, limit);
1805
- return cardsToReturn.map((cardId) => {
1806
- return {
1807
- cardID: cardId,
1808
- courseID: this.course.getCourseID(),
1809
- contentSourceType: "course",
1810
- contentSourceID: this.course.getCourseID(),
1811
- status: "new"
1812
- };
1813
- });
1814
- }
1815
- /**
1816
- * Get cards in hardcoded order with scores based on position.
1817
- *
1818
- * Earlier cards in the sequence get higher scores.
1819
- * Score formula: 1.0 - (position / totalCards) * 0.5
1820
- * This ensures scores range from 1.0 (first card) to 0.5+ (last card).
1821
- *
1822
- * This method supports both the legacy signature (limit only) and the
1823
- * CardGenerator interface signature (limit, context).
1824
- *
1825
- * @param limit - Maximum number of cards to return
1826
- * @param _context - Optional GeneratorContext (currently unused, but required for interface)
1827
- */
1828
- async getWeightedCards(limit, _context) {
1829
- const activeCardIds = (await this.user.getActiveCards()).map((c) => c.cardID);
1830
- const reviews = await this.getPendingReviews();
1831
- const newCardIds = this.orderedCardIds.filter((cardId) => !activeCardIds.includes(cardId));
1832
- const totalCards = newCardIds.length;
1833
- const scoredNew = newCardIds.slice(0, limit).map((cardId, index) => {
1834
- const position = index + 1;
1835
- const score = Math.max(0.5, 1 - index / totalCards * 0.5);
1836
- return {
1837
- cardId,
1838
- courseId: this.course.getCourseID(),
1839
- score,
1840
- provenance: [
1841
- {
1842
- strategy: "hardcodedOrder",
1843
- strategyName: this.strategyName || this.name,
1844
- strategyId: this.strategyId || "NAVIGATION_STRATEGY-hardcoded",
1845
- action: "generated",
1846
- score,
1847
- reason: `Position ${position} of ${totalCards} in fixed sequence, new card`
1848
- }
1849
- ]
1850
- };
1851
- });
1852
- const scoredReviews = reviews.map((r) => ({
1853
- cardId: r.cardID,
1854
- courseId: r.courseID,
1855
- score: 1,
1856
- provenance: [
1857
- {
1858
- strategy: "hardcodedOrder",
1859
- strategyName: this.strategyName || this.name,
1860
- strategyId: this.strategyId || "NAVIGATION_STRATEGY-hardcoded",
1861
- action: "generated",
1862
- score: 1,
1863
- reason: "Scheduled review, highest priority"
1864
- }
1865
- ]
1866
- }));
1867
- const all = [...scoredReviews, ...scoredNew];
1868
- all.sort((a, b) => b.score - a.score);
1869
- return all.slice(0, limit);
1870
- }
1871
- };
1872
- }
1873
- });
1874
-
1875
- // src/core/navigators/hierarchyDefinition.ts
1876
- var hierarchyDefinition_exports = {};
1877
- __export(hierarchyDefinition_exports, {
1878
- default: () => HierarchyDefinitionNavigator
1879
- });
1880
- import { toCourseElo as toCourseElo4 } from "@vue-skuilder/common";
1881
- var DEFAULT_MIN_COUNT, HierarchyDefinitionNavigator;
1882
- var init_hierarchyDefinition = __esm({
1883
- "src/core/navigators/hierarchyDefinition.ts"() {
1884
- "use strict";
1885
- init_navigators();
1886
- DEFAULT_MIN_COUNT = 3;
1887
- HierarchyDefinitionNavigator = class extends ContentNavigator {
1888
- config;
1889
- _strategyData;
1890
- /** Human-readable name for CardFilter interface */
1891
- name;
1892
- constructor(user, course, _strategyData) {
1893
- super(user, course, _strategyData);
1894
- this._strategyData = _strategyData;
1895
- this.config = this.parseConfig(_strategyData.serializedData);
1896
- this.name = _strategyData.name || "Hierarchy Definition";
1897
- }
1898
- parseConfig(serializedData) {
1899
- try {
1900
- const parsed = JSON.parse(serializedData);
1901
- return {
1902
- prerequisites: parsed.prerequisites || {}
1903
- };
1904
- } catch {
1905
- return {
1906
- prerequisites: {}
1907
- };
1908
- }
1909
- }
1910
- /**
1911
- * Check if a specific prerequisite is satisfied
1912
- */
1913
- isPrerequisiteMet(prereq, userTagElo, userGlobalElo) {
1914
- if (!userTagElo) return false;
1915
- const minCount = prereq.masteryThreshold?.minCount ?? DEFAULT_MIN_COUNT;
1916
- if (userTagElo.count < minCount) return false;
1917
- if (prereq.masteryThreshold?.minElo !== void 0) {
1918
- return userTagElo.score >= prereq.masteryThreshold.minElo;
1919
- } else {
1920
- return userTagElo.score >= userGlobalElo;
1921
- }
1922
- }
1923
- /**
1924
- * Get the set of tags the user has mastered.
1925
- * A tag is "mastered" if it appears as a prerequisite somewhere and meets its threshold.
1926
- */
1927
- async getMasteredTags(context) {
1928
- const mastered = /* @__PURE__ */ new Set();
1929
- try {
1930
- const courseReg = await context.user.getCourseRegDoc(context.course.getCourseID());
1931
- const userElo = toCourseElo4(courseReg.elo);
1932
- for (const prereqs of Object.values(this.config.prerequisites)) {
1933
- for (const prereq of prereqs) {
1934
- const tagElo = userElo.tags[prereq.tag];
1935
- if (this.isPrerequisiteMet(prereq, tagElo, userElo.global.score)) {
1936
- mastered.add(prereq.tag);
1937
- }
1938
- }
1939
- }
1940
- } catch {
1941
- }
1942
- return mastered;
1943
- }
1944
- /**
1945
- * Get the set of tags that are unlocked (prerequisites met)
1946
- */
1947
- getUnlockedTags(masteredTags) {
1948
- const unlocked = /* @__PURE__ */ new Set();
1949
- for (const [tagId, prereqs] of Object.entries(this.config.prerequisites)) {
1950
- const allPrereqsMet = prereqs.every((prereq) => masteredTags.has(prereq.tag));
1951
- if (allPrereqsMet) {
1952
- unlocked.add(tagId);
1953
- }
1954
- }
1955
- return unlocked;
1956
- }
1957
- /**
1958
- * Check if a tag has prerequisites defined in config
1959
- */
1960
- hasPrerequisites(tagId) {
1961
- return tagId in this.config.prerequisites;
1962
- }
1963
- /**
1964
- * Check if a card is unlocked and generate reason.
1965
- */
1966
- async checkCardUnlock(card, course, unlockedTags, masteredTags) {
1967
- try {
1968
- const cardTags = card.tags ?? [];
1969
- const lockedTags = cardTags.filter(
1970
- (tag) => this.hasPrerequisites(tag) && !unlockedTags.has(tag)
1971
- );
1972
- if (lockedTags.length === 0) {
1973
- const tagList = cardTags.length > 0 ? cardTags.join(", ") : "none";
1974
- return {
1975
- isUnlocked: true,
1976
- reason: `Prerequisites met, tags: ${tagList}`
1977
- };
1978
- }
1979
- const missingPrereqs = lockedTags.flatMap((tag) => {
1980
- const prereqs = this.config.prerequisites[tag] || [];
1981
- return prereqs.filter((p) => !masteredTags.has(p.tag)).map((p) => p.tag);
1982
- });
1983
- return {
1984
- isUnlocked: false,
1985
- reason: `Blocked: missing prerequisites ${missingPrereqs.join(", ")} for tags ${lockedTags.join(", ")}`
1986
- };
1987
- } catch {
1988
- return {
1989
- isUnlocked: true,
1990
- reason: "Prerequisites check skipped (tag lookup failed)"
1991
- };
1992
- }
1993
- }
1994
- /**
1995
- * CardFilter.transform implementation.
1996
- *
1997
- * Apply prerequisite gating to cards. Cards with locked tags receive score: 0.
1998
- */
1999
- async transform(cards, context) {
2000
- const masteredTags = await this.getMasteredTags(context);
2001
- const unlockedTags = this.getUnlockedTags(masteredTags);
2002
- const gated = [];
2003
- for (const card of cards) {
2004
- const { isUnlocked, reason } = await this.checkCardUnlock(
2005
- card,
2006
- context.course,
2007
- unlockedTags,
2008
- masteredTags
2009
- );
2010
- const finalScore = isUnlocked ? card.score : 0;
2011
- const action = isUnlocked ? "passed" : "penalized";
2012
- gated.push({
2013
- ...card,
2014
- score: finalScore,
2015
- provenance: [
2016
- ...card.provenance,
2017
- {
2018
- strategy: "hierarchyDefinition",
2019
- strategyName: this.strategyName || this.name,
2020
- strategyId: this.strategyId || "NAVIGATION_STRATEGY-hierarchy",
2021
- action,
2022
- score: finalScore,
2023
- reason
2024
- }
2025
- ]
2026
- });
2027
- }
2028
- return gated;
2029
- }
2030
- /**
2031
- * Legacy getWeightedCards - now throws as filters should not be used as generators.
2032
- *
2033
- * Use transform() via Pipeline instead.
2034
- */
2035
- async getWeightedCards(_limit) {
2036
- throw new Error(
2037
- "HierarchyDefinitionNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
2038
- );
2039
- }
2040
- // Legacy methods - stub implementations since filters don't generate cards
2041
- async getNewCards(_n) {
2042
- return [];
2043
- }
2044
- async getPendingReviews() {
2045
- return [];
2046
- }
2047
- };
2048
- }
2049
- });
2050
-
2051
- // src/core/navigators/inferredPreference.ts
2052
- var inferredPreference_exports = {};
2053
- __export(inferredPreference_exports, {
2054
- INFERRED_PREFERENCE_NAVIGATOR_STUB: () => INFERRED_PREFERENCE_NAVIGATOR_STUB
2055
- });
2056
- var INFERRED_PREFERENCE_NAVIGATOR_STUB;
2057
- var init_inferredPreference = __esm({
2058
- "src/core/navigators/inferredPreference.ts"() {
2059
- "use strict";
2060
- INFERRED_PREFERENCE_NAVIGATOR_STUB = true;
2061
- }
2062
- });
2063
-
2064
- // src/core/navigators/interferenceMitigator.ts
2065
- var interferenceMitigator_exports = {};
2066
- __export(interferenceMitigator_exports, {
2067
- default: () => InterferenceMitigatorNavigator
2068
- });
2069
- import { toCourseElo as toCourseElo5 } from "@vue-skuilder/common";
2070
- var DEFAULT_MIN_COUNT2, DEFAULT_MIN_ELAPSED_DAYS, DEFAULT_INTERFERENCE_DECAY, InterferenceMitigatorNavigator;
2071
- var init_interferenceMitigator = __esm({
2072
- "src/core/navigators/interferenceMitigator.ts"() {
2073
- "use strict";
2074
- init_navigators();
2075
- DEFAULT_MIN_COUNT2 = 10;
2076
- DEFAULT_MIN_ELAPSED_DAYS = 3;
2077
- DEFAULT_INTERFERENCE_DECAY = 0.8;
2078
- InterferenceMitigatorNavigator = class extends ContentNavigator {
2079
- config;
2080
- _strategyData;
2081
- /** Human-readable name for CardFilter interface */
2082
- name;
2083
- /** Precomputed map: tag -> set of { partner, decay } it interferes with */
2084
- interferenceMap;
2085
- constructor(user, course, _strategyData) {
2086
- super(user, course, _strategyData);
2087
- this._strategyData = _strategyData;
2088
- this.config = this.parseConfig(_strategyData.serializedData);
2089
- this.interferenceMap = this.buildInterferenceMap();
2090
- this.name = _strategyData.name || "Interference Mitigator";
2091
- }
2092
- parseConfig(serializedData) {
2093
- try {
2094
- const parsed = JSON.parse(serializedData);
2095
- let sets = parsed.interferenceSets || [];
2096
- if (sets.length > 0 && Array.isArray(sets[0])) {
2097
- sets = sets.map((tags) => ({ tags }));
2098
- }
2099
- return {
2100
- interferenceSets: sets,
2101
- maturityThreshold: {
2102
- minCount: parsed.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT2,
2103
- minElo: parsed.maturityThreshold?.minElo,
2104
- minElapsedDays: parsed.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS
2105
- },
2106
- defaultDecay: parsed.defaultDecay ?? DEFAULT_INTERFERENCE_DECAY
2107
- };
2108
- } catch {
2109
- return {
2110
- interferenceSets: [],
2111
- maturityThreshold: {
2112
- minCount: DEFAULT_MIN_COUNT2,
2113
- minElapsedDays: DEFAULT_MIN_ELAPSED_DAYS
2114
- },
2115
- defaultDecay: DEFAULT_INTERFERENCE_DECAY
2116
- };
2117
- }
2118
- }
2119
- /**
2120
- * Build a map from each tag to its interference partners with decay coefficients.
2121
- * If tags A, B, C are in an interference group with decay 0.8, then:
2122
- * - A interferes with B (decay 0.8) and C (decay 0.8)
2123
- * - B interferes with A (decay 0.8) and C (decay 0.8)
2124
- * - etc.
2125
- */
2126
- buildInterferenceMap() {
2127
- const map = /* @__PURE__ */ new Map();
2128
- for (const group of this.config.interferenceSets) {
2129
- const decay = group.decay ?? this.config.defaultDecay ?? DEFAULT_INTERFERENCE_DECAY;
2130
- for (const tag of group.tags) {
2131
- if (!map.has(tag)) {
2132
- map.set(tag, []);
2133
- }
2134
- const partners = map.get(tag);
2135
- for (const other of group.tags) {
2136
- if (other !== tag) {
2137
- const existing = partners.find((p) => p.partner === other);
2138
- if (existing) {
2139
- existing.decay = Math.max(existing.decay, decay);
2140
- } else {
2141
- partners.push({ partner: other, decay });
2142
- }
2143
- }
2144
- }
2145
- }
2146
- }
2147
- return map;
2148
- }
2149
- /**
2150
- * Get the set of tags that are currently immature for this user.
2151
- * A tag is immature if the user has interacted with it but hasn't
2152
- * reached the maturity threshold.
2153
- */
2154
- async getImmatureTags(context) {
2155
- const immature = /* @__PURE__ */ new Set();
2156
- try {
2157
- const courseReg = await context.user.getCourseRegDoc(context.course.getCourseID());
2158
- const userElo = toCourseElo5(courseReg.elo);
2159
- const minCount = this.config.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT2;
2160
- const minElo = this.config.maturityThreshold?.minElo;
2161
- const minElapsedDays = this.config.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS;
2162
- const minCountForElapsed = minElapsedDays * 2;
2163
- for (const [tagId, tagElo] of Object.entries(userElo.tags)) {
2164
- if (tagElo.count === 0) continue;
2165
- const belowCount = tagElo.count < minCount;
2166
- const belowElo = minElo !== void 0 && tagElo.score < minElo;
2167
- const belowElapsed = tagElo.count < minCountForElapsed;
2168
- if (belowCount || belowElo || belowElapsed) {
2169
- immature.add(tagId);
2170
- }
2171
- }
2172
- } catch {
2173
- }
2174
- return immature;
2175
- }
2176
- /**
2177
- * Get all tags that interfere with any immature tag, along with their decay coefficients.
2178
- * These are the tags we want to avoid introducing.
2179
- */
2180
- getTagsToAvoid(immatureTags) {
2181
- const avoid = /* @__PURE__ */ new Map();
2182
- for (const immatureTag of immatureTags) {
2183
- const partners = this.interferenceMap.get(immatureTag);
2184
- if (partners) {
2185
- for (const { partner, decay } of partners) {
2186
- if (!immatureTags.has(partner)) {
2187
- const existing = avoid.get(partner) ?? 0;
2188
- avoid.set(partner, Math.max(existing, decay));
2189
- }
2190
- }
2191
- }
2192
- }
2193
- return avoid;
2194
- }
2195
- /**
2196
- * Compute interference score reduction for a card.
2197
- * Returns: { multiplier, interfering tags, reason }
2198
- */
2199
- computeInterferenceEffect(cardTags, tagsToAvoid, immatureTags) {
2200
- if (tagsToAvoid.size === 0) {
2201
- return {
2202
- multiplier: 1,
2203
- interferingTags: [],
2204
- reason: "No interference detected"
2205
- };
2206
- }
2207
- let multiplier = 1;
2208
- const interferingTags = [];
2209
- for (const tag of cardTags) {
2210
- const decay = tagsToAvoid.get(tag);
2211
- if (decay !== void 0) {
2212
- interferingTags.push(tag);
2213
- multiplier *= 1 - decay;
2214
- }
2215
- }
2216
- if (interferingTags.length === 0) {
2217
- return {
2218
- multiplier: 1,
2219
- interferingTags: [],
2220
- reason: "No interference detected"
2221
- };
2222
- }
2223
- const causingTags = /* @__PURE__ */ new Set();
2224
- for (const tag of interferingTags) {
2225
- for (const immatureTag of immatureTags) {
2226
- const partners = this.interferenceMap.get(immatureTag);
2227
- if (partners?.some((p) => p.partner === tag)) {
2228
- causingTags.add(immatureTag);
2229
- }
2230
- }
2231
- }
2232
- const reason = `Interferes with immature tags ${Array.from(causingTags).join(", ")} (tags: ${interferingTags.join(", ")}, multiplier: ${multiplier.toFixed(2)})`;
2233
- return { multiplier, interferingTags, reason };
2234
- }
2235
- /**
2236
- * CardFilter.transform implementation.
2237
- *
2238
- * Apply interference-aware scoring. Cards with tags that interfere with
2239
- * immature learnings get reduced scores.
2240
- */
2241
- async transform(cards, context) {
2242
- const immatureTags = await this.getImmatureTags(context);
2243
- const tagsToAvoid = this.getTagsToAvoid(immatureTags);
2244
- const adjusted = [];
2245
- for (const card of cards) {
2246
- const cardTags = card.tags ?? [];
2247
- const { multiplier, reason } = this.computeInterferenceEffect(
2248
- cardTags,
2249
- tagsToAvoid,
2250
- immatureTags
2251
- );
2252
- const finalScore = card.score * multiplier;
2253
- const action = multiplier < 1 ? "penalized" : multiplier > 1 ? "boosted" : "passed";
2254
- adjusted.push({
2255
- ...card,
2256
- score: finalScore,
2257
- provenance: [
2258
- ...card.provenance,
2259
- {
2260
- strategy: "interferenceMitigator",
2261
- strategyName: this.strategyName || this.name,
2262
- strategyId: this.strategyId || "NAVIGATION_STRATEGY-interference",
2263
- action,
2264
- score: finalScore,
2265
- reason
2266
- }
2267
- ]
2268
- });
2269
- }
2270
- return adjusted;
2271
- }
2272
- /**
2273
- * Legacy getWeightedCards - now throws as filters should not be used as generators.
2274
- *
2275
- * Use transform() via Pipeline instead.
2276
- */
2277
- async getWeightedCards(_limit) {
2278
- throw new Error(
2279
- "InterferenceMitigatorNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
2280
- );
2281
- }
2282
- // Legacy methods - stub implementations since filters don't generate cards
2283
- async getNewCards(_n) {
2284
- return [];
2285
- }
2286
- async getPendingReviews() {
2287
- return [];
2288
- }
2289
- };
2290
- }
2291
- });
2292
-
2293
- // src/core/navigators/relativePriority.ts
2294
- var relativePriority_exports = {};
2295
- __export(relativePriority_exports, {
2296
- default: () => RelativePriorityNavigator
2297
- });
2298
- var DEFAULT_PRIORITY, DEFAULT_PRIORITY_INFLUENCE, DEFAULT_COMBINE_MODE, RelativePriorityNavigator;
2299
- var init_relativePriority = __esm({
2300
- "src/core/navigators/relativePriority.ts"() {
2301
- "use strict";
2302
- init_navigators();
2303
- DEFAULT_PRIORITY = 0.5;
2304
- DEFAULT_PRIORITY_INFLUENCE = 0.5;
2305
- DEFAULT_COMBINE_MODE = "max";
2306
- RelativePriorityNavigator = class extends ContentNavigator {
2307
- config;
2308
- _strategyData;
2309
- /** Human-readable name for CardFilter interface */
2310
- name;
2311
- constructor(user, course, _strategyData) {
2312
- super(user, course, _strategyData);
2313
- this._strategyData = _strategyData;
2314
- this.config = this.parseConfig(_strategyData.serializedData);
2315
- this.name = _strategyData.name || "Relative Priority";
2316
- }
2317
- parseConfig(serializedData) {
2318
- try {
2319
- const parsed = JSON.parse(serializedData);
2320
- return {
2321
- tagPriorities: parsed.tagPriorities || {},
2322
- defaultPriority: parsed.defaultPriority ?? DEFAULT_PRIORITY,
2323
- combineMode: parsed.combineMode ?? DEFAULT_COMBINE_MODE,
2324
- priorityInfluence: parsed.priorityInfluence ?? DEFAULT_PRIORITY_INFLUENCE
2325
- };
2326
- } catch {
2327
- return {
2328
- tagPriorities: {},
2329
- defaultPriority: DEFAULT_PRIORITY,
2330
- combineMode: DEFAULT_COMBINE_MODE,
2331
- priorityInfluence: DEFAULT_PRIORITY_INFLUENCE
2332
- };
2333
- }
2334
- }
2335
- /**
2336
- * Look up the priority for a tag.
2337
- */
2338
- getTagPriority(tagId) {
2339
- return this.config.tagPriorities[tagId] ?? this.config.defaultPriority ?? DEFAULT_PRIORITY;
2340
- }
2341
- /**
2342
- * Compute combined priority for a card based on its tags.
2343
- */
2344
- computeCardPriority(cardTags) {
2345
- if (cardTags.length === 0) {
2346
- return this.config.defaultPriority ?? DEFAULT_PRIORITY;
2347
- }
2348
- const priorities = cardTags.map((tag) => this.getTagPriority(tag));
2349
- switch (this.config.combineMode) {
2350
- case "max":
2351
- return Math.max(...priorities);
2352
- case "min":
2353
- return Math.min(...priorities);
2354
- case "average":
2355
- return priorities.reduce((sum, p) => sum + p, 0) / priorities.length;
2356
- default:
2357
- return Math.max(...priorities);
2358
- }
2359
- }
2360
- /**
2361
- * Compute boost factor based on priority.
2362
- *
2363
- * The formula: 1 + (priority - 0.5) * priorityInfluence
2364
- *
2365
- * This creates a multiplier centered around 1.0:
2366
- * - Priority 1.0 with influence 0.5 → 1.25 (25% boost)
2367
- * - Priority 0.5 with any influence → 1.00 (neutral)
2368
- * - Priority 0.0 with influence 0.5 → 0.75 (25% reduction)
2369
- */
2370
- computeBoostFactor(priority) {
2371
- const influence = this.config.priorityInfluence ?? DEFAULT_PRIORITY_INFLUENCE;
2372
- return 1 + (priority - 0.5) * influence;
2373
- }
2374
- /**
2375
- * Build human-readable reason for priority adjustment.
2376
- */
2377
- buildPriorityReason(cardTags, priority, boostFactor, finalScore) {
2378
- if (cardTags.length === 0) {
2379
- return `No tags, neutral priority (${priority.toFixed(2)})`;
2380
- }
2381
- const tagList = cardTags.slice(0, 3).join(", ");
2382
- const more = cardTags.length > 3 ? ` (+${cardTags.length - 3} more)` : "";
2383
- if (boostFactor === 1) {
2384
- return `Neutral priority (${priority.toFixed(2)}) for tags: ${tagList}${more}`;
2385
- } else if (boostFactor > 1) {
2386
- return `High-priority tags: ${tagList}${more} (priority ${priority.toFixed(2)} \u2192 boost ${boostFactor.toFixed(2)}x \u2192 ${finalScore.toFixed(2)})`;
2387
- } else {
2388
- return `Low-priority tags: ${tagList}${more} (priority ${priority.toFixed(2)} \u2192 reduce ${boostFactor.toFixed(2)}x \u2192 ${finalScore.toFixed(2)})`;
2389
- }
2390
- }
2391
- /**
2392
- * CardFilter.transform implementation.
2393
- *
2394
- * Apply priority-adjusted scoring. Cards with high-priority tags get boosted,
2395
- * cards with low-priority tags get reduced scores.
2396
- */
2397
- async transform(cards, _context) {
2398
- const adjusted = await Promise.all(
2399
- cards.map(async (card) => {
2400
- const cardTags = card.tags ?? [];
2401
- const priority = this.computeCardPriority(cardTags);
2402
- const boostFactor = this.computeBoostFactor(priority);
2403
- const finalScore = Math.max(0, Math.min(1, card.score * boostFactor));
2404
- const action = boostFactor > 1 ? "boosted" : boostFactor < 1 ? "penalized" : "passed";
2405
- const reason = this.buildPriorityReason(cardTags, priority, boostFactor, finalScore);
2406
- return {
2407
- ...card,
2408
- score: finalScore,
2409
- provenance: [
2410
- ...card.provenance,
2411
- {
2412
- strategy: "relativePriority",
2413
- strategyName: this.strategyName || this.name,
2414
- strategyId: this.strategyId || "NAVIGATION_STRATEGY-priority",
2415
- action,
2416
- score: finalScore,
2417
- reason
2418
- }
2419
- ]
2420
- };
2421
- })
2422
- );
2423
- return adjusted;
2424
- }
2425
- /**
2426
- * Legacy getWeightedCards - now throws as filters should not be used as generators.
2427
- *
2428
- * Use transform() via Pipeline instead.
2429
- */
2430
- async getWeightedCards(_limit) {
2431
- throw new Error(
2432
- "RelativePriorityNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
2433
- );
2434
- }
2435
- // Legacy methods - stub implementations since filters don't generate cards
2436
- async getNewCards(_n) {
2437
- return [];
2438
- }
2439
- async getPendingReviews() {
2440
- return [];
2441
- }
2442
- };
2443
- }
2444
- });
2445
-
2446
- // src/core/navigators/srs.ts
2447
- var srs_exports = {};
2448
- __export(srs_exports, {
2449
- default: () => SRSNavigator
2450
- });
2451
- import moment3 from "moment";
2452
- var SRSNavigator;
2453
- var init_srs = __esm({
2454
- "src/core/navigators/srs.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"() {
2455
1516
  "use strict";
2456
1517
  init_navigators();
1518
+ init_logger();
2457
1519
  SRSNavigator = class extends ContentNavigator {
2458
1520
  /** Human-readable name for CardGenerator interface */
2459
1521
  name;
@@ -2489,6 +1551,7 @@ var init_srs = __esm({
2489
1551
  cardId: review.cardId,
2490
1552
  courseId: review.courseId,
2491
1553
  score,
1554
+ reviewID: review._id,
2492
1555
  provenance: [
2493
1556
  {
2494
1557
  strategy: "srs",
@@ -2501,6 +1564,7 @@ var init_srs = __esm({
2501
1564
  ]
2502
1565
  };
2503
1566
  });
1567
+ logger.debug(`[srsNav] got ${scored.length} weighted cards`);
2504
1568
  return scored.sort((a, b) => b.score - a.score).slice(0, limit);
2505
1569
  }
2506
1570
  /**
@@ -2526,314 +1590,117 @@ var init_srs = __esm({
2526
1590
  const hoursOverdue = now.diff(due, "hours");
2527
1591
  const relativeOverdue = hoursOverdue / intervalHours;
2528
1592
  const recencyFactor = 0.3 + 0.7 * Math.exp(-intervalHours / 720);
2529
- const overdueContribution = Math.min(1, Math.max(0, relativeOverdue));
2530
- const urgency = overdueContribution * 0.5 + recencyFactor * 0.5;
2531
- const score = Math.min(0.95, 0.5 + urgency * 0.45);
2532
- const reason = `${Math.round(hoursOverdue)}h overdue (interval: ${Math.round(intervalHours)}h, relative: ${relativeOverdue.toFixed(2)}), recency: ${recencyFactor.toFixed(2)}, review`;
2533
- return { score, reason };
2534
- }
2535
- /**
2536
- * Get pending reviews in legacy format.
2537
- *
2538
- * Returns all pending reviews for the course, enriched with session item fields.
2539
- */
2540
- async getPendingReviews() {
2541
- if (!this.user || !this.course) {
2542
- throw new Error("SRSNavigator requires user and course to be set");
2543
- }
2544
- const reviews = await this.user.getPendingReviews(this.course.getCourseID());
2545
- return reviews.map((r) => ({
2546
- ...r,
2547
- contentSourceType: "course",
2548
- contentSourceID: this.course.getCourseID(),
2549
- cardID: r.cardId,
2550
- courseID: r.courseId,
2551
- qualifiedID: `${r.courseId}-${r.cardId}`,
2552
- reviewID: r._id,
2553
- status: "review"
2554
- }));
2555
- }
2556
- /**
2557
- * SRS does not generate new cards.
2558
- * Use ELONavigator or another generator for new cards.
2559
- */
2560
- async getNewCards(_n) {
2561
- return [];
2562
- }
2563
- };
2564
- }
2565
- });
2566
-
2567
- // src/core/navigators/userGoal.ts
2568
- var userGoal_exports = {};
2569
- __export(userGoal_exports, {
2570
- USER_GOAL_NAVIGATOR_STUB: () => USER_GOAL_NAVIGATOR_STUB
2571
- });
2572
- var USER_GOAL_NAVIGATOR_STUB;
2573
- var init_userGoal = __esm({
2574
- "src/core/navigators/userGoal.ts"() {
2575
- "use strict";
2576
- USER_GOAL_NAVIGATOR_STUB = true;
2577
- }
2578
- });
2579
-
2580
- // import("./**/*") in src/core/navigators/index.ts
2581
- var globImport;
2582
- var init_ = __esm({
2583
- 'import("./**/*") in src/core/navigators/index.ts'() {
2584
- globImport = __glob({
2585
- "./CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
2586
- "./Pipeline.ts": () => Promise.resolve().then(() => (init_Pipeline(), Pipeline_exports)),
2587
- "./PipelineAssembler.ts": () => Promise.resolve().then(() => (init_PipelineAssembler(), PipelineAssembler_exports)),
2588
- "./elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
2589
- "./filters/eloDistance.ts": () => Promise.resolve().then(() => (init_eloDistance(), eloDistance_exports)),
2590
- "./filters/index.ts": () => Promise.resolve().then(() => (init_filters(), filters_exports)),
2591
- "./filters/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports)),
2592
- "./filters/userTagPreference.ts": () => Promise.resolve().then(() => (init_userTagPreference(), userTagPreference_exports)),
2593
- "./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
2594
- "./generators/types.ts": () => Promise.resolve().then(() => (init_types2(), types_exports2)),
2595
- "./hardcodedOrder.ts": () => Promise.resolve().then(() => (init_hardcodedOrder(), hardcodedOrder_exports)),
2596
- "./hierarchyDefinition.ts": () => Promise.resolve().then(() => (init_hierarchyDefinition(), hierarchyDefinition_exports)),
2597
- "./index.ts": () => Promise.resolve().then(() => (init_navigators(), navigators_exports)),
2598
- "./inferredPreference.ts": () => Promise.resolve().then(() => (init_inferredPreference(), inferredPreference_exports)),
2599
- "./interferenceMitigator.ts": () => Promise.resolve().then(() => (init_interferenceMitigator(), interferenceMitigator_exports)),
2600
- "./relativePriority.ts": () => Promise.resolve().then(() => (init_relativePriority(), relativePriority_exports)),
2601
- "./srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
2602
- "./userGoal.ts": () => Promise.resolve().then(() => (init_userGoal(), userGoal_exports))
2603
- });
2604
- }
2605
- });
2606
-
2607
- // src/core/navigators/index.ts
2608
- var navigators_exports = {};
2609
- __export(navigators_exports, {
2610
- ContentNavigator: () => ContentNavigator,
2611
- NavigatorRole: () => NavigatorRole,
2612
- NavigatorRoles: () => NavigatorRoles,
2613
- Navigators: () => Navigators,
2614
- getCardOrigin: () => getCardOrigin,
2615
- isFilter: () => isFilter,
2616
- isGenerator: () => isGenerator
2617
- });
2618
- function getCardOrigin(card) {
2619
- if (card.provenance.length === 0) {
2620
- throw new Error("Card has no provenance - cannot determine origin");
2621
- }
2622
- const firstEntry = card.provenance[0];
2623
- const reason = firstEntry.reason.toLowerCase();
2624
- if (reason.includes("failed")) {
2625
- return "failed";
2626
- }
2627
- if (reason.includes("review")) {
2628
- return "review";
2629
- }
2630
- return "new";
2631
- }
2632
- function isGenerator(impl) {
2633
- return NavigatorRoles[impl] === "generator" /* GENERATOR */;
2634
- }
2635
- function isFilter(impl) {
2636
- return NavigatorRoles[impl] === "filter" /* FILTER */;
2637
- }
2638
- var Navigators, NavigatorRole, NavigatorRoles, ContentNavigator;
2639
- var init_navigators = __esm({
2640
- "src/core/navigators/index.ts"() {
2641
- "use strict";
2642
- init_logger();
2643
- init_();
2644
- Navigators = /* @__PURE__ */ ((Navigators2) => {
2645
- Navigators2["ELO"] = "elo";
2646
- Navigators2["SRS"] = "srs";
2647
- Navigators2["HARDCODED"] = "hardcodedOrder";
2648
- Navigators2["HIERARCHY"] = "hierarchyDefinition";
2649
- Navigators2["INTERFERENCE"] = "interferenceMitigator";
2650
- Navigators2["RELATIVE_PRIORITY"] = "relativePriority";
2651
- Navigators2["USER_TAG_PREFERENCE"] = "userTagPreference";
2652
- return Navigators2;
2653
- })(Navigators || {});
2654
- NavigatorRole = /* @__PURE__ */ ((NavigatorRole2) => {
2655
- NavigatorRole2["GENERATOR"] = "generator";
2656
- NavigatorRole2["FILTER"] = "filter";
2657
- return NavigatorRole2;
2658
- })(NavigatorRole || {});
2659
- NavigatorRoles = {
2660
- ["elo" /* ELO */]: "generator" /* GENERATOR */,
2661
- ["srs" /* SRS */]: "generator" /* GENERATOR */,
2662
- ["hardcodedOrder" /* HARDCODED */]: "generator" /* GENERATOR */,
2663
- ["hierarchyDefinition" /* HIERARCHY */]: "filter" /* FILTER */,
2664
- ["interferenceMitigator" /* INTERFERENCE */]: "filter" /* FILTER */,
2665
- ["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */,
2666
- ["userTagPreference" /* USER_TAG_PREFERENCE */]: "filter" /* FILTER */
2667
- };
2668
- ContentNavigator = class {
2669
- /** User interface for this navigation session */
2670
- user;
2671
- /** Course interface for this navigation session */
2672
- course;
2673
- /** Human-readable name for this strategy instance (from ContentNavigationStrategyData.name) */
2674
- strategyName;
2675
- /** Unique document ID for this strategy instance (from ContentNavigationStrategyData._id) */
2676
- strategyId;
2677
- /**
2678
- * Constructor for standard navigators.
2679
- * Call this from subclass constructors to initialize common fields.
2680
- *
2681
- * Note: CompositeGenerator doesn't use this pattern and should call super() without args.
2682
- */
2683
- constructor(user, course, strategyData) {
2684
- if (user && course && strategyData) {
2685
- this.user = user;
2686
- this.course = course;
2687
- this.strategyName = strategyData.name;
2688
- this.strategyId = strategyData._id;
2689
- }
2690
- }
2691
- // ============================================================================
2692
- // STRATEGY STATE HELPERS
2693
- // ============================================================================
2694
- //
2695
- // These methods allow strategies to persist their own state (user preferences,
2696
- // learned patterns, temporal tracking) in the user database.
2697
- //
2698
- // ============================================================================
2699
- /**
2700
- * Unique key identifying this strategy for state storage.
2701
- *
2702
- * Defaults to the constructor name (e.g., "UserTagPreferenceFilter").
2703
- * Override in subclasses if multiple instances of the same strategy type
2704
- * need separate state storage.
2705
- */
2706
- get strategyKey() {
2707
- return this.constructor.name;
2708
- }
2709
- /**
2710
- * Get this strategy's persisted state for the current course.
2711
- *
2712
- * @returns The strategy's data payload, or null if no state exists
2713
- * @throws Error if user or course is not initialized
2714
- */
2715
- async getStrategyState() {
2716
- if (!this.user || !this.course) {
2717
- throw new Error(
2718
- `Cannot get strategy state: navigator not properly initialized. Ensure user and course are provided to constructor.`
2719
- );
2720
- }
2721
- return this.user.getStrategyState(this.course.getCourseID(), this.strategyKey);
2722
- }
2723
- /**
2724
- * Persist this strategy's state for the current course.
2725
- *
2726
- * @param data - The strategy's data payload to store
2727
- * @throws Error if user or course is not initialized
2728
- */
2729
- async putStrategyState(data) {
2730
- if (!this.user || !this.course) {
2731
- throw new Error(
2732
- `Cannot put strategy state: navigator not properly initialized. Ensure user and course are provided to constructor.`
2733
- );
2734
- }
2735
- return this.user.putStrategyState(this.course.getCourseID(), this.strategyKey, data);
2736
- }
2737
- /**
2738
- * Factory method to create navigator instances dynamically.
2739
- *
2740
- * @param user - User interface
2741
- * @param course - Course interface
2742
- * @param strategyData - Strategy configuration document
2743
- * @returns the runtime object used to steer a study session.
2744
- */
2745
- static async create(user, course, strategyData) {
2746
- const implementingClass = strategyData.implementingClass;
2747
- let NavigatorImpl;
2748
- const variations = [".ts", ".js", ""];
2749
- for (const ext of variations) {
2750
- try {
2751
- const module = await globImport(`./${implementingClass}${ext}`);
2752
- NavigatorImpl = module.default;
2753
- break;
2754
- } catch (e) {
2755
- logger.debug(`Failed to load with extension ${ext}:`, e);
2756
- }
2757
- }
2758
- if (!NavigatorImpl) {
2759
- throw new Error(`Could not load navigator implementation for: ${implementingClass}`);
2760
- }
2761
- return new NavigatorImpl(user, course, strategyData);
2762
- }
2763
- /**
2764
- * Get cards with suitability scores and provenance trails.
2765
- *
2766
- * **This is the PRIMARY API for navigation strategies.**
2767
- *
2768
- * Returns cards ranked by suitability score (0-1). Higher scores indicate
2769
- * better candidates for presentation. Each card includes a provenance trail
2770
- * documenting how strategies contributed to the final score.
2771
- *
2772
- * ## For Generators
2773
- * Override this method to generate candidates and compute scores based on
2774
- * your strategy's logic (e.g., ELO proximity, review urgency). Create the
2775
- * initial provenance entry with action='generated'.
2776
- *
2777
- * ## Default Implementation
2778
- * The base class provides a backward-compatible default that:
2779
- * 1. Calls legacy getNewCards() and getPendingReviews()
2780
- * 2. Assigns score=1.0 to all cards
2781
- * 3. Creates minimal provenance from legacy methods
2782
- * 4. Returns combined results up to limit
2783
- *
2784
- * This allows existing strategies to work without modification while
2785
- * new strategies can override with proper scoring and provenance.
2786
- *
2787
- * @param limit - Maximum cards to return
2788
- * @returns Cards sorted by score descending, with provenance trails
2789
- */
2790
- async getWeightedCards(limit) {
2791
- const newCards = await this.getNewCards(limit);
2792
- const reviews = await this.getPendingReviews();
2793
- const weighted = [
2794
- ...newCards.map((c) => ({
2795
- cardId: c.cardID,
2796
- courseId: c.courseID,
2797
- score: 1,
2798
- provenance: [
2799
- {
2800
- strategy: "legacy",
2801
- strategyName: this.strategyName || "Legacy API",
2802
- strategyId: this.strategyId || "legacy-fallback",
2803
- action: "generated",
2804
- score: 1,
2805
- reason: "Generated via legacy getNewCards(), new card"
2806
- }
2807
- ]
2808
- })),
2809
- ...reviews.map((r) => ({
2810
- cardId: r.cardID,
2811
- courseId: r.courseID,
2812
- score: 1,
2813
- provenance: [
2814
- {
2815
- strategy: "legacy",
2816
- strategyName: this.strategyName || "Legacy API",
2817
- strategyId: this.strategyId || "legacy-fallback",
2818
- action: "generated",
2819
- score: 1,
2820
- reason: "Generated via legacy getPendingReviews(), review"
2821
- }
2822
- ]
2823
- }))
2824
- ];
2825
- return weighted.slice(0, limit);
1593
+ const overdueContribution = Math.min(1, Math.max(0, relativeOverdue));
1594
+ const urgency = overdueContribution * 0.5 + recencyFactor * 0.5;
1595
+ const score = Math.min(0.95, 0.5 + urgency * 0.45);
1596
+ const reason = `${Math.round(hoursOverdue)}h overdue (interval: ${Math.round(intervalHours)}h, relative: ${relativeOverdue.toFixed(2)}), recency: ${recencyFactor.toFixed(2)}, review`;
1597
+ return { score, reason };
2826
1598
  }
2827
1599
  };
2828
1600
  }
2829
1601
  });
2830
1602
 
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;
1608
+ }
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();
1695
+ }
1696
+ });
1697
+
2831
1698
  // src/impl/couch/courseDB.ts
2832
1699
  import {
2833
1700
  EloToNumber,
2834
1701
  Status,
2835
1702
  blankCourseElo as blankCourseElo2,
2836
- toCourseElo as toCourseElo6
1703
+ toCourseElo as toCourseElo4
2837
1704
  } from "@vue-skuilder/common";
2838
1705
  function randIntWeightedTowardZero(n) {
2839
1706
  return Math.floor(Math.random() * Math.random() * Math.random() * n);
@@ -2922,12 +1789,8 @@ var init_courseDB = __esm({
2922
1789
  init_courseAPI();
2923
1790
  init_courseLookupDB();
2924
1791
  init_navigators();
2925
- init_Pipeline();
2926
1792
  init_PipelineAssembler();
2927
- init_CompositeGenerator();
2928
- init_elo();
2929
- init_srs();
2930
- init_eloDistance();
1793
+ init_defaults();
2931
1794
  CoursesDB = class {
2932
1795
  _courseIDs;
2933
1796
  constructor(courseIDs) {
@@ -3039,7 +1902,7 @@ var init_courseDB = __esm({
3039
1902
  docs.rows.forEach((r) => {
3040
1903
  if (isSuccessRow(r)) {
3041
1904
  if (r.doc && r.doc.elo) {
3042
- ret.push(toCourseElo6(r.doc.elo));
1905
+ ret.push(toCourseElo4(r.doc.elo));
3043
1906
  } else {
3044
1907
  logger.warn("no elo data for card: " + r.id);
3045
1908
  ret.push(blankCourseElo2());
@@ -3341,7 +2204,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
3341
2204
  logger.debug(
3342
2205
  "[courseDB] No strategy documents found, using default Pipeline(Composite(ELO, SRS), [eloDistanceFilter])"
3343
2206
  );
3344
- return this.createDefaultPipeline(user);
2207
+ return createDefaultPipeline(user, this);
3345
2208
  }
3346
2209
  const assembler = new PipelineAssembler();
3347
2210
  const { pipeline, generatorStrategies, filterStrategies, warnings } = await assembler.assemble({
@@ -3354,7 +2217,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
3354
2217
  }
3355
2218
  if (!pipeline) {
3356
2219
  logger.debug("[courseDB] Pipeline assembly failed, using default pipeline");
3357
- return this.createDefaultPipeline(user);
2220
+ return createDefaultPipeline(user, this);
3358
2221
  }
3359
2222
  logger.debug(
3360
2223
  `[courseDB] Using assembled pipeline with ${generatorStrategies.length} generator(s) and ${filterStrategies.length} filter(s)`
@@ -3365,69 +2228,12 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
3365
2228
  throw e;
3366
2229
  }
3367
2230
  }
3368
- makeDefaultEloStrategy() {
3369
- return {
3370
- _id: "NAVIGATION_STRATEGY-ELO-default",
3371
- docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
3372
- name: "ELO (default)",
3373
- description: "Default ELO-based navigation strategy for new cards",
3374
- implementingClass: "elo" /* ELO */,
3375
- course: this.id,
3376
- serializedData: ""
3377
- };
3378
- }
3379
- makeDefaultSrsStrategy() {
3380
- return {
3381
- _id: "NAVIGATION_STRATEGY-SRS-default",
3382
- docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
3383
- name: "SRS (default)",
3384
- description: "Default SRS-based navigation strategy for reviews",
3385
- implementingClass: "srs" /* SRS */,
3386
- course: this.id,
3387
- serializedData: ""
3388
- };
3389
- }
3390
- /**
3391
- * Creates the default navigation pipeline for courses with no configured strategies.
3392
- *
3393
- * Default: Pipeline(Composite(ELO, SRS), [eloDistanceFilter])
3394
- * - ELO generator: scores new cards by skill proximity
3395
- * - SRS generator: scores reviews by overdueness and interval recency
3396
- * - ELO distance filter: penalizes cards far from user's current level
3397
- */
3398
- createDefaultPipeline(user) {
3399
- const eloNavigator = new ELONavigator(user, this, this.makeDefaultEloStrategy());
3400
- const srsNavigator = new SRSNavigator(user, this, this.makeDefaultSrsStrategy());
3401
- const compositeGenerator = new CompositeGenerator([eloNavigator, srsNavigator]);
3402
- const eloDistanceFilter = createEloDistanceFilter();
3403
- return new Pipeline(compositeGenerator, [eloDistanceFilter], user, this);
3404
- }
3405
2231
  ////////////////////////////////////
3406
2232
  // END NavigationStrategyManager implementation
3407
2233
  ////////////////////////////////////
3408
2234
  ////////////////////////////////////
3409
2235
  // StudyContentSource implementation
3410
2236
  ////////////////////////////////////
3411
- async getNewCards(limit = 99) {
3412
- const u = await this._getCurrentUser();
3413
- try {
3414
- const navigator = await this.createNavigator(u);
3415
- return navigator.getNewCards(limit);
3416
- } catch (e) {
3417
- logger.error(`[courseDB] Error in getNewCards: ${e}`);
3418
- throw e;
3419
- }
3420
- }
3421
- async getPendingReviews() {
3422
- const u = await this._getCurrentUser();
3423
- try {
3424
- const navigator = await this.createNavigator(u);
3425
- return navigator.getPendingReviews();
3426
- } catch (e) {
3427
- logger.error(`[courseDB] Error in getPendingReviews: ${e}`);
3428
- throw e;
3429
- }
3430
- }
3431
2237
  /**
3432
2238
  * Get cards with suitability scores for presentation.
3433
2239
  *
@@ -3667,79 +2473,27 @@ var init_classroomDB2 = __esm({
3667
2473
  setChangeFcn(f) {
3668
2474
  void this.userMessages.on("change", f);
3669
2475
  }
3670
- async getPendingReviews() {
3671
- const u = this._user;
3672
- return (await u.getPendingReviews()).filter((r) => r.scheduledFor === "classroom" && r.schedulingAgentId === this._id).map((r) => {
3673
- return {
3674
- ...r,
3675
- qualifiedID: `${r.courseId}-${r.cardId}`,
3676
- courseID: r.courseId,
3677
- cardID: r.cardId,
3678
- contentSourceType: "classroom",
3679
- contentSourceID: this._id,
3680
- reviewID: r._id,
3681
- status: "review"
3682
- };
3683
- });
3684
- }
3685
- async getNewCards() {
3686
- const activeCards = await this._user.getActiveCards();
3687
- const now = moment4.utc();
3688
- const assigned = await this.getAssignedContent();
3689
- const due = assigned.filter((c) => now.isAfter(moment4.utc(c.activeOn, REVIEW_TIME_FORMAT2)));
3690
- logger.info(`Due content: ${JSON.stringify(due)}`);
3691
- let ret = [];
3692
- for (let i = 0; i < due.length; i++) {
3693
- const content = due[i];
3694
- if (content.type === "course") {
3695
- const db = new CourseDB(content.courseID, async () => this._user);
3696
- ret = ret.concat(await db.getNewCards());
3697
- } else if (content.type === "tag") {
3698
- const tagDoc = await getTag(content.courseID, content.tagID);
3699
- ret = ret.concat(
3700
- tagDoc.taggedCards.map((c) => {
3701
- return {
3702
- courseID: content.courseID,
3703
- cardID: c,
3704
- qualifiedID: `${content.courseID}-${c}`,
3705
- contentSourceType: "classroom",
3706
- contentSourceID: this._id,
3707
- status: "new"
3708
- };
3709
- })
3710
- );
3711
- } else if (content.type === "card") {
3712
- ret.push(await getCourseDB2(content.courseID).get(content.cardID));
3713
- }
3714
- }
3715
- logger.info(
3716
- `New Cards from classroom ${this._cfg.name}: ${ret.map((c) => `${c.courseID}-${c.cardID}`)}`
3717
- );
3718
- return ret.filter((c) => {
3719
- if (activeCards.some((ac) => c.cardID === ac.cardID)) {
3720
- return false;
3721
- } else {
3722
- return true;
3723
- }
3724
- });
3725
- }
3726
2476
  /**
3727
2477
  * Get cards with suitability scores for presentation.
3728
2478
  *
3729
- * This implementation wraps the legacy getNewCards/getPendingReviews methods,
3730
- * assigning score=1.0 to all cards. StudentClassroomDB does not currently
3731
- * 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.
3732
2481
  *
3733
2482
  * @param limit - Maximum number of cards to return
3734
2483
  * @returns Cards sorted by score descending (all scores = 1.0)
3735
2484
  */
3736
2485
  async getWeightedCards(limit) {
3737
- const [newCards, reviews] = await Promise.all([this.getNewCards(), this.getPendingReviews()]);
3738
- const weighted = [
3739
- ...newCards.map((c) => ({
3740
- cardId: c.cardID,
3741
- 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,
3742
2495
  score: 1,
2496
+ reviewID: r._id,
3743
2497
  provenance: [
3744
2498
  {
3745
2499
  strategy: "classroom",
@@ -3747,27 +2501,84 @@ var init_classroomDB2 = __esm({
3747
2501
  strategyId: "CLASSROOM",
3748
2502
  action: "generated",
3749
2503
  score: 1,
3750
- reason: "Classroom legacy getNewCards(), new card"
2504
+ reason: "Classroom scheduled review"
3751
2505
  }
3752
2506
  ]
3753
- })),
3754
- ...reviews.map((r) => ({
3755
- cardId: r.cardID,
3756
- courseId: r.courseID,
3757
- score: 1,
3758
- provenance: [
3759
- {
3760
- strategy: "classroom",
3761
- strategyName: "Classroom",
3762
- strategyId: "CLASSROOM",
3763
- action: "generated",
3764
- score: 1,
3765
- 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
+ });
3766
2535
  }
3767
- ]
3768
- }))
3769
- ];
3770
- 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);
3771
2582
  }
3772
2583
  };
3773
2584
  TeacherClassroomDB = class _TeacherClassroomDB extends ClassroomDBBase {
@@ -4203,8 +3014,8 @@ import moment5 from "moment";
4203
3014
  import process2 from "process";
4204
3015
  function createPouchDBConfig() {
4205
3016
  const hasExplicitCredentials = ENV.COUCHDB_USERNAME && ENV.COUCHDB_PASSWORD;
4206
- const isNodeEnvironment2 = typeof window === "undefined";
4207
- if (hasExplicitCredentials && isNodeEnvironment2) {
3017
+ const isNodeEnvironment = typeof window === "undefined";
3018
+ if (hasExplicitCredentials && isNodeEnvironment) {
4208
3019
  return {
4209
3020
  fetch(url, opts = {}) {
4210
3021
  const basicAuth = btoa(`${ENV.COUCHDB_USERNAME}:${ENV.COUCHDB_PASSWORD}`);
@@ -5364,8 +4175,8 @@ var init_PouchDataLayerProvider = __esm({
5364
4175
  }
5365
4176
  async initialize() {
5366
4177
  if (this.initialized) return;
5367
- const isNodeEnvironment2 = typeof process !== "undefined" && process.versions != null && process.versions.node != null;
5368
- if (isNodeEnvironment2) {
4178
+ const isNodeEnvironment = typeof process !== "undefined" && process.versions != null && process.versions.node != null;
4179
+ if (isNodeEnvironment) {
5369
4180
  logger.info(
5370
4181
  "CouchDataLayerProvider: Running in Node.js environment, creating guest UserDB for testing."
5371
4182
  );
@@ -5427,11 +4238,11 @@ var init_StaticDataUnpacker = __esm({
5427
4238
  init_logger();
5428
4239
  init_core();
5429
4240
  pathUtils = {
5430
- isAbsolute: (path3) => {
5431
- if (/^[a-zA-Z]:[\\/]/.test(path3) || /^\\\\/.test(path3)) {
4241
+ isAbsolute: (path2) => {
4242
+ if (/^[a-zA-Z]:[\\/]/.test(path2) || /^\\\\/.test(path2)) {
5432
4243
  return true;
5433
4244
  }
5434
- if (path3.startsWith("/")) {
4245
+ if (path2.startsWith("/")) {
5435
4246
  return true;
5436
4247
  }
5437
4248
  return false;
@@ -5478,6 +4289,36 @@ var init_StaticDataUnpacker = __esm({
5478
4289
  logger.error(`Document ${id} not found in chunk ${chunk.id}`);
5479
4290
  throw new Error(`Document ${id} not found in chunk ${chunk.id}`);
5480
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
+ }
5481
4322
  /**
5482
4323
  * Query cards by ELO score, returning card IDs sorted by ELO
5483
4324
  */
@@ -5514,7 +4355,14 @@ var init_StaticDataUnpacker = __esm({
5514
4355
  * Get all tag names mapped to their card arrays
5515
4356
  */
5516
4357
  async getTagsIndex() {
5517
- return await this.loadIndex("tags");
4358
+ try {
4359
+ return await this.loadIndex("tags");
4360
+ } catch {
4361
+ return {
4362
+ byCard: {},
4363
+ byTag: {}
4364
+ };
4365
+ }
5518
4366
  }
5519
4367
  getDocTypeFromId(id) {
5520
4368
  for (const docTypeKey in DocTypePrefixes) {
@@ -5805,8 +4653,9 @@ var init_courseDB2 = __esm({
5805
4653
  "src/impl/static/courseDB.ts"() {
5806
4654
  "use strict";
5807
4655
  init_types_legacy();
5808
- init_navigators();
5809
4656
  init_logger();
4657
+ init_defaults();
4658
+ init_PipelineAssembler();
5810
4659
  StaticCourseDB = class {
5811
4660
  constructor(courseId, unpacker, userDB, manifest) {
5812
4661
  this.courseId = courseId;
@@ -5885,21 +4734,6 @@ var init_courseDB2 = __esm({
5885
4734
  async updateCardElo(cardId, _elo) {
5886
4735
  return { ok: true, id: cardId, rev: "1-static" };
5887
4736
  }
5888
- async getNewCards(limit = 99) {
5889
- const activeCards = await this.userDB.getActiveCards();
5890
- return (await this.getCardsCenteredAtELO({ limit, elo: "user" }, (c) => {
5891
- if (activeCards.some((ac) => c.cardID === ac.cardID)) {
5892
- return false;
5893
- } else {
5894
- return true;
5895
- }
5896
- })).map((c) => {
5897
- return {
5898
- ...c,
5899
- status: "new"
5900
- };
5901
- });
5902
- }
5903
4737
  async getCardsCenteredAtELO(options, filter) {
5904
4738
  let targetElo = typeof options.elo === "number" ? options.elo : 1e3;
5905
4739
  if (options.elo === "user") {
@@ -6085,19 +4919,23 @@ var init_courseDB2 = __esm({
6085
4919
  return [];
6086
4920
  }
6087
4921
  // Navigation Strategy Manager implementation
6088
- async getNavigationStrategy(_id) {
6089
- return {
6090
- _id: "NAVIGATION_STRATEGY-ELO",
6091
- docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
6092
- name: "ELO",
6093
- description: "ELO-based navigation strategy",
6094
- implementingClass: "elo" /* ELO */,
6095
- course: this.courseId,
6096
- serializedData: ""
6097
- };
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
+ }
6098
4929
  }
6099
4930
  async getAllNavigationStrategies() {
6100
- 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
+ }
6101
4939
  }
6102
4940
  async addNavigationStrategy(_data) {
6103
4941
  throw new Error("Cannot add navigation strategies in static mode");
@@ -6105,9 +4943,52 @@ var init_courseDB2 = __esm({
6105
4943
  async updateNavigationStrategy(_id, _data) {
6106
4944
  throw new Error("Cannot update navigation strategies in static mode");
6107
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
+ }
6108
4983
  // Study Content Source implementation
6109
- async getPendingReviews() {
6110
- 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
+ }
6111
4992
  }
6112
4993
  // Attachment helper methods (internal use, not part of interface)
6113
4994
  /**
@@ -6506,108 +5387,71 @@ var init_TagFilteredContentSource = __esm({
6506
5387
  return finalCardIds;
6507
5388
  }
6508
5389
  /**
6509
- * 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)
6510
5398
  */
6511
- async getNewCards(limit) {
5399
+ async getWeightedCards(limit) {
6512
5400
  if (!hasActiveFilter(this.filter)) {
6513
- logger.warn("[TagFilteredContentSource] getNewCards called with no active filter");
5401
+ logger.warn("[TagFilteredContentSource] getWeightedCards called with no active filter");
6514
5402
  return [];
6515
5403
  }
6516
5404
  const eligibleCardIds = await this.resolveFilteredCardIds();
6517
5405
  const activeCards = await this.user.getActiveCards();
6518
5406
  const activeCardIds = new Set(activeCards.map((c) => c.cardID));
6519
- const newItems = [];
5407
+ const newCardWeighted = [];
6520
5408
  for (const cardId of eligibleCardIds) {
6521
5409
  if (!activeCardIds.has(cardId)) {
6522
- newItems.push({
6523
- courseID: this.courseId,
6524
- cardID: cardId,
6525
- contentSourceType: "course",
6526
- contentSourceID: this.courseId,
6527
- 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
+ ]
6528
5424
  });
6529
5425
  }
6530
- if (limit !== void 0 && newItems.length >= limit) {
5426
+ if (newCardWeighted.length >= limit) {
6531
5427
  break;
6532
5428
  }
6533
5429
  }
6534
- logger.info(`[TagFilteredContentSource] Found ${newItems.length} new cards matching filter`);
6535
- return newItems;
6536
- }
6537
- /**
6538
- * Gets pending reviews, filtered to only include cards that match the tag filter.
6539
- */
6540
- async getPendingReviews() {
6541
- if (!hasActiveFilter(this.filter)) {
6542
- logger.warn("[TagFilteredContentSource] getPendingReviews called with no active filter");
6543
- return [];
6544
- }
6545
- const eligibleCardIds = await this.resolveFilteredCardIds();
5430
+ logger.info(
5431
+ `[TagFilteredContentSource] Found ${newCardWeighted.length} new cards matching filter`
5432
+ );
6546
5433
  const allReviews = await this.user.getPendingReviews(this.courseId);
6547
- const filteredReviews = allReviews.filter((review) => {
6548
- return eligibleCardIds.has(review.cardId);
6549
- });
5434
+ const filteredReviews = allReviews.filter((review) => eligibleCardIds.has(review.cardId));
6550
5435
  logger.info(
6551
5436
  `[TagFilteredContentSource] Found ${filteredReviews.length} pending reviews matching filter (of ${allReviews.length} total)`
6552
5437
  );
6553
- return filteredReviews.map((r) => ({
6554
- ...r,
6555
- courseID: r.courseId,
6556
- cardID: r.cardId,
6557
- contentSourceType: "course",
6558
- contentSourceID: this.courseId,
5438
+ const reviewWeighted = filteredReviews.map((r) => ({
5439
+ cardId: r.cardId,
5440
+ courseId: r.courseId,
5441
+ score: 1,
6559
5442
  reviewID: r._id,
6560
- 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
+ ]
6561
5453
  }));
6562
- }
6563
- /**
6564
- * Get cards with suitability scores for presentation.
6565
- *
6566
- * This implementation wraps the legacy getNewCards/getPendingReviews methods,
6567
- * assigning score=1.0 to all cards. TagFilteredContentSource does not currently
6568
- * support pluggable navigation strategies - it returns flat-scored candidates.
6569
- *
6570
- * @param limit - Maximum number of cards to return
6571
- * @returns Cards sorted by score descending (all scores = 1.0)
6572
- */
6573
- async getWeightedCards(limit) {
6574
- const [newCards, reviews] = await Promise.all([
6575
- this.getNewCards(limit),
6576
- this.getPendingReviews()
6577
- ]);
6578
- const weighted = [
6579
- ...reviews.map((r) => ({
6580
- cardId: r.cardID,
6581
- courseId: r.courseID,
6582
- score: 1,
6583
- provenance: [
6584
- {
6585
- strategy: "tagFilter",
6586
- strategyName: "Tag Filter",
6587
- strategyId: "TAG_FILTER",
6588
- action: "generated",
6589
- score: 1,
6590
- reason: `Tag-filtered review (tags: ${this.filter.include.join(", ")})`
6591
- }
6592
- ]
6593
- })),
6594
- ...newCards.map((c) => ({
6595
- cardId: c.cardID,
6596
- courseId: c.courseID,
6597
- score: 1,
6598
- provenance: [
6599
- {
6600
- strategy: "tagFilter",
6601
- strategyName: "Tag Filter",
6602
- strategyId: "TAG_FILTER",
6603
- action: "generated",
6604
- score: 1,
6605
- reason: `Tag-filtered new card (tags: ${this.filter.include.join(", ")})`
6606
- }
6607
- ]
6608
- }))
6609
- ];
6610
- return weighted.slice(0, limit);
5454
+ return [...reviewWeighted, ...newCardWeighted].slice(0, limit);
6611
5455
  }
6612
5456
  /**
6613
5457
  * Clears the cached resolved card IDs.
@@ -6829,7 +5673,7 @@ var init_cardProcessor = __esm({
6829
5673
  });
6830
5674
 
6831
5675
  // src/core/bulkImport/types.ts
6832
- var init_types3 = __esm({
5676
+ var init_types = __esm({
6833
5677
  "src/core/bulkImport/types.ts"() {
6834
5678
  "use strict";
6835
5679
  }
@@ -6840,7 +5684,7 @@ var init_bulkImport = __esm({
6840
5684
  "src/core/bulkImport/index.ts"() {
6841
5685
  "use strict";
6842
5686
  init_cardProcessor();
6843
- init_types3();
5687
+ init_types();
6844
5688
  }
6845
5689
  });
6846
5690
 
@@ -6974,7 +5818,7 @@ var SrsService = class {
6974
5818
 
6975
5819
  // src/study/services/EloService.ts
6976
5820
  init_logger();
6977
- import { adjustCourseScores, toCourseElo as toCourseElo7 } from "@vue-skuilder/common";
5821
+ import { adjustCourseScores, toCourseElo as toCourseElo5 } from "@vue-skuilder/common";
6978
5822
  var EloService = class {
6979
5823
  dataLayer;
6980
5824
  user;
@@ -6996,7 +5840,7 @@ var EloService = class {
6996
5840
  logger.warn(`k value interpretation not currently implemented`);
6997
5841
  }
6998
5842
  const courseDB = this.dataLayer.getCourseDB(currentCard.card.course_id);
6999
- 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);
7000
5844
  const cardElo = (await courseDB.getCardEloData([currentCard.card.card_id]))[0];
7001
5845
  if (cardElo && userElo) {
7002
5846
  const eloUpdate = adjustCourseScores(userElo, cardElo, userScore);
@@ -7205,156 +6049,124 @@ init_logger();
7205
6049
  import {
7206
6050
  displayableDataToViewData,
7207
6051
  isCourseElo,
7208
- toCourseElo as toCourseElo8
6052
+ toCourseElo as toCourseElo6
7209
6053
  } from "@vue-skuilder/common";
7210
-
7211
- // src/study/ItemQueue.ts
7212
- var ItemQueue = class {
7213
- q = [];
7214
- seenCardIds = [];
7215
- _dequeueCount = 0;
7216
- get dequeueCount() {
7217
- return this._dequeueCount;
7218
- }
7219
- add(item, cardId) {
7220
- if (this.seenCardIds.find((d) => d === cardId)) {
7221
- return;
7222
- }
7223
- this.seenCardIds.push(cardId);
7224
- this.q.push(item);
7225
- }
7226
- addAll(items, cardIdExtractor) {
7227
- items.forEach((i) => this.add(i, cardIdExtractor(i)));
7228
- }
7229
- get length() {
7230
- return this.q.length;
7231
- }
7232
- peek(index) {
7233
- return this.q[index];
7234
- }
7235
- dequeue(cardIdExtractor) {
7236
- if (this.q.length !== 0) {
7237
- this._dequeueCount++;
7238
- const item = this.q.splice(0, 1)[0];
7239
- if (cardIdExtractor) {
7240
- const cardId = cardIdExtractor(item);
7241
- const index = this.seenCardIds.indexOf(cardId);
7242
- if (index > -1) {
7243
- this.seenCardIds.splice(index, 1);
7244
- }
7245
- }
7246
- return item;
7247
- } else {
7248
- return null;
7249
- }
7250
- }
7251
- get toString() {
7252
- return `${typeof this.q[0]}:
7253
- ` + this.q.map((i) => ` ${i.courseID}+${i.cardID}: ${i.status}`).join("\n");
7254
- }
7255
- };
7256
-
7257
- // 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
+ }
7258
6079
  var CardHydrationService = class {
7259
- constructor(getViewComponent, getCourseDB3, selectNextItemToHydrate, removeItemFromQueue, hasAvailableCards) {
6080
+ constructor(getViewComponent, getCourseDB3, getItemsToHydrate) {
7260
6081
  this.getViewComponent = getViewComponent;
7261
6082
  this.getCourseDB = getCourseDB3;
7262
- this.selectNextItemToHydrate = selectNextItemToHydrate;
7263
- this.removeItemFromQueue = removeItemFromQueue;
7264
- this.hasAvailableCards = hasAvailableCards;
6083
+ this.getItemsToHydrate = getItemsToHydrate;
7265
6084
  }
7266
- hydratedQ = new ItemQueue();
7267
- failedCardCache = /* @__PURE__ */ new Map();
6085
+ hydratedCards = /* @__PURE__ */ new Map();
6086
+ hydrationInFlight = /* @__PURE__ */ new Set();
7268
6087
  hydrationInProgress = false;
7269
- BUFFER_SIZE = 5;
7270
6088
  /**
7271
- * Get the next hydrated card from the queue.
7272
- * @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.
7273
6097
  */
7274
- dequeueHydratedCard() {
7275
- 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);
7276
6106
  }
7277
6107
  /**
7278
6108
  * Check if hydration should be triggered and start background hydration if needed.
7279
6109
  */
7280
6110
  async ensureHydratedCards() {
7281
- if (this.hydratedQ.length < 3) {
7282
- void this.fillHydratedQueue();
7283
- }
6111
+ void this.fillHydratedCards();
7284
6112
  }
7285
6113
  /**
7286
- * Wait for a hydrated card to become available.
6114
+ * Wait for a specific card to become hydrated.
7287
6115
  * @returns Promise that resolves to a hydrated card or null
7288
6116
  */
7289
- async waitForHydratedCard() {
7290
- if (this.hydratedQ.length === 0 && this.hasAvailableCards()) {
7291
- void this.fillHydratedQueue();
6117
+ async waitForCard(cardId) {
6118
+ if (this.hydratedCards.has(cardId)) {
6119
+ return this.hydratedCards.get(cardId);
7292
6120
  }
7293
- while (this.hydratedQ.length === 0 && this.hasAvailableCards()) {
7294
- await new Promise((resolve) => setTimeout(resolve, 25));
6121
+ if (!this.hydrationInProgress) {
6122
+ void this.fillHydratedCards();
7295
6123
  }
7296
- 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;
7297
6138
  }
7298
6139
  /**
7299
- * Get current hydrated queue length.
6140
+ * Get current hydrated cache size.
7300
6141
  */
7301
6142
  get hydratedCount() {
7302
- return this.hydratedQ.length;
6143
+ return this.hydratedCards.size;
7303
6144
  }
7304
6145
  /**
7305
- * Get current failed card cache size.
6146
+ * Get list of currently hydrated card IDs (for debugging).
7306
6147
  */
7307
- get failedCacheSize() {
7308
- return this.failedCardCache.size;
6148
+ getHydratedCardIds() {
6149
+ return Array.from(this.hydratedCards.keys());
7309
6150
  }
7310
6151
  /**
7311
- * 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.
7312
6154
  */
7313
- async fillHydratedQueue() {
6155
+ async fillHydratedCards() {
7314
6156
  if (this.hydrationInProgress) {
7315
6157
  return;
7316
6158
  }
7317
6159
  this.hydrationInProgress = true;
7318
6160
  try {
7319
- while (this.hydratedQ.length < this.BUFFER_SIZE) {
7320
- const nextItem = this.selectNextItemToHydrate();
7321
- if (!nextItem) {
7322
- 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;
7323
6165
  }
7324
6166
  try {
7325
- if (this.failedCardCache.has(nextItem.cardID)) {
7326
- const cachedCard = this.failedCardCache.get(nextItem.cardID);
7327
- this.hydratedQ.add(cachedCard, cachedCard.item.cardID);
7328
- this.failedCardCache.delete(nextItem.cardID);
7329
- } else {
7330
- const courseDB = this.getCourseDB(nextItem.courseID);
7331
- const cardData = await courseDB.getCourseDoc(nextItem.cardID);
7332
- if (!isCourseElo(cardData.elo)) {
7333
- cardData.elo = toCourseElo8(cardData.elo);
7334
- }
7335
- const view = this.getViewComponent(cardData.id_view);
7336
- const dataDocs = await Promise.all(
7337
- cardData.id_displayable_data.map(
7338
- (id) => courseDB.getCourseDoc(id, {
7339
- attachments: true,
7340
- binary: true
7341
- })
7342
- )
7343
- );
7344
- const data = dataDocs.map(displayableDataToViewData).reverse();
7345
- this.hydratedQ.add(
7346
- {
7347
- item: nextItem,
7348
- view,
7349
- data
7350
- },
7351
- nextItem.cardID
7352
- );
7353
- }
6167
+ await this.hydrateCard(item);
7354
6168
  } catch (e) {
7355
- logger.error(`Error hydrating card ${nextItem.cardID}:`, e);
7356
- } finally {
7357
- this.removeItemFromQueue(nextItem);
6169
+ logger.error(`[CardHydrationService] Error hydrating card ${item.cardID}:`, e);
7358
6170
  }
7359
6171
  }
7360
6172
  } finally {
@@ -7362,10 +6174,97 @@ var CardHydrationService = class {
7362
6174
  }
7363
6175
  }
7364
6176
  /**
7365
- * Cache a failed card for quick re-access.
6177
+ * Hydrate a single card and add to cache.
7366
6178
  */
7367
- cacheFailedCard(card) {
7368
- 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");
7369
6268
  }
7370
6269
  };
7371
6270
 
@@ -7907,7 +6806,7 @@ try {
7907
6806
  }
7908
6807
  } catch {
7909
6808
  }
7910
- async function validateStaticCourse(staticPath, fs3) {
6809
+ async function validateStaticCourse(staticPath, fs2) {
7911
6810
  const validation = {
7912
6811
  valid: true,
7913
6812
  manifestExists: false,
@@ -7917,8 +6816,8 @@ async function validateStaticCourse(staticPath, fs3) {
7917
6816
  warnings: []
7918
6817
  };
7919
6818
  try {
7920
- if (fs3) {
7921
- const stats = await fs3.stat(staticPath);
6819
+ if (fs2) {
6820
+ const stats = await fs2.stat(staticPath);
7922
6821
  if (!stats.isDirectory()) {
7923
6822
  validation.errors.push(`Path is not a directory: ${staticPath}`);
7924
6823
  validation.valid = false;
@@ -7938,11 +6837,11 @@ async function validateStaticCourse(staticPath, fs3) {
7938
6837
  }
7939
6838
  let manifestPath = `${staticPath}/manifest.json`;
7940
6839
  try {
7941
- if (fs3) {
7942
- manifestPath = fs3.joinPath(staticPath, "manifest.json");
7943
- if (await fs3.exists(manifestPath)) {
6840
+ if (fs2) {
6841
+ manifestPath = fs2.joinPath(staticPath, "manifest.json");
6842
+ if (await fs2.exists(manifestPath)) {
7944
6843
  validation.manifestExists = true;
7945
- const manifestContent = await fs3.readFile(manifestPath);
6844
+ const manifestContent = await fs2.readFile(manifestPath);
7946
6845
  const manifest = JSON.parse(manifestContent);
7947
6846
  validation.courseId = manifest.courseId;
7948
6847
  validation.courseName = manifest.courseName;
@@ -7974,10 +6873,10 @@ async function validateStaticCourse(staticPath, fs3) {
7974
6873
  }
7975
6874
  let chunksPath = `${staticPath}/chunks`;
7976
6875
  try {
7977
- if (fs3) {
7978
- chunksPath = fs3.joinPath(staticPath, "chunks");
7979
- if (await fs3.exists(chunksPath)) {
7980
- 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);
7981
6880
  if (chunksStats.isDirectory()) {
7982
6881
  validation.chunksExist = true;
7983
6882
  } else {
@@ -8005,10 +6904,10 @@ async function validateStaticCourse(staticPath, fs3) {
8005
6904
  }
8006
6905
  let attachmentsPath;
8007
6906
  try {
8008
- if (fs3) {
8009
- attachmentsPath = fs3.joinPath(staticPath, "attachments");
8010
- if (await fs3.exists(attachmentsPath)) {
8011
- 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);
8012
6911
  if (attachmentsStats.isDirectory()) {
8013
6912
  validation.attachmentsExist = true;
8014
6913
  }
@@ -8786,26 +7685,43 @@ var StaticToCouchDBMigrator = class {
8786
7685
  /**
8787
7686
  * Check if a path is a local file path (vs URL)
8788
7687
  */
8789
- isLocalPath(path3) {
8790
- return !path3.startsWith("http://") && !path3.startsWith("https://");
7688
+ isLocalPath(path2) {
7689
+ return !path2.startsWith("http://") && !path2.startsWith("https://");
8791
7690
  }
8792
7691
  };
8793
7692
 
8794
7693
  // src/util/index.ts
8795
7694
  init_dataDirectory();
8796
- init_tuiLogger();
8797
7695
 
8798
7696
  // src/study/SessionController.ts
8799
7697
  init_navigators();
8800
- function randomInt(min, max) {
8801
- return Math.floor(Math.random() * (max - min + 1)) + min;
8802
- }
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();
8803
7718
  var SessionController = class extends Loggable {
8804
7719
  _className = "SessionController";
8805
7720
  services;
8806
7721
  srsService;
8807
7722
  eloService;
8808
7723
  hydrationService;
7724
+ mixer;
8809
7725
  sources;
8810
7726
  // dataLayer and getViewComponent now injected into CardHydrationService
8811
7727
  _sessionRecord = [];
@@ -8833,18 +7749,21 @@ var SessionController = class extends Loggable {
8833
7749
  // @ts-expect-error NodeJS.Timeout type not available in browser context
8834
7750
  _intervalHandle;
8835
7751
  /**
8836
- *
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)
8837
7757
  */
8838
- constructor(sources, time, dataLayer, getViewComponent) {
7758
+ constructor(sources, time, dataLayer, getViewComponent, mixer) {
8839
7759
  super();
7760
+ this.mixer = mixer || new QuotaRoundRobinMixer();
8840
7761
  this.srsService = new SrsService(dataLayer.getUserDB());
8841
7762
  this.eloService = new EloService(dataLayer, dataLayer.getUserDB());
8842
7763
  this.hydrationService = new CardHydrationService(
8843
7764
  getViewComponent,
8844
7765
  (courseId) => dataLayer.getCourseDB(courseId),
8845
- () => this._selectNextItemToHydrate(),
8846
- (item) => this.removeItemFromQueue(item),
8847
- () => this.hasAvailableCards()
7766
+ () => this._getItemsToHydrate()
8848
7767
  );
8849
7768
  this.services = {
8850
7769
  response: new ResponseProcessor(this.srsService, this.eloService)
@@ -8898,16 +7817,12 @@ var SessionController = class extends Loggable {
8898
7817
  return ret;
8899
7818
  }
8900
7819
  async prepareSession() {
8901
- try {
8902
- const hasWeightedCards = this.sources.some((s) => typeof s.getWeightedCards === "function");
8903
- if (hasWeightedCards) {
8904
- await this.getWeightedContent();
8905
- } else {
8906
- await Promise.all([this.getScheduledReviews(), this.getNewCards()]);
8907
- }
8908
- } catch (e) {
8909
- 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
+ );
8910
7824
  }
7825
+ await this.getWeightedContent();
8911
7826
  await this.hydrationService.ensureHydratedCards();
8912
7827
  this._intervalHandle = setInterval(() => {
8913
7828
  this.tick();
@@ -8945,14 +7860,10 @@ var SessionController = class extends Loggable {
8945
7860
  }
8946
7861
  return items;
8947
7862
  };
8948
- const extractHydratedItems = () => {
8949
- const items = [];
8950
- return items;
8951
- };
8952
7863
  return {
8953
7864
  api: {
8954
7865
  mode: supportsWeightedCards ? "weighted" : "legacy",
8955
- 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."
8956
7867
  },
8957
7868
  reviewQueue: {
8958
7869
  length: this.reviewQ.length,
@@ -8971,162 +7882,97 @@ var SessionController = class extends Loggable {
8971
7882
  },
8972
7883
  hydratedCache: {
8973
7884
  count: this.hydrationService.hydratedCount,
8974
- failedCacheSize: this.hydrationService.failedCacheSize,
8975
- items: extractHydratedItems()
7885
+ cardIds: this.hydrationService.getHydratedCardIds()
8976
7886
  }
8977
7887
  };
8978
7888
  }
8979
7889
  /**
8980
- * Fetch content using the new getWeightedCards API.
7890
+ * Fetch content using the getWeightedCards API and mix across sources.
8981
7891
  *
8982
- * This method uses getWeightedCards() to get scored candidates, then uses the
8983
- * scores to determine ordering. For reviews, we still need the full ScheduledCard
8984
- * data from getPendingReviews(), so we fetch both and use scores for ordering.
8985
- *
8986
- * The hybrid approach:
8987
- * 1. Fetch weighted cards to get scoring/ordering information
8988
- * 2. Fetch full review data via legacy getPendingReviews()
8989
- * 3. Order reviews by their weighted scores
8990
- * 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
8991
7897
  */
8992
7898
  async getWeightedContent() {
8993
7899
  const limit = 20;
8994
- const allWeighted = [];
8995
- const allReviews = [];
8996
- const allNewCards = [];
8997
- 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];
8998
7903
  try {
8999
- const reviews = await source.getPendingReviews().catch((error) => {
9000
- this.error(`Failed to get reviews for source:`, error);
9001
- return [];
7904
+ const weighted = await source.getWeightedCards(limit);
7905
+ batches.push({
7906
+ sourceIndex: i,
7907
+ weighted
9002
7908
  });
9003
- allReviews.push(...reviews);
9004
- if (typeof source.getWeightedCards === "function") {
9005
- const weighted = await source.getWeightedCards(limit);
9006
- allWeighted.push(...weighted);
9007
- } else {
9008
- const newCards = await source.getNewCards(limit);
9009
- allNewCards.push(...newCards);
9010
- allWeighted.push(
9011
- ...newCards.map((c) => ({
9012
- cardId: c.cardID,
9013
- courseId: c.courseID,
9014
- score: 1,
9015
- provenance: [
9016
- {
9017
- strategy: "legacy",
9018
- strategyName: "Legacy Fallback",
9019
- strategyId: "legacy-fallback",
9020
- action: "generated",
9021
- score: 1,
9022
- reason: "Fallback to legacy getNewCards(), new card"
9023
- }
9024
- ]
9025
- })),
9026
- ...reviews.map((r) => ({
9027
- cardId: r.cardID,
9028
- courseId: r.courseID,
9029
- score: 1,
9030
- provenance: [
9031
- {
9032
- strategy: "legacy",
9033
- strategyName: "Legacy Fallback",
9034
- strategyId: "legacy-fallback",
9035
- action: "generated",
9036
- score: 1,
9037
- reason: "Fallback to legacy getPendingReviews(), review"
9038
- }
9039
- ]
9040
- }))
9041
- );
9042
- }
9043
7909
  } catch (error) {
9044
- 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
+ }
9045
7914
  }
9046
7915
  }
9047
- const scoreMap = /* @__PURE__ */ new Map();
9048
- for (const w of allWeighted) {
9049
- const key = `${w.courseId}::${w.cardId}`;
9050
- 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
+ );
9051
7920
  }
9052
- const scoredReviews = allReviews.map((r) => ({
9053
- review: r,
9054
- score: scoreMap.get(`${r.courseID}::${r.cardID}`) ?? 1
9055
- }));
9056
- scoredReviews.sort((a, b) => b.score - a.score);
9057
- let report = "Weighted content session created with:\n";
9058
- for (const { review, score } of scoredReviews) {
9059
- this.reviewQ.add(review, review.cardID);
9060
- 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)})
9061
7937
  `;
9062
7938
  }
9063
- const newCardWeighted = allWeighted.filter((w) => getCardOrigin(w) === "new").sort((a, b) => b.score - a.score);
9064
- for (const card of newCardWeighted) {
7939
+ for (const w of newWeighted) {
9065
7940
  const newItem = {
9066
- cardID: card.cardId,
9067
- courseID: card.courseId,
7941
+ cardID: w.cardId,
7942
+ courseID: w.courseId,
9068
7943
  contentSourceType: "course",
9069
- contentSourceID: card.courseId,
7944
+ contentSourceID: w.courseId,
9070
7945
  status: "new"
9071
7946
  };
9072
- this.newQ.add(newItem, card.cardId);
9073
- 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)})
9074
7949
  `;
9075
7950
  }
9076
7951
  this.log(report);
9077
7952
  }
9078
7953
  /**
9079
- * @deprecated Use getWeightedContent() instead. This method is kept for backward
9080
- * 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).
9081
7957
  */
9082
- async getScheduledReviews() {
9083
- const reviews = await Promise.all(
9084
- this.sources.map(
9085
- (c) => c.getPendingReviews().catch((error) => {
9086
- this.error(`Failed to get reviews for source ${c}:`, error);
9087
- return [];
9088
- })
9089
- )
9090
- );
9091
- const dueCards = [];
9092
- while (reviews.length != 0 && reviews.some((r) => r.length > 0)) {
9093
- const index = randomInt(0, reviews.length - 1);
9094
- const source = reviews[index];
9095
- if (source.length === 0) {
9096
- reviews.splice(index, 1);
9097
- continue;
9098
- } else {
9099
- dueCards.push(source.shift());
9100
- }
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));
9101
7963
  }
9102
- let report = "Review session created with:\n";
9103
- this.reviewQ.addAll(dueCards, (c) => c.cardID);
9104
- report += dueCards.map((card) => `Card ${card.courseID}::${card.cardID} `).join("\n");
9105
- 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;
9106
7971
  }
9107
7972
  /**
9108
- * @deprecated Use getWeightedContent() instead. This method is kept for backward
9109
- * 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.
9110
7975
  */
9111
- async getNewCards(n = 10) {
9112
- const perCourse = Math.ceil(n / this.sources.length);
9113
- const newContent = await Promise.all(this.sources.map((c) => c.getNewCards(perCourse)));
9114
- newContent.forEach((newContentFromSource) => {
9115
- newContentFromSource.filter((c) => {
9116
- return this._sessionRecord.find((record) => record.card.card_id === c.cardID) === void 0;
9117
- });
9118
- });
9119
- while (n > 0 && newContent.some((nc) => nc.length > 0)) {
9120
- for (let i = 0; i < newContent.length; i++) {
9121
- if (newContent[i].length > 0) {
9122
- const item = newContent[i].splice(0, 1)[0];
9123
- this.log(`Adding new card: ${item.courseID}::${item.cardID}`);
9124
- this.newQ.add(item, item.cardID);
9125
- n--;
9126
- }
9127
- }
9128
- }
9129
- }
9130
7976
  _selectNextItemToHydrate() {
9131
7977
  const choice = Math.random();
9132
7978
  let newBound = 0.1;
@@ -9183,16 +8029,18 @@ var SessionController = class extends Loggable {
9183
8029
  this._currentCard = null;
9184
8030
  return null;
9185
8031
  }
9186
- let card = this.hydrationService.dequeueHydratedCard();
9187
- if (!card && this.hasAvailableCards()) {
9188
- card = await this.hydrationService.waitForHydratedCard();
9189
- }
9190
- await this.hydrationService.ensureHydratedCards();
9191
- if (card) {
9192
- this._currentCard = card;
9193
- } else {
8032
+ const nextItem = this._selectNextItemToHydrate();
8033
+ if (!nextItem) {
9194
8034
  this._currentCard = null;
8035
+ return null;
9195
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;
9196
8044
  return card;
9197
8045
  }
9198
8046
  /**
@@ -9228,8 +8076,8 @@ var SessionController = class extends Loggable {
9228
8076
  dismissCurrentCard(action = "dismiss-success") {
9229
8077
  if (this._currentCard) {
9230
8078
  if (action === "dismiss-success") {
8079
+ this.hydrationService.removeCard(this._currentCard.item.cardID);
9231
8080
  } else if (action === "marked-failed") {
9232
- this.hydrationService.cacheFailedCard(this._currentCard);
9233
8081
  let failedItem;
9234
8082
  if (isReview(this._currentCard.item)) {
9235
8083
  failedItem = {
@@ -9251,22 +8099,21 @@ var SessionController = class extends Loggable {
9251
8099
  }
9252
8100
  this.failedQ.add(failedItem, failedItem.cardID);
9253
8101
  } else if (action === "dismiss-error") {
8102
+ this.hydrationService.removeCard(this._currentCard.item.cardID);
9254
8103
  } else if (action === "dismiss-failed") {
8104
+ this.hydrationService.removeCard(this._currentCard.item.cardID);
9255
8105
  }
9256
8106
  }
9257
8107
  }
9258
- hasAvailableCards() {
9259
- return this.reviewQ.length > 0 || this.newQ.length > 0 || this.failedQ.length > 0;
9260
- }
9261
8108
  /**
9262
- * Helper method for CardHydrationService to remove items from appropriate queue.
8109
+ * Remove an item from its source queue after consumption by nextCard().
9263
8110
  */
9264
8111
  removeItemFromQueue(item) {
9265
- if (this.reviewQ.peek(0) === item) {
8112
+ if (this.reviewQ.peek(0)?.cardID === item.cardID) {
9266
8113
  this.reviewQ.dequeue((queueItem) => queueItem.cardID);
9267
- } else if (this.newQ.peek(0) === item) {
8114
+ } else if (this.newQ.peek(0)?.cardID === item.cardID) {
9268
8115
  this.newQ.dequeue((queueItem) => queueItem.cardID);
9269
- } else {
8116
+ } else if (this.failedQ.peek(0)?.cardID === item.cardID) {
9270
8117
  this.failedQ.dequeue((queueItem) => queueItem.cardID);
9271
8118
  }
9272
8119
  }
@@ -9291,6 +8138,7 @@ export {
9291
8138
  NavigatorRole,
9292
8139
  NavigatorRoles,
9293
8140
  Navigators,
8141
+ QuotaRoundRobinMixer,
9294
8142
  SessionController,
9295
8143
  StaticToCouchDBMigrator,
9296
8144
  TagFilteredContentSource,
@@ -9304,22 +8152,17 @@ export {
9304
8152
  getCardOrigin,
9305
8153
  getDataLayer,
9306
8154
  getDbPath,
9307
- getLogFilePath,
9308
8155
  getStudySource,
9309
8156
  importParsedCards,
9310
8157
  initializeDataDirectory,
9311
8158
  initializeDataLayer,
9312
- initializeTuiLogging,
9313
8159
  isFilter,
9314
8160
  isGenerator,
9315
8161
  isQuestionRecord,
9316
8162
  isReview,
9317
8163
  log,
9318
- logger2 as logger,
9319
8164
  newInterval,
9320
8165
  parseCardHistoryID,
9321
- showUserError,
9322
- showUserMessage,
9323
8166
  validateMigration,
9324
8167
  validateProcessorConfig,
9325
8168
  validateStaticCourse