@vue-skuilder/db 0.1.16 → 0.1.18

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 (80) hide show
  1. package/dist/{userDB-DNa0XPtn.d.ts → classroomDB-BgfrVb8d.d.ts} +357 -103
  2. package/dist/{userDB-BqwxtJ_7.d.mts → classroomDB-CTOenngH.d.cts} +358 -104
  3. package/dist/core/index.d.cts +230 -0
  4. package/dist/core/index.d.ts +161 -23
  5. package/dist/core/index.js +1964 -154
  6. package/dist/core/index.js.map +1 -1
  7. package/dist/core/index.mjs +1925 -121
  8. package/dist/core/index.mjs.map +1 -1
  9. package/dist/{dataLayerProvider-BV5iZqt_.d.ts → dataLayerProvider-CZxC9GtB.d.ts} +1 -1
  10. package/dist/{dataLayerProvider-VlngD19_.d.mts → dataLayerProvider-D6PoCwS6.d.cts} +1 -1
  11. package/dist/impl/couch/{index.d.mts → index.d.cts} +46 -5
  12. package/dist/impl/couch/index.d.ts +44 -3
  13. package/dist/impl/couch/index.js +1971 -171
  14. package/dist/impl/couch/index.js.map +1 -1
  15. package/dist/impl/couch/index.mjs +1933 -134
  16. package/dist/impl/couch/index.mjs.map +1 -1
  17. package/dist/impl/static/{index.d.mts → index.d.cts} +5 -6
  18. package/dist/impl/static/index.d.ts +2 -3
  19. package/dist/impl/static/index.js +1614 -119
  20. package/dist/impl/static/index.js.map +1 -1
  21. package/dist/impl/static/index.mjs +1585 -92
  22. package/dist/impl/static/index.mjs.map +1 -1
  23. package/dist/{index-Bmll7Xse.d.mts → index-D-Fa4Smt.d.cts} +1 -1
  24. package/dist/{index.d.mts → index.d.cts} +97 -13
  25. package/dist/index.d.ts +90 -6
  26. package/dist/index.js +2085 -153
  27. package/dist/index.js.map +1 -1
  28. package/dist/index.mjs +2031 -106
  29. package/dist/index.mjs.map +1 -1
  30. package/dist/pouch/index.js +3 -3
  31. package/dist/{types-Dbp5DaRR.d.mts → types-CzPDLAK6.d.cts} +1 -1
  32. package/dist/util/packer/{index.d.mts → index.d.cts} +3 -3
  33. package/dist/util/packer/index.js.map +1 -1
  34. package/dist/util/packer/index.mjs.map +1 -1
  35. package/docs/brainstorm-navigation-paradigm.md +369 -0
  36. package/docs/navigators-architecture.md +265 -0
  37. package/docs/todo-evolutionary-orchestration.md +310 -0
  38. package/docs/todo-nominal-tag-types.md +121 -0
  39. package/docs/todo-pipeline-optimization.md +117 -0
  40. package/docs/todo-strategy-authoring.md +401 -0
  41. package/docs/todo-strategy-state-storage.md +278 -0
  42. package/eslint.config.mjs +1 -1
  43. package/package.json +9 -4
  44. package/src/core/interfaces/contentSource.ts +88 -4
  45. package/src/core/interfaces/navigationStrategyManager.ts +0 -5
  46. package/src/core/navigators/CompositeGenerator.ts +268 -0
  47. package/src/core/navigators/Pipeline.ts +205 -0
  48. package/src/core/navigators/PipelineAssembler.ts +194 -0
  49. package/src/core/navigators/elo.ts +104 -15
  50. package/src/core/navigators/filters/eloDistance.ts +132 -0
  51. package/src/core/navigators/filters/index.ts +6 -0
  52. package/src/core/navigators/filters/types.ts +115 -0
  53. package/src/core/navigators/generators/index.ts +2 -0
  54. package/src/core/navigators/generators/types.ts +107 -0
  55. package/src/core/navigators/hardcodedOrder.ts +111 -12
  56. package/src/core/navigators/hierarchyDefinition.ts +266 -0
  57. package/src/core/navigators/index.ts +345 -3
  58. package/src/core/navigators/interferenceMitigator.ts +367 -0
  59. package/src/core/navigators/relativePriority.ts +267 -0
  60. package/src/core/navigators/srs.ts +195 -0
  61. package/src/impl/couch/classroomDB.ts +51 -0
  62. package/src/impl/couch/courseDB.ts +117 -39
  63. package/src/impl/static/courseDB.ts +0 -4
  64. package/src/study/SessionController.ts +149 -1
  65. package/src/study/TagFilteredContentSource.ts +255 -0
  66. package/src/study/index.ts +1 -0
  67. package/src/util/dataDirectory.test.ts +51 -22
  68. package/src/util/logger.ts +0 -1
  69. package/tests/core/navigators/CompositeGenerator.test.ts +455 -0
  70. package/tests/core/navigators/Pipeline.test.ts +405 -0
  71. package/tests/core/navigators/PipelineAssembler.test.ts +351 -0
  72. package/tests/core/navigators/SRSNavigator.test.ts +344 -0
  73. package/tests/core/navigators/eloDistanceFilter.test.ts +192 -0
  74. package/tests/core/navigators/navigators.test.ts +710 -0
  75. package/tsconfig.json +1 -1
  76. package/vitest.config.ts +29 -0
  77. package/dist/core/index.d.mts +0 -92
  78. /package/dist/{SyncStrategy-CyATpyLQ.d.mts → SyncStrategy-CyATpyLQ.d.cts} +0 -0
  79. /package/dist/pouch/{index.d.mts → index.d.cts} +0 -0
  80. /package/dist/{types-legacy-6ettoclI.d.mts → types-legacy-6ettoclI.d.cts} +0 -0
package/dist/index.js CHANGED
@@ -181,9 +181,9 @@ var import_pouchdb, import_pouchdb_find, import_pouchdb_authentication, pouchdb_
181
181
  var init_pouchdb_setup = __esm({
182
182
  "src/impl/couch/pouchdb-setup.ts"() {
183
183
  "use strict";
184
- import_pouchdb = __toESM(require("pouchdb"));
185
- import_pouchdb_find = __toESM(require("pouchdb-find"));
186
- import_pouchdb_authentication = __toESM(require("@nilock2/pouchdb-authentication"));
184
+ import_pouchdb = __toESM(require("pouchdb"), 1);
185
+ import_pouchdb_find = __toESM(require("pouchdb-find"), 1);
186
+ import_pouchdb_authentication = __toESM(require("@nilock2/pouchdb-authentication"), 1);
187
187
  import_pouchdb.default.plugin(import_pouchdb_find.default);
188
188
  import_pouchdb.default.plugin(import_pouchdb_authentication.default);
189
189
  import_pouchdb.default.defaults({
@@ -264,8 +264,8 @@ var fs, path, logFile, isNodeEnvironment, logger2;
264
264
  var init_tuiLogger = __esm({
265
265
  "src/util/tuiLogger.ts"() {
266
266
  "use strict";
267
- fs = __toESM(require("fs"));
268
- path = __toESM(require("path"));
267
+ fs = __toESM(require("fs"), 1);
268
+ path = __toESM(require("path"), 1);
269
269
  init_dataDirectory();
270
270
  logFile = null;
271
271
  isNodeEnvironment = false;
@@ -316,9 +316,9 @@ var fs2, path2, os;
316
316
  var init_dataDirectory = __esm({
317
317
  "src/util/dataDirectory.ts"() {
318
318
  "use strict";
319
- fs2 = __toESM(require("fs"));
320
- path2 = __toESM(require("path"));
321
- os = __toESM(require("os"));
319
+ fs2 = __toESM(require("fs"), 1);
320
+ path2 = __toESM(require("path"), 1);
321
+ os = __toESM(require("os"), 1);
322
322
  init_tuiLogger();
323
323
  init_factory();
324
324
  }
@@ -404,7 +404,7 @@ var import_moment, REVIEW_TIME_FORMAT, log2;
404
404
  var init_userDBHelpers = __esm({
405
405
  "src/impl/common/userDBHelpers.ts"() {
406
406
  "use strict";
407
- import_moment = __toESM(require("moment"));
407
+ import_moment = __toESM(require("moment"), 1);
408
408
  init_core();
409
409
  init_logger();
410
410
  init_pouchdb_setup();
@@ -560,7 +560,7 @@ var import_moment2, UsrCrsData;
560
560
  var init_user_course_relDB = __esm({
561
561
  "src/impl/couch/user-course-relDB.ts"() {
562
562
  "use strict";
563
- import_moment2 = __toESM(require("moment"));
563
+ import_moment2 = __toESM(require("moment"), 1);
564
564
  init_logger();
565
565
  UsrCrsData = class {
566
566
  user;
@@ -945,23 +945,460 @@ var init_courseLookupDB = __esm({
945
945
  }
946
946
  });
947
947
 
948
+ // src/core/navigators/CompositeGenerator.ts
949
+ var CompositeGenerator_exports = {};
950
+ __export(CompositeGenerator_exports, {
951
+ AggregationMode: () => AggregationMode,
952
+ default: () => CompositeGenerator
953
+ });
954
+ var AggregationMode, DEFAULT_AGGREGATION_MODE, FREQUENCY_BOOST_FACTOR, CompositeGenerator;
955
+ var init_CompositeGenerator = __esm({
956
+ "src/core/navigators/CompositeGenerator.ts"() {
957
+ "use strict";
958
+ init_navigators();
959
+ init_logger();
960
+ AggregationMode = /* @__PURE__ */ ((AggregationMode2) => {
961
+ AggregationMode2["MAX"] = "max";
962
+ AggregationMode2["AVERAGE"] = "average";
963
+ AggregationMode2["FREQUENCY_BOOST"] = "frequencyBoost";
964
+ return AggregationMode2;
965
+ })(AggregationMode || {});
966
+ DEFAULT_AGGREGATION_MODE = "frequencyBoost" /* FREQUENCY_BOOST */;
967
+ FREQUENCY_BOOST_FACTOR = 0.1;
968
+ CompositeGenerator = class _CompositeGenerator extends ContentNavigator {
969
+ /** Human-readable name for CardGenerator interface */
970
+ name = "Composite Generator";
971
+ generators;
972
+ aggregationMode;
973
+ constructor(generators, aggregationMode = DEFAULT_AGGREGATION_MODE) {
974
+ super();
975
+ this.generators = generators;
976
+ this.aggregationMode = aggregationMode;
977
+ if (generators.length === 0) {
978
+ throw new Error("CompositeGenerator requires at least one generator");
979
+ }
980
+ logger.debug(
981
+ `[CompositeGenerator] Created with ${generators.length} generators, mode: ${aggregationMode}`
982
+ );
983
+ }
984
+ /**
985
+ * Creates a CompositeGenerator from strategy data.
986
+ *
987
+ * This is a convenience factory for use by PipelineAssembler.
988
+ */
989
+ static async fromStrategies(user, course, strategies, aggregationMode = DEFAULT_AGGREGATION_MODE) {
990
+ const generators = await Promise.all(
991
+ strategies.map((s) => ContentNavigator.create(user, course, s))
992
+ );
993
+ return new _CompositeGenerator(generators, aggregationMode);
994
+ }
995
+ /**
996
+ * Get weighted cards from all generators, merge and deduplicate.
997
+ *
998
+ * Cards appearing in multiple generators receive a score boost.
999
+ * Provenance tracks which generators produced each card and how scores were aggregated.
1000
+ *
1001
+ * This method supports both the legacy signature (limit only) and the
1002
+ * CardGenerator interface signature (limit, context).
1003
+ *
1004
+ * @param limit - Maximum number of cards to return
1005
+ * @param context - Optional GeneratorContext passed to child generators
1006
+ */
1007
+ async getWeightedCards(limit, context) {
1008
+ const results = await Promise.all(
1009
+ this.generators.map((g) => g.getWeightedCards(limit, context))
1010
+ );
1011
+ const byCardId = /* @__PURE__ */ new Map();
1012
+ for (const cards of results) {
1013
+ for (const card of cards) {
1014
+ const existing = byCardId.get(card.cardId) || [];
1015
+ existing.push(card);
1016
+ byCardId.set(card.cardId, existing);
1017
+ }
1018
+ }
1019
+ const merged = [];
1020
+ for (const [, cards] of byCardId) {
1021
+ const aggregatedScore = this.aggregateScores(cards);
1022
+ const finalScore = Math.min(1, aggregatedScore);
1023
+ const mergedProvenance = cards.flatMap((c) => c.provenance);
1024
+ const initialScore = cards[0].score;
1025
+ const action = finalScore > initialScore ? "boosted" : finalScore < initialScore ? "penalized" : "passed";
1026
+ const reason = this.buildAggregationReason(cards, finalScore);
1027
+ merged.push({
1028
+ ...cards[0],
1029
+ score: finalScore,
1030
+ provenance: [
1031
+ ...mergedProvenance,
1032
+ {
1033
+ strategy: "composite",
1034
+ strategyName: "Composite Generator",
1035
+ strategyId: "COMPOSITE_GENERATOR",
1036
+ action,
1037
+ score: finalScore,
1038
+ reason
1039
+ }
1040
+ ]
1041
+ });
1042
+ }
1043
+ return merged.sort((a, b) => b.score - a.score).slice(0, limit);
1044
+ }
1045
+ /**
1046
+ * Build human-readable reason for score aggregation.
1047
+ */
1048
+ buildAggregationReason(cards, finalScore) {
1049
+ const count = cards.length;
1050
+ const scores = cards.map((c) => c.score.toFixed(2)).join(", ");
1051
+ if (count === 1) {
1052
+ return `Single generator, score ${finalScore.toFixed(2)}`;
1053
+ }
1054
+ const strategies = cards.map((c) => c.provenance[0]?.strategy || "unknown").join(", ");
1055
+ switch (this.aggregationMode) {
1056
+ case "max" /* MAX */:
1057
+ return `Max of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
1058
+ case "average" /* AVERAGE */:
1059
+ return `Average of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
1060
+ case "frequencyBoost" /* FREQUENCY_BOOST */: {
1061
+ const avg = cards.reduce((sum, c) => sum + c.score, 0) / count;
1062
+ const boost = 1 + FREQUENCY_BOOST_FACTOR * (count - 1);
1063
+ return `Frequency boost from ${count} generators (${strategies}): avg ${avg.toFixed(2)} \xD7 ${boost.toFixed(2)} \u2192 ${finalScore.toFixed(2)}`;
1064
+ }
1065
+ default:
1066
+ return `Aggregated from ${count} generators: ${finalScore.toFixed(2)}`;
1067
+ }
1068
+ }
1069
+ /**
1070
+ * Aggregate scores from multiple generators for the same card.
1071
+ */
1072
+ aggregateScores(cards) {
1073
+ const scores = cards.map((c) => c.score);
1074
+ switch (this.aggregationMode) {
1075
+ case "max" /* MAX */:
1076
+ return Math.max(...scores);
1077
+ case "average" /* AVERAGE */:
1078
+ return scores.reduce((sum, s) => sum + s, 0) / scores.length;
1079
+ case "frequencyBoost" /* FREQUENCY_BOOST */: {
1080
+ const avg = scores.reduce((sum, s) => sum + s, 0) / scores.length;
1081
+ const frequencyBoost = 1 + FREQUENCY_BOOST_FACTOR * (cards.length - 1);
1082
+ return avg * frequencyBoost;
1083
+ }
1084
+ default:
1085
+ return scores[0];
1086
+ }
1087
+ }
1088
+ /**
1089
+ * Get new cards from all generators, merged and deduplicated.
1090
+ */
1091
+ async getNewCards(n) {
1092
+ const legacyGenerators = this.generators.filter(
1093
+ (g) => g instanceof ContentNavigator
1094
+ );
1095
+ const results = await Promise.all(legacyGenerators.map((g) => g.getNewCards(n)));
1096
+ const seen = /* @__PURE__ */ new Set();
1097
+ const merged = [];
1098
+ for (const cards of results) {
1099
+ for (const card of cards) {
1100
+ if (!seen.has(card.cardID)) {
1101
+ seen.add(card.cardID);
1102
+ merged.push(card);
1103
+ }
1104
+ }
1105
+ }
1106
+ return n ? merged.slice(0, n) : merged;
1107
+ }
1108
+ /**
1109
+ * Get pending reviews from all generators, merged and deduplicated.
1110
+ */
1111
+ async getPendingReviews() {
1112
+ const legacyGenerators = this.generators.filter(
1113
+ (g) => g instanceof ContentNavigator
1114
+ );
1115
+ const results = await Promise.all(legacyGenerators.map((g) => g.getPendingReviews()));
1116
+ const seen = /* @__PURE__ */ new Set();
1117
+ const merged = [];
1118
+ for (const reviews of results) {
1119
+ for (const review of reviews) {
1120
+ if (!seen.has(review.cardID)) {
1121
+ seen.add(review.cardID);
1122
+ merged.push(review);
1123
+ }
1124
+ }
1125
+ }
1126
+ return merged;
1127
+ }
1128
+ };
1129
+ }
1130
+ });
1131
+
1132
+ // src/core/navigators/Pipeline.ts
1133
+ var Pipeline_exports = {};
1134
+ __export(Pipeline_exports, {
1135
+ Pipeline: () => Pipeline
1136
+ });
1137
+ var import_common5, Pipeline;
1138
+ var init_Pipeline = __esm({
1139
+ "src/core/navigators/Pipeline.ts"() {
1140
+ "use strict";
1141
+ import_common5 = require("@vue-skuilder/common");
1142
+ init_navigators();
1143
+ init_logger();
1144
+ Pipeline = class extends ContentNavigator {
1145
+ generator;
1146
+ filters;
1147
+ /**
1148
+ * Create a new pipeline.
1149
+ *
1150
+ * @param generator - The generator (or CompositeGenerator) that produces candidates
1151
+ * @param filters - Filters to apply sequentially (order doesn't matter for multipliers)
1152
+ * @param user - User database interface
1153
+ * @param course - Course database interface
1154
+ */
1155
+ constructor(generator, filters, user, course) {
1156
+ super();
1157
+ this.generator = generator;
1158
+ this.filters = filters;
1159
+ this.user = user;
1160
+ this.course = course;
1161
+ logger.debug(
1162
+ `[Pipeline] Created with generator '${generator.name}' and ${filters.length} filters: ${filters.map((f) => f.name).join(", ")}`
1163
+ );
1164
+ }
1165
+ /**
1166
+ * Get weighted cards by running generator and applying filters.
1167
+ *
1168
+ * 1. Build shared context (user ELO, etc.)
1169
+ * 2. Get candidates from generator (passing context)
1170
+ * 3. Apply each filter sequentially
1171
+ * 4. Remove zero-score cards
1172
+ * 5. Sort by score descending
1173
+ * 6. Return top N
1174
+ *
1175
+ * @param limit - Maximum number of cards to return
1176
+ * @returns Cards sorted by score descending
1177
+ */
1178
+ async getWeightedCards(limit) {
1179
+ const context = await this.buildContext();
1180
+ const overFetchMultiplier = 2 + this.filters.length * 0.5;
1181
+ const fetchLimit = Math.ceil(limit * overFetchMultiplier);
1182
+ logger.debug(
1183
+ `[Pipeline] Fetching ${fetchLimit} candidates from generator '${this.generator.name}'`
1184
+ );
1185
+ let cards = await this.generator.getWeightedCards(fetchLimit, context);
1186
+ logger.debug(`[Pipeline] Generator returned ${cards.length} candidates`);
1187
+ for (const filter of this.filters) {
1188
+ const beforeCount = cards.length;
1189
+ cards = await filter.transform(cards, context);
1190
+ logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeCount} \u2192 ${cards.length} cards`);
1191
+ }
1192
+ cards = cards.filter((c) => c.score > 0);
1193
+ cards.sort((a, b) => b.score - a.score);
1194
+ const result = cards.slice(0, limit);
1195
+ logger.debug(
1196
+ `[Pipeline] Returning ${result.length} cards (top scores: ${result.slice(0, 3).map((c) => c.score.toFixed(2)).join(", ")}...)`
1197
+ );
1198
+ return result;
1199
+ }
1200
+ /**
1201
+ * Build shared context for generator and filters.
1202
+ *
1203
+ * Called once per getWeightedCards() invocation.
1204
+ * Contains data that the generator and multiple filters might need.
1205
+ *
1206
+ * The context satisfies both GeneratorContext and FilterContext interfaces.
1207
+ */
1208
+ async buildContext() {
1209
+ let userElo = 1e3;
1210
+ try {
1211
+ const courseReg = await this.user.getCourseRegDoc(this.course.getCourseID());
1212
+ const courseElo = (0, import_common5.toCourseElo)(courseReg.elo);
1213
+ userElo = courseElo.global.score;
1214
+ } catch (e) {
1215
+ logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`);
1216
+ }
1217
+ return {
1218
+ user: this.user,
1219
+ course: this.course,
1220
+ userElo
1221
+ };
1222
+ }
1223
+ // ===========================================================================
1224
+ // Legacy StudyContentSource methods
1225
+ // ===========================================================================
1226
+ //
1227
+ // These delegate to the generator for backward compatibility.
1228
+ // Eventually SessionController will use getWeightedCards() exclusively.
1229
+ //
1230
+ /**
1231
+ * Get new cards via legacy API.
1232
+ * Delegates to the generator if it supports the legacy interface.
1233
+ */
1234
+ async getNewCards(n) {
1235
+ if ("getNewCards" in this.generator && typeof this.generator.getNewCards === "function") {
1236
+ return this.generator.getNewCards(n);
1237
+ }
1238
+ return [];
1239
+ }
1240
+ /**
1241
+ * Get pending reviews via legacy API.
1242
+ * Delegates to the generator if it supports the legacy interface.
1243
+ */
1244
+ async getPendingReviews() {
1245
+ if ("getPendingReviews" in this.generator && typeof this.generator.getPendingReviews === "function") {
1246
+ return this.generator.getPendingReviews();
1247
+ }
1248
+ return [];
1249
+ }
1250
+ /**
1251
+ * Get the course ID for this pipeline.
1252
+ */
1253
+ getCourseID() {
1254
+ return this.course.getCourseID();
1255
+ }
1256
+ };
1257
+ }
1258
+ });
1259
+
1260
+ // src/core/navigators/PipelineAssembler.ts
1261
+ var PipelineAssembler_exports = {};
1262
+ __export(PipelineAssembler_exports, {
1263
+ PipelineAssembler: () => PipelineAssembler
1264
+ });
1265
+ var PipelineAssembler;
1266
+ var init_PipelineAssembler = __esm({
1267
+ "src/core/navigators/PipelineAssembler.ts"() {
1268
+ "use strict";
1269
+ init_navigators();
1270
+ init_Pipeline();
1271
+ init_types_legacy();
1272
+ init_logger();
1273
+ init_CompositeGenerator();
1274
+ PipelineAssembler = class {
1275
+ /**
1276
+ * Assembles a navigation pipeline from strategy documents.
1277
+ *
1278
+ * 1. Separates into generators and filters by role
1279
+ * 2. Validates at least one generator exists (or creates default ELO)
1280
+ * 3. Instantiates generators - wraps multiple in CompositeGenerator
1281
+ * 4. Instantiates filters
1282
+ * 5. Returns Pipeline(generator, filters)
1283
+ *
1284
+ * @param input - Strategy documents plus user/course interfaces
1285
+ * @returns Assembled pipeline and any warnings
1286
+ */
1287
+ async assemble(input) {
1288
+ const { strategies, user, course } = input;
1289
+ const warnings = [];
1290
+ if (strategies.length === 0) {
1291
+ return {
1292
+ pipeline: null,
1293
+ generatorStrategies: [],
1294
+ filterStrategies: [],
1295
+ warnings
1296
+ };
1297
+ }
1298
+ const generatorStrategies = [];
1299
+ const filterStrategies = [];
1300
+ for (const s of strategies) {
1301
+ if (isGenerator(s.implementingClass)) {
1302
+ generatorStrategies.push(s);
1303
+ } else if (isFilter(s.implementingClass)) {
1304
+ filterStrategies.push(s);
1305
+ } else {
1306
+ warnings.push(`Unknown strategy type '${s.implementingClass}', skipping: ${s.name}`);
1307
+ }
1308
+ }
1309
+ if (generatorStrategies.length === 0) {
1310
+ if (filterStrategies.length > 0) {
1311
+ logger.debug(
1312
+ "[PipelineAssembler] No generator found, using default ELO with configured filters"
1313
+ );
1314
+ generatorStrategies.push(this.makeDefaultEloStrategy(course.getCourseID()));
1315
+ } else {
1316
+ warnings.push("No generator strategy found");
1317
+ return {
1318
+ pipeline: null,
1319
+ generatorStrategies: [],
1320
+ filterStrategies: [],
1321
+ warnings
1322
+ };
1323
+ }
1324
+ }
1325
+ let generator;
1326
+ if (generatorStrategies.length === 1) {
1327
+ const nav = await ContentNavigator.create(user, course, generatorStrategies[0]);
1328
+ generator = nav;
1329
+ logger.debug(`[PipelineAssembler] Using single generator: ${generatorStrategies[0].name}`);
1330
+ } else {
1331
+ logger.debug(
1332
+ `[PipelineAssembler] Using CompositeGenerator for ${generatorStrategies.length} generators: ${generatorStrategies.map((g) => g.name).join(", ")}`
1333
+ );
1334
+ generator = await CompositeGenerator.fromStrategies(user, course, generatorStrategies);
1335
+ }
1336
+ const filters = [];
1337
+ const sortedFilterStrategies = [...filterStrategies].sort(
1338
+ (a, b) => a.name.localeCompare(b.name)
1339
+ );
1340
+ for (const filterStrategy of sortedFilterStrategies) {
1341
+ try {
1342
+ const nav = await ContentNavigator.create(user, course, filterStrategy);
1343
+ if ("transform" in nav && typeof nav.transform === "function") {
1344
+ filters.push(nav);
1345
+ logger.debug(`[PipelineAssembler] Added filter: ${filterStrategy.name}`);
1346
+ } else {
1347
+ warnings.push(
1348
+ `Filter '${filterStrategy.name}' does not implement CardFilter.transform(), skipping`
1349
+ );
1350
+ }
1351
+ } catch (e) {
1352
+ warnings.push(`Failed to instantiate filter '${filterStrategy.name}': ${e}`);
1353
+ }
1354
+ }
1355
+ const pipeline = new Pipeline(generator, filters, user, course);
1356
+ logger.debug(
1357
+ `[PipelineAssembler] Assembled pipeline with ${generatorStrategies.length} generator(s) and ${filters.length} filter(s)`
1358
+ );
1359
+ return {
1360
+ pipeline,
1361
+ generatorStrategies,
1362
+ filterStrategies: sortedFilterStrategies,
1363
+ warnings
1364
+ };
1365
+ }
1366
+ /**
1367
+ * Creates a default ELO generator strategy.
1368
+ * Used when filters are configured but no generator is specified.
1369
+ */
1370
+ makeDefaultEloStrategy(courseId) {
1371
+ return {
1372
+ _id: "NAVIGATION_STRATEGY-ELO-default",
1373
+ course: courseId,
1374
+ docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
1375
+ name: "ELO (default)",
1376
+ description: "Default ELO-based generator",
1377
+ implementingClass: "elo" /* ELO */,
1378
+ serializedData: ""
1379
+ };
1380
+ }
1381
+ };
1382
+ }
1383
+ });
1384
+
948
1385
  // src/core/navigators/elo.ts
949
1386
  var elo_exports = {};
950
1387
  __export(elo_exports, {
951
1388
  default: () => ELONavigator
952
1389
  });
953
- var ELONavigator;
1390
+ var import_common6, ELONavigator;
954
1391
  var init_elo = __esm({
955
1392
  "src/core/navigators/elo.ts"() {
956
1393
  "use strict";
957
1394
  init_navigators();
1395
+ import_common6 = require("@vue-skuilder/common");
958
1396
  ELONavigator = class extends ContentNavigator {
959
- user;
960
- course;
961
- constructor(user, course) {
962
- super();
963
- this.user = user;
964
- this.course = course;
1397
+ /** Human-readable name for CardGenerator interface */
1398
+ name;
1399
+ constructor(user, course, strategyData) {
1400
+ super(user, course, strategyData);
1401
+ this.name = strategyData?.name || "ELO";
965
1402
  }
966
1403
  async getPendingReviews() {
967
1404
  const reviews = await this.user.getPendingReviews(this.course.getCourseID());
@@ -1007,10 +1444,154 @@ var init_elo = __esm({
1007
1444
  };
1008
1445
  });
1009
1446
  }
1447
+ /**
1448
+ * Get new cards with suitability scores based on ELO distance.
1449
+ *
1450
+ * Cards closer to user's ELO get higher scores.
1451
+ * Score formula: max(0, 1 - distance / 500)
1452
+ *
1453
+ * NOTE: This generator only handles NEW cards. Reviews are handled by
1454
+ * SRSNavigator. Use CompositeGenerator to combine both.
1455
+ *
1456
+ * This method supports both the legacy signature (limit only) and the
1457
+ * CardGenerator interface signature (limit, context).
1458
+ *
1459
+ * @param limit - Maximum number of cards to return
1460
+ * @param context - Optional GeneratorContext (used when called via Pipeline)
1461
+ */
1462
+ async getWeightedCards(limit, context) {
1463
+ let userGlobalElo;
1464
+ if (context?.userElo !== void 0) {
1465
+ userGlobalElo = context.userElo;
1466
+ } else {
1467
+ const courseReg = await this.user.getCourseRegDoc(this.course.getCourseID());
1468
+ const userElo = (0, import_common6.toCourseElo)(courseReg.elo);
1469
+ userGlobalElo = userElo.global.score;
1470
+ }
1471
+ const newCards = await this.getNewCards(limit);
1472
+ const cardIds = newCards.map((c) => c.cardID);
1473
+ const cardEloData = await this.course.getCardEloData(cardIds);
1474
+ const scored = newCards.map((c, i) => {
1475
+ const cardElo = cardEloData[i]?.global?.score ?? 1e3;
1476
+ const distance = Math.abs(cardElo - userGlobalElo);
1477
+ const score = Math.max(0, 1 - distance / 500);
1478
+ return {
1479
+ cardId: c.cardID,
1480
+ courseId: c.courseID,
1481
+ score,
1482
+ provenance: [
1483
+ {
1484
+ strategy: "elo",
1485
+ strategyName: this.strategyName || this.name,
1486
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-ELO-default",
1487
+ action: "generated",
1488
+ score,
1489
+ reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userGlobalElo)}), new card`
1490
+ }
1491
+ ]
1492
+ };
1493
+ });
1494
+ scored.sort((a, b) => b.score - a.score);
1495
+ return scored.slice(0, limit);
1496
+ }
1010
1497
  };
1011
1498
  }
1012
1499
  });
1013
1500
 
1501
+ // src/core/navigators/filters/eloDistance.ts
1502
+ var eloDistance_exports = {};
1503
+ __export(eloDistance_exports, {
1504
+ DEFAULT_HALF_LIFE: () => DEFAULT_HALF_LIFE,
1505
+ DEFAULT_MAX_MULTIPLIER: () => DEFAULT_MAX_MULTIPLIER,
1506
+ DEFAULT_MIN_MULTIPLIER: () => DEFAULT_MIN_MULTIPLIER,
1507
+ createEloDistanceFilter: () => createEloDistanceFilter
1508
+ });
1509
+ function computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier) {
1510
+ const normalizedDistance = distance / halfLife;
1511
+ const decay = Math.exp(-(normalizedDistance * normalizedDistance));
1512
+ return minMultiplier + (maxMultiplier - minMultiplier) * decay;
1513
+ }
1514
+ function createEloDistanceFilter(config) {
1515
+ const halfLife = config?.halfLife ?? DEFAULT_HALF_LIFE;
1516
+ const minMultiplier = config?.minMultiplier ?? DEFAULT_MIN_MULTIPLIER;
1517
+ const maxMultiplier = config?.maxMultiplier ?? DEFAULT_MAX_MULTIPLIER;
1518
+ return {
1519
+ name: "ELO Distance Filter",
1520
+ async transform(cards, context) {
1521
+ const { course, userElo } = context;
1522
+ const cardIds = cards.map((c) => c.cardId);
1523
+ const cardElos = await course.getCardEloData(cardIds);
1524
+ return cards.map((card, i) => {
1525
+ const cardElo = cardElos[i]?.global?.score ?? 1e3;
1526
+ const distance = Math.abs(cardElo - userElo);
1527
+ const multiplier = computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier);
1528
+ const newScore = card.score * multiplier;
1529
+ const action = multiplier < maxMultiplier - 0.01 ? "penalized" : "passed";
1530
+ return {
1531
+ ...card,
1532
+ score: newScore,
1533
+ provenance: [
1534
+ ...card.provenance,
1535
+ {
1536
+ strategy: "eloDistance",
1537
+ strategyName: "ELO Distance Filter",
1538
+ strategyId: "ELO_DISTANCE_FILTER",
1539
+ action,
1540
+ score: newScore,
1541
+ reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userElo)}) \u2192 ${multiplier.toFixed(2)}x`
1542
+ }
1543
+ ]
1544
+ };
1545
+ });
1546
+ }
1547
+ };
1548
+ }
1549
+ var DEFAULT_HALF_LIFE, DEFAULT_MIN_MULTIPLIER, DEFAULT_MAX_MULTIPLIER;
1550
+ var init_eloDistance = __esm({
1551
+ "src/core/navigators/filters/eloDistance.ts"() {
1552
+ "use strict";
1553
+ DEFAULT_HALF_LIFE = 200;
1554
+ DEFAULT_MIN_MULTIPLIER = 0.3;
1555
+ DEFAULT_MAX_MULTIPLIER = 1;
1556
+ }
1557
+ });
1558
+
1559
+ // src/core/navigators/filters/index.ts
1560
+ var filters_exports = {};
1561
+ __export(filters_exports, {
1562
+ createEloDistanceFilter: () => createEloDistanceFilter
1563
+ });
1564
+ var init_filters = __esm({
1565
+ "src/core/navigators/filters/index.ts"() {
1566
+ "use strict";
1567
+ init_eloDistance();
1568
+ }
1569
+ });
1570
+
1571
+ // src/core/navigators/filters/types.ts
1572
+ var types_exports = {};
1573
+ var init_types = __esm({
1574
+ "src/core/navigators/filters/types.ts"() {
1575
+ "use strict";
1576
+ }
1577
+ });
1578
+
1579
+ // src/core/navigators/generators/index.ts
1580
+ var generators_exports = {};
1581
+ var init_generators = __esm({
1582
+ "src/core/navigators/generators/index.ts"() {
1583
+ "use strict";
1584
+ }
1585
+ });
1586
+
1587
+ // src/core/navigators/generators/types.ts
1588
+ var types_exports2 = {};
1589
+ var init_types2 = __esm({
1590
+ "src/core/navigators/generators/types.ts"() {
1591
+ "use strict";
1592
+ }
1593
+ });
1594
+
1014
1595
  // src/core/navigators/hardcodedOrder.ts
1015
1596
  var hardcodedOrder_exports = {};
1016
1597
  __export(hardcodedOrder_exports, {
@@ -1023,13 +1604,12 @@ var init_hardcodedOrder = __esm({
1023
1604
  init_navigators();
1024
1605
  init_logger();
1025
1606
  HardcodedOrderNavigator = class extends ContentNavigator {
1607
+ /** Human-readable name for CardGenerator interface */
1608
+ name;
1026
1609
  orderedCardIds = [];
1027
- user;
1028
- course;
1029
1610
  constructor(user, course, strategyData) {
1030
- super();
1031
- this.user = user;
1032
- this.course = course;
1611
+ super(user, course, strategyData);
1612
+ this.name = strategyData.name || "Hardcoded Order";
1033
1613
  if (strategyData.serializedData) {
1034
1614
  try {
1035
1615
  this.orderedCardIds = JSON.parse(strategyData.serializedData);
@@ -1037,36 +1617,792 @@ var init_hardcodedOrder = __esm({
1037
1617
  logger.error("Failed to parse serializedData for HardcodedOrderNavigator", e);
1038
1618
  }
1039
1619
  }
1040
- }
1041
- async getPendingReviews() {
1620
+ }
1621
+ async getPendingReviews() {
1622
+ const reviews = await this.user.getPendingReviews(this.course.getCourseID());
1623
+ return reviews.map((r) => {
1624
+ return {
1625
+ ...r,
1626
+ contentSourceType: "course",
1627
+ contentSourceID: this.course.getCourseID(),
1628
+ cardID: r.cardId,
1629
+ courseID: r.courseId,
1630
+ reviewID: r._id,
1631
+ status: "review"
1632
+ };
1633
+ });
1634
+ }
1635
+ async getNewCards(limit = 99) {
1636
+ const activeCardIds = (await this.user.getActiveCards()).map((c) => c.cardID);
1637
+ const newCardIds = this.orderedCardIds.filter((cardId) => !activeCardIds.includes(cardId));
1638
+ const cardsToReturn = newCardIds.slice(0, limit);
1639
+ return cardsToReturn.map((cardId) => {
1640
+ return {
1641
+ cardID: cardId,
1642
+ courseID: this.course.getCourseID(),
1643
+ contentSourceType: "course",
1644
+ contentSourceID: this.course.getCourseID(),
1645
+ status: "new"
1646
+ };
1647
+ });
1648
+ }
1649
+ /**
1650
+ * Get cards in hardcoded order with scores based on position.
1651
+ *
1652
+ * Earlier cards in the sequence get higher scores.
1653
+ * Score formula: 1.0 - (position / totalCards) * 0.5
1654
+ * This ensures scores range from 1.0 (first card) to 0.5+ (last card).
1655
+ *
1656
+ * This method supports both the legacy signature (limit only) and the
1657
+ * CardGenerator interface signature (limit, context).
1658
+ *
1659
+ * @param limit - Maximum number of cards to return
1660
+ * @param _context - Optional GeneratorContext (currently unused, but required for interface)
1661
+ */
1662
+ async getWeightedCards(limit, _context) {
1663
+ const activeCardIds = (await this.user.getActiveCards()).map((c) => c.cardID);
1664
+ const reviews = await this.getPendingReviews();
1665
+ const newCardIds = this.orderedCardIds.filter((cardId) => !activeCardIds.includes(cardId));
1666
+ const totalCards = newCardIds.length;
1667
+ const scoredNew = newCardIds.slice(0, limit).map((cardId, index) => {
1668
+ const position = index + 1;
1669
+ const score = Math.max(0.5, 1 - index / totalCards * 0.5);
1670
+ return {
1671
+ cardId,
1672
+ courseId: this.course.getCourseID(),
1673
+ score,
1674
+ provenance: [
1675
+ {
1676
+ strategy: "hardcodedOrder",
1677
+ strategyName: this.strategyName || this.name,
1678
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-hardcoded",
1679
+ action: "generated",
1680
+ score,
1681
+ reason: `Position ${position} of ${totalCards} in fixed sequence, new card`
1682
+ }
1683
+ ]
1684
+ };
1685
+ });
1686
+ const scoredReviews = reviews.map((r) => ({
1687
+ cardId: r.cardID,
1688
+ courseId: r.courseID,
1689
+ score: 1,
1690
+ provenance: [
1691
+ {
1692
+ strategy: "hardcodedOrder",
1693
+ strategyName: this.strategyName || this.name,
1694
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-hardcoded",
1695
+ action: "generated",
1696
+ score: 1,
1697
+ reason: "Scheduled review, highest priority"
1698
+ }
1699
+ ]
1700
+ }));
1701
+ const all = [...scoredReviews, ...scoredNew];
1702
+ all.sort((a, b) => b.score - a.score);
1703
+ return all.slice(0, limit);
1704
+ }
1705
+ };
1706
+ }
1707
+ });
1708
+
1709
+ // src/core/navigators/hierarchyDefinition.ts
1710
+ var hierarchyDefinition_exports = {};
1711
+ __export(hierarchyDefinition_exports, {
1712
+ default: () => HierarchyDefinitionNavigator
1713
+ });
1714
+ var import_common7, DEFAULT_MIN_COUNT, HierarchyDefinitionNavigator;
1715
+ var init_hierarchyDefinition = __esm({
1716
+ "src/core/navigators/hierarchyDefinition.ts"() {
1717
+ "use strict";
1718
+ init_navigators();
1719
+ import_common7 = require("@vue-skuilder/common");
1720
+ DEFAULT_MIN_COUNT = 3;
1721
+ HierarchyDefinitionNavigator = class extends ContentNavigator {
1722
+ config;
1723
+ _strategyData;
1724
+ /** Human-readable name for CardFilter interface */
1725
+ name;
1726
+ constructor(user, course, _strategyData) {
1727
+ super(user, course, _strategyData);
1728
+ this._strategyData = _strategyData;
1729
+ this.config = this.parseConfig(_strategyData.serializedData);
1730
+ this.name = _strategyData.name || "Hierarchy Definition";
1731
+ }
1732
+ parseConfig(serializedData) {
1733
+ try {
1734
+ const parsed = JSON.parse(serializedData);
1735
+ return {
1736
+ prerequisites: parsed.prerequisites || {}
1737
+ };
1738
+ } catch {
1739
+ return {
1740
+ prerequisites: {}
1741
+ };
1742
+ }
1743
+ }
1744
+ /**
1745
+ * Check if a specific prerequisite is satisfied
1746
+ */
1747
+ isPrerequisiteMet(prereq, userTagElo, userGlobalElo) {
1748
+ if (!userTagElo) return false;
1749
+ const minCount = prereq.masteryThreshold?.minCount ?? DEFAULT_MIN_COUNT;
1750
+ if (userTagElo.count < minCount) return false;
1751
+ if (prereq.masteryThreshold?.minElo !== void 0) {
1752
+ return userTagElo.score >= prereq.masteryThreshold.minElo;
1753
+ } else {
1754
+ return userTagElo.score >= userGlobalElo;
1755
+ }
1756
+ }
1757
+ /**
1758
+ * Get the set of tags the user has mastered.
1759
+ * A tag is "mastered" if it appears as a prerequisite somewhere and meets its threshold.
1760
+ */
1761
+ async getMasteredTags(context) {
1762
+ const mastered = /* @__PURE__ */ new Set();
1763
+ try {
1764
+ const courseReg = await context.user.getCourseRegDoc(context.course.getCourseID());
1765
+ const userElo = (0, import_common7.toCourseElo)(courseReg.elo);
1766
+ for (const prereqs of Object.values(this.config.prerequisites)) {
1767
+ for (const prereq of prereqs) {
1768
+ const tagElo = userElo.tags[prereq.tag];
1769
+ if (this.isPrerequisiteMet(prereq, tagElo, userElo.global.score)) {
1770
+ mastered.add(prereq.tag);
1771
+ }
1772
+ }
1773
+ }
1774
+ } catch {
1775
+ }
1776
+ return mastered;
1777
+ }
1778
+ /**
1779
+ * Get the set of tags that are unlocked (prerequisites met)
1780
+ */
1781
+ getUnlockedTags(masteredTags) {
1782
+ const unlocked = /* @__PURE__ */ new Set();
1783
+ for (const [tagId, prereqs] of Object.entries(this.config.prerequisites)) {
1784
+ const allPrereqsMet = prereqs.every((prereq) => masteredTags.has(prereq.tag));
1785
+ if (allPrereqsMet) {
1786
+ unlocked.add(tagId);
1787
+ }
1788
+ }
1789
+ return unlocked;
1790
+ }
1791
+ /**
1792
+ * Check if a tag has prerequisites defined in config
1793
+ */
1794
+ hasPrerequisites(tagId) {
1795
+ return tagId in this.config.prerequisites;
1796
+ }
1797
+ /**
1798
+ * Check if a card is unlocked and generate reason.
1799
+ */
1800
+ async checkCardUnlock(cardId, course, unlockedTags, masteredTags) {
1801
+ try {
1802
+ const tagResponse = await course.getAppliedTags(cardId);
1803
+ const cardTags = tagResponse.rows.map((row) => row.value?.name || row.key);
1804
+ const lockedTags = cardTags.filter(
1805
+ (tag) => this.hasPrerequisites(tag) && !unlockedTags.has(tag)
1806
+ );
1807
+ if (lockedTags.length === 0) {
1808
+ const tagList = cardTags.length > 0 ? cardTags.join(", ") : "none";
1809
+ return {
1810
+ isUnlocked: true,
1811
+ reason: `Prerequisites met, tags: ${tagList}`
1812
+ };
1813
+ }
1814
+ const missingPrereqs = lockedTags.flatMap((tag) => {
1815
+ const prereqs = this.config.prerequisites[tag] || [];
1816
+ return prereqs.filter((p) => !masteredTags.has(p.tag)).map((p) => p.tag);
1817
+ });
1818
+ return {
1819
+ isUnlocked: false,
1820
+ reason: `Blocked: missing prerequisites ${missingPrereqs.join(", ")} for tags ${lockedTags.join(", ")}`
1821
+ };
1822
+ } catch {
1823
+ return {
1824
+ isUnlocked: true,
1825
+ reason: "Prerequisites check skipped (tag lookup failed)"
1826
+ };
1827
+ }
1828
+ }
1829
+ /**
1830
+ * CardFilter.transform implementation.
1831
+ *
1832
+ * Apply prerequisite gating to cards. Cards with locked tags receive score: 0.
1833
+ */
1834
+ async transform(cards, context) {
1835
+ const masteredTags = await this.getMasteredTags(context);
1836
+ const unlockedTags = this.getUnlockedTags(masteredTags);
1837
+ const gated = [];
1838
+ for (const card of cards) {
1839
+ const { isUnlocked, reason } = await this.checkCardUnlock(
1840
+ card.cardId,
1841
+ context.course,
1842
+ unlockedTags,
1843
+ masteredTags
1844
+ );
1845
+ const finalScore = isUnlocked ? card.score : 0;
1846
+ const action = isUnlocked ? "passed" : "penalized";
1847
+ gated.push({
1848
+ ...card,
1849
+ score: finalScore,
1850
+ provenance: [
1851
+ ...card.provenance,
1852
+ {
1853
+ strategy: "hierarchyDefinition",
1854
+ strategyName: this.strategyName || this.name,
1855
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-hierarchy",
1856
+ action,
1857
+ score: finalScore,
1858
+ reason
1859
+ }
1860
+ ]
1861
+ });
1862
+ }
1863
+ return gated;
1864
+ }
1865
+ /**
1866
+ * Legacy getWeightedCards - now throws as filters should not be used as generators.
1867
+ *
1868
+ * Use transform() via Pipeline instead.
1869
+ */
1870
+ async getWeightedCards(_limit) {
1871
+ throw new Error(
1872
+ "HierarchyDefinitionNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
1873
+ );
1874
+ }
1875
+ // Legacy methods - stub implementations since filters don't generate cards
1876
+ async getNewCards(_n) {
1877
+ return [];
1878
+ }
1879
+ async getPendingReviews() {
1880
+ return [];
1881
+ }
1882
+ };
1883
+ }
1884
+ });
1885
+
1886
+ // src/core/navigators/interferenceMitigator.ts
1887
+ var interferenceMitigator_exports = {};
1888
+ __export(interferenceMitigator_exports, {
1889
+ default: () => InterferenceMitigatorNavigator
1890
+ });
1891
+ var import_common8, DEFAULT_MIN_COUNT2, DEFAULT_MIN_ELAPSED_DAYS, DEFAULT_INTERFERENCE_DECAY, InterferenceMitigatorNavigator;
1892
+ var init_interferenceMitigator = __esm({
1893
+ "src/core/navigators/interferenceMitigator.ts"() {
1894
+ "use strict";
1895
+ init_navigators();
1896
+ import_common8 = require("@vue-skuilder/common");
1897
+ DEFAULT_MIN_COUNT2 = 10;
1898
+ DEFAULT_MIN_ELAPSED_DAYS = 3;
1899
+ DEFAULT_INTERFERENCE_DECAY = 0.8;
1900
+ InterferenceMitigatorNavigator = class extends ContentNavigator {
1901
+ config;
1902
+ _strategyData;
1903
+ /** Human-readable name for CardFilter interface */
1904
+ name;
1905
+ /** Precomputed map: tag -> set of { partner, decay } it interferes with */
1906
+ interferenceMap;
1907
+ constructor(user, course, _strategyData) {
1908
+ super(user, course, _strategyData);
1909
+ this._strategyData = _strategyData;
1910
+ this.config = this.parseConfig(_strategyData.serializedData);
1911
+ this.interferenceMap = this.buildInterferenceMap();
1912
+ this.name = _strategyData.name || "Interference Mitigator";
1913
+ }
1914
+ parseConfig(serializedData) {
1915
+ try {
1916
+ const parsed = JSON.parse(serializedData);
1917
+ let sets = parsed.interferenceSets || [];
1918
+ if (sets.length > 0 && Array.isArray(sets[0])) {
1919
+ sets = sets.map((tags) => ({ tags }));
1920
+ }
1921
+ return {
1922
+ interferenceSets: sets,
1923
+ maturityThreshold: {
1924
+ minCount: parsed.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT2,
1925
+ minElo: parsed.maturityThreshold?.minElo,
1926
+ minElapsedDays: parsed.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS
1927
+ },
1928
+ defaultDecay: parsed.defaultDecay ?? DEFAULT_INTERFERENCE_DECAY
1929
+ };
1930
+ } catch {
1931
+ return {
1932
+ interferenceSets: [],
1933
+ maturityThreshold: {
1934
+ minCount: DEFAULT_MIN_COUNT2,
1935
+ minElapsedDays: DEFAULT_MIN_ELAPSED_DAYS
1936
+ },
1937
+ defaultDecay: DEFAULT_INTERFERENCE_DECAY
1938
+ };
1939
+ }
1940
+ }
1941
+ /**
1942
+ * Build a map from each tag to its interference partners with decay coefficients.
1943
+ * If tags A, B, C are in an interference group with decay 0.8, then:
1944
+ * - A interferes with B (decay 0.8) and C (decay 0.8)
1945
+ * - B interferes with A (decay 0.8) and C (decay 0.8)
1946
+ * - etc.
1947
+ */
1948
+ buildInterferenceMap() {
1949
+ const map = /* @__PURE__ */ new Map();
1950
+ for (const group of this.config.interferenceSets) {
1951
+ const decay = group.decay ?? this.config.defaultDecay ?? DEFAULT_INTERFERENCE_DECAY;
1952
+ for (const tag of group.tags) {
1953
+ if (!map.has(tag)) {
1954
+ map.set(tag, []);
1955
+ }
1956
+ const partners = map.get(tag);
1957
+ for (const other of group.tags) {
1958
+ if (other !== tag) {
1959
+ const existing = partners.find((p) => p.partner === other);
1960
+ if (existing) {
1961
+ existing.decay = Math.max(existing.decay, decay);
1962
+ } else {
1963
+ partners.push({ partner: other, decay });
1964
+ }
1965
+ }
1966
+ }
1967
+ }
1968
+ }
1969
+ return map;
1970
+ }
1971
+ /**
1972
+ * Get the set of tags that are currently immature for this user.
1973
+ * A tag is immature if the user has interacted with it but hasn't
1974
+ * reached the maturity threshold.
1975
+ */
1976
+ async getImmatureTags(context) {
1977
+ const immature = /* @__PURE__ */ new Set();
1978
+ try {
1979
+ const courseReg = await context.user.getCourseRegDoc(context.course.getCourseID());
1980
+ const userElo = (0, import_common8.toCourseElo)(courseReg.elo);
1981
+ const minCount = this.config.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT2;
1982
+ const minElo = this.config.maturityThreshold?.minElo;
1983
+ const minElapsedDays = this.config.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS;
1984
+ const minCountForElapsed = minElapsedDays * 2;
1985
+ for (const [tagId, tagElo] of Object.entries(userElo.tags)) {
1986
+ if (tagElo.count === 0) continue;
1987
+ const belowCount = tagElo.count < minCount;
1988
+ const belowElo = minElo !== void 0 && tagElo.score < minElo;
1989
+ const belowElapsed = tagElo.count < minCountForElapsed;
1990
+ if (belowCount || belowElo || belowElapsed) {
1991
+ immature.add(tagId);
1992
+ }
1993
+ }
1994
+ } catch {
1995
+ }
1996
+ return immature;
1997
+ }
1998
+ /**
1999
+ * Get all tags that interfere with any immature tag, along with their decay coefficients.
2000
+ * These are the tags we want to avoid introducing.
2001
+ */
2002
+ getTagsToAvoid(immatureTags) {
2003
+ const avoid = /* @__PURE__ */ new Map();
2004
+ for (const immatureTag of immatureTags) {
2005
+ const partners = this.interferenceMap.get(immatureTag);
2006
+ if (partners) {
2007
+ for (const { partner, decay } of partners) {
2008
+ if (!immatureTags.has(partner)) {
2009
+ const existing = avoid.get(partner) ?? 0;
2010
+ avoid.set(partner, Math.max(existing, decay));
2011
+ }
2012
+ }
2013
+ }
2014
+ }
2015
+ return avoid;
2016
+ }
2017
+ /**
2018
+ * Get tags for a single card
2019
+ */
2020
+ async getCardTags(cardId, course) {
2021
+ try {
2022
+ const tagResponse = await course.getAppliedTags(cardId);
2023
+ return tagResponse.rows.map((row) => row.value?.name || row.key).filter(Boolean);
2024
+ } catch {
2025
+ return [];
2026
+ }
2027
+ }
2028
+ /**
2029
+ * Compute interference score reduction for a card.
2030
+ * Returns: { multiplier, interfering tags, reason }
2031
+ */
2032
+ computeInterferenceEffect(cardTags, tagsToAvoid, immatureTags) {
2033
+ if (tagsToAvoid.size === 0) {
2034
+ return {
2035
+ multiplier: 1,
2036
+ interferingTags: [],
2037
+ reason: "No interference detected"
2038
+ };
2039
+ }
2040
+ let multiplier = 1;
2041
+ const interferingTags = [];
2042
+ for (const tag of cardTags) {
2043
+ const decay = tagsToAvoid.get(tag);
2044
+ if (decay !== void 0) {
2045
+ interferingTags.push(tag);
2046
+ multiplier *= 1 - decay;
2047
+ }
2048
+ }
2049
+ if (interferingTags.length === 0) {
2050
+ return {
2051
+ multiplier: 1,
2052
+ interferingTags: [],
2053
+ reason: "No interference detected"
2054
+ };
2055
+ }
2056
+ const causingTags = /* @__PURE__ */ new Set();
2057
+ for (const tag of interferingTags) {
2058
+ for (const immatureTag of immatureTags) {
2059
+ const partners = this.interferenceMap.get(immatureTag);
2060
+ if (partners?.some((p) => p.partner === tag)) {
2061
+ causingTags.add(immatureTag);
2062
+ }
2063
+ }
2064
+ }
2065
+ const reason = `Interferes with immature tags ${Array.from(causingTags).join(", ")} (tags: ${interferingTags.join(", ")}, multiplier: ${multiplier.toFixed(2)})`;
2066
+ return { multiplier, interferingTags, reason };
2067
+ }
2068
+ /**
2069
+ * CardFilter.transform implementation.
2070
+ *
2071
+ * Apply interference-aware scoring. Cards with tags that interfere with
2072
+ * immature learnings get reduced scores.
2073
+ */
2074
+ async transform(cards, context) {
2075
+ const immatureTags = await this.getImmatureTags(context);
2076
+ const tagsToAvoid = this.getTagsToAvoid(immatureTags);
2077
+ const adjusted = [];
2078
+ for (const card of cards) {
2079
+ const cardTags = await this.getCardTags(card.cardId, context.course);
2080
+ const { multiplier, reason } = this.computeInterferenceEffect(
2081
+ cardTags,
2082
+ tagsToAvoid,
2083
+ immatureTags
2084
+ );
2085
+ const finalScore = card.score * multiplier;
2086
+ const action = multiplier < 1 ? "penalized" : multiplier > 1 ? "boosted" : "passed";
2087
+ adjusted.push({
2088
+ ...card,
2089
+ score: finalScore,
2090
+ provenance: [
2091
+ ...card.provenance,
2092
+ {
2093
+ strategy: "interferenceMitigator",
2094
+ strategyName: this.strategyName || this.name,
2095
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-interference",
2096
+ action,
2097
+ score: finalScore,
2098
+ reason
2099
+ }
2100
+ ]
2101
+ });
2102
+ }
2103
+ return adjusted;
2104
+ }
2105
+ /**
2106
+ * Legacy getWeightedCards - now throws as filters should not be used as generators.
2107
+ *
2108
+ * Use transform() via Pipeline instead.
2109
+ */
2110
+ async getWeightedCards(_limit) {
2111
+ throw new Error(
2112
+ "InterferenceMitigatorNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
2113
+ );
2114
+ }
2115
+ // Legacy methods - stub implementations since filters don't generate cards
2116
+ async getNewCards(_n) {
2117
+ return [];
2118
+ }
2119
+ async getPendingReviews() {
2120
+ return [];
2121
+ }
2122
+ };
2123
+ }
2124
+ });
2125
+
2126
+ // src/core/navigators/relativePriority.ts
2127
+ var relativePriority_exports = {};
2128
+ __export(relativePriority_exports, {
2129
+ default: () => RelativePriorityNavigator
2130
+ });
2131
+ var DEFAULT_PRIORITY, DEFAULT_PRIORITY_INFLUENCE, DEFAULT_COMBINE_MODE, RelativePriorityNavigator;
2132
+ var init_relativePriority = __esm({
2133
+ "src/core/navigators/relativePriority.ts"() {
2134
+ "use strict";
2135
+ init_navigators();
2136
+ DEFAULT_PRIORITY = 0.5;
2137
+ DEFAULT_PRIORITY_INFLUENCE = 0.5;
2138
+ DEFAULT_COMBINE_MODE = "max";
2139
+ RelativePriorityNavigator = class extends ContentNavigator {
2140
+ config;
2141
+ _strategyData;
2142
+ /** Human-readable name for CardFilter interface */
2143
+ name;
2144
+ constructor(user, course, _strategyData) {
2145
+ super(user, course, _strategyData);
2146
+ this._strategyData = _strategyData;
2147
+ this.config = this.parseConfig(_strategyData.serializedData);
2148
+ this.name = _strategyData.name || "Relative Priority";
2149
+ }
2150
+ parseConfig(serializedData) {
2151
+ try {
2152
+ const parsed = JSON.parse(serializedData);
2153
+ return {
2154
+ tagPriorities: parsed.tagPriorities || {},
2155
+ defaultPriority: parsed.defaultPriority ?? DEFAULT_PRIORITY,
2156
+ combineMode: parsed.combineMode ?? DEFAULT_COMBINE_MODE,
2157
+ priorityInfluence: parsed.priorityInfluence ?? DEFAULT_PRIORITY_INFLUENCE
2158
+ };
2159
+ } catch {
2160
+ return {
2161
+ tagPriorities: {},
2162
+ defaultPriority: DEFAULT_PRIORITY,
2163
+ combineMode: DEFAULT_COMBINE_MODE,
2164
+ priorityInfluence: DEFAULT_PRIORITY_INFLUENCE
2165
+ };
2166
+ }
2167
+ }
2168
+ /**
2169
+ * Look up the priority for a tag.
2170
+ */
2171
+ getTagPriority(tagId) {
2172
+ return this.config.tagPriorities[tagId] ?? this.config.defaultPriority ?? DEFAULT_PRIORITY;
2173
+ }
2174
+ /**
2175
+ * Compute combined priority for a card based on its tags.
2176
+ */
2177
+ computeCardPriority(cardTags) {
2178
+ if (cardTags.length === 0) {
2179
+ return this.config.defaultPriority ?? DEFAULT_PRIORITY;
2180
+ }
2181
+ const priorities = cardTags.map((tag) => this.getTagPriority(tag));
2182
+ switch (this.config.combineMode) {
2183
+ case "max":
2184
+ return Math.max(...priorities);
2185
+ case "min":
2186
+ return Math.min(...priorities);
2187
+ case "average":
2188
+ return priorities.reduce((sum, p) => sum + p, 0) / priorities.length;
2189
+ default:
2190
+ return Math.max(...priorities);
2191
+ }
2192
+ }
2193
+ /**
2194
+ * Compute boost factor based on priority.
2195
+ *
2196
+ * The formula: 1 + (priority - 0.5) * priorityInfluence
2197
+ *
2198
+ * This creates a multiplier centered around 1.0:
2199
+ * - Priority 1.0 with influence 0.5 → 1.25 (25% boost)
2200
+ * - Priority 0.5 with any influence → 1.00 (neutral)
2201
+ * - Priority 0.0 with influence 0.5 → 0.75 (25% reduction)
2202
+ */
2203
+ computeBoostFactor(priority) {
2204
+ const influence = this.config.priorityInfluence ?? DEFAULT_PRIORITY_INFLUENCE;
2205
+ return 1 + (priority - 0.5) * influence;
2206
+ }
2207
+ /**
2208
+ * Build human-readable reason for priority adjustment.
2209
+ */
2210
+ buildPriorityReason(cardTags, priority, boostFactor, finalScore) {
2211
+ if (cardTags.length === 0) {
2212
+ return `No tags, neutral priority (${priority.toFixed(2)})`;
2213
+ }
2214
+ const tagList = cardTags.slice(0, 3).join(", ");
2215
+ const more = cardTags.length > 3 ? ` (+${cardTags.length - 3} more)` : "";
2216
+ if (boostFactor === 1) {
2217
+ return `Neutral priority (${priority.toFixed(2)}) for tags: ${tagList}${more}`;
2218
+ } else if (boostFactor > 1) {
2219
+ return `High-priority tags: ${tagList}${more} (priority ${priority.toFixed(2)} \u2192 boost ${boostFactor.toFixed(2)}x \u2192 ${finalScore.toFixed(2)})`;
2220
+ } else {
2221
+ return `Low-priority tags: ${tagList}${more} (priority ${priority.toFixed(2)} \u2192 reduce ${boostFactor.toFixed(2)}x \u2192 ${finalScore.toFixed(2)})`;
2222
+ }
2223
+ }
2224
+ /**
2225
+ * Get tags for a single card.
2226
+ */
2227
+ async getCardTags(cardId, course) {
2228
+ try {
2229
+ const tagResponse = await course.getAppliedTags(cardId);
2230
+ return tagResponse.rows.map((r) => r.doc?.name).filter((x) => !!x);
2231
+ } catch {
2232
+ return [];
2233
+ }
2234
+ }
2235
+ /**
2236
+ * CardFilter.transform implementation.
2237
+ *
2238
+ * Apply priority-adjusted scoring. Cards with high-priority tags get boosted,
2239
+ * cards with low-priority tags get reduced scores.
2240
+ */
2241
+ async transform(cards, context) {
2242
+ const adjusted = await Promise.all(
2243
+ cards.map(async (card) => {
2244
+ const cardTags = await this.getCardTags(card.cardId, context.course);
2245
+ const priority = this.computeCardPriority(cardTags);
2246
+ const boostFactor = this.computeBoostFactor(priority);
2247
+ const finalScore = Math.max(0, Math.min(1, card.score * boostFactor));
2248
+ const action = boostFactor > 1 ? "boosted" : boostFactor < 1 ? "penalized" : "passed";
2249
+ const reason = this.buildPriorityReason(cardTags, priority, boostFactor, finalScore);
2250
+ return {
2251
+ ...card,
2252
+ score: finalScore,
2253
+ provenance: [
2254
+ ...card.provenance,
2255
+ {
2256
+ strategy: "relativePriority",
2257
+ strategyName: this.strategyName || this.name,
2258
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-priority",
2259
+ action,
2260
+ score: finalScore,
2261
+ reason
2262
+ }
2263
+ ]
2264
+ };
2265
+ })
2266
+ );
2267
+ return adjusted;
2268
+ }
2269
+ /**
2270
+ * Legacy getWeightedCards - now throws as filters should not be used as generators.
2271
+ *
2272
+ * Use transform() via Pipeline instead.
2273
+ */
2274
+ async getWeightedCards(_limit) {
2275
+ throw new Error(
2276
+ "RelativePriorityNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
2277
+ );
2278
+ }
2279
+ // Legacy methods - stub implementations since filters don't generate cards
2280
+ async getNewCards(_n) {
2281
+ return [];
2282
+ }
2283
+ async getPendingReviews() {
2284
+ return [];
2285
+ }
2286
+ };
2287
+ }
2288
+ });
2289
+
2290
+ // src/core/navigators/srs.ts
2291
+ var srs_exports = {};
2292
+ __export(srs_exports, {
2293
+ default: () => SRSNavigator
2294
+ });
2295
+ var import_moment3, SRSNavigator;
2296
+ var init_srs = __esm({
2297
+ "src/core/navigators/srs.ts"() {
2298
+ "use strict";
2299
+ import_moment3 = __toESM(require("moment"), 1);
2300
+ init_navigators();
2301
+ SRSNavigator = class extends ContentNavigator {
2302
+ /** Human-readable name for CardGenerator interface */
2303
+ name;
2304
+ constructor(user, course, strategyData) {
2305
+ super(user, course, strategyData);
2306
+ this.name = strategyData?.name || "SRS";
2307
+ }
2308
+ /**
2309
+ * Get review cards scored by urgency.
2310
+ *
2311
+ * Score formula combines:
2312
+ * - Relative overdueness: hoursOverdue / intervalHours
2313
+ * - Interval recency: exponential decay favoring shorter intervals
2314
+ *
2315
+ * Cards not yet due are excluded (not scored as 0).
2316
+ *
2317
+ * This method supports both the legacy signature (limit only) and the
2318
+ * CardGenerator interface signature (limit, context).
2319
+ *
2320
+ * @param limit - Maximum number of cards to return
2321
+ * @param _context - Optional GeneratorContext (currently unused, but required for interface)
2322
+ */
2323
+ async getWeightedCards(limit, _context) {
2324
+ if (!this.user || !this.course) {
2325
+ throw new Error("SRSNavigator requires user and course to be set");
2326
+ }
1042
2327
  const reviews = await this.user.getPendingReviews(this.course.getCourseID());
1043
- return reviews.map((r) => {
2328
+ const now = import_moment3.default.utc();
2329
+ const dueReviews = reviews.filter((r) => now.isAfter(import_moment3.default.utc(r.reviewTime)));
2330
+ const scored = dueReviews.map((review) => {
2331
+ const { score, reason } = this.computeUrgencyScore(review, now);
1044
2332
  return {
1045
- ...r,
1046
- contentSourceType: "course",
1047
- contentSourceID: this.course.getCourseID(),
1048
- cardID: r.cardId,
1049
- courseID: r.courseId,
1050
- reviewID: r._id,
1051
- status: "review"
2333
+ cardId: review.cardId,
2334
+ courseId: review.courseId,
2335
+ score,
2336
+ provenance: [
2337
+ {
2338
+ strategy: "srs",
2339
+ strategyName: this.strategyName || this.name,
2340
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-SRS-default",
2341
+ action: "generated",
2342
+ score,
2343
+ reason
2344
+ }
2345
+ ]
1052
2346
  };
1053
2347
  });
2348
+ return scored.sort((a, b) => b.score - a.score).slice(0, limit);
1054
2349
  }
1055
- async getNewCards(limit = 99) {
1056
- const activeCardIds = (await this.user.getActiveCards()).map((c) => c.cardID);
1057
- const newCardIds = this.orderedCardIds.filter(
1058
- (cardId) => !activeCardIds.includes(cardId)
1059
- );
1060
- const cardsToReturn = newCardIds.slice(0, limit);
1061
- return cardsToReturn.map((cardId) => {
1062
- return {
1063
- cardID: cardId,
1064
- courseID: this.course.getCourseID(),
1065
- contentSourceType: "course",
1066
- contentSourceID: this.course.getCourseID(),
1067
- status: "new"
1068
- };
1069
- });
2350
+ /**
2351
+ * Compute urgency score for a review card.
2352
+ *
2353
+ * Two factors:
2354
+ * 1. Relative overdueness = hoursOverdue / intervalHours
2355
+ * - 2 days overdue on 3-day interval = 0.67 (urgent)
2356
+ * - 2 days overdue on 180-day interval = 0.01 (not urgent)
2357
+ *
2358
+ * 2. Interval recency factor = 0.3 + 0.7 * exp(-intervalHours / 720)
2359
+ * - 24h interval → ~1.0 (very recent learning)
2360
+ * - 30 days (720h) → ~0.56
2361
+ * - 180 days → ~0.30
2362
+ *
2363
+ * Combined: base 0.5 + weighted average of factors * 0.45
2364
+ * Result range: approximately 0.5 to 0.95
2365
+ */
2366
+ computeUrgencyScore(review, now) {
2367
+ const scheduledAt = import_moment3.default.utc(review.scheduledAt);
2368
+ const due = import_moment3.default.utc(review.reviewTime);
2369
+ const intervalHours = Math.max(1, due.diff(scheduledAt, "hours"));
2370
+ const hoursOverdue = now.diff(due, "hours");
2371
+ const relativeOverdue = hoursOverdue / intervalHours;
2372
+ const recencyFactor = 0.3 + 0.7 * Math.exp(-intervalHours / 720);
2373
+ const overdueContribution = Math.min(1, Math.max(0, relativeOverdue));
2374
+ const urgency = overdueContribution * 0.5 + recencyFactor * 0.5;
2375
+ const score = Math.min(0.95, 0.5 + urgency * 0.45);
2376
+ const reason = `${Math.round(hoursOverdue)}h overdue (interval: ${Math.round(intervalHours)}h, relative: ${relativeOverdue.toFixed(2)}), recency: ${recencyFactor.toFixed(2)}, review`;
2377
+ return { score, reason };
2378
+ }
2379
+ /**
2380
+ * Get pending reviews in legacy format.
2381
+ *
2382
+ * Returns all pending reviews for the course, enriched with session item fields.
2383
+ */
2384
+ async getPendingReviews() {
2385
+ if (!this.user || !this.course) {
2386
+ throw new Error("SRSNavigator requires user and course to be set");
2387
+ }
2388
+ const reviews = await this.user.getPendingReviews(this.course.getCourseID());
2389
+ return reviews.map((r) => ({
2390
+ ...r,
2391
+ contentSourceType: "course",
2392
+ contentSourceID: this.course.getCourseID(),
2393
+ cardID: r.cardId,
2394
+ courseID: r.courseId,
2395
+ qualifiedID: `${r.courseId}-${r.cardId}`,
2396
+ reviewID: r._id,
2397
+ status: "review"
2398
+ }));
2399
+ }
2400
+ /**
2401
+ * SRS does not generate new cards.
2402
+ * Use ELONavigator or another generator for new cards.
2403
+ */
2404
+ async getNewCards(_n) {
2405
+ return [];
1070
2406
  }
1071
2407
  };
1072
2408
  }
@@ -1077,9 +2413,21 @@ var globImport;
1077
2413
  var init_ = __esm({
1078
2414
  'import("./**/*") in src/core/navigators/index.ts'() {
1079
2415
  globImport = __glob({
2416
+ "./CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
2417
+ "./Pipeline.ts": () => Promise.resolve().then(() => (init_Pipeline(), Pipeline_exports)),
2418
+ "./PipelineAssembler.ts": () => Promise.resolve().then(() => (init_PipelineAssembler(), PipelineAssembler_exports)),
1080
2419
  "./elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
2420
+ "./filters/eloDistance.ts": () => Promise.resolve().then(() => (init_eloDistance(), eloDistance_exports)),
2421
+ "./filters/index.ts": () => Promise.resolve().then(() => (init_filters(), filters_exports)),
2422
+ "./filters/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports)),
2423
+ "./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
2424
+ "./generators/types.ts": () => Promise.resolve().then(() => (init_types2(), types_exports2)),
1081
2425
  "./hardcodedOrder.ts": () => Promise.resolve().then(() => (init_hardcodedOrder(), hardcodedOrder_exports)),
1082
- "./index.ts": () => Promise.resolve().then(() => (init_navigators(), navigators_exports))
2426
+ "./hierarchyDefinition.ts": () => Promise.resolve().then(() => (init_hierarchyDefinition(), hierarchyDefinition_exports)),
2427
+ "./index.ts": () => Promise.resolve().then(() => (init_navigators(), navigators_exports)),
2428
+ "./interferenceMitigator.ts": () => Promise.resolve().then(() => (init_interferenceMitigator(), interferenceMitigator_exports)),
2429
+ "./relativePriority.ts": () => Promise.resolve().then(() => (init_relativePriority(), relativePriority_exports)),
2430
+ "./srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports))
1083
2431
  });
1084
2432
  }
1085
2433
  });
@@ -1088,9 +2436,34 @@ var init_ = __esm({
1088
2436
  var navigators_exports = {};
1089
2437
  __export(navigators_exports, {
1090
2438
  ContentNavigator: () => ContentNavigator,
1091
- Navigators: () => Navigators
2439
+ NavigatorRole: () => NavigatorRole,
2440
+ NavigatorRoles: () => NavigatorRoles,
2441
+ Navigators: () => Navigators,
2442
+ getCardOrigin: () => getCardOrigin,
2443
+ isFilter: () => isFilter,
2444
+ isGenerator: () => isGenerator
1092
2445
  });
1093
- var Navigators, ContentNavigator;
2446
+ function getCardOrigin(card) {
2447
+ if (card.provenance.length === 0) {
2448
+ throw new Error("Card has no provenance - cannot determine origin");
2449
+ }
2450
+ const firstEntry = card.provenance[0];
2451
+ const reason = firstEntry.reason.toLowerCase();
2452
+ if (reason.includes("failed")) {
2453
+ return "failed";
2454
+ }
2455
+ if (reason.includes("review")) {
2456
+ return "review";
2457
+ }
2458
+ return "new";
2459
+ }
2460
+ function isGenerator(impl) {
2461
+ return NavigatorRoles[impl] === "generator" /* GENERATOR */;
2462
+ }
2463
+ function isFilter(impl) {
2464
+ return NavigatorRoles[impl] === "filter" /* FILTER */;
2465
+ }
2466
+ var Navigators, NavigatorRole, NavigatorRoles, ContentNavigator;
1094
2467
  var init_navigators = __esm({
1095
2468
  "src/core/navigators/index.ts"() {
1096
2469
  "use strict";
@@ -1098,14 +2471,55 @@ var init_navigators = __esm({
1098
2471
  init_();
1099
2472
  Navigators = /* @__PURE__ */ ((Navigators2) => {
1100
2473
  Navigators2["ELO"] = "elo";
2474
+ Navigators2["SRS"] = "srs";
1101
2475
  Navigators2["HARDCODED"] = "hardcodedOrder";
2476
+ Navigators2["HIERARCHY"] = "hierarchyDefinition";
2477
+ Navigators2["INTERFERENCE"] = "interferenceMitigator";
2478
+ Navigators2["RELATIVE_PRIORITY"] = "relativePriority";
1102
2479
  return Navigators2;
1103
2480
  })(Navigators || {});
2481
+ NavigatorRole = /* @__PURE__ */ ((NavigatorRole2) => {
2482
+ NavigatorRole2["GENERATOR"] = "generator";
2483
+ NavigatorRole2["FILTER"] = "filter";
2484
+ return NavigatorRole2;
2485
+ })(NavigatorRole || {});
2486
+ NavigatorRoles = {
2487
+ ["elo" /* ELO */]: "generator" /* GENERATOR */,
2488
+ ["srs" /* SRS */]: "generator" /* GENERATOR */,
2489
+ ["hardcodedOrder" /* HARDCODED */]: "generator" /* GENERATOR */,
2490
+ ["hierarchyDefinition" /* HIERARCHY */]: "filter" /* FILTER */,
2491
+ ["interferenceMitigator" /* INTERFERENCE */]: "filter" /* FILTER */,
2492
+ ["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */
2493
+ };
1104
2494
  ContentNavigator = class {
2495
+ /** User interface for this navigation session */
2496
+ user;
2497
+ /** Course interface for this navigation session */
2498
+ course;
2499
+ /** Human-readable name for this strategy instance (from ContentNavigationStrategyData.name) */
2500
+ strategyName;
2501
+ /** Unique document ID for this strategy instance (from ContentNavigationStrategyData._id) */
2502
+ strategyId;
2503
+ /**
2504
+ * Constructor for standard navigators.
2505
+ * Call this from subclass constructors to initialize common fields.
2506
+ *
2507
+ * Note: CompositeGenerator doesn't use this pattern and should call super() without args.
2508
+ */
2509
+ constructor(user, course, strategyData) {
2510
+ if (user && course && strategyData) {
2511
+ this.user = user;
2512
+ this.course = course;
2513
+ this.strategyName = strategyData.name;
2514
+ this.strategyId = strategyData._id;
2515
+ }
2516
+ }
1105
2517
  /**
2518
+ * Factory method to create navigator instances dynamically.
1106
2519
  *
1107
- * @param user
1108
- * @param strategyData
2520
+ * @param user - User interface
2521
+ * @param course - Course interface
2522
+ * @param strategyData - Strategy configuration document
1109
2523
  * @returns the runtime object used to steer a study session.
1110
2524
  */
1111
2525
  static async create(user, course, strategyData) {
@@ -1126,6 +2540,70 @@ var init_navigators = __esm({
1126
2540
  }
1127
2541
  return new NavigatorImpl(user, course, strategyData);
1128
2542
  }
2543
+ /**
2544
+ * Get cards with suitability scores and provenance trails.
2545
+ *
2546
+ * **This is the PRIMARY API for navigation strategies.**
2547
+ *
2548
+ * Returns cards ranked by suitability score (0-1). Higher scores indicate
2549
+ * better candidates for presentation. Each card includes a provenance trail
2550
+ * documenting how strategies contributed to the final score.
2551
+ *
2552
+ * ## For Generators
2553
+ * Override this method to generate candidates and compute scores based on
2554
+ * your strategy's logic (e.g., ELO proximity, review urgency). Create the
2555
+ * initial provenance entry with action='generated'.
2556
+ *
2557
+ * ## Default Implementation
2558
+ * The base class provides a backward-compatible default that:
2559
+ * 1. Calls legacy getNewCards() and getPendingReviews()
2560
+ * 2. Assigns score=1.0 to all cards
2561
+ * 3. Creates minimal provenance from legacy methods
2562
+ * 4. Returns combined results up to limit
2563
+ *
2564
+ * This allows existing strategies to work without modification while
2565
+ * new strategies can override with proper scoring and provenance.
2566
+ *
2567
+ * @param limit - Maximum cards to return
2568
+ * @returns Cards sorted by score descending, with provenance trails
2569
+ */
2570
+ async getWeightedCards(limit) {
2571
+ const newCards = await this.getNewCards(limit);
2572
+ const reviews = await this.getPendingReviews();
2573
+ const weighted = [
2574
+ ...newCards.map((c) => ({
2575
+ cardId: c.cardID,
2576
+ courseId: c.courseID,
2577
+ score: 1,
2578
+ provenance: [
2579
+ {
2580
+ strategy: "legacy",
2581
+ strategyName: this.strategyName || "Legacy API",
2582
+ strategyId: this.strategyId || "legacy-fallback",
2583
+ action: "generated",
2584
+ score: 1,
2585
+ reason: "Generated via legacy getNewCards(), new card"
2586
+ }
2587
+ ]
2588
+ })),
2589
+ ...reviews.map((r) => ({
2590
+ cardId: r.cardID,
2591
+ courseId: r.courseID,
2592
+ score: 1,
2593
+ provenance: [
2594
+ {
2595
+ strategy: "legacy",
2596
+ strategyName: this.strategyName || "Legacy API",
2597
+ strategyId: this.strategyId || "legacy-fallback",
2598
+ action: "generated",
2599
+ score: 1,
2600
+ reason: "Generated via legacy getPendingReviews(), review"
2601
+ }
2602
+ ]
2603
+ }))
2604
+ ];
2605
+ return weighted.slice(0, limit);
2606
+ }
1129
2607
  };
1130
2608
  }
1131
2609
  });
@@ -1206,11 +2684,11 @@ ${JSON.stringify(config)}
1206
2684
  function isSuccessRow(row) {
1207
2685
  return "doc" in row && row.doc !== null && row.doc !== void 0;
1208
2686
  }
1209
- var import_common5, CoursesDB, CourseDB;
2687
+ var import_common9, CoursesDB, CourseDB;
1210
2688
  var init_courseDB = __esm({
1211
2689
  "src/impl/couch/courseDB.ts"() {
1212
2690
  "use strict";
1213
- import_common5 = require("@vue-skuilder/common");
2691
+ import_common9 = require("@vue-skuilder/common");
1214
2692
  init_couch();
1215
2693
  init_updateQueue();
1216
2694
  init_types_legacy();
@@ -1219,6 +2697,12 @@ var init_courseDB = __esm({
1219
2697
  init_courseAPI();
1220
2698
  init_courseLookupDB();
1221
2699
  init_navigators();
2700
+ init_Pipeline();
2701
+ init_PipelineAssembler();
2702
+ init_CompositeGenerator();
2703
+ init_elo();
2704
+ init_srs();
2705
+ init_eloDistance();
1222
2706
  CoursesDB = class {
1223
2707
  _courseIDs;
1224
2708
  constructor(courseIDs) {
@@ -1330,14 +2814,14 @@ var init_courseDB = __esm({
1330
2814
  docs.rows.forEach((r) => {
1331
2815
  if (isSuccessRow(r)) {
1332
2816
  if (r.doc && r.doc.elo) {
1333
- ret.push((0, import_common5.toCourseElo)(r.doc.elo));
2817
+ ret.push((0, import_common9.toCourseElo)(r.doc.elo));
1334
2818
  } else {
1335
2819
  logger.warn("no elo data for card: " + r.id);
1336
- ret.push((0, import_common5.blankCourseElo)());
2820
+ ret.push((0, import_common9.blankCourseElo)());
1337
2821
  }
1338
2822
  } else {
1339
2823
  logger.warn("no elo data for card: " + JSON.stringify(r));
1340
- ret.push((0, import_common5.blankCourseElo)());
2824
+ ret.push((0, import_common9.blankCourseElo)());
1341
2825
  }
1342
2826
  });
1343
2827
  return ret;
@@ -1519,7 +3003,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
1519
3003
  async getCourseTagStubs() {
1520
3004
  return getCourseTagStubs(this.id);
1521
3005
  }
1522
- async addNote(codeCourse, shape, data, author, tags, uploads, elo = (0, import_common5.blankCourseElo)()) {
3006
+ async addNote(codeCourse, shape, data, author, tags, uploads, elo = (0, import_common9.blankCourseElo)()) {
1523
3007
  try {
1524
3008
  const resp = await addNote55(this.id, codeCourse, shape, data, author, tags, uploads, elo);
1525
3009
  if (resp.ok) {
@@ -1528,19 +3012,19 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
1528
3012
  `[courseDB.addNote] Note added but card creation failed: ${resp.cardCreationError}`
1529
3013
  );
1530
3014
  return {
1531
- status: import_common5.Status.error,
3015
+ status: import_common9.Status.error,
1532
3016
  message: `Note was added but no cards were created: ${resp.cardCreationError}`,
1533
3017
  id: resp.id
1534
3018
  };
1535
3019
  }
1536
3020
  return {
1537
- status: import_common5.Status.ok,
3021
+ status: import_common9.Status.ok,
1538
3022
  message: "",
1539
3023
  id: resp.id
1540
3024
  };
1541
3025
  } else {
1542
3026
  return {
1543
- status: import_common5.Status.error,
3027
+ status: import_common9.Status.error,
1544
3028
  message: "Unexpected error adding note"
1545
3029
  };
1546
3030
  }
@@ -1552,7 +3036,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
1552
3036
  message: ${err.message}`
1553
3037
  );
1554
3038
  return {
1555
- status: import_common5.Status.error,
3039
+ status: import_common9.Status.error,
1556
3040
  message: `Error adding note to course. ${e.reason || err.message}`
1557
3041
  };
1558
3042
  }
@@ -1603,42 +3087,82 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
1603
3087
  logger.debug(JSON.stringify(data));
1604
3088
  return Promise.resolve();
1605
3089
  }
1606
- async surfaceNavigationStrategy() {
3090
+ /**
3091
+ * Creates an instantiated navigator for this course.
3092
+ *
3093
+ * Handles multiple generators by wrapping them in CompositeGenerator.
3094
+ * This is the preferred method for getting a ready-to-use navigator.
3095
+ *
3096
+ * @param user - User database interface
3097
+ * @returns Instantiated ContentNavigator ready for use
3098
+ */
3099
+ async createNavigator(user) {
1607
3100
  try {
1608
- const config = await this.getCourseConfig();
1609
- if (config.defaultNavigationStrategyId) {
1610
- try {
1611
- const strategy = await this.getNavigationStrategy(config.defaultNavigationStrategyId);
1612
- if (strategy) {
1613
- logger.debug(`Surfacing strategy ${strategy.name} from course config`);
1614
- return strategy;
1615
- }
1616
- } catch (e) {
1617
- logger.warn(
1618
- // @ts-expect-error tmp: defaultNavigationStrategyId property does not yet exist
1619
- `Failed to load strategy '${config.defaultNavigationStrategyId}' specified in course config. Falling back to ELO.`,
1620
- e
1621
- );
1622
- }
3101
+ const allStrategies = await this.getAllNavigationStrategies();
3102
+ if (allStrategies.length === 0) {
3103
+ logger.debug(
3104
+ "[courseDB] No strategy documents found, using default Pipeline(Composite(ELO, SRS), [eloDistanceFilter])"
3105
+ );
3106
+ return this.createDefaultPipeline(user);
1623
3107
  }
1624
- } catch (e) {
1625
- logger.warn(
1626
- "Could not retrieve course config to determine navigation strategy. Falling back to ELO.",
1627
- e
3108
+ const assembler = new PipelineAssembler();
3109
+ const { pipeline, generatorStrategies, filterStrategies, warnings } = await assembler.assemble({
3110
+ strategies: allStrategies,
3111
+ user,
3112
+ course: this
3113
+ });
3114
+ for (const warning of warnings) {
3115
+ logger.warn(`[PipelineAssembler] ${warning}`);
3116
+ }
3117
+ if (!pipeline) {
3118
+ logger.debug("[courseDB] Pipeline assembly failed, using default pipeline");
3119
+ return this.createDefaultPipeline(user);
3120
+ }
3121
+ logger.debug(
3122
+ `[courseDB] Using assembled pipeline with ${generatorStrategies.length} generator(s) and ${filterStrategies.length} filter(s)`
1628
3123
  );
3124
+ return pipeline;
3125
+ } catch (e) {
3126
+ logger.error(`[courseDB] Error creating navigator: ${e}`);
3127
+ throw e;
1629
3128
  }
1630
- logger.warn(`Returning hard-coded default ELO navigator`);
1631
- const ret = {
1632
- _id: "NAVIGATION_STRATEGY-ELO",
3129
+ }
3130
+ makeDefaultEloStrategy() {
3131
+ return {
3132
+ _id: "NAVIGATION_STRATEGY-ELO-default",
1633
3133
  docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
1634
- name: "ELO",
1635
- description: "ELO-based navigation strategy",
3134
+ name: "ELO (default)",
3135
+ description: "Default ELO-based navigation strategy for new cards",
1636
3136
  implementingClass: "elo" /* ELO */,
1637
3137
  course: this.id,
1638
3138
  serializedData: ""
1639
- // serde is a noop for ELO navigator.
1640
3139
  };
1641
- return Promise.resolve(ret);
3140
+ }
3141
+ makeDefaultSrsStrategy() {
3142
+ return {
3143
+ _id: "NAVIGATION_STRATEGY-SRS-default",
3144
+ docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
3145
+ name: "SRS (default)",
3146
+ description: "Default SRS-based navigation strategy for reviews",
3147
+ implementingClass: "srs" /* SRS */,
3148
+ course: this.id,
3149
+ serializedData: ""
3150
+ };
3151
+ }
3152
+ /**
3153
+ * Creates the default navigation pipeline for courses with no configured strategies.
3154
+ *
3155
+ * Default: Pipeline(Composite(ELO, SRS), [eloDistanceFilter])
3156
+ * - ELO generator: scores new cards by skill proximity
3157
+ * - SRS generator: scores reviews by overdueness and interval recency
3158
+ * - ELO distance filter: penalizes cards far from user's current level
3159
+ */
3160
+ createDefaultPipeline(user) {
3161
+ const eloNavigator = new ELONavigator(user, this, this.makeDefaultEloStrategy());
3162
+ const srsNavigator = new SRSNavigator(user, this, this.makeDefaultSrsStrategy());
3163
+ const compositeGenerator = new CompositeGenerator([eloNavigator, srsNavigator]);
3164
+ const eloDistanceFilter = createEloDistanceFilter();
3165
+ return new Pipeline(compositeGenerator, [eloDistanceFilter], user, this);
1642
3166
  }
1643
3167
  ////////////////////////////////////
1644
3168
  // END NavigationStrategyManager implementation
@@ -1649,22 +3173,39 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
1649
3173
  async getNewCards(limit = 99) {
1650
3174
  const u = await this._getCurrentUser();
1651
3175
  try {
1652
- const strategy = await this.surfaceNavigationStrategy();
1653
- const navigator = await ContentNavigator.create(u, this, strategy);
3176
+ const navigator = await this.createNavigator(u);
1654
3177
  return navigator.getNewCards(limit);
1655
3178
  } catch (e) {
1656
- logger.error(`[courseDB] Error surfacing a NavigationStrategy: ${e}`);
3179
+ logger.error(`[courseDB] Error in getNewCards: ${e}`);
1657
3180
  throw e;
1658
3181
  }
1659
3182
  }
1660
3183
  async getPendingReviews() {
1661
3184
  const u = await this._getCurrentUser();
1662
3185
  try {
1663
- const strategy = await this.surfaceNavigationStrategy();
1664
- const navigator = await ContentNavigator.create(u, this, strategy);
3186
+ const navigator = await this.createNavigator(u);
1665
3187
  return navigator.getPendingReviews();
1666
3188
  } catch (e) {
1667
- logger.error(`[courseDB] Error surfacing a NavigationStrategy: ${e}`);
3189
+ logger.error(`[courseDB] Error in getPendingReviews: ${e}`);
3190
+ throw e;
3191
+ }
3192
+ }
3193
+ /**
3194
+ * Get cards with suitability scores for presentation.
3195
+ *
3196
+ * This is the PRIMARY API for content sources going forward. Delegates to the
3197
+ * course's configured NavigationStrategy to get scored candidates.
3198
+ *
3199
+ * @param limit - Maximum number of cards to return
3200
+ * @returns Cards sorted by score descending
3201
+ */
3202
+ async getWeightedCards(limit) {
3203
+ const u = await this._getCurrentUser();
3204
+ try {
3205
+ const navigator = await this.createNavigator(u);
3206
+ return navigator.getWeightedCards(limit);
3207
+ } catch (e) {
3208
+ logger.error(`[courseDB] Error getting weighted cards: ${e}`);
1668
3209
  throw e;
1669
3210
  }
1670
3211
  }
@@ -1680,7 +3221,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
1680
3221
  const courseDoc = (await u.getCourseRegistrationsDoc()).courses.find((c) => {
1681
3222
  return c.courseID === this.id;
1682
3223
  });
1683
- targetElo = (0, import_common5.EloToNumber)(courseDoc.elo);
3224
+ targetElo = (0, import_common9.EloToNumber)(courseDoc.elo);
1684
3225
  } catch {
1685
3226
  targetElo = 1e3;
1686
3227
  }
@@ -1804,13 +3345,13 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
1804
3345
  });
1805
3346
 
1806
3347
  // src/impl/couch/classroomDB.ts
1807
- var import_moment3, classroomLookupDBTitle, CLASSROOM_CONFIG, ClassroomDBBase, StudentClassroomDB, TeacherClassroomDB, ClassroomLookupDB;
3348
+ var import_moment4, classroomLookupDBTitle, CLASSROOM_CONFIG, ClassroomDBBase, StudentClassroomDB, TeacherClassroomDB, ClassroomLookupDB;
1808
3349
  var init_classroomDB2 = __esm({
1809
3350
  "src/impl/couch/classroomDB.ts"() {
1810
3351
  "use strict";
1811
3352
  init_factory();
1812
3353
  init_logger();
1813
- import_moment3 = __toESM(require("moment"));
3354
+ import_moment4 = __toESM(require("moment"), 1);
1814
3355
  init_pouchdb_setup();
1815
3356
  init_couch();
1816
3357
  init_courseDB();
@@ -1905,9 +3446,9 @@ var init_classroomDB2 = __esm({
1905
3446
  }
1906
3447
  async getNewCards() {
1907
3448
  const activeCards = await this._user.getActiveCards();
1908
- const now = import_moment3.default.utc();
3449
+ const now = import_moment4.default.utc();
1909
3450
  const assigned = await this.getAssignedContent();
1910
- const due = assigned.filter((c) => now.isAfter(import_moment3.default.utc(c.activeOn, REVIEW_TIME_FORMAT2)));
3451
+ const due = assigned.filter((c) => now.isAfter(import_moment4.default.utc(c.activeOn, REVIEW_TIME_FORMAT2)));
1911
3452
  logger.info(`Due content: ${JSON.stringify(due)}`);
1912
3453
  let ret = [];
1913
3454
  for (let i = 0; i < due.length; i++) {
@@ -1944,6 +3485,52 @@ var init_classroomDB2 = __esm({
1944
3485
  }
1945
3486
  });
1946
3487
  }
3488
+ /**
3489
+ * Get cards with suitability scores for presentation.
3490
+ *
3491
+ * This implementation wraps the legacy getNewCards/getPendingReviews methods,
3492
+ * assigning score=1.0 to all cards. StudentClassroomDB does not currently
3493
+ * support pluggable navigation strategies.
3494
+ *
3495
+ * @param limit - Maximum number of cards to return
3496
+ * @returns Cards sorted by score descending (all scores = 1.0)
3497
+ */
3498
+ async getWeightedCards(limit) {
3499
+ const [newCards, reviews] = await Promise.all([this.getNewCards(), this.getPendingReviews()]);
3500
+ const weighted = [
3501
+ ...newCards.map((c) => ({
3502
+ cardId: c.cardID,
3503
+ courseId: c.courseID,
3504
+ score: 1,
3505
+ provenance: [
3506
+ {
3507
+ strategy: "classroom",
3508
+ strategyName: "Classroom",
3509
+ strategyId: "CLASSROOM",
3510
+ action: "generated",
3511
+ score: 1,
3512
+ reason: "Classroom legacy getNewCards(), new card"
3513
+ }
3514
+ ]
3515
+ })),
3516
+ ...reviews.map((r) => ({
3517
+ cardId: r.cardID,
3518
+ courseId: r.courseID,
3519
+ score: 1,
3520
+ provenance: [
3521
+ {
3522
+ strategy: "classroom",
3523
+ strategyName: "Classroom",
3524
+ strategyId: "CLASSROOM",
3525
+ action: "generated",
3526
+ score: 1,
3527
+ reason: "Classroom legacy getPendingReviews(), review"
3528
+ }
3529
+ ]
3530
+ }))
3531
+ ];
3532
+ return weighted.slice(0, limit);
3533
+ }
1947
3534
  };
1948
3535
  TeacherClassroomDB = class _TeacherClassroomDB extends ClassroomDBBase {
1949
3536
  _stuDb;
@@ -2000,8 +3587,8 @@ var init_classroomDB2 = __esm({
2000
3587
  type: "tag",
2001
3588
  _id: id,
2002
3589
  assignedBy: content.assignedBy,
2003
- assignedOn: import_moment3.default.utc(),
2004
- activeOn: content.activeOn || import_moment3.default.utc()
3590
+ assignedOn: import_moment4.default.utc(),
3591
+ activeOn: content.activeOn || import_moment4.default.utc()
2005
3592
  });
2006
3593
  } else {
2007
3594
  put = await this._db.put({
@@ -2009,8 +3596,8 @@ var init_classroomDB2 = __esm({
2009
3596
  type: "course",
2010
3597
  _id: id,
2011
3598
  assignedBy: content.assignedBy,
2012
- assignedOn: import_moment3.default.utc(),
2013
- activeOn: content.activeOn || import_moment3.default.utc()
3599
+ assignedOn: import_moment4.default.utc(),
3600
+ activeOn: content.activeOn || import_moment4.default.utc()
2014
3601
  });
2015
3602
  }
2016
3603
  if (put.ok) {
@@ -2140,7 +3727,7 @@ var init_auth = __esm({
2140
3727
  "use strict";
2141
3728
  init_factory();
2142
3729
  init_logger();
2143
- import_cross_fetch = __toESM(require("cross-fetch"));
3730
+ import_cross_fetch = __toESM(require("cross-fetch"), 1);
2144
3731
  }
2145
3732
  });
2146
3733
 
@@ -2149,14 +3736,14 @@ var CouchDBSyncStrategy_exports = {};
2149
3736
  __export(CouchDBSyncStrategy_exports, {
2150
3737
  CouchDBSyncStrategy: () => CouchDBSyncStrategy
2151
3738
  });
2152
- var import_common6, log3, CouchDBSyncStrategy;
3739
+ var import_common10, log3, CouchDBSyncStrategy;
2153
3740
  var init_CouchDBSyncStrategy = __esm({
2154
3741
  "src/impl/couch/CouchDBSyncStrategy.ts"() {
2155
3742
  "use strict";
2156
3743
  init_factory();
2157
3744
  init_types_legacy();
2158
3745
  init_logger();
2159
- import_common6 = require("@vue-skuilder/common");
3746
+ import_common10 = require("@vue-skuilder/common");
2160
3747
  init_common();
2161
3748
  init_pouchdb_setup();
2162
3749
  init_couch();
@@ -2227,32 +3814,32 @@ var init_CouchDBSyncStrategy = __esm({
2227
3814
  }
2228
3815
  }
2229
3816
  return {
2230
- status: import_common6.Status.ok,
3817
+ status: import_common10.Status.ok,
2231
3818
  error: void 0
2232
3819
  };
2233
3820
  } else {
2234
3821
  return {
2235
- status: import_common6.Status.error,
3822
+ status: import_common10.Status.error,
2236
3823
  error: "Failed to log in after account creation"
2237
3824
  };
2238
3825
  }
2239
3826
  } else {
2240
3827
  logger.warn(`Signup not OK: ${JSON.stringify(signupRequest)}`);
2241
3828
  return {
2242
- status: import_common6.Status.error,
3829
+ status: import_common10.Status.error,
2243
3830
  error: "Account creation failed"
2244
3831
  };
2245
3832
  }
2246
3833
  } catch (e) {
2247
3834
  if (e.reason === "Document update conflict.") {
2248
3835
  return {
2249
- status: import_common6.Status.error,
3836
+ status: import_common10.Status.error,
2250
3837
  error: "This username is taken!"
2251
3838
  };
2252
3839
  }
2253
3840
  logger.error(`Error on signup: ${JSON.stringify(e)}`);
2254
3841
  return {
2255
- status: import_common6.Status.error,
3842
+ status: import_common10.Status.error,
2256
3843
  error: e.message || "Unknown error during account creation"
2257
3844
  };
2258
3845
  }
@@ -2426,17 +4013,17 @@ function getStartAndEndKeys2(key) {
2426
4013
  endkey: key + "\uFFF0"
2427
4014
  };
2428
4015
  }
2429
- var import_cross_fetch2, import_moment4, import_process, isBrowser, GUEST_LOCAL_DB, localUserDB, pouchDBincludeCredentialsConfig, REVIEW_TIME_FORMAT2;
4016
+ var import_cross_fetch2, import_moment5, import_process, isBrowser, GUEST_LOCAL_DB, localUserDB, pouchDBincludeCredentialsConfig, REVIEW_TIME_FORMAT2;
2430
4017
  var init_couch = __esm({
2431
4018
  "src/impl/couch/index.ts"() {
2432
4019
  "use strict";
2433
4020
  init_factory();
2434
4021
  init_types_legacy();
2435
- import_cross_fetch2 = __toESM(require("cross-fetch"));
2436
- import_moment4 = __toESM(require("moment"));
4022
+ import_cross_fetch2 = __toESM(require("cross-fetch"), 1);
4023
+ import_moment5 = __toESM(require("moment"), 1);
2437
4024
  init_logger();
2438
4025
  init_pouchdb_setup();
2439
- import_process = __toESM(require("process"));
4026
+ import_process = __toESM(require("process"), 1);
2440
4027
  init_contentSource();
2441
4028
  init_adminDB2();
2442
4029
  init_classroomDB2();
@@ -2631,14 +4218,14 @@ async function dropUserFromClassroom(user, classID) {
2631
4218
  async function getUserClassrooms(user) {
2632
4219
  return getOrCreateClassroomRegistrationsDoc(user);
2633
4220
  }
2634
- var import_common8, import_moment5, log4, BaseUser, userCoursesDoc, userClassroomsDoc;
4221
+ var import_common12, import_moment6, log4, BaseUser, userCoursesDoc, userClassroomsDoc;
2635
4222
  var init_BaseUserDB = __esm({
2636
4223
  "src/impl/common/BaseUserDB.ts"() {
2637
4224
  "use strict";
2638
4225
  init_core();
2639
4226
  init_util();
2640
- import_common8 = require("@vue-skuilder/common");
2641
- import_moment5 = __toESM(require("moment"));
4227
+ import_common12 = require("@vue-skuilder/common");
4228
+ import_moment6 = __toESM(require("moment"), 1);
2642
4229
  init_types_legacy();
2643
4230
  init_logger();
2644
4231
  init_userDBHelpers();
@@ -2687,7 +4274,7 @@ Currently logged-in as ${this._username}.`
2687
4274
  );
2688
4275
  }
2689
4276
  const result = await this.syncStrategy.createAccount(username, password);
2690
- if (result.status === import_common8.Status.ok) {
4277
+ if (result.status === import_common12.Status.ok) {
2691
4278
  log4(`Account created successfully, updating username to ${username}`);
2692
4279
  this._username = username;
2693
4280
  try {
@@ -2729,7 +4316,7 @@ Currently logged-in as ${this._username}.`
2729
4316
  async resetUserData() {
2730
4317
  if (this.syncStrategy.canAuthenticate()) {
2731
4318
  return {
2732
- status: import_common8.Status.error,
4319
+ status: import_common12.Status.error,
2733
4320
  error: "Reset user data is only available for local-only mode. Use logout instead for remote sync."
2734
4321
  };
2735
4322
  }
@@ -2748,11 +4335,11 @@ Currently logged-in as ${this._username}.`
2748
4335
  await localDB.bulkDocs(docsToDelete);
2749
4336
  }
2750
4337
  await this.init();
2751
- return { status: import_common8.Status.ok };
4338
+ return { status: import_common12.Status.ok };
2752
4339
  } catch (error) {
2753
4340
  logger.error("Failed to reset user data:", error);
2754
4341
  return {
2755
- status: import_common8.Status.error,
4342
+ status: import_common12.Status.error,
2756
4343
  error: error instanceof Error ? error.message : "Unknown error during reset"
2757
4344
  };
2758
4345
  }
@@ -2899,7 +4486,7 @@ Currently logged-in as ${this._username}.`
2899
4486
  );
2900
4487
  return reviews.rows.filter((r) => {
2901
4488
  if (r.id.startsWith(DocTypePrefixes["SCHEDULED_CARD" /* SCHEDULED_CARD */])) {
2902
- const date = import_moment5.default.utc(
4489
+ const date = import_moment6.default.utc(
2903
4490
  r.id.substr(DocTypePrefixes["SCHEDULED_CARD" /* SCHEDULED_CARD */].length),
2904
4491
  REVIEW_TIME_FORMAT
2905
4492
  );
@@ -2912,11 +4499,11 @@ Currently logged-in as ${this._username}.`
2912
4499
  }).map((r) => r.doc);
2913
4500
  }
2914
4501
  async getReviewsForcast(daysCount) {
2915
- const time = import_moment5.default.utc().add(daysCount, "days");
4502
+ const time = import_moment6.default.utc().add(daysCount, "days");
2916
4503
  return this.getReviewstoDate(time);
2917
4504
  }
2918
4505
  async getPendingReviews(course_id) {
2919
- const now = import_moment5.default.utc();
4506
+ const now = import_moment6.default.utc();
2920
4507
  return this.getReviewstoDate(now, course_id);
2921
4508
  }
2922
4509
  async getScheduledReviewCount(course_id) {
@@ -3203,7 +4790,7 @@ Currently logged-in as ${this._username}.`
3203
4790
  */
3204
4791
  async putCardRecord(record) {
3205
4792
  const cardHistoryID = getCardHistoryID(record.courseID, record.cardID);
3206
- record.timeStamp = import_moment5.default.utc(record.timeStamp).toString();
4793
+ record.timeStamp = import_moment6.default.utc(record.timeStamp).toString();
3207
4794
  try {
3208
4795
  const cardHistory = await this.update(
3209
4796
  cardHistoryID,
@@ -3219,7 +4806,7 @@ Currently logged-in as ${this._username}.`
3219
4806
  const ret = {
3220
4807
  ...record2
3221
4808
  };
3222
- ret.timeStamp = import_moment5.default.utc(record2.timeStamp);
4809
+ ret.timeStamp = import_moment6.default.utc(record2.timeStamp);
3223
4810
  return ret;
3224
4811
  });
3225
4812
  return cardHistory;
@@ -3925,11 +5512,11 @@ var init_StaticDataUnpacker = __esm({
3925
5512
  });
3926
5513
 
3927
5514
  // src/impl/static/courseDB.ts
3928
- var import_common10, StaticCourseDB;
5515
+ var import_common14, StaticCourseDB;
3929
5516
  var init_courseDB2 = __esm({
3930
5517
  "src/impl/static/courseDB.ts"() {
3931
5518
  "use strict";
3932
- import_common10 = require("@vue-skuilder/common");
5519
+ import_common14 = require("@vue-skuilder/common");
3933
5520
  init_types_legacy();
3934
5521
  init_navigators();
3935
5522
  init_logger();
@@ -4192,7 +5779,7 @@ var init_courseDB2 = __esm({
4192
5779
  }
4193
5780
  async addNote(_codeCourse, _shape, _data, _author, _tags, _uploads, _elo) {
4194
5781
  return {
4195
- status: import_common10.Status.error,
5782
+ status: import_common14.Status.error,
4196
5783
  message: "Cannot add notes in static mode"
4197
5784
  };
4198
5785
  }
@@ -4223,9 +5810,6 @@ var init_courseDB2 = __esm({
4223
5810
  async updateNavigationStrategy(_id, _data) {
4224
5811
  throw new Error("Cannot update navigation strategies in static mode");
4225
5812
  }
4226
- async surfaceNavigationStrategy() {
4227
- return this.getNavigationStrategy("ELO");
4228
- }
4229
5813
  // Study Content Source implementation
4230
5814
  async getPendingReviews() {
4231
5815
  return [];
@@ -4546,6 +6130,213 @@ var init_factory = __esm({
4546
6130
  }
4547
6131
  });
4548
6132
 
6133
+ // src/study/TagFilteredContentSource.ts
6134
+ var import_common18, TagFilteredContentSource;
6135
+ var init_TagFilteredContentSource = __esm({
6136
+ "src/study/TagFilteredContentSource.ts"() {
6137
+ "use strict";
6138
+ import_common18 = require("@vue-skuilder/common");
6139
+ init_courseDB();
6140
+ init_logger();
6141
+ TagFilteredContentSource = class {
6142
+ courseId;
6143
+ filter;
6144
+ user;
6145
+ // Cache resolved card IDs to avoid repeated lookups within a session
6146
+ resolvedCardIds = null;
6147
+ constructor(courseId, filter, user) {
6148
+ this.courseId = courseId;
6149
+ this.filter = filter;
6150
+ this.user = user;
6151
+ logger.info(
6152
+ `[TagFilteredContentSource] Created for course "${courseId}" with filter:`,
6153
+ JSON.stringify(filter)
6154
+ );
6155
+ }
6156
+ /**
6157
+ * Resolves the TagFilter to a set of eligible card IDs.
6158
+ *
6159
+ * - Cards in `include` tags are OR'd together (card needs at least one)
6160
+ * - Cards in `exclude` tags are removed from the result
6161
+ */
6162
+ async resolveFilteredCardIds() {
6163
+ if (this.resolvedCardIds !== null) {
6164
+ return this.resolvedCardIds;
6165
+ }
6166
+ const includedCardIds = /* @__PURE__ */ new Set();
6167
+ if (this.filter.include.length > 0) {
6168
+ for (const tagName of this.filter.include) {
6169
+ try {
6170
+ const tagDoc = await getTag(this.courseId, tagName);
6171
+ tagDoc.taggedCards.forEach((cardId) => includedCardIds.add(cardId));
6172
+ } catch (error) {
6173
+ logger.warn(
6174
+ `[TagFilteredContentSource] Could not resolve tag "${tagName}" for inclusion:`,
6175
+ error
6176
+ );
6177
+ }
6178
+ }
6179
+ }
6180
+ if (includedCardIds.size === 0 && this.filter.include.length > 0) {
6181
+ logger.warn(
6182
+ `[TagFilteredContentSource] No cards found for include tags: ${this.filter.include.join(", ")}`
6183
+ );
6184
+ this.resolvedCardIds = /* @__PURE__ */ new Set();
6185
+ return this.resolvedCardIds;
6186
+ }
6187
+ const excludedCardIds = /* @__PURE__ */ new Set();
6188
+ if (this.filter.exclude.length > 0) {
6189
+ for (const tagName of this.filter.exclude) {
6190
+ try {
6191
+ const tagDoc = await getTag(this.courseId, tagName);
6192
+ tagDoc.taggedCards.forEach((cardId) => excludedCardIds.add(cardId));
6193
+ } catch (error) {
6194
+ logger.warn(
6195
+ `[TagFilteredContentSource] Could not resolve tag "${tagName}" for exclusion:`,
6196
+ error
6197
+ );
6198
+ }
6199
+ }
6200
+ }
6201
+ const finalCardIds = /* @__PURE__ */ new Set();
6202
+ for (const cardId of includedCardIds) {
6203
+ if (!excludedCardIds.has(cardId)) {
6204
+ finalCardIds.add(cardId);
6205
+ }
6206
+ }
6207
+ logger.info(
6208
+ `[TagFilteredContentSource] Resolved ${finalCardIds.size} cards (included: ${includedCardIds.size}, excluded: ${excludedCardIds.size})`
6209
+ );
6210
+ this.resolvedCardIds = finalCardIds;
6211
+ return finalCardIds;
6212
+ }
6213
+ /**
6214
+ * Gets new cards that match the tag filter and are not already active for the user.
6215
+ */
6216
+ async getNewCards(limit) {
6217
+ if (!(0, import_common18.hasActiveFilter)(this.filter)) {
6218
+ logger.warn("[TagFilteredContentSource] getNewCards called with no active filter");
6219
+ return [];
6220
+ }
6221
+ const eligibleCardIds = await this.resolveFilteredCardIds();
6222
+ const activeCards = await this.user.getActiveCards();
6223
+ const activeCardIds = new Set(activeCards.map((c) => c.cardID));
6224
+ const newItems = [];
6225
+ for (const cardId of eligibleCardIds) {
6226
+ if (!activeCardIds.has(cardId)) {
6227
+ newItems.push({
6228
+ courseID: this.courseId,
6229
+ cardID: cardId,
6230
+ contentSourceType: "course",
6231
+ contentSourceID: this.courseId,
6232
+ status: "new"
6233
+ });
6234
+ }
6235
+ if (limit !== void 0 && newItems.length >= limit) {
6236
+ break;
6237
+ }
6238
+ }
6239
+ logger.info(`[TagFilteredContentSource] Found ${newItems.length} new cards matching filter`);
6240
+ return newItems;
6241
+ }
6242
+ /**
6243
+ * Gets pending reviews, filtered to only include cards that match the tag filter.
6244
+ */
6245
+ async getPendingReviews() {
6246
+ if (!(0, import_common18.hasActiveFilter)(this.filter)) {
6247
+ logger.warn("[TagFilteredContentSource] getPendingReviews called with no active filter");
6248
+ return [];
6249
+ }
6250
+ const eligibleCardIds = await this.resolveFilteredCardIds();
6251
+ const allReviews = await this.user.getPendingReviews(this.courseId);
6252
+ const filteredReviews = allReviews.filter((review) => {
6253
+ return eligibleCardIds.has(review.cardId);
6254
+ });
6255
+ logger.info(
6256
+ `[TagFilteredContentSource] Found ${filteredReviews.length} pending reviews matching filter (of ${allReviews.length} total)`
6257
+ );
6258
+ return filteredReviews.map((r) => ({
6259
+ ...r,
6260
+ courseID: r.courseId,
6261
+ cardID: r.cardId,
6262
+ contentSourceType: "course",
6263
+ contentSourceID: this.courseId,
6264
+ reviewID: r._id,
6265
+ status: "review"
6266
+ }));
6267
+ }
6268
+ /**
6269
+ * Get cards with suitability scores for presentation.
6270
+ *
6271
+ * This implementation wraps the legacy getNewCards/getPendingReviews methods,
6272
+ * assigning score=1.0 to all cards. TagFilteredContentSource does not currently
6273
+ * support pluggable navigation strategies - it returns flat-scored candidates.
6274
+ *
6275
+ * @param limit - Maximum number of cards to return
6276
+ * @returns Cards sorted by score descending (all scores = 1.0)
6277
+ */
6278
+ async getWeightedCards(limit) {
6279
+ const [newCards, reviews] = await Promise.all([
6280
+ this.getNewCards(limit),
6281
+ this.getPendingReviews()
6282
+ ]);
6283
+ const weighted = [
6284
+ ...reviews.map((r) => ({
6285
+ cardId: r.cardID,
6286
+ courseId: r.courseID,
6287
+ score: 1,
6288
+ provenance: [
6289
+ {
6290
+ strategy: "tagFilter",
6291
+ strategyName: "Tag Filter",
6292
+ strategyId: "TAG_FILTER",
6293
+ action: "generated",
6294
+ score: 1,
6295
+ reason: `Tag-filtered review (tags: ${this.filter.include.join(", ")})`
6296
+ }
6297
+ ]
6298
+ })),
6299
+ ...newCards.map((c) => ({
6300
+ cardId: c.cardID,
6301
+ courseId: c.courseID,
6302
+ score: 1,
6303
+ provenance: [
6304
+ {
6305
+ strategy: "tagFilter",
6306
+ strategyName: "Tag Filter",
6307
+ strategyId: "TAG_FILTER",
6308
+ action: "generated",
6309
+ score: 1,
6310
+ reason: `Tag-filtered new card (tags: ${this.filter.include.join(", ")})`
6311
+ }
6312
+ ]
6313
+ }))
6314
+ ];
6315
+ return weighted.slice(0, limit);
6316
+ }
6317
+ /**
6318
+ * Clears the cached resolved card IDs.
6319
+ * Call this if the underlying tag data may have changed during a session.
6320
+ */
6321
+ clearCache() {
6322
+ this.resolvedCardIds = null;
6323
+ }
6324
+ /**
6325
+ * Returns the course ID this source is filtering.
6326
+ */
6327
+ getCourseId() {
6328
+ return this.courseId;
6329
+ }
6330
+ /**
6331
+ * Returns the active tag filter.
6332
+ */
6333
+ getFilter() {
6334
+ return this.filter;
6335
+ }
6336
+ };
6337
+ }
6338
+ });
6339
+
4549
6340
  // src/core/interfaces/contentSource.ts
4550
6341
  function isReview(item) {
4551
6342
  const ret = item.status === "review" || item.status === "failed-review" || "reviewID" in item;
@@ -4555,14 +6346,20 @@ async function getStudySource(source, user) {
4555
6346
  if (source.type === "classroom") {
4556
6347
  return await StudentClassroomDB.factory(source.id, user);
4557
6348
  } else {
6349
+ if ((0, import_common19.hasActiveFilter)(source.tagFilter)) {
6350
+ return new TagFilteredContentSource(source.id, source.tagFilter, user);
6351
+ }
4558
6352
  return getDataLayer().getCourseDB(source.id);
4559
6353
  }
4560
6354
  }
6355
+ var import_common19;
4561
6356
  var init_contentSource = __esm({
4562
6357
  "src/core/interfaces/contentSource.ts"() {
4563
6358
  "use strict";
4564
6359
  init_factory();
4565
6360
  init_classroomDB2();
6361
+ import_common19 = require("@vue-skuilder/common");
6362
+ init_TagFilteredContentSource();
4566
6363
  }
4567
6364
  });
4568
6365
 
@@ -4675,7 +6472,7 @@ elo: ${elo}`;
4675
6472
  misc: {}
4676
6473
  } : void 0
4677
6474
  );
4678
- if (result.status === import_common14.Status.ok) {
6475
+ if (result.status === import_common20.Status.ok) {
4679
6476
  return {
4680
6477
  originalText,
4681
6478
  status: "success",
@@ -4719,17 +6516,17 @@ function validateProcessorConfig(config) {
4719
6516
  }
4720
6517
  return { isValid: true };
4721
6518
  }
4722
- var import_common14;
6519
+ var import_common20;
4723
6520
  var init_cardProcessor = __esm({
4724
6521
  "src/core/bulkImport/cardProcessor.ts"() {
4725
6522
  "use strict";
4726
- import_common14 = require("@vue-skuilder/common");
6523
+ import_common20 = require("@vue-skuilder/common");
4727
6524
  init_logger();
4728
6525
  }
4729
6526
  });
4730
6527
 
4731
6528
  // src/core/bulkImport/types.ts
4732
- var init_types = __esm({
6529
+ var init_types3 = __esm({
4733
6530
  "src/core/bulkImport/types.ts"() {
4734
6531
  "use strict";
4735
6532
  }
@@ -4740,7 +6537,7 @@ var init_bulkImport = __esm({
4740
6537
  "src/core/bulkImport/index.ts"() {
4741
6538
  "use strict";
4742
6539
  init_cardProcessor();
4743
- init_types();
6540
+ init_types3();
4744
6541
  }
4745
6542
  });
4746
6543
 
@@ -4771,15 +6568,19 @@ __export(index_exports, {
4771
6568
  GuestUsername: () => GuestUsername,
4772
6569
  Loggable: () => Loggable,
4773
6570
  NOT_SET: () => NOT_SET,
6571
+ NavigatorRole: () => NavigatorRole,
6572
+ NavigatorRoles: () => NavigatorRoles,
4774
6573
  Navigators: () => Navigators,
4775
6574
  SessionController: () => SessionController,
4776
6575
  StaticToCouchDBMigrator: () => StaticToCouchDBMigrator,
6576
+ TagFilteredContentSource: () => TagFilteredContentSource,
4777
6577
  _resetDataLayer: () => _resetDataLayer,
4778
6578
  areQuestionRecords: () => areQuestionRecords,
4779
6579
  docIsDeleted: () => docIsDeleted,
4780
6580
  ensureAppDataDirectory: () => ensureAppDataDirectory,
4781
6581
  getAppDataDirectory: () => getAppDataDirectory,
4782
6582
  getCardHistoryID: () => getCardHistoryID,
6583
+ getCardOrigin: () => getCardOrigin,
4783
6584
  getDataLayer: () => getDataLayer,
4784
6585
  getDbPath: () => getDbPath,
4785
6586
  getLogFilePath: () => getLogFilePath,
@@ -4788,6 +6589,8 @@ __export(index_exports, {
4788
6589
  initializeDataDirectory: () => initializeDataDirectory,
4789
6590
  initializeDataLayer: () => initializeDataLayer,
4790
6591
  initializeTuiLogging: () => initializeTuiLogging,
6592
+ isFilter: () => isFilter,
6593
+ isGenerator: () => isGenerator,
4791
6594
  isQuestionRecord: () => isQuestionRecord,
4792
6595
  isReview: () => isReview,
4793
6596
  log: () => log,
@@ -4805,14 +6608,14 @@ init_core();
4805
6608
  init_courseLookupDB();
4806
6609
 
4807
6610
  // src/study/services/SrsService.ts
4808
- var import_moment7 = __toESM(require("moment"));
6611
+ var import_moment8 = __toESM(require("moment"), 1);
4809
6612
  init_couch();
4810
6613
 
4811
6614
  // src/study/SpacedRepetition.ts
4812
6615
  init_util();
4813
- var import_moment6 = __toESM(require("moment"));
6616
+ var import_moment7 = __toESM(require("moment"), 1);
4814
6617
  init_logger();
4815
- var duration = import_moment6.default.duration;
6618
+ var duration = import_moment7.default.duration;
4816
6619
  function newInterval(user, cardHistory) {
4817
6620
  if (areQuestionRecords(cardHistory)) {
4818
6621
  return newQuestionInterval(user, cardHistory);
@@ -4876,8 +6679,8 @@ function getInitialInterval(cardHistory) {
4876
6679
  return 60 * 60 * 24 * 3;
4877
6680
  }
4878
6681
  function secondsBetween(start, end) {
4879
- start = (0, import_moment6.default)(start);
4880
- end = (0, import_moment6.default)(end);
6682
+ start = (0, import_moment7.default)(start);
6683
+ end = (0, import_moment7.default)(end);
4881
6684
  const ret = duration(end.diff(start)).asSeconds();
4882
6685
  return ret;
4883
6686
  }
@@ -4897,7 +6700,7 @@ var SrsService = class {
4897
6700
  */
4898
6701
  async scheduleReview(history, item) {
4899
6702
  const nextInterval = newInterval(this.user, history);
4900
- const nextReviewTime = import_moment7.default.utc().add(nextInterval, "seconds");
6703
+ const nextReviewTime = import_moment8.default.utc().add(nextInterval, "seconds");
4901
6704
  if (isReview(item)) {
4902
6705
  logger.info(`[SrsService] Removing previously scheduled review for: ${item.cardID}`);
4903
6706
  void this.user.removeScheduledCardReview(item.reviewID);
@@ -4914,7 +6717,7 @@ var SrsService = class {
4914
6717
  };
4915
6718
 
4916
6719
  // src/study/services/EloService.ts
4917
- var import_common15 = require("@vue-skuilder/common");
6720
+ var import_common21 = require("@vue-skuilder/common");
4918
6721
  init_logger();
4919
6722
  var EloService = class {
4920
6723
  dataLayer;
@@ -4937,10 +6740,10 @@ var EloService = class {
4937
6740
  logger.warn(`k value interpretation not currently implemented`);
4938
6741
  }
4939
6742
  const courseDB = this.dataLayer.getCourseDB(currentCard.card.course_id);
4940
- const userElo = (0, import_common15.toCourseElo)(userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo);
6743
+ const userElo = (0, import_common21.toCourseElo)(userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo);
4941
6744
  const cardElo = (await courseDB.getCardEloData([currentCard.card.card_id]))[0];
4942
6745
  if (cardElo && userElo) {
4943
- const eloUpdate = (0, import_common15.adjustCourseScores)(userElo, cardElo, userScore);
6746
+ const eloUpdate = (0, import_common21.adjustCourseScores)(userElo, cardElo, userScore);
4944
6747
  userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo = eloUpdate.userElo;
4945
6748
  const results = await Promise.allSettled([
4946
6749
  this.user.updateUserElo(course_id, eloUpdate.userElo),
@@ -5142,7 +6945,7 @@ var ResponseProcessor = class {
5142
6945
  };
5143
6946
 
5144
6947
  // src/study/services/CardHydrationService.ts
5145
- var import_common16 = require("@vue-skuilder/common");
6948
+ var import_common22 = require("@vue-skuilder/common");
5146
6949
  init_logger();
5147
6950
 
5148
6951
  // src/study/ItemQueue.ts
@@ -5266,8 +7069,8 @@ var CardHydrationService = class {
5266
7069
  } else {
5267
7070
  const courseDB = this.getCourseDB(nextItem.courseID);
5268
7071
  const cardData = await courseDB.getCourseDoc(nextItem.cardID);
5269
- if (!(0, import_common16.isCourseElo)(cardData.elo)) {
5270
- cardData.elo = (0, import_common16.toCourseElo)(cardData.elo);
7072
+ if (!(0, import_common22.isCourseElo)(cardData.elo)) {
7073
+ cardData.elo = (0, import_common22.toCourseElo)(cardData.elo);
5271
7074
  }
5272
7075
  const view = this.getViewComponent(cardData.id_view);
5273
7076
  const dataDocs = await Promise.all(
@@ -5278,7 +7081,7 @@ var CardHydrationService = class {
5278
7081
  })
5279
7082
  )
5280
7083
  );
5281
- const data = dataDocs.map(import_common16.displayableDataToViewData).reverse();
7084
+ const data = dataDocs.map(import_common22.displayableDataToViewData).reverse();
5282
7085
  this.hydratedQ.add(
5283
7086
  {
5284
7087
  item: nextItem,
@@ -6733,6 +8536,7 @@ init_dataDirectory();
6733
8536
  init_tuiLogger();
6734
8537
 
6735
8538
  // src/study/SessionController.ts
8539
+ init_navigators();
6736
8540
  function randomInt(min, max) {
6737
8541
  return Math.floor(Math.random() * (max - min + 1)) + min;
6738
8542
  }
@@ -6835,7 +8639,12 @@ var SessionController = class extends Loggable {
6835
8639
  }
6836
8640
  async prepareSession() {
6837
8641
  try {
6838
- await Promise.all([this.getScheduledReviews(), this.getNewCards()]);
8642
+ const hasWeightedCards = this.sources.some((s) => typeof s.getWeightedCards === "function");
8643
+ if (hasWeightedCards) {
8644
+ await this.getWeightedContent();
8645
+ } else {
8646
+ await Promise.all([this.getScheduledReviews(), this.getNewCards()]);
8647
+ }
6839
8648
  } catch (e) {
6840
8649
  this.error("Error preparing study session:", e);
6841
8650
  }
@@ -6861,6 +8670,9 @@ var SessionController = class extends Loggable {
6861
8670
  * Used by SessionControllerDebug component for runtime inspection.
6862
8671
  */
6863
8672
  getDebugInfo() {
8673
+ const supportsWeightedCards = this.sources.some(
8674
+ (s) => typeof s.getWeightedCards === "function"
8675
+ );
6864
8676
  const extractQueueItems = (queue, limit = 10) => {
6865
8677
  const items = [];
6866
8678
  for (let i = 0; i < Math.min(queue.length, limit); i++) {
@@ -6878,6 +8690,10 @@ var SessionController = class extends Loggable {
6878
8690
  return items;
6879
8691
  };
6880
8692
  return {
8693
+ api: {
8694
+ mode: supportsWeightedCards ? "weighted" : "legacy",
8695
+ description: supportsWeightedCards ? "Using getWeightedCards() API with scored candidates" : "Using legacy getNewCards()/getPendingReviews() API"
8696
+ },
6881
8697
  reviewQueue: {
6882
8698
  length: this.reviewQ.length,
6883
8699
  dequeueCount: this.reviewQ.dequeueCount,
@@ -6900,6 +8716,109 @@ var SessionController = class extends Loggable {
6900
8716
  }
6901
8717
  };
6902
8718
  }
8719
+ /**
8720
+ * Fetch content using the new getWeightedCards API.
8721
+ *
8722
+ * This method uses getWeightedCards() to get scored candidates, then uses the
8723
+ * scores to determine ordering. For reviews, we still need the full ScheduledCard
8724
+ * data from getPendingReviews(), so we fetch both and use scores for ordering.
8725
+ *
8726
+ * The hybrid approach:
8727
+ * 1. Fetch weighted cards to get scoring/ordering information
8728
+ * 2. Fetch full review data via legacy getPendingReviews()
8729
+ * 3. Order reviews by their weighted scores
8730
+ * 4. Add new cards ordered by their weighted scores
8731
+ */
8732
+ async getWeightedContent() {
8733
+ const limit = 20;
8734
+ const allWeighted = [];
8735
+ const allReviews = [];
8736
+ const allNewCards = [];
8737
+ for (const source of this.sources) {
8738
+ try {
8739
+ const reviews = await source.getPendingReviews().catch((error) => {
8740
+ this.error(`Failed to get reviews for source:`, error);
8741
+ return [];
8742
+ });
8743
+ allReviews.push(...reviews);
8744
+ if (typeof source.getWeightedCards === "function") {
8745
+ const weighted = await source.getWeightedCards(limit);
8746
+ allWeighted.push(...weighted);
8747
+ } else {
8748
+ const newCards = await source.getNewCards(limit);
8749
+ allNewCards.push(...newCards);
8750
+ allWeighted.push(
8751
+ ...newCards.map((c) => ({
8752
+ cardId: c.cardID,
8753
+ courseId: c.courseID,
8754
+ score: 1,
8755
+ provenance: [
8756
+ {
8757
+ strategy: "legacy",
8758
+ strategyName: "Legacy Fallback",
8759
+ strategyId: "legacy-fallback",
8760
+ action: "generated",
8761
+ score: 1,
8762
+ reason: "Fallback to legacy getNewCards(), new card"
8763
+ }
8764
+ ]
8765
+ })),
8766
+ ...reviews.map((r) => ({
8767
+ cardId: r.cardID,
8768
+ courseId: r.courseID,
8769
+ score: 1,
8770
+ provenance: [
8771
+ {
8772
+ strategy: "legacy",
8773
+ strategyName: "Legacy Fallback",
8774
+ strategyId: "legacy-fallback",
8775
+ action: "generated",
8776
+ score: 1,
8777
+ reason: "Fallback to legacy getPendingReviews(), review"
8778
+ }
8779
+ ]
8780
+ }))
8781
+ );
8782
+ }
8783
+ } catch (error) {
8784
+ this.error(`Failed to get content from source:`, error);
8785
+ }
8786
+ }
8787
+ const scoreMap = /* @__PURE__ */ new Map();
8788
+ for (const w of allWeighted) {
8789
+ const key = `${w.courseId}::${w.cardId}`;
8790
+ scoreMap.set(key, w.score);
8791
+ }
8792
+ const scoredReviews = allReviews.map((r) => ({
8793
+ review: r,
8794
+ score: scoreMap.get(`${r.courseID}::${r.cardID}`) ?? 1
8795
+ }));
8796
+ scoredReviews.sort((a, b) => b.score - a.score);
8797
+ let report = "Weighted content session created with:\n";
8798
+ for (const { review, score } of scoredReviews) {
8799
+ this.reviewQ.add(review, review.cardID);
8800
+ report += `Review: ${review.courseID}::${review.cardID} (score: ${score.toFixed(2)})
8801
+ `;
8802
+ }
8803
+ const newCardWeighted = allWeighted.filter((w) => getCardOrigin(w) === "new").sort((a, b) => b.score - a.score);
8804
+ for (const card of newCardWeighted) {
8805
+ const newItem = {
8806
+ cardID: card.cardId,
8807
+ courseID: card.courseId,
8808
+ contentSourceType: "course",
8809
+ contentSourceID: card.courseId,
8810
+ status: "new"
8811
+ };
8812
+ this.newQ.add(newItem, card.cardId);
8813
+ report += `New: ${card.courseId}::${card.cardId} (score: ${card.score.toFixed(2)})
8814
+ `;
8815
+ }
8816
+ this.log(report);
8817
+ }
8818
+ /**
8819
+ * @deprecated Use getWeightedContent() instead. This method is kept for backward
8820
+ * compatibility with sources that don't support getWeightedCards().
8821
+ */
6903
8822
  async getScheduledReviews() {
6904
8823
  const reviews = await Promise.all(
6905
8824
  this.sources.map(
@@ -6925,6 +8844,10 @@ var SessionController = class extends Loggable {
6925
8844
  report += dueCards.map((card) => `Card ${card.courseID}::${card.cardID} `).join("\n");
6926
8845
  this.log(report);
6927
8846
  }
8847
+ /**
8848
+ * @deprecated Use getWeightedContent() instead. This method is kept for backward
8849
+ * compatibility with sources that don't support getWeightedCards().
8850
+ */
6928
8851
  async getNewCards(n = 10) {
6929
8852
  const perCourse = Math.ceil(n / this.sources.length);
6930
8853
  const newContent = await Promise.all(this.sources.map((c) => c.getNewCards(perCourse)));
@@ -7089,6 +9012,9 @@ var SessionController = class extends Loggable {
7089
9012
  }
7090
9013
  };
7091
9014
 
9015
+ // src/study/index.ts
9016
+ init_TagFilteredContentSource();
9017
+
7092
9018
  // src/index.ts
7093
9019
  init_factory();
7094
9020
  // Annotate the CommonJS export names for ESM import in node:
@@ -7103,15 +9029,19 @@ init_factory();
7103
9029
  GuestUsername,
7104
9030
  Loggable,
7105
9031
  NOT_SET,
9032
+ NavigatorRole,
9033
+ NavigatorRoles,
7106
9034
  Navigators,
7107
9035
  SessionController,
7108
9036
  StaticToCouchDBMigrator,
9037
+ TagFilteredContentSource,
7109
9038
  _resetDataLayer,
7110
9039
  areQuestionRecords,
7111
9040
  docIsDeleted,
7112
9041
  ensureAppDataDirectory,
7113
9042
  getAppDataDirectory,
7114
9043
  getCardHistoryID,
9044
+ getCardOrigin,
7115
9045
  getDataLayer,
7116
9046
  getDbPath,
7117
9047
  getLogFilePath,
@@ -7120,6 +9050,8 @@ init_factory();
7120
9050
  initializeDataDirectory,
7121
9051
  initializeDataLayer,
7122
9052
  initializeTuiLogging,
9053
+ isFilter,
9054
+ isGenerator,
7123
9055
  isQuestionRecord,
7124
9056
  isReview,
7125
9057
  log,