@vue-skuilder/db 0.1.17 → 0.1.20

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 (92) hide show
  1. package/dist/{userDB-BqwxtJ_7.d.mts → classroomDB-CZdMBiTU.d.ts} +427 -104
  2. package/dist/{userDB-DNa0XPtn.d.ts → classroomDB-PxDZTky3.d.cts} +427 -104
  3. package/dist/core/index.d.cts +304 -0
  4. package/dist/core/index.d.ts +237 -25
  5. package/dist/core/index.js +2246 -118
  6. package/dist/core/index.js.map +1 -1
  7. package/dist/core/index.mjs +2235 -114
  8. package/dist/core/index.mjs.map +1 -1
  9. package/dist/{dataLayerProvider-VlngD19_.d.mts → dataLayerProvider-D0MoZMjH.d.cts} +1 -1
  10. package/dist/{dataLayerProvider-BV5iZqt_.d.ts → dataLayerProvider-D8o6ZnKW.d.ts} +1 -1
  11. package/dist/impl/couch/{index.d.mts → index.d.cts} +47 -5
  12. package/dist/impl/couch/index.d.ts +46 -4
  13. package/dist/impl/couch/index.js +2250 -134
  14. package/dist/impl/couch/index.js.map +1 -1
  15. package/dist/impl/couch/index.mjs +2212 -97
  16. package/dist/impl/couch/index.mjs.map +1 -1
  17. package/dist/impl/static/{index.d.mts → index.d.cts} +6 -6
  18. package/dist/impl/static/index.d.ts +5 -5
  19. package/dist/impl/static/index.js +1950 -143
  20. package/dist/impl/static/index.js.map +1 -1
  21. package/dist/impl/static/index.mjs +1922 -117
  22. package/dist/impl/static/index.mjs.map +1 -1
  23. package/dist/{index-Bmll7Xse.d.mts → index-B_j6u5E4.d.cts} +1 -1
  24. package/dist/{index-CD8BZz2k.d.ts → index-Dj0SEgk3.d.ts} +1 -1
  25. package/dist/{index.d.mts → index.d.cts} +97 -13
  26. package/dist/index.d.ts +96 -12
  27. package/dist/index.js +2439 -180
  28. package/dist/index.js.map +1 -1
  29. package/dist/index.mjs +2386 -135
  30. package/dist/index.mjs.map +1 -1
  31. package/dist/pouch/index.js +3 -3
  32. package/dist/{types-Dbp5DaRR.d.mts → types-Bn0itutr.d.cts} +1 -1
  33. package/dist/{types-CewsN87z.d.ts → types-DQaXnuoc.d.ts} +1 -1
  34. package/dist/{types-legacy-6ettoclI.d.ts → types-legacy-DDY4N-Uq.d.cts} +3 -1
  35. package/dist/{types-legacy-6ettoclI.d.mts → types-legacy-DDY4N-Uq.d.ts} +3 -1
  36. package/dist/util/packer/{index.d.mts → index.d.cts} +3 -3
  37. package/dist/util/packer/index.d.ts +3 -3
  38. package/dist/util/packer/index.js.map +1 -1
  39. package/dist/util/packer/index.mjs.map +1 -1
  40. package/docs/brainstorm-navigation-paradigm.md +369 -0
  41. package/docs/navigators-architecture.md +370 -0
  42. package/docs/todo-evolutionary-orchestration.md +310 -0
  43. package/docs/todo-nominal-tag-types.md +121 -0
  44. package/docs/todo-strategy-authoring.md +401 -0
  45. package/eslint.config.mjs +1 -1
  46. package/package.json +9 -4
  47. package/src/core/index.ts +1 -0
  48. package/src/core/interfaces/contentSource.ts +88 -4
  49. package/src/core/interfaces/courseDB.ts +13 -0
  50. package/src/core/interfaces/navigationStrategyManager.ts +0 -5
  51. package/src/core/interfaces/userDB.ts +32 -0
  52. package/src/core/navigators/CompositeGenerator.ts +268 -0
  53. package/src/core/navigators/Pipeline.ts +318 -0
  54. package/src/core/navigators/PipelineAssembler.ts +194 -0
  55. package/src/core/navigators/elo.ts +104 -15
  56. package/src/core/navigators/filters/eloDistance.ts +132 -0
  57. package/src/core/navigators/filters/index.ts +9 -0
  58. package/src/core/navigators/filters/types.ts +115 -0
  59. package/src/core/navigators/filters/userTagPreference.ts +232 -0
  60. package/src/core/navigators/generators/index.ts +2 -0
  61. package/src/core/navigators/generators/types.ts +107 -0
  62. package/src/core/navigators/hardcodedOrder.ts +111 -12
  63. package/src/core/navigators/hierarchyDefinition.ts +266 -0
  64. package/src/core/navigators/index.ts +404 -3
  65. package/src/core/navigators/inferredPreference.ts +107 -0
  66. package/src/core/navigators/interferenceMitigator.ts +355 -0
  67. package/src/core/navigators/relativePriority.ts +255 -0
  68. package/src/core/navigators/srs.ts +195 -0
  69. package/src/core/navigators/userGoal.ts +136 -0
  70. package/src/core/types/strategyState.ts +84 -0
  71. package/src/core/types/types-legacy.ts +2 -0
  72. package/src/impl/common/BaseUserDB.ts +74 -7
  73. package/src/impl/couch/adminDB.ts +1 -2
  74. package/src/impl/couch/classroomDB.ts +51 -0
  75. package/src/impl/couch/courseDB.ts +147 -49
  76. package/src/impl/static/courseDB.ts +11 -4
  77. package/src/study/SessionController.ts +149 -1
  78. package/src/study/TagFilteredContentSource.ts +255 -0
  79. package/src/study/index.ts +1 -0
  80. package/src/util/dataDirectory.test.ts +51 -22
  81. package/src/util/logger.ts +0 -1
  82. package/tests/core/navigators/CompositeGenerator.test.ts +455 -0
  83. package/tests/core/navigators/Pipeline.test.ts +406 -0
  84. package/tests/core/navigators/PipelineAssembler.test.ts +351 -0
  85. package/tests/core/navigators/SRSNavigator.test.ts +344 -0
  86. package/tests/core/navigators/eloDistanceFilter.test.ts +192 -0
  87. package/tests/core/navigators/navigators.test.ts +710 -0
  88. package/tsconfig.json +1 -1
  89. package/vitest.config.ts +29 -0
  90. package/dist/core/index.d.mts +0 -92
  91. /package/dist/{SyncStrategy-CyATpyLQ.d.mts → SyncStrategy-CyATpyLQ.d.cts} +0 -0
  92. /package/dist/pouch/{index.d.mts → index.d.cts} +0 -0
package/dist/index.mjs CHANGED
@@ -100,6 +100,7 @@ var init_types_legacy = __esm({
100
100
  DocType3["SCHEDULED_CARD"] = "SCHEDULED_CARD";
101
101
  DocType3["TAG"] = "TAG";
102
102
  DocType3["NAVIGATION_STRATEGY"] = "NAVIGATION_STRATEGY";
103
+ DocType3["STRATEGY_STATE"] = "STRATEGY_STATE";
103
104
  return DocType3;
104
105
  })(DocType || {});
105
106
  DocTypePrefixes = {
@@ -113,7 +114,8 @@ var init_types_legacy = __esm({
113
114
  ["QUESTION" /* QUESTIONTYPE */]: "QUESTION",
114
115
  ["VIEW" /* VIEW */]: "VIEW",
115
116
  ["PEDAGOGY" /* PEDAGOGY */]: "PEDAGOGY",
116
- ["NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */]: "NAVIGATION_STRATEGY"
117
+ ["NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */]: "NAVIGATION_STRATEGY",
118
+ ["STRATEGY_STATE" /* STRATEGY_STATE */]: "STRATEGY_STATE"
117
119
  };
118
120
  }
119
121
  });
@@ -922,23 +924,518 @@ var init_courseLookupDB = __esm({
922
924
  }
923
925
  });
924
926
 
927
+ // src/core/navigators/CompositeGenerator.ts
928
+ var CompositeGenerator_exports = {};
929
+ __export(CompositeGenerator_exports, {
930
+ AggregationMode: () => AggregationMode,
931
+ default: () => CompositeGenerator
932
+ });
933
+ var AggregationMode, DEFAULT_AGGREGATION_MODE, FREQUENCY_BOOST_FACTOR, CompositeGenerator;
934
+ var init_CompositeGenerator = __esm({
935
+ "src/core/navigators/CompositeGenerator.ts"() {
936
+ "use strict";
937
+ init_navigators();
938
+ init_logger();
939
+ AggregationMode = /* @__PURE__ */ ((AggregationMode2) => {
940
+ AggregationMode2["MAX"] = "max";
941
+ AggregationMode2["AVERAGE"] = "average";
942
+ AggregationMode2["FREQUENCY_BOOST"] = "frequencyBoost";
943
+ return AggregationMode2;
944
+ })(AggregationMode || {});
945
+ DEFAULT_AGGREGATION_MODE = "frequencyBoost" /* FREQUENCY_BOOST */;
946
+ FREQUENCY_BOOST_FACTOR = 0.1;
947
+ CompositeGenerator = class _CompositeGenerator extends ContentNavigator {
948
+ /** Human-readable name for CardGenerator interface */
949
+ name = "Composite Generator";
950
+ generators;
951
+ aggregationMode;
952
+ constructor(generators, aggregationMode = DEFAULT_AGGREGATION_MODE) {
953
+ super();
954
+ this.generators = generators;
955
+ this.aggregationMode = aggregationMode;
956
+ if (generators.length === 0) {
957
+ throw new Error("CompositeGenerator requires at least one generator");
958
+ }
959
+ logger.debug(
960
+ `[CompositeGenerator] Created with ${generators.length} generators, mode: ${aggregationMode}`
961
+ );
962
+ }
963
+ /**
964
+ * Creates a CompositeGenerator from strategy data.
965
+ *
966
+ * This is a convenience factory for use by PipelineAssembler.
967
+ */
968
+ static async fromStrategies(user, course, strategies, aggregationMode = DEFAULT_AGGREGATION_MODE) {
969
+ const generators = await Promise.all(
970
+ strategies.map((s) => ContentNavigator.create(user, course, s))
971
+ );
972
+ return new _CompositeGenerator(generators, aggregationMode);
973
+ }
974
+ /**
975
+ * Get weighted cards from all generators, merge and deduplicate.
976
+ *
977
+ * Cards appearing in multiple generators receive a score boost.
978
+ * Provenance tracks which generators produced each card and how scores were aggregated.
979
+ *
980
+ * This method supports both the legacy signature (limit only) and the
981
+ * CardGenerator interface signature (limit, context).
982
+ *
983
+ * @param limit - Maximum number of cards to return
984
+ * @param context - Optional GeneratorContext passed to child generators
985
+ */
986
+ async getWeightedCards(limit, context) {
987
+ const results = await Promise.all(
988
+ this.generators.map((g) => g.getWeightedCards(limit, context))
989
+ );
990
+ const byCardId = /* @__PURE__ */ new Map();
991
+ for (const cards of results) {
992
+ for (const card of cards) {
993
+ const existing = byCardId.get(card.cardId) || [];
994
+ existing.push(card);
995
+ byCardId.set(card.cardId, existing);
996
+ }
997
+ }
998
+ const merged = [];
999
+ for (const [, cards] of byCardId) {
1000
+ const aggregatedScore = this.aggregateScores(cards);
1001
+ const finalScore = Math.min(1, aggregatedScore);
1002
+ const mergedProvenance = cards.flatMap((c) => c.provenance);
1003
+ const initialScore = cards[0].score;
1004
+ const action = finalScore > initialScore ? "boosted" : finalScore < initialScore ? "penalized" : "passed";
1005
+ const reason = this.buildAggregationReason(cards, finalScore);
1006
+ merged.push({
1007
+ ...cards[0],
1008
+ score: finalScore,
1009
+ provenance: [
1010
+ ...mergedProvenance,
1011
+ {
1012
+ strategy: "composite",
1013
+ strategyName: "Composite Generator",
1014
+ strategyId: "COMPOSITE_GENERATOR",
1015
+ action,
1016
+ score: finalScore,
1017
+ reason
1018
+ }
1019
+ ]
1020
+ });
1021
+ }
1022
+ return merged.sort((a, b) => b.score - a.score).slice(0, limit);
1023
+ }
1024
+ /**
1025
+ * Build human-readable reason for score aggregation.
1026
+ */
1027
+ buildAggregationReason(cards, finalScore) {
1028
+ const count = cards.length;
1029
+ const scores = cards.map((c) => c.score.toFixed(2)).join(", ");
1030
+ if (count === 1) {
1031
+ return `Single generator, score ${finalScore.toFixed(2)}`;
1032
+ }
1033
+ const strategies = cards.map((c) => c.provenance[0]?.strategy || "unknown").join(", ");
1034
+ switch (this.aggregationMode) {
1035
+ case "max" /* MAX */:
1036
+ return `Max of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
1037
+ case "average" /* AVERAGE */:
1038
+ return `Average of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
1039
+ case "frequencyBoost" /* FREQUENCY_BOOST */: {
1040
+ const avg = cards.reduce((sum, c) => sum + c.score, 0) / count;
1041
+ const boost = 1 + FREQUENCY_BOOST_FACTOR * (count - 1);
1042
+ return `Frequency boost from ${count} generators (${strategies}): avg ${avg.toFixed(2)} \xD7 ${boost.toFixed(2)} \u2192 ${finalScore.toFixed(2)}`;
1043
+ }
1044
+ default:
1045
+ return `Aggregated from ${count} generators: ${finalScore.toFixed(2)}`;
1046
+ }
1047
+ }
1048
+ /**
1049
+ * Aggregate scores from multiple generators for the same card.
1050
+ */
1051
+ aggregateScores(cards) {
1052
+ const scores = cards.map((c) => c.score);
1053
+ switch (this.aggregationMode) {
1054
+ case "max" /* MAX */:
1055
+ return Math.max(...scores);
1056
+ case "average" /* AVERAGE */:
1057
+ return scores.reduce((sum, s) => sum + s, 0) / scores.length;
1058
+ case "frequencyBoost" /* FREQUENCY_BOOST */: {
1059
+ const avg = scores.reduce((sum, s) => sum + s, 0) / scores.length;
1060
+ const frequencyBoost = 1 + FREQUENCY_BOOST_FACTOR * (cards.length - 1);
1061
+ return avg * frequencyBoost;
1062
+ }
1063
+ default:
1064
+ return scores[0];
1065
+ }
1066
+ }
1067
+ /**
1068
+ * Get new cards from all generators, merged and deduplicated.
1069
+ */
1070
+ async getNewCards(n) {
1071
+ const legacyGenerators = this.generators.filter(
1072
+ (g) => g instanceof ContentNavigator
1073
+ );
1074
+ const results = await Promise.all(legacyGenerators.map((g) => g.getNewCards(n)));
1075
+ const seen = /* @__PURE__ */ new Set();
1076
+ const merged = [];
1077
+ for (const cards of results) {
1078
+ for (const card of cards) {
1079
+ if (!seen.has(card.cardID)) {
1080
+ seen.add(card.cardID);
1081
+ merged.push(card);
1082
+ }
1083
+ }
1084
+ }
1085
+ return n ? merged.slice(0, n) : merged;
1086
+ }
1087
+ /**
1088
+ * Get pending reviews from all generators, merged and deduplicated.
1089
+ */
1090
+ async getPendingReviews() {
1091
+ const legacyGenerators = this.generators.filter(
1092
+ (g) => g instanceof ContentNavigator
1093
+ );
1094
+ const results = await Promise.all(legacyGenerators.map((g) => g.getPendingReviews()));
1095
+ const seen = /* @__PURE__ */ new Set();
1096
+ const merged = [];
1097
+ for (const reviews of results) {
1098
+ for (const review of reviews) {
1099
+ if (!seen.has(review.cardID)) {
1100
+ seen.add(review.cardID);
1101
+ merged.push(review);
1102
+ }
1103
+ }
1104
+ }
1105
+ return merged;
1106
+ }
1107
+ };
1108
+ }
1109
+ });
1110
+
1111
+ // src/core/navigators/Pipeline.ts
1112
+ var Pipeline_exports = {};
1113
+ __export(Pipeline_exports, {
1114
+ Pipeline: () => Pipeline
1115
+ });
1116
+ import { toCourseElo as toCourseElo2 } from "@vue-skuilder/common";
1117
+ function logPipelineConfig(generator, filters) {
1118
+ const filterList = filters.length > 0 ? "\n - " + filters.map((f) => f.name).join("\n - ") : " none";
1119
+ logger.info(
1120
+ `[Pipeline] Configuration:
1121
+ Generator: ${generator.name}
1122
+ Filters:${filterList}`
1123
+ );
1124
+ }
1125
+ function logTagHydration(cards, tagsByCard) {
1126
+ const totalTags = Array.from(tagsByCard.values()).reduce((sum, tags) => sum + tags.length, 0);
1127
+ const cardsWithTags = Array.from(tagsByCard.values()).filter((tags) => tags.length > 0).length;
1128
+ logger.debug(
1129
+ `[Pipeline] Tag hydration: ${cards.length} cards, ${cardsWithTags} have tags (${totalTags} total tags) - single batch query`
1130
+ );
1131
+ }
1132
+ function logExecutionSummary(generatorName, generatedCount, filterCount, finalCount, topScores) {
1133
+ const scoreDisplay = topScores.length > 0 ? topScores.map((s) => s.toFixed(2)).join(", ") : "none";
1134
+ logger.info(
1135
+ `[Pipeline] Execution: ${generatorName} produced ${generatedCount} \u2192 ${filterCount} filters \u2192 ${finalCount} results (top scores: ${scoreDisplay})`
1136
+ );
1137
+ }
1138
+ function logCardProvenance(cards, maxCards = 3) {
1139
+ const cardsToLog = cards.slice(0, maxCards);
1140
+ logger.debug(`[Pipeline] Provenance for top ${cardsToLog.length} cards:`);
1141
+ for (const card of cardsToLog) {
1142
+ logger.debug(`[Pipeline] ${card.cardId} (final score: ${card.score.toFixed(3)}):`);
1143
+ for (const entry of card.provenance) {
1144
+ const scoreChange = entry.score.toFixed(3);
1145
+ const action = entry.action.padEnd(9);
1146
+ logger.debug(
1147
+ `[Pipeline] ${action} ${scoreChange} - ${entry.strategyName}: ${entry.reason}`
1148
+ );
1149
+ }
1150
+ }
1151
+ }
1152
+ var Pipeline;
1153
+ var init_Pipeline = __esm({
1154
+ "src/core/navigators/Pipeline.ts"() {
1155
+ "use strict";
1156
+ init_navigators();
1157
+ init_logger();
1158
+ Pipeline = class extends ContentNavigator {
1159
+ generator;
1160
+ filters;
1161
+ /**
1162
+ * Create a new pipeline.
1163
+ *
1164
+ * @param generator - The generator (or CompositeGenerator) that produces candidates
1165
+ * @param filters - Filters to apply sequentially (order doesn't matter for multipliers)
1166
+ * @param user - User database interface
1167
+ * @param course - Course database interface
1168
+ */
1169
+ constructor(generator, filters, user, course) {
1170
+ super();
1171
+ this.generator = generator;
1172
+ this.filters = filters;
1173
+ this.user = user;
1174
+ this.course = course;
1175
+ logPipelineConfig(generator, filters);
1176
+ }
1177
+ /**
1178
+ * Get weighted cards by running generator and applying filters.
1179
+ *
1180
+ * 1. Build shared context (user ELO, etc.)
1181
+ * 2. Get candidates from generator (passing context)
1182
+ * 3. Batch hydrate tags for all candidates
1183
+ * 4. Apply each filter sequentially
1184
+ * 5. Remove zero-score cards
1185
+ * 6. Sort by score descending
1186
+ * 7. Return top N
1187
+ *
1188
+ * @param limit - Maximum number of cards to return
1189
+ * @returns Cards sorted by score descending
1190
+ */
1191
+ async getWeightedCards(limit) {
1192
+ const context = await this.buildContext();
1193
+ const overFetchMultiplier = 2 + this.filters.length * 0.5;
1194
+ const fetchLimit = Math.ceil(limit * overFetchMultiplier);
1195
+ logger.debug(
1196
+ `[Pipeline] Fetching ${fetchLimit} candidates from generator '${this.generator.name}'`
1197
+ );
1198
+ let cards = await this.generator.getWeightedCards(fetchLimit, context);
1199
+ const generatedCount = cards.length;
1200
+ logger.debug(`[Pipeline] Generator returned ${generatedCount} candidates`);
1201
+ cards = await this.hydrateTags(cards);
1202
+ for (const filter of this.filters) {
1203
+ const beforeCount = cards.length;
1204
+ cards = await filter.transform(cards, context);
1205
+ logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeCount} \u2192 ${cards.length} cards`);
1206
+ }
1207
+ cards = cards.filter((c) => c.score > 0);
1208
+ cards.sort((a, b) => b.score - a.score);
1209
+ const result = cards.slice(0, limit);
1210
+ const topScores = result.slice(0, 3).map((c) => c.score);
1211
+ logExecutionSummary(this.generator.name, generatedCount, this.filters.length, result.length, topScores);
1212
+ logCardProvenance(result, 3);
1213
+ return result;
1214
+ }
1215
+ /**
1216
+ * Batch hydrate tags for all cards.
1217
+ *
1218
+ * Fetches tags for all cards in a single database query and attaches them
1219
+ * to the WeightedCard objects. Filters can then use card.tags instead of
1220
+ * making individual getAppliedTags() calls.
1221
+ *
1222
+ * @param cards - Cards to hydrate
1223
+ * @returns Cards with tags populated
1224
+ */
1225
+ async hydrateTags(cards) {
1226
+ if (cards.length === 0) {
1227
+ return cards;
1228
+ }
1229
+ const cardIds = cards.map((c) => c.cardId);
1230
+ const tagsByCard = await this.course.getAppliedTagsBatch(cardIds);
1231
+ logTagHydration(cards, tagsByCard);
1232
+ return cards.map((card) => ({
1233
+ ...card,
1234
+ tags: tagsByCard.get(card.cardId) ?? []
1235
+ }));
1236
+ }
1237
+ /**
1238
+ * Build shared context for generator and filters.
1239
+ *
1240
+ * Called once per getWeightedCards() invocation.
1241
+ * Contains data that the generator and multiple filters might need.
1242
+ *
1243
+ * The context satisfies both GeneratorContext and FilterContext interfaces.
1244
+ */
1245
+ async buildContext() {
1246
+ let userElo = 1e3;
1247
+ try {
1248
+ const courseReg = await this.user.getCourseRegDoc(this.course.getCourseID());
1249
+ const courseElo = toCourseElo2(courseReg.elo);
1250
+ userElo = courseElo.global.score;
1251
+ } catch (e) {
1252
+ logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`);
1253
+ }
1254
+ return {
1255
+ user: this.user,
1256
+ course: this.course,
1257
+ userElo
1258
+ };
1259
+ }
1260
+ // ===========================================================================
1261
+ // Legacy StudyContentSource methods
1262
+ // ===========================================================================
1263
+ //
1264
+ // These delegate to the generator for backward compatibility.
1265
+ // Eventually SessionController will use getWeightedCards() exclusively.
1266
+ //
1267
+ /**
1268
+ * Get new cards via legacy API.
1269
+ * Delegates to the generator if it supports the legacy interface.
1270
+ */
1271
+ async getNewCards(n) {
1272
+ if ("getNewCards" in this.generator && typeof this.generator.getNewCards === "function") {
1273
+ return this.generator.getNewCards(n);
1274
+ }
1275
+ return [];
1276
+ }
1277
+ /**
1278
+ * Get pending reviews via legacy API.
1279
+ * Delegates to the generator if it supports the legacy interface.
1280
+ */
1281
+ async getPendingReviews() {
1282
+ if ("getPendingReviews" in this.generator && typeof this.generator.getPendingReviews === "function") {
1283
+ return this.generator.getPendingReviews();
1284
+ }
1285
+ return [];
1286
+ }
1287
+ /**
1288
+ * Get the course ID for this pipeline.
1289
+ */
1290
+ getCourseID() {
1291
+ return this.course.getCourseID();
1292
+ }
1293
+ };
1294
+ }
1295
+ });
1296
+
1297
+ // src/core/navigators/PipelineAssembler.ts
1298
+ var PipelineAssembler_exports = {};
1299
+ __export(PipelineAssembler_exports, {
1300
+ PipelineAssembler: () => PipelineAssembler
1301
+ });
1302
+ var PipelineAssembler;
1303
+ var init_PipelineAssembler = __esm({
1304
+ "src/core/navigators/PipelineAssembler.ts"() {
1305
+ "use strict";
1306
+ init_navigators();
1307
+ init_Pipeline();
1308
+ init_types_legacy();
1309
+ init_logger();
1310
+ init_CompositeGenerator();
1311
+ PipelineAssembler = class {
1312
+ /**
1313
+ * Assembles a navigation pipeline from strategy documents.
1314
+ *
1315
+ * 1. Separates into generators and filters by role
1316
+ * 2. Validates at least one generator exists (or creates default ELO)
1317
+ * 3. Instantiates generators - wraps multiple in CompositeGenerator
1318
+ * 4. Instantiates filters
1319
+ * 5. Returns Pipeline(generator, filters)
1320
+ *
1321
+ * @param input - Strategy documents plus user/course interfaces
1322
+ * @returns Assembled pipeline and any warnings
1323
+ */
1324
+ async assemble(input) {
1325
+ const { strategies, user, course } = input;
1326
+ const warnings = [];
1327
+ if (strategies.length === 0) {
1328
+ return {
1329
+ pipeline: null,
1330
+ generatorStrategies: [],
1331
+ filterStrategies: [],
1332
+ warnings
1333
+ };
1334
+ }
1335
+ const generatorStrategies = [];
1336
+ const filterStrategies = [];
1337
+ for (const s of strategies) {
1338
+ if (isGenerator(s.implementingClass)) {
1339
+ generatorStrategies.push(s);
1340
+ } else if (isFilter(s.implementingClass)) {
1341
+ filterStrategies.push(s);
1342
+ } else {
1343
+ warnings.push(`Unknown strategy type '${s.implementingClass}', skipping: ${s.name}`);
1344
+ }
1345
+ }
1346
+ if (generatorStrategies.length === 0) {
1347
+ if (filterStrategies.length > 0) {
1348
+ logger.debug(
1349
+ "[PipelineAssembler] No generator found, using default ELO with configured filters"
1350
+ );
1351
+ generatorStrategies.push(this.makeDefaultEloStrategy(course.getCourseID()));
1352
+ } else {
1353
+ warnings.push("No generator strategy found");
1354
+ return {
1355
+ pipeline: null,
1356
+ generatorStrategies: [],
1357
+ filterStrategies: [],
1358
+ warnings
1359
+ };
1360
+ }
1361
+ }
1362
+ let generator;
1363
+ if (generatorStrategies.length === 1) {
1364
+ const nav = await ContentNavigator.create(user, course, generatorStrategies[0]);
1365
+ generator = nav;
1366
+ logger.debug(`[PipelineAssembler] Using single generator: ${generatorStrategies[0].name}`);
1367
+ } else {
1368
+ logger.debug(
1369
+ `[PipelineAssembler] Using CompositeGenerator for ${generatorStrategies.length} generators: ${generatorStrategies.map((g) => g.name).join(", ")}`
1370
+ );
1371
+ generator = await CompositeGenerator.fromStrategies(user, course, generatorStrategies);
1372
+ }
1373
+ const filters = [];
1374
+ const sortedFilterStrategies = [...filterStrategies].sort(
1375
+ (a, b) => a.name.localeCompare(b.name)
1376
+ );
1377
+ for (const filterStrategy of sortedFilterStrategies) {
1378
+ try {
1379
+ const nav = await ContentNavigator.create(user, course, filterStrategy);
1380
+ if ("transform" in nav && typeof nav.transform === "function") {
1381
+ filters.push(nav);
1382
+ logger.debug(`[PipelineAssembler] Added filter: ${filterStrategy.name}`);
1383
+ } else {
1384
+ warnings.push(
1385
+ `Filter '${filterStrategy.name}' does not implement CardFilter.transform(), skipping`
1386
+ );
1387
+ }
1388
+ } catch (e) {
1389
+ warnings.push(`Failed to instantiate filter '${filterStrategy.name}': ${e}`);
1390
+ }
1391
+ }
1392
+ const pipeline = new Pipeline(generator, filters, user, course);
1393
+ logger.debug(
1394
+ `[PipelineAssembler] Assembled pipeline with ${generatorStrategies.length} generator(s) and ${filters.length} filter(s)`
1395
+ );
1396
+ return {
1397
+ pipeline,
1398
+ generatorStrategies,
1399
+ filterStrategies: sortedFilterStrategies,
1400
+ warnings
1401
+ };
1402
+ }
1403
+ /**
1404
+ * Creates a default ELO generator strategy.
1405
+ * Used when filters are configured but no generator is specified.
1406
+ */
1407
+ makeDefaultEloStrategy(courseId) {
1408
+ return {
1409
+ _id: "NAVIGATION_STRATEGY-ELO-default",
1410
+ course: courseId,
1411
+ docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
1412
+ name: "ELO (default)",
1413
+ description: "Default ELO-based generator",
1414
+ implementingClass: "elo" /* ELO */,
1415
+ serializedData: ""
1416
+ };
1417
+ }
1418
+ };
1419
+ }
1420
+ });
1421
+
925
1422
  // src/core/navigators/elo.ts
926
1423
  var elo_exports = {};
927
1424
  __export(elo_exports, {
928
1425
  default: () => ELONavigator
929
1426
  });
1427
+ import { toCourseElo as toCourseElo3 } from "@vue-skuilder/common";
930
1428
  var ELONavigator;
931
1429
  var init_elo = __esm({
932
1430
  "src/core/navigators/elo.ts"() {
933
1431
  "use strict";
934
1432
  init_navigators();
935
1433
  ELONavigator = class extends ContentNavigator {
936
- user;
937
- course;
938
- constructor(user, course) {
939
- super();
940
- this.user = user;
941
- this.course = course;
1434
+ /** Human-readable name for CardGenerator interface */
1435
+ name;
1436
+ constructor(user, course, strategyData) {
1437
+ super(user, course, strategyData);
1438
+ this.name = strategyData?.name || "ELO";
942
1439
  }
943
1440
  async getPendingReviews() {
944
1441
  const reviews = await this.user.getPendingReviews(this.course.getCourseID());
@@ -984,79 +1481,1125 @@ var init_elo = __esm({
984
1481
  };
985
1482
  });
986
1483
  }
1484
+ /**
1485
+ * Get new cards with suitability scores based on ELO distance.
1486
+ *
1487
+ * Cards closer to user's ELO get higher scores.
1488
+ * Score formula: max(0, 1 - distance / 500)
1489
+ *
1490
+ * NOTE: This generator only handles NEW cards. Reviews are handled by
1491
+ * SRSNavigator. Use CompositeGenerator to combine both.
1492
+ *
1493
+ * This method supports both the legacy signature (limit only) and the
1494
+ * CardGenerator interface signature (limit, context).
1495
+ *
1496
+ * @param limit - Maximum number of cards to return
1497
+ * @param context - Optional GeneratorContext (used when called via Pipeline)
1498
+ */
1499
+ async getWeightedCards(limit, context) {
1500
+ let userGlobalElo;
1501
+ if (context?.userElo !== void 0) {
1502
+ userGlobalElo = context.userElo;
1503
+ } else {
1504
+ const courseReg = await this.user.getCourseRegDoc(this.course.getCourseID());
1505
+ const userElo = toCourseElo3(courseReg.elo);
1506
+ userGlobalElo = userElo.global.score;
1507
+ }
1508
+ const newCards = await this.getNewCards(limit);
1509
+ const cardIds = newCards.map((c) => c.cardID);
1510
+ const cardEloData = await this.course.getCardEloData(cardIds);
1511
+ const scored = newCards.map((c, i) => {
1512
+ const cardElo = cardEloData[i]?.global?.score ?? 1e3;
1513
+ const distance = Math.abs(cardElo - userGlobalElo);
1514
+ const score = Math.max(0, 1 - distance / 500);
1515
+ return {
1516
+ cardId: c.cardID,
1517
+ courseId: c.courseID,
1518
+ score,
1519
+ provenance: [
1520
+ {
1521
+ strategy: "elo",
1522
+ strategyName: this.strategyName || this.name,
1523
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-ELO-default",
1524
+ action: "generated",
1525
+ score,
1526
+ reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userGlobalElo)}), new card`
1527
+ }
1528
+ ]
1529
+ };
1530
+ });
1531
+ scored.sort((a, b) => b.score - a.score);
1532
+ return scored.slice(0, limit);
1533
+ }
987
1534
  };
988
1535
  }
989
1536
  });
990
1537
 
991
- // src/core/navigators/hardcodedOrder.ts
992
- var hardcodedOrder_exports = {};
993
- __export(hardcodedOrder_exports, {
994
- default: () => HardcodedOrderNavigator
1538
+ // src/core/navigators/filters/eloDistance.ts
1539
+ var eloDistance_exports = {};
1540
+ __export(eloDistance_exports, {
1541
+ DEFAULT_HALF_LIFE: () => DEFAULT_HALF_LIFE,
1542
+ DEFAULT_MAX_MULTIPLIER: () => DEFAULT_MAX_MULTIPLIER,
1543
+ DEFAULT_MIN_MULTIPLIER: () => DEFAULT_MIN_MULTIPLIER,
1544
+ createEloDistanceFilter: () => createEloDistanceFilter
995
1545
  });
996
- var HardcodedOrderNavigator;
997
- var init_hardcodedOrder = __esm({
998
- "src/core/navigators/hardcodedOrder.ts"() {
1546
+ function computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier) {
1547
+ const normalizedDistance = distance / halfLife;
1548
+ const decay = Math.exp(-(normalizedDistance * normalizedDistance));
1549
+ return minMultiplier + (maxMultiplier - minMultiplier) * decay;
1550
+ }
1551
+ function createEloDistanceFilter(config) {
1552
+ const halfLife = config?.halfLife ?? DEFAULT_HALF_LIFE;
1553
+ const minMultiplier = config?.minMultiplier ?? DEFAULT_MIN_MULTIPLIER;
1554
+ const maxMultiplier = config?.maxMultiplier ?? DEFAULT_MAX_MULTIPLIER;
1555
+ return {
1556
+ name: "ELO Distance Filter",
1557
+ async transform(cards, context) {
1558
+ const { course, userElo } = context;
1559
+ const cardIds = cards.map((c) => c.cardId);
1560
+ const cardElos = await course.getCardEloData(cardIds);
1561
+ return cards.map((card, i) => {
1562
+ const cardElo = cardElos[i]?.global?.score ?? 1e3;
1563
+ const distance = Math.abs(cardElo - userElo);
1564
+ const multiplier = computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier);
1565
+ const newScore = card.score * multiplier;
1566
+ const action = multiplier < maxMultiplier - 0.01 ? "penalized" : "passed";
1567
+ return {
1568
+ ...card,
1569
+ score: newScore,
1570
+ provenance: [
1571
+ ...card.provenance,
1572
+ {
1573
+ strategy: "eloDistance",
1574
+ strategyName: "ELO Distance Filter",
1575
+ strategyId: "ELO_DISTANCE_FILTER",
1576
+ action,
1577
+ score: newScore,
1578
+ reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userElo)}) \u2192 ${multiplier.toFixed(2)}x`
1579
+ }
1580
+ ]
1581
+ };
1582
+ });
1583
+ }
1584
+ };
1585
+ }
1586
+ var DEFAULT_HALF_LIFE, DEFAULT_MIN_MULTIPLIER, DEFAULT_MAX_MULTIPLIER;
1587
+ var init_eloDistance = __esm({
1588
+ "src/core/navigators/filters/eloDistance.ts"() {
1589
+ "use strict";
1590
+ DEFAULT_HALF_LIFE = 200;
1591
+ DEFAULT_MIN_MULTIPLIER = 0.3;
1592
+ DEFAULT_MAX_MULTIPLIER = 1;
1593
+ }
1594
+ });
1595
+
1596
+ // src/core/navigators/filters/userTagPreference.ts
1597
+ var userTagPreference_exports = {};
1598
+ __export(userTagPreference_exports, {
1599
+ default: () => UserTagPreferenceFilter
1600
+ });
1601
+ var UserTagPreferenceFilter;
1602
+ var init_userTagPreference = __esm({
1603
+ "src/core/navigators/filters/userTagPreference.ts"() {
999
1604
  "use strict";
1000
1605
  init_navigators();
1001
- init_logger();
1002
- HardcodedOrderNavigator = class extends ContentNavigator {
1003
- orderedCardIds = [];
1004
- user;
1005
- course;
1606
+ UserTagPreferenceFilter = class extends ContentNavigator {
1607
+ _strategyData;
1608
+ /** Human-readable name for CardFilter interface */
1609
+ name;
1006
1610
  constructor(user, course, strategyData) {
1007
- super();
1008
- this.user = user;
1009
- this.course = course;
1010
- if (strategyData.serializedData) {
1011
- try {
1012
- this.orderedCardIds = JSON.parse(strategyData.serializedData);
1013
- } catch (e) {
1014
- logger.error("Failed to parse serializedData for HardcodedOrderNavigator", e);
1015
- }
1611
+ super(user, course, strategyData);
1612
+ this._strategyData = strategyData;
1613
+ this.name = strategyData.name || "User Tag Preferences";
1614
+ }
1615
+ /**
1616
+ * Compute multiplier for a card based on its tags and user preferences.
1617
+ * Returns the maximum multiplier among all matching tags, or 1.0 if no matches.
1618
+ */
1619
+ computeMultiplier(cardTags, boostMap) {
1620
+ const multipliers = cardTags.map((tag) => boostMap[tag]).filter((val) => val !== void 0);
1621
+ if (multipliers.length === 0) {
1622
+ return 1;
1016
1623
  }
1624
+ return Math.max(...multipliers);
1017
1625
  }
1018
- async getPendingReviews() {
1019
- const reviews = await this.user.getPendingReviews(this.course.getCourseID());
1020
- return reviews.map((r) => {
1021
- return {
1022
- ...r,
1023
- contentSourceType: "course",
1024
- contentSourceID: this.course.getCourseID(),
1025
- cardID: r.cardId,
1026
- courseID: r.courseId,
1027
- reviewID: r._id,
1028
- status: "review"
1029
- };
1030
- });
1626
+ /**
1627
+ * Build human-readable reason for the filter's decision.
1628
+ */
1629
+ buildReason(cardTags, boostMap, multiplier) {
1630
+ const matchingTags = cardTags.filter((tag) => boostMap[tag] === multiplier);
1631
+ if (multiplier === 0) {
1632
+ return `Excluded by user preference: ${matchingTags.join(", ")} (${multiplier}x)`;
1633
+ }
1634
+ if (multiplier < 1) {
1635
+ return `Penalized by user preference: ${matchingTags.join(", ")} (${multiplier.toFixed(2)}x)`;
1636
+ }
1637
+ if (multiplier > 1) {
1638
+ return `Boosted by user preference: ${matchingTags.join(", ")} (${multiplier.toFixed(2)}x)`;
1639
+ }
1640
+ return "No matching user preferences";
1031
1641
  }
1032
- async getNewCards(limit = 99) {
1033
- const activeCardIds = (await this.user.getActiveCards()).map((c) => c.cardID);
1034
- const newCardIds = this.orderedCardIds.filter(
1035
- (cardId) => !activeCardIds.includes(cardId)
1036
- );
1037
- const cardsToReturn = newCardIds.slice(0, limit);
1038
- return cardsToReturn.map((cardId) => {
1039
- return {
1040
- cardID: cardId,
1041
- courseID: this.course.getCourseID(),
1042
- contentSourceType: "course",
1043
- contentSourceID: this.course.getCourseID(),
1642
+ /**
1643
+ * CardFilter.transform implementation.
1644
+ *
1645
+ * Apply user tag preferences:
1646
+ * 1. Read preferences from strategy state
1647
+ * 2. If no preferences, pass through unchanged
1648
+ * 3. For each card:
1649
+ * - Look up tag in boost record
1650
+ * - If tag found: apply multiplier (0 = exclude, 1 = neutral, >1 = boost)
1651
+ * - If multiple tags match: use max multiplier
1652
+ * - Append provenance with clear reason
1653
+ */
1654
+ async transform(cards, _context) {
1655
+ const prefs = await this.getStrategyState();
1656
+ if (!prefs || Object.keys(prefs.boost).length === 0) {
1657
+ return cards.map((card) => ({
1658
+ ...card,
1659
+ provenance: [
1660
+ ...card.provenance,
1661
+ {
1662
+ strategy: "userTagPreference",
1663
+ strategyName: this.strategyName || this.name,
1664
+ strategyId: this.strategyId || this._strategyData._id,
1665
+ action: "passed",
1666
+ score: card.score,
1667
+ reason: "No user tag preferences configured"
1668
+ }
1669
+ ]
1670
+ }));
1671
+ }
1672
+ const adjusted = await Promise.all(
1673
+ cards.map(async (card) => {
1674
+ const cardTags = card.tags ?? [];
1675
+ const multiplier = this.computeMultiplier(cardTags, prefs.boost);
1676
+ const finalScore = Math.min(1, card.score * multiplier);
1677
+ let action;
1678
+ if (multiplier === 0 || multiplier < 1) {
1679
+ action = "penalized";
1680
+ } else if (multiplier > 1) {
1681
+ action = "boosted";
1682
+ } else {
1683
+ action = "passed";
1684
+ }
1685
+ return {
1686
+ ...card,
1687
+ score: finalScore,
1688
+ provenance: [
1689
+ ...card.provenance,
1690
+ {
1691
+ strategy: "userTagPreference",
1692
+ strategyName: this.strategyName || this.name,
1693
+ strategyId: this.strategyId || this._strategyData._id,
1694
+ action,
1695
+ score: finalScore,
1696
+ reason: this.buildReason(cardTags, prefs.boost, multiplier)
1697
+ }
1698
+ ]
1699
+ };
1700
+ })
1701
+ );
1702
+ return adjusted;
1703
+ }
1704
+ /**
1705
+ * Legacy getWeightedCards - throws as filters should not be used as generators.
1706
+ */
1707
+ async getWeightedCards(_limit) {
1708
+ throw new Error(
1709
+ "UserTagPreferenceFilter is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
1710
+ );
1711
+ }
1712
+ // Legacy methods - stub implementations since filters don't generate cards
1713
+ async getNewCards(_n) {
1714
+ return [];
1715
+ }
1716
+ async getPendingReviews() {
1717
+ return [];
1718
+ }
1719
+ };
1720
+ }
1721
+ });
1722
+
1723
+ // src/core/navigators/filters/index.ts
1724
+ var filters_exports = {};
1725
+ __export(filters_exports, {
1726
+ UserTagPreferenceFilter: () => UserTagPreferenceFilter,
1727
+ createEloDistanceFilter: () => createEloDistanceFilter
1728
+ });
1729
+ var init_filters = __esm({
1730
+ "src/core/navigators/filters/index.ts"() {
1731
+ "use strict";
1732
+ init_eloDistance();
1733
+ init_userTagPreference();
1734
+ }
1735
+ });
1736
+
1737
+ // src/core/navigators/filters/types.ts
1738
+ var types_exports = {};
1739
+ var init_types = __esm({
1740
+ "src/core/navigators/filters/types.ts"() {
1741
+ "use strict";
1742
+ }
1743
+ });
1744
+
1745
+ // src/core/navigators/generators/index.ts
1746
+ var generators_exports = {};
1747
+ var init_generators = __esm({
1748
+ "src/core/navigators/generators/index.ts"() {
1749
+ "use strict";
1750
+ }
1751
+ });
1752
+
1753
+ // src/core/navigators/generators/types.ts
1754
+ var types_exports2 = {};
1755
+ var init_types2 = __esm({
1756
+ "src/core/navigators/generators/types.ts"() {
1757
+ "use strict";
1758
+ }
1759
+ });
1760
+
1761
+ // src/core/navigators/hardcodedOrder.ts
1762
+ var hardcodedOrder_exports = {};
1763
+ __export(hardcodedOrder_exports, {
1764
+ default: () => HardcodedOrderNavigator
1765
+ });
1766
+ var HardcodedOrderNavigator;
1767
+ var init_hardcodedOrder = __esm({
1768
+ "src/core/navigators/hardcodedOrder.ts"() {
1769
+ "use strict";
1770
+ init_navigators();
1771
+ init_logger();
1772
+ HardcodedOrderNavigator = class extends ContentNavigator {
1773
+ /** Human-readable name for CardGenerator interface */
1774
+ name;
1775
+ orderedCardIds = [];
1776
+ constructor(user, course, strategyData) {
1777
+ super(user, course, strategyData);
1778
+ this.name = strategyData.name || "Hardcoded Order";
1779
+ if (strategyData.serializedData) {
1780
+ try {
1781
+ this.orderedCardIds = JSON.parse(strategyData.serializedData);
1782
+ } catch (e) {
1783
+ logger.error("Failed to parse serializedData for HardcodedOrderNavigator", e);
1784
+ }
1785
+ }
1786
+ }
1787
+ async getPendingReviews() {
1788
+ const reviews = await this.user.getPendingReviews(this.course.getCourseID());
1789
+ return reviews.map((r) => {
1790
+ return {
1791
+ ...r,
1792
+ contentSourceType: "course",
1793
+ contentSourceID: this.course.getCourseID(),
1794
+ cardID: r.cardId,
1795
+ courseID: r.courseId,
1796
+ reviewID: r._id,
1797
+ status: "review"
1798
+ };
1799
+ });
1800
+ }
1801
+ async getNewCards(limit = 99) {
1802
+ const activeCardIds = (await this.user.getActiveCards()).map((c) => c.cardID);
1803
+ const newCardIds = this.orderedCardIds.filter((cardId) => !activeCardIds.includes(cardId));
1804
+ const cardsToReturn = newCardIds.slice(0, limit);
1805
+ return cardsToReturn.map((cardId) => {
1806
+ return {
1807
+ cardID: cardId,
1808
+ courseID: this.course.getCourseID(),
1809
+ contentSourceType: "course",
1810
+ contentSourceID: this.course.getCourseID(),
1044
1811
  status: "new"
1045
1812
  };
1046
1813
  });
1047
1814
  }
1815
+ /**
1816
+ * Get cards in hardcoded order with scores based on position.
1817
+ *
1818
+ * Earlier cards in the sequence get higher scores.
1819
+ * Score formula: 1.0 - (position / totalCards) * 0.5
1820
+ * This ensures scores range from 1.0 (first card) to 0.5+ (last card).
1821
+ *
1822
+ * This method supports both the legacy signature (limit only) and the
1823
+ * CardGenerator interface signature (limit, context).
1824
+ *
1825
+ * @param limit - Maximum number of cards to return
1826
+ * @param _context - Optional GeneratorContext (currently unused, but required for interface)
1827
+ */
1828
+ async getWeightedCards(limit, _context) {
1829
+ const activeCardIds = (await this.user.getActiveCards()).map((c) => c.cardID);
1830
+ const reviews = await this.getPendingReviews();
1831
+ const newCardIds = this.orderedCardIds.filter((cardId) => !activeCardIds.includes(cardId));
1832
+ const totalCards = newCardIds.length;
1833
+ const scoredNew = newCardIds.slice(0, limit).map((cardId, index) => {
1834
+ const position = index + 1;
1835
+ const score = Math.max(0.5, 1 - index / totalCards * 0.5);
1836
+ return {
1837
+ cardId,
1838
+ courseId: this.course.getCourseID(),
1839
+ score,
1840
+ provenance: [
1841
+ {
1842
+ strategy: "hardcodedOrder",
1843
+ strategyName: this.strategyName || this.name,
1844
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-hardcoded",
1845
+ action: "generated",
1846
+ score,
1847
+ reason: `Position ${position} of ${totalCards} in fixed sequence, new card`
1848
+ }
1849
+ ]
1850
+ };
1851
+ });
1852
+ const scoredReviews = reviews.map((r) => ({
1853
+ cardId: r.cardID,
1854
+ courseId: r.courseID,
1855
+ score: 1,
1856
+ provenance: [
1857
+ {
1858
+ strategy: "hardcodedOrder",
1859
+ strategyName: this.strategyName || this.name,
1860
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-hardcoded",
1861
+ action: "generated",
1862
+ score: 1,
1863
+ reason: "Scheduled review, highest priority"
1864
+ }
1865
+ ]
1866
+ }));
1867
+ const all = [...scoredReviews, ...scoredNew];
1868
+ all.sort((a, b) => b.score - a.score);
1869
+ return all.slice(0, limit);
1870
+ }
1871
+ };
1872
+ }
1873
+ });
1874
+
1875
+ // src/core/navigators/hierarchyDefinition.ts
1876
+ var hierarchyDefinition_exports = {};
1877
+ __export(hierarchyDefinition_exports, {
1878
+ default: () => HierarchyDefinitionNavigator
1879
+ });
1880
+ import { toCourseElo as toCourseElo4 } from "@vue-skuilder/common";
1881
+ var DEFAULT_MIN_COUNT, HierarchyDefinitionNavigator;
1882
+ var init_hierarchyDefinition = __esm({
1883
+ "src/core/navigators/hierarchyDefinition.ts"() {
1884
+ "use strict";
1885
+ init_navigators();
1886
+ DEFAULT_MIN_COUNT = 3;
1887
+ HierarchyDefinitionNavigator = class extends ContentNavigator {
1888
+ config;
1889
+ _strategyData;
1890
+ /** Human-readable name for CardFilter interface */
1891
+ name;
1892
+ constructor(user, course, _strategyData) {
1893
+ super(user, course, _strategyData);
1894
+ this._strategyData = _strategyData;
1895
+ this.config = this.parseConfig(_strategyData.serializedData);
1896
+ this.name = _strategyData.name || "Hierarchy Definition";
1897
+ }
1898
+ parseConfig(serializedData) {
1899
+ try {
1900
+ const parsed = JSON.parse(serializedData);
1901
+ return {
1902
+ prerequisites: parsed.prerequisites || {}
1903
+ };
1904
+ } catch {
1905
+ return {
1906
+ prerequisites: {}
1907
+ };
1908
+ }
1909
+ }
1910
+ /**
1911
+ * Check if a specific prerequisite is satisfied
1912
+ */
1913
+ isPrerequisiteMet(prereq, userTagElo, userGlobalElo) {
1914
+ if (!userTagElo) return false;
1915
+ const minCount = prereq.masteryThreshold?.minCount ?? DEFAULT_MIN_COUNT;
1916
+ if (userTagElo.count < minCount) return false;
1917
+ if (prereq.masteryThreshold?.minElo !== void 0) {
1918
+ return userTagElo.score >= prereq.masteryThreshold.minElo;
1919
+ } else {
1920
+ return userTagElo.score >= userGlobalElo;
1921
+ }
1922
+ }
1923
+ /**
1924
+ * Get the set of tags the user has mastered.
1925
+ * A tag is "mastered" if it appears as a prerequisite somewhere and meets its threshold.
1926
+ */
1927
+ async getMasteredTags(context) {
1928
+ const mastered = /* @__PURE__ */ new Set();
1929
+ try {
1930
+ const courseReg = await context.user.getCourseRegDoc(context.course.getCourseID());
1931
+ const userElo = toCourseElo4(courseReg.elo);
1932
+ for (const prereqs of Object.values(this.config.prerequisites)) {
1933
+ for (const prereq of prereqs) {
1934
+ const tagElo = userElo.tags[prereq.tag];
1935
+ if (this.isPrerequisiteMet(prereq, tagElo, userElo.global.score)) {
1936
+ mastered.add(prereq.tag);
1937
+ }
1938
+ }
1939
+ }
1940
+ } catch {
1941
+ }
1942
+ return mastered;
1943
+ }
1944
+ /**
1945
+ * Get the set of tags that are unlocked (prerequisites met)
1946
+ */
1947
+ getUnlockedTags(masteredTags) {
1948
+ const unlocked = /* @__PURE__ */ new Set();
1949
+ for (const [tagId, prereqs] of Object.entries(this.config.prerequisites)) {
1950
+ const allPrereqsMet = prereqs.every((prereq) => masteredTags.has(prereq.tag));
1951
+ if (allPrereqsMet) {
1952
+ unlocked.add(tagId);
1953
+ }
1954
+ }
1955
+ return unlocked;
1956
+ }
1957
+ /**
1958
+ * Check if a tag has prerequisites defined in config
1959
+ */
1960
+ hasPrerequisites(tagId) {
1961
+ return tagId in this.config.prerequisites;
1962
+ }
1963
+ /**
1964
+ * Check if a card is unlocked and generate reason.
1965
+ */
1966
+ async checkCardUnlock(card, course, unlockedTags, masteredTags) {
1967
+ try {
1968
+ const cardTags = card.tags ?? [];
1969
+ const lockedTags = cardTags.filter(
1970
+ (tag) => this.hasPrerequisites(tag) && !unlockedTags.has(tag)
1971
+ );
1972
+ if (lockedTags.length === 0) {
1973
+ const tagList = cardTags.length > 0 ? cardTags.join(", ") : "none";
1974
+ return {
1975
+ isUnlocked: true,
1976
+ reason: `Prerequisites met, tags: ${tagList}`
1977
+ };
1978
+ }
1979
+ const missingPrereqs = lockedTags.flatMap((tag) => {
1980
+ const prereqs = this.config.prerequisites[tag] || [];
1981
+ return prereqs.filter((p) => !masteredTags.has(p.tag)).map((p) => p.tag);
1982
+ });
1983
+ return {
1984
+ isUnlocked: false,
1985
+ reason: `Blocked: missing prerequisites ${missingPrereqs.join(", ")} for tags ${lockedTags.join(", ")}`
1986
+ };
1987
+ } catch {
1988
+ return {
1989
+ isUnlocked: true,
1990
+ reason: "Prerequisites check skipped (tag lookup failed)"
1991
+ };
1992
+ }
1993
+ }
1994
+ /**
1995
+ * CardFilter.transform implementation.
1996
+ *
1997
+ * Apply prerequisite gating to cards. Cards with locked tags receive score: 0.
1998
+ */
1999
+ async transform(cards, context) {
2000
+ const masteredTags = await this.getMasteredTags(context);
2001
+ const unlockedTags = this.getUnlockedTags(masteredTags);
2002
+ const gated = [];
2003
+ for (const card of cards) {
2004
+ const { isUnlocked, reason } = await this.checkCardUnlock(
2005
+ card,
2006
+ context.course,
2007
+ unlockedTags,
2008
+ masteredTags
2009
+ );
2010
+ const finalScore = isUnlocked ? card.score : 0;
2011
+ const action = isUnlocked ? "passed" : "penalized";
2012
+ gated.push({
2013
+ ...card,
2014
+ score: finalScore,
2015
+ provenance: [
2016
+ ...card.provenance,
2017
+ {
2018
+ strategy: "hierarchyDefinition",
2019
+ strategyName: this.strategyName || this.name,
2020
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-hierarchy",
2021
+ action,
2022
+ score: finalScore,
2023
+ reason
2024
+ }
2025
+ ]
2026
+ });
2027
+ }
2028
+ return gated;
2029
+ }
2030
+ /**
2031
+ * Legacy getWeightedCards - now throws as filters should not be used as generators.
2032
+ *
2033
+ * Use transform() via Pipeline instead.
2034
+ */
2035
+ async getWeightedCards(_limit) {
2036
+ throw new Error(
2037
+ "HierarchyDefinitionNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
2038
+ );
2039
+ }
2040
+ // Legacy methods - stub implementations since filters don't generate cards
2041
+ async getNewCards(_n) {
2042
+ return [];
2043
+ }
2044
+ async getPendingReviews() {
2045
+ return [];
2046
+ }
2047
+ };
2048
+ }
2049
+ });
2050
+
2051
+ // src/core/navigators/inferredPreference.ts
2052
+ var inferredPreference_exports = {};
2053
+ __export(inferredPreference_exports, {
2054
+ INFERRED_PREFERENCE_NAVIGATOR_STUB: () => INFERRED_PREFERENCE_NAVIGATOR_STUB
2055
+ });
2056
+ var INFERRED_PREFERENCE_NAVIGATOR_STUB;
2057
+ var init_inferredPreference = __esm({
2058
+ "src/core/navigators/inferredPreference.ts"() {
2059
+ "use strict";
2060
+ INFERRED_PREFERENCE_NAVIGATOR_STUB = true;
2061
+ }
2062
+ });
2063
+
2064
+ // src/core/navigators/interferenceMitigator.ts
2065
+ var interferenceMitigator_exports = {};
2066
+ __export(interferenceMitigator_exports, {
2067
+ default: () => InterferenceMitigatorNavigator
2068
+ });
2069
+ import { toCourseElo as toCourseElo5 } from "@vue-skuilder/common";
2070
+ var DEFAULT_MIN_COUNT2, DEFAULT_MIN_ELAPSED_DAYS, DEFAULT_INTERFERENCE_DECAY, InterferenceMitigatorNavigator;
2071
+ var init_interferenceMitigator = __esm({
2072
+ "src/core/navigators/interferenceMitigator.ts"() {
2073
+ "use strict";
2074
+ init_navigators();
2075
+ DEFAULT_MIN_COUNT2 = 10;
2076
+ DEFAULT_MIN_ELAPSED_DAYS = 3;
2077
+ DEFAULT_INTERFERENCE_DECAY = 0.8;
2078
+ InterferenceMitigatorNavigator = class extends ContentNavigator {
2079
+ config;
2080
+ _strategyData;
2081
+ /** Human-readable name for CardFilter interface */
2082
+ name;
2083
+ /** Precomputed map: tag -> set of { partner, decay } it interferes with */
2084
+ interferenceMap;
2085
+ constructor(user, course, _strategyData) {
2086
+ super(user, course, _strategyData);
2087
+ this._strategyData = _strategyData;
2088
+ this.config = this.parseConfig(_strategyData.serializedData);
2089
+ this.interferenceMap = this.buildInterferenceMap();
2090
+ this.name = _strategyData.name || "Interference Mitigator";
2091
+ }
2092
+ parseConfig(serializedData) {
2093
+ try {
2094
+ const parsed = JSON.parse(serializedData);
2095
+ let sets = parsed.interferenceSets || [];
2096
+ if (sets.length > 0 && Array.isArray(sets[0])) {
2097
+ sets = sets.map((tags) => ({ tags }));
2098
+ }
2099
+ return {
2100
+ interferenceSets: sets,
2101
+ maturityThreshold: {
2102
+ minCount: parsed.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT2,
2103
+ minElo: parsed.maturityThreshold?.minElo,
2104
+ minElapsedDays: parsed.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS
2105
+ },
2106
+ defaultDecay: parsed.defaultDecay ?? DEFAULT_INTERFERENCE_DECAY
2107
+ };
2108
+ } catch {
2109
+ return {
2110
+ interferenceSets: [],
2111
+ maturityThreshold: {
2112
+ minCount: DEFAULT_MIN_COUNT2,
2113
+ minElapsedDays: DEFAULT_MIN_ELAPSED_DAYS
2114
+ },
2115
+ defaultDecay: DEFAULT_INTERFERENCE_DECAY
2116
+ };
2117
+ }
2118
+ }
2119
+ /**
2120
+ * Build a map from each tag to its interference partners with decay coefficients.
2121
+ * If tags A, B, C are in an interference group with decay 0.8, then:
2122
+ * - A interferes with B (decay 0.8) and C (decay 0.8)
2123
+ * - B interferes with A (decay 0.8) and C (decay 0.8)
2124
+ * - etc.
2125
+ */
2126
+ buildInterferenceMap() {
2127
+ const map = /* @__PURE__ */ new Map();
2128
+ for (const group of this.config.interferenceSets) {
2129
+ const decay = group.decay ?? this.config.defaultDecay ?? DEFAULT_INTERFERENCE_DECAY;
2130
+ for (const tag of group.tags) {
2131
+ if (!map.has(tag)) {
2132
+ map.set(tag, []);
2133
+ }
2134
+ const partners = map.get(tag);
2135
+ for (const other of group.tags) {
2136
+ if (other !== tag) {
2137
+ const existing = partners.find((p) => p.partner === other);
2138
+ if (existing) {
2139
+ existing.decay = Math.max(existing.decay, decay);
2140
+ } else {
2141
+ partners.push({ partner: other, decay });
2142
+ }
2143
+ }
2144
+ }
2145
+ }
2146
+ }
2147
+ return map;
2148
+ }
2149
+ /**
2150
+ * Get the set of tags that are currently immature for this user.
2151
+ * A tag is immature if the user has interacted with it but hasn't
2152
+ * reached the maturity threshold.
2153
+ */
2154
+ async getImmatureTags(context) {
2155
+ const immature = /* @__PURE__ */ new Set();
2156
+ try {
2157
+ const courseReg = await context.user.getCourseRegDoc(context.course.getCourseID());
2158
+ const userElo = toCourseElo5(courseReg.elo);
2159
+ const minCount = this.config.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT2;
2160
+ const minElo = this.config.maturityThreshold?.minElo;
2161
+ const minElapsedDays = this.config.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS;
2162
+ const minCountForElapsed = minElapsedDays * 2;
2163
+ for (const [tagId, tagElo] of Object.entries(userElo.tags)) {
2164
+ if (tagElo.count === 0) continue;
2165
+ const belowCount = tagElo.count < minCount;
2166
+ const belowElo = minElo !== void 0 && tagElo.score < minElo;
2167
+ const belowElapsed = tagElo.count < minCountForElapsed;
2168
+ if (belowCount || belowElo || belowElapsed) {
2169
+ immature.add(tagId);
2170
+ }
2171
+ }
2172
+ } catch {
2173
+ }
2174
+ return immature;
2175
+ }
2176
+ /**
2177
+ * Get all tags that interfere with any immature tag, along with their decay coefficients.
2178
+ * These are the tags we want to avoid introducing.
2179
+ */
2180
+ getTagsToAvoid(immatureTags) {
2181
+ const avoid = /* @__PURE__ */ new Map();
2182
+ for (const immatureTag of immatureTags) {
2183
+ const partners = this.interferenceMap.get(immatureTag);
2184
+ if (partners) {
2185
+ for (const { partner, decay } of partners) {
2186
+ if (!immatureTags.has(partner)) {
2187
+ const existing = avoid.get(partner) ?? 0;
2188
+ avoid.set(partner, Math.max(existing, decay));
2189
+ }
2190
+ }
2191
+ }
2192
+ }
2193
+ return avoid;
2194
+ }
2195
+ /**
2196
+ * Compute interference score reduction for a card.
2197
+ * Returns: { multiplier, interfering tags, reason }
2198
+ */
2199
+ computeInterferenceEffect(cardTags, tagsToAvoid, immatureTags) {
2200
+ if (tagsToAvoid.size === 0) {
2201
+ return {
2202
+ multiplier: 1,
2203
+ interferingTags: [],
2204
+ reason: "No interference detected"
2205
+ };
2206
+ }
2207
+ let multiplier = 1;
2208
+ const interferingTags = [];
2209
+ for (const tag of cardTags) {
2210
+ const decay = tagsToAvoid.get(tag);
2211
+ if (decay !== void 0) {
2212
+ interferingTags.push(tag);
2213
+ multiplier *= 1 - decay;
2214
+ }
2215
+ }
2216
+ if (interferingTags.length === 0) {
2217
+ return {
2218
+ multiplier: 1,
2219
+ interferingTags: [],
2220
+ reason: "No interference detected"
2221
+ };
2222
+ }
2223
+ const causingTags = /* @__PURE__ */ new Set();
2224
+ for (const tag of interferingTags) {
2225
+ for (const immatureTag of immatureTags) {
2226
+ const partners = this.interferenceMap.get(immatureTag);
2227
+ if (partners?.some((p) => p.partner === tag)) {
2228
+ causingTags.add(immatureTag);
2229
+ }
2230
+ }
2231
+ }
2232
+ const reason = `Interferes with immature tags ${Array.from(causingTags).join(", ")} (tags: ${interferingTags.join(", ")}, multiplier: ${multiplier.toFixed(2)})`;
2233
+ return { multiplier, interferingTags, reason };
2234
+ }
2235
+ /**
2236
+ * CardFilter.transform implementation.
2237
+ *
2238
+ * Apply interference-aware scoring. Cards with tags that interfere with
2239
+ * immature learnings get reduced scores.
2240
+ */
2241
+ async transform(cards, context) {
2242
+ const immatureTags = await this.getImmatureTags(context);
2243
+ const tagsToAvoid = this.getTagsToAvoid(immatureTags);
2244
+ const adjusted = [];
2245
+ for (const card of cards) {
2246
+ const cardTags = card.tags ?? [];
2247
+ const { multiplier, reason } = this.computeInterferenceEffect(
2248
+ cardTags,
2249
+ tagsToAvoid,
2250
+ immatureTags
2251
+ );
2252
+ const finalScore = card.score * multiplier;
2253
+ const action = multiplier < 1 ? "penalized" : multiplier > 1 ? "boosted" : "passed";
2254
+ adjusted.push({
2255
+ ...card,
2256
+ score: finalScore,
2257
+ provenance: [
2258
+ ...card.provenance,
2259
+ {
2260
+ strategy: "interferenceMitigator",
2261
+ strategyName: this.strategyName || this.name,
2262
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-interference",
2263
+ action,
2264
+ score: finalScore,
2265
+ reason
2266
+ }
2267
+ ]
2268
+ });
2269
+ }
2270
+ return adjusted;
2271
+ }
2272
+ /**
2273
+ * Legacy getWeightedCards - now throws as filters should not be used as generators.
2274
+ *
2275
+ * Use transform() via Pipeline instead.
2276
+ */
2277
+ async getWeightedCards(_limit) {
2278
+ throw new Error(
2279
+ "InterferenceMitigatorNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
2280
+ );
2281
+ }
2282
+ // Legacy methods - stub implementations since filters don't generate cards
2283
+ async getNewCards(_n) {
2284
+ return [];
2285
+ }
2286
+ async getPendingReviews() {
2287
+ return [];
2288
+ }
2289
+ };
2290
+ }
2291
+ });
2292
+
2293
+ // src/core/navigators/relativePriority.ts
2294
+ var relativePriority_exports = {};
2295
+ __export(relativePriority_exports, {
2296
+ default: () => RelativePriorityNavigator
2297
+ });
2298
+ var DEFAULT_PRIORITY, DEFAULT_PRIORITY_INFLUENCE, DEFAULT_COMBINE_MODE, RelativePriorityNavigator;
2299
+ var init_relativePriority = __esm({
2300
+ "src/core/navigators/relativePriority.ts"() {
2301
+ "use strict";
2302
+ init_navigators();
2303
+ DEFAULT_PRIORITY = 0.5;
2304
+ DEFAULT_PRIORITY_INFLUENCE = 0.5;
2305
+ DEFAULT_COMBINE_MODE = "max";
2306
+ RelativePriorityNavigator = class extends ContentNavigator {
2307
+ config;
2308
+ _strategyData;
2309
+ /** Human-readable name for CardFilter interface */
2310
+ name;
2311
+ constructor(user, course, _strategyData) {
2312
+ super(user, course, _strategyData);
2313
+ this._strategyData = _strategyData;
2314
+ this.config = this.parseConfig(_strategyData.serializedData);
2315
+ this.name = _strategyData.name || "Relative Priority";
2316
+ }
2317
+ parseConfig(serializedData) {
2318
+ try {
2319
+ const parsed = JSON.parse(serializedData);
2320
+ return {
2321
+ tagPriorities: parsed.tagPriorities || {},
2322
+ defaultPriority: parsed.defaultPriority ?? DEFAULT_PRIORITY,
2323
+ combineMode: parsed.combineMode ?? DEFAULT_COMBINE_MODE,
2324
+ priorityInfluence: parsed.priorityInfluence ?? DEFAULT_PRIORITY_INFLUENCE
2325
+ };
2326
+ } catch {
2327
+ return {
2328
+ tagPriorities: {},
2329
+ defaultPriority: DEFAULT_PRIORITY,
2330
+ combineMode: DEFAULT_COMBINE_MODE,
2331
+ priorityInfluence: DEFAULT_PRIORITY_INFLUENCE
2332
+ };
2333
+ }
2334
+ }
2335
+ /**
2336
+ * Look up the priority for a tag.
2337
+ */
2338
+ getTagPriority(tagId) {
2339
+ return this.config.tagPriorities[tagId] ?? this.config.defaultPriority ?? DEFAULT_PRIORITY;
2340
+ }
2341
+ /**
2342
+ * Compute combined priority for a card based on its tags.
2343
+ */
2344
+ computeCardPriority(cardTags) {
2345
+ if (cardTags.length === 0) {
2346
+ return this.config.defaultPriority ?? DEFAULT_PRIORITY;
2347
+ }
2348
+ const priorities = cardTags.map((tag) => this.getTagPriority(tag));
2349
+ switch (this.config.combineMode) {
2350
+ case "max":
2351
+ return Math.max(...priorities);
2352
+ case "min":
2353
+ return Math.min(...priorities);
2354
+ case "average":
2355
+ return priorities.reduce((sum, p) => sum + p, 0) / priorities.length;
2356
+ default:
2357
+ return Math.max(...priorities);
2358
+ }
2359
+ }
2360
+ /**
2361
+ * Compute boost factor based on priority.
2362
+ *
2363
+ * The formula: 1 + (priority - 0.5) * priorityInfluence
2364
+ *
2365
+ * This creates a multiplier centered around 1.0:
2366
+ * - Priority 1.0 with influence 0.5 → 1.25 (25% boost)
2367
+ * - Priority 0.5 with any influence → 1.00 (neutral)
2368
+ * - Priority 0.0 with influence 0.5 → 0.75 (25% reduction)
2369
+ */
2370
+ computeBoostFactor(priority) {
2371
+ const influence = this.config.priorityInfluence ?? DEFAULT_PRIORITY_INFLUENCE;
2372
+ return 1 + (priority - 0.5) * influence;
2373
+ }
2374
+ /**
2375
+ * Build human-readable reason for priority adjustment.
2376
+ */
2377
+ buildPriorityReason(cardTags, priority, boostFactor, finalScore) {
2378
+ if (cardTags.length === 0) {
2379
+ return `No tags, neutral priority (${priority.toFixed(2)})`;
2380
+ }
2381
+ const tagList = cardTags.slice(0, 3).join(", ");
2382
+ const more = cardTags.length > 3 ? ` (+${cardTags.length - 3} more)` : "";
2383
+ if (boostFactor === 1) {
2384
+ return `Neutral priority (${priority.toFixed(2)}) for tags: ${tagList}${more}`;
2385
+ } else if (boostFactor > 1) {
2386
+ return `High-priority tags: ${tagList}${more} (priority ${priority.toFixed(2)} \u2192 boost ${boostFactor.toFixed(2)}x \u2192 ${finalScore.toFixed(2)})`;
2387
+ } else {
2388
+ return `Low-priority tags: ${tagList}${more} (priority ${priority.toFixed(2)} \u2192 reduce ${boostFactor.toFixed(2)}x \u2192 ${finalScore.toFixed(2)})`;
2389
+ }
2390
+ }
2391
+ /**
2392
+ * CardFilter.transform implementation.
2393
+ *
2394
+ * Apply priority-adjusted scoring. Cards with high-priority tags get boosted,
2395
+ * cards with low-priority tags get reduced scores.
2396
+ */
2397
+ async transform(cards, _context) {
2398
+ const adjusted = await Promise.all(
2399
+ cards.map(async (card) => {
2400
+ const cardTags = card.tags ?? [];
2401
+ const priority = this.computeCardPriority(cardTags);
2402
+ const boostFactor = this.computeBoostFactor(priority);
2403
+ const finalScore = Math.max(0, Math.min(1, card.score * boostFactor));
2404
+ const action = boostFactor > 1 ? "boosted" : boostFactor < 1 ? "penalized" : "passed";
2405
+ const reason = this.buildPriorityReason(cardTags, priority, boostFactor, finalScore);
2406
+ return {
2407
+ ...card,
2408
+ score: finalScore,
2409
+ provenance: [
2410
+ ...card.provenance,
2411
+ {
2412
+ strategy: "relativePriority",
2413
+ strategyName: this.strategyName || this.name,
2414
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-priority",
2415
+ action,
2416
+ score: finalScore,
2417
+ reason
2418
+ }
2419
+ ]
2420
+ };
2421
+ })
2422
+ );
2423
+ return adjusted;
2424
+ }
2425
+ /**
2426
+ * Legacy getWeightedCards - now throws as filters should not be used as generators.
2427
+ *
2428
+ * Use transform() via Pipeline instead.
2429
+ */
2430
+ async getWeightedCards(_limit) {
2431
+ throw new Error(
2432
+ "RelativePriorityNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
2433
+ );
2434
+ }
2435
+ // Legacy methods - stub implementations since filters don't generate cards
2436
+ async getNewCards(_n) {
2437
+ return [];
2438
+ }
2439
+ async getPendingReviews() {
2440
+ return [];
2441
+ }
2442
+ };
2443
+ }
2444
+ });
2445
+
2446
+ // src/core/navigators/srs.ts
2447
+ var srs_exports = {};
2448
+ __export(srs_exports, {
2449
+ default: () => SRSNavigator
2450
+ });
2451
+ import moment3 from "moment";
2452
+ var SRSNavigator;
2453
+ var init_srs = __esm({
2454
+ "src/core/navigators/srs.ts"() {
2455
+ "use strict";
2456
+ init_navigators();
2457
+ SRSNavigator = class extends ContentNavigator {
2458
+ /** Human-readable name for CardGenerator interface */
2459
+ name;
2460
+ constructor(user, course, strategyData) {
2461
+ super(user, course, strategyData);
2462
+ this.name = strategyData?.name || "SRS";
2463
+ }
2464
+ /**
2465
+ * Get review cards scored by urgency.
2466
+ *
2467
+ * Score formula combines:
2468
+ * - Relative overdueness: hoursOverdue / intervalHours
2469
+ * - Interval recency: exponential decay favoring shorter intervals
2470
+ *
2471
+ * Cards not yet due are excluded (not scored as 0).
2472
+ *
2473
+ * This method supports both the legacy signature (limit only) and the
2474
+ * CardGenerator interface signature (limit, context).
2475
+ *
2476
+ * @param limit - Maximum number of cards to return
2477
+ * @param _context - Optional GeneratorContext (currently unused, but required for interface)
2478
+ */
2479
+ async getWeightedCards(limit, _context) {
2480
+ if (!this.user || !this.course) {
2481
+ throw new Error("SRSNavigator requires user and course to be set");
2482
+ }
2483
+ const reviews = await this.user.getPendingReviews(this.course.getCourseID());
2484
+ const now = moment3.utc();
2485
+ const dueReviews = reviews.filter((r) => now.isAfter(moment3.utc(r.reviewTime)));
2486
+ const scored = dueReviews.map((review) => {
2487
+ const { score, reason } = this.computeUrgencyScore(review, now);
2488
+ return {
2489
+ cardId: review.cardId,
2490
+ courseId: review.courseId,
2491
+ score,
2492
+ provenance: [
2493
+ {
2494
+ strategy: "srs",
2495
+ strategyName: this.strategyName || this.name,
2496
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-SRS-default",
2497
+ action: "generated",
2498
+ score,
2499
+ reason
2500
+ }
2501
+ ]
2502
+ };
2503
+ });
2504
+ return scored.sort((a, b) => b.score - a.score).slice(0, limit);
2505
+ }
2506
+ /**
2507
+ * Compute urgency score for a review card.
2508
+ *
2509
+ * Two factors:
2510
+ * 1. Relative overdueness = hoursOverdue / intervalHours
2511
+ * - 2 days overdue on 3-day interval = 0.67 (urgent)
2512
+ * - 2 days overdue on 180-day interval = 0.01 (not urgent)
2513
+ *
2514
+ * 2. Interval recency factor = 0.3 + 0.7 * exp(-intervalHours / 720)
2515
+ * - 24h interval → ~1.0 (very recent learning)
2516
+ * - 30 days (720h) → ~0.56
2517
+ * - 180 days → ~0.30
2518
+ *
2519
+ * Combined: base 0.5 + weighted average of factors * 0.45
2520
+ * Result range: approximately 0.5 to 0.95
2521
+ */
2522
+ computeUrgencyScore(review, now) {
2523
+ const scheduledAt = moment3.utc(review.scheduledAt);
2524
+ const due = moment3.utc(review.reviewTime);
2525
+ const intervalHours = Math.max(1, due.diff(scheduledAt, "hours"));
2526
+ const hoursOverdue = now.diff(due, "hours");
2527
+ const relativeOverdue = hoursOverdue / intervalHours;
2528
+ const recencyFactor = 0.3 + 0.7 * Math.exp(-intervalHours / 720);
2529
+ const overdueContribution = Math.min(1, Math.max(0, relativeOverdue));
2530
+ const urgency = overdueContribution * 0.5 + recencyFactor * 0.5;
2531
+ const score = Math.min(0.95, 0.5 + urgency * 0.45);
2532
+ const reason = `${Math.round(hoursOverdue)}h overdue (interval: ${Math.round(intervalHours)}h, relative: ${relativeOverdue.toFixed(2)}), recency: ${recencyFactor.toFixed(2)}, review`;
2533
+ return { score, reason };
2534
+ }
2535
+ /**
2536
+ * Get pending reviews in legacy format.
2537
+ *
2538
+ * Returns all pending reviews for the course, enriched with session item fields.
2539
+ */
2540
+ async getPendingReviews() {
2541
+ if (!this.user || !this.course) {
2542
+ throw new Error("SRSNavigator requires user and course to be set");
2543
+ }
2544
+ const reviews = await this.user.getPendingReviews(this.course.getCourseID());
2545
+ return reviews.map((r) => ({
2546
+ ...r,
2547
+ contentSourceType: "course",
2548
+ contentSourceID: this.course.getCourseID(),
2549
+ cardID: r.cardId,
2550
+ courseID: r.courseId,
2551
+ qualifiedID: `${r.courseId}-${r.cardId}`,
2552
+ reviewID: r._id,
2553
+ status: "review"
2554
+ }));
2555
+ }
2556
+ /**
2557
+ * SRS does not generate new cards.
2558
+ * Use ELONavigator or another generator for new cards.
2559
+ */
2560
+ async getNewCards(_n) {
2561
+ return [];
2562
+ }
1048
2563
  };
1049
2564
  }
1050
2565
  });
1051
2566
 
2567
+ // src/core/navigators/userGoal.ts
2568
+ var userGoal_exports = {};
2569
+ __export(userGoal_exports, {
2570
+ USER_GOAL_NAVIGATOR_STUB: () => USER_GOAL_NAVIGATOR_STUB
2571
+ });
2572
+ var USER_GOAL_NAVIGATOR_STUB;
2573
+ var init_userGoal = __esm({
2574
+ "src/core/navigators/userGoal.ts"() {
2575
+ "use strict";
2576
+ USER_GOAL_NAVIGATOR_STUB = true;
2577
+ }
2578
+ });
2579
+
1052
2580
  // import("./**/*") in src/core/navigators/index.ts
1053
2581
  var globImport;
1054
2582
  var init_ = __esm({
1055
2583
  'import("./**/*") in src/core/navigators/index.ts'() {
1056
2584
  globImport = __glob({
2585
+ "./CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
2586
+ "./Pipeline.ts": () => Promise.resolve().then(() => (init_Pipeline(), Pipeline_exports)),
2587
+ "./PipelineAssembler.ts": () => Promise.resolve().then(() => (init_PipelineAssembler(), PipelineAssembler_exports)),
1057
2588
  "./elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
2589
+ "./filters/eloDistance.ts": () => Promise.resolve().then(() => (init_eloDistance(), eloDistance_exports)),
2590
+ "./filters/index.ts": () => Promise.resolve().then(() => (init_filters(), filters_exports)),
2591
+ "./filters/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports)),
2592
+ "./filters/userTagPreference.ts": () => Promise.resolve().then(() => (init_userTagPreference(), userTagPreference_exports)),
2593
+ "./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
2594
+ "./generators/types.ts": () => Promise.resolve().then(() => (init_types2(), types_exports2)),
1058
2595
  "./hardcodedOrder.ts": () => Promise.resolve().then(() => (init_hardcodedOrder(), hardcodedOrder_exports)),
1059
- "./index.ts": () => Promise.resolve().then(() => (init_navigators(), navigators_exports))
2596
+ "./hierarchyDefinition.ts": () => Promise.resolve().then(() => (init_hierarchyDefinition(), hierarchyDefinition_exports)),
2597
+ "./index.ts": () => Promise.resolve().then(() => (init_navigators(), navigators_exports)),
2598
+ "./inferredPreference.ts": () => Promise.resolve().then(() => (init_inferredPreference(), inferredPreference_exports)),
2599
+ "./interferenceMitigator.ts": () => Promise.resolve().then(() => (init_interferenceMitigator(), interferenceMitigator_exports)),
2600
+ "./relativePriority.ts": () => Promise.resolve().then(() => (init_relativePriority(), relativePriority_exports)),
2601
+ "./srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
2602
+ "./userGoal.ts": () => Promise.resolve().then(() => (init_userGoal(), userGoal_exports))
1060
2603
  });
1061
2604
  }
1062
2605
  });
@@ -1065,9 +2608,34 @@ var init_ = __esm({
1065
2608
  var navigators_exports = {};
1066
2609
  __export(navigators_exports, {
1067
2610
  ContentNavigator: () => ContentNavigator,
1068
- Navigators: () => Navigators
2611
+ NavigatorRole: () => NavigatorRole,
2612
+ NavigatorRoles: () => NavigatorRoles,
2613
+ Navigators: () => Navigators,
2614
+ getCardOrigin: () => getCardOrigin,
2615
+ isFilter: () => isFilter,
2616
+ isGenerator: () => isGenerator
1069
2617
  });
1070
- var Navigators, ContentNavigator;
2618
+ function getCardOrigin(card) {
2619
+ if (card.provenance.length === 0) {
2620
+ throw new Error("Card has no provenance - cannot determine origin");
2621
+ }
2622
+ const firstEntry = card.provenance[0];
2623
+ const reason = firstEntry.reason.toLowerCase();
2624
+ if (reason.includes("failed")) {
2625
+ return "failed";
2626
+ }
2627
+ if (reason.includes("review")) {
2628
+ return "review";
2629
+ }
2630
+ return "new";
2631
+ }
2632
+ function isGenerator(impl) {
2633
+ return NavigatorRoles[impl] === "generator" /* GENERATOR */;
2634
+ }
2635
+ function isFilter(impl) {
2636
+ return NavigatorRoles[impl] === "filter" /* FILTER */;
2637
+ }
2638
+ var Navigators, NavigatorRole, NavigatorRoles, ContentNavigator;
1071
2639
  var init_navigators = __esm({
1072
2640
  "src/core/navigators/index.ts"() {
1073
2641
  "use strict";
@@ -1075,14 +2643,103 @@ var init_navigators = __esm({
1075
2643
  init_();
1076
2644
  Navigators = /* @__PURE__ */ ((Navigators2) => {
1077
2645
  Navigators2["ELO"] = "elo";
2646
+ Navigators2["SRS"] = "srs";
1078
2647
  Navigators2["HARDCODED"] = "hardcodedOrder";
2648
+ Navigators2["HIERARCHY"] = "hierarchyDefinition";
2649
+ Navigators2["INTERFERENCE"] = "interferenceMitigator";
2650
+ Navigators2["RELATIVE_PRIORITY"] = "relativePriority";
2651
+ Navigators2["USER_TAG_PREFERENCE"] = "userTagPreference";
1079
2652
  return Navigators2;
1080
2653
  })(Navigators || {});
2654
+ NavigatorRole = /* @__PURE__ */ ((NavigatorRole2) => {
2655
+ NavigatorRole2["GENERATOR"] = "generator";
2656
+ NavigatorRole2["FILTER"] = "filter";
2657
+ return NavigatorRole2;
2658
+ })(NavigatorRole || {});
2659
+ NavigatorRoles = {
2660
+ ["elo" /* ELO */]: "generator" /* GENERATOR */,
2661
+ ["srs" /* SRS */]: "generator" /* GENERATOR */,
2662
+ ["hardcodedOrder" /* HARDCODED */]: "generator" /* GENERATOR */,
2663
+ ["hierarchyDefinition" /* HIERARCHY */]: "filter" /* FILTER */,
2664
+ ["interferenceMitigator" /* INTERFERENCE */]: "filter" /* FILTER */,
2665
+ ["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */,
2666
+ ["userTagPreference" /* USER_TAG_PREFERENCE */]: "filter" /* FILTER */
2667
+ };
1081
2668
  ContentNavigator = class {
2669
+ /** User interface for this navigation session */
2670
+ user;
2671
+ /** Course interface for this navigation session */
2672
+ course;
2673
+ /** Human-readable name for this strategy instance (from ContentNavigationStrategyData.name) */
2674
+ strategyName;
2675
+ /** Unique document ID for this strategy instance (from ContentNavigationStrategyData._id) */
2676
+ strategyId;
2677
+ /**
2678
+ * Constructor for standard navigators.
2679
+ * Call this from subclass constructors to initialize common fields.
2680
+ *
2681
+ * Note: CompositeGenerator doesn't use this pattern and should call super() without args.
2682
+ */
2683
+ constructor(user, course, strategyData) {
2684
+ if (user && course && strategyData) {
2685
+ this.user = user;
2686
+ this.course = course;
2687
+ this.strategyName = strategyData.name;
2688
+ this.strategyId = strategyData._id;
2689
+ }
2690
+ }
2691
+ // ============================================================================
2692
+ // STRATEGY STATE HELPERS
2693
+ // ============================================================================
2694
+ //
2695
+ // These methods allow strategies to persist their own state (user preferences,
2696
+ // learned patterns, temporal tracking) in the user database.
2697
+ //
2698
+ // ============================================================================
2699
+ /**
2700
+ * Unique key identifying this strategy for state storage.
2701
+ *
2702
+ * Defaults to the constructor name (e.g., "UserTagPreferenceFilter").
2703
+ * Override in subclasses if multiple instances of the same strategy type
2704
+ * need separate state storage.
2705
+ */
2706
+ get strategyKey() {
2707
+ return this.constructor.name;
2708
+ }
2709
+ /**
2710
+ * Get this strategy's persisted state for the current course.
2711
+ *
2712
+ * @returns The strategy's data payload, or null if no state exists
2713
+ * @throws Error if user or course is not initialized
2714
+ */
2715
+ async getStrategyState() {
2716
+ if (!this.user || !this.course) {
2717
+ throw new Error(
2718
+ `Cannot get strategy state: navigator not properly initialized. Ensure user and course are provided to constructor.`
2719
+ );
2720
+ }
2721
+ return this.user.getStrategyState(this.course.getCourseID(), this.strategyKey);
2722
+ }
1082
2723
  /**
2724
+ * Persist this strategy's state for the current course.
1083
2725
  *
1084
- * @param user
1085
- * @param strategyData
2726
+ * @param data - The strategy's data payload to store
2727
+ * @throws Error if user or course is not initialized
2728
+ */
2729
+ async putStrategyState(data) {
2730
+ if (!this.user || !this.course) {
2731
+ throw new Error(
2732
+ `Cannot put strategy state: navigator not properly initialized. Ensure user and course are provided to constructor.`
2733
+ );
2734
+ }
2735
+ return this.user.putStrategyState(this.course.getCourseID(), this.strategyKey, data);
2736
+ }
2737
+ /**
2738
+ * Factory method to create navigator instances dynamically.
2739
+ *
2740
+ * @param user - User interface
2741
+ * @param course - Course interface
2742
+ * @param strategyData - Strategy configuration document
1086
2743
  * @returns the runtime object used to steer a study session.
1087
2744
  */
1088
2745
  static async create(user, course, strategyData) {
@@ -1103,6 +2760,70 @@ var init_navigators = __esm({
1103
2760
  }
1104
2761
  return new NavigatorImpl(user, course, strategyData);
1105
2762
  }
2763
+ /**
2764
+ * Get cards with suitability scores and provenance trails.
2765
+ *
2766
+ * **This is the PRIMARY API for navigation strategies.**
2767
+ *
2768
+ * Returns cards ranked by suitability score (0-1). Higher scores indicate
2769
+ * better candidates for presentation. Each card includes a provenance trail
2770
+ * documenting how strategies contributed to the final score.
2771
+ *
2772
+ * ## For Generators
2773
+ * Override this method to generate candidates and compute scores based on
2774
+ * your strategy's logic (e.g., ELO proximity, review urgency). Create the
2775
+ * initial provenance entry with action='generated'.
2776
+ *
2777
+ * ## Default Implementation
2778
+ * The base class provides a backward-compatible default that:
2779
+ * 1. Calls legacy getNewCards() and getPendingReviews()
2780
+ * 2. Assigns score=1.0 to all cards
2781
+ * 3. Creates minimal provenance from legacy methods
2782
+ * 4. Returns combined results up to limit
2783
+ *
2784
+ * This allows existing strategies to work without modification while
2785
+ * new strategies can override with proper scoring and provenance.
2786
+ *
2787
+ * @param limit - Maximum cards to return
2788
+ * @returns Cards sorted by score descending, with provenance trails
2789
+ */
2790
+ async getWeightedCards(limit) {
2791
+ const newCards = await this.getNewCards(limit);
2792
+ const reviews = await this.getPendingReviews();
2793
+ const weighted = [
2794
+ ...newCards.map((c) => ({
2795
+ cardId: c.cardID,
2796
+ courseId: c.courseID,
2797
+ score: 1,
2798
+ provenance: [
2799
+ {
2800
+ strategy: "legacy",
2801
+ strategyName: this.strategyName || "Legacy API",
2802
+ strategyId: this.strategyId || "legacy-fallback",
2803
+ action: "generated",
2804
+ score: 1,
2805
+ reason: "Generated via legacy getNewCards(), new card"
2806
+ }
2807
+ ]
2808
+ })),
2809
+ ...reviews.map((r) => ({
2810
+ cardId: r.cardID,
2811
+ courseId: r.courseID,
2812
+ score: 1,
2813
+ provenance: [
2814
+ {
2815
+ strategy: "legacy",
2816
+ strategyName: this.strategyName || "Legacy API",
2817
+ strategyId: this.strategyId || "legacy-fallback",
2818
+ action: "generated",
2819
+ score: 1,
2820
+ reason: "Generated via legacy getPendingReviews(), review"
2821
+ }
2822
+ ]
2823
+ }))
2824
+ ];
2825
+ return weighted.slice(0, limit);
2826
+ }
1106
2827
  };
1107
2828
  }
1108
2829
  });
@@ -1112,7 +2833,7 @@ import {
1112
2833
  EloToNumber,
1113
2834
  Status,
1114
2835
  blankCourseElo as blankCourseElo2,
1115
- toCourseElo as toCourseElo2
2836
+ toCourseElo as toCourseElo6
1116
2837
  } from "@vue-skuilder/common";
1117
2838
  function randIntWeightedTowardZero(n) {
1118
2839
  return Math.floor(Math.random() * Math.random() * Math.random() * n);
@@ -1201,6 +2922,12 @@ var init_courseDB = __esm({
1201
2922
  init_courseAPI();
1202
2923
  init_courseLookupDB();
1203
2924
  init_navigators();
2925
+ init_Pipeline();
2926
+ init_PipelineAssembler();
2927
+ init_CompositeGenerator();
2928
+ init_elo();
2929
+ init_srs();
2930
+ init_eloDistance();
1204
2931
  CoursesDB = class {
1205
2932
  _courseIDs;
1206
2933
  constructor(courseIDs) {
@@ -1312,7 +3039,7 @@ var init_courseDB = __esm({
1312
3039
  docs.rows.forEach((r) => {
1313
3040
  if (isSuccessRow(r)) {
1314
3041
  if (r.doc && r.doc.elo) {
1315
- ret.push(toCourseElo2(r.doc.elo));
3042
+ ret.push(toCourseElo6(r.doc.elo));
1316
3043
  } else {
1317
3044
  logger.warn("no elo data for card: " + r.id);
1318
3045
  ret.push(blankCourseElo2());
@@ -1381,15 +3108,6 @@ var init_courseDB = __esm({
1381
3108
  ret[r.id] = r.doc.id_displayable_data;
1382
3109
  }
1383
3110
  });
1384
- await Promise.all(
1385
- cards.rows.map((r) => {
1386
- return async () => {
1387
- if (isSuccessRow(r)) {
1388
- ret[r.id] = r.doc.id_displayable_data;
1389
- }
1390
- };
1391
- })
1392
- );
1393
3111
  return ret;
1394
3112
  }
1395
3113
  async getCardsByELO(elo, cardLimit) {
@@ -1474,6 +3192,28 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
1474
3192
  throw new Error(`Failed to find tags for card ${this.id}-${cardId}`);
1475
3193
  }
1476
3194
  }
3195
+ async getAppliedTagsBatch(cardIds) {
3196
+ if (cardIds.length === 0) {
3197
+ return /* @__PURE__ */ new Map();
3198
+ }
3199
+ const db = getCourseDB2(this.id);
3200
+ const result = await db.query("getTags", {
3201
+ keys: cardIds,
3202
+ include_docs: false
3203
+ });
3204
+ const tagsByCard = /* @__PURE__ */ new Map();
3205
+ for (const cardId of cardIds) {
3206
+ tagsByCard.set(cardId, []);
3207
+ }
3208
+ for (const row of result.rows) {
3209
+ const cardId = row.key;
3210
+ const tagName = row.value?.name;
3211
+ if (tagName && tagsByCard.has(cardId)) {
3212
+ tagsByCard.get(cardId).push(tagName);
3213
+ }
3214
+ }
3215
+ return tagsByCard;
3216
+ }
1477
3217
  async addTagToCard(cardId, tagId, updateELO) {
1478
3218
  return await addTagToCard(
1479
3219
  this.id,
@@ -1585,42 +3325,82 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
1585
3325
  logger.debug(JSON.stringify(data));
1586
3326
  return Promise.resolve();
1587
3327
  }
1588
- async surfaceNavigationStrategy() {
3328
+ /**
3329
+ * Creates an instantiated navigator for this course.
3330
+ *
3331
+ * Handles multiple generators by wrapping them in CompositeGenerator.
3332
+ * This is the preferred method for getting a ready-to-use navigator.
3333
+ *
3334
+ * @param user - User database interface
3335
+ * @returns Instantiated ContentNavigator ready for use
3336
+ */
3337
+ async createNavigator(user) {
1589
3338
  try {
1590
- const config = await this.getCourseConfig();
1591
- if (config.defaultNavigationStrategyId) {
1592
- try {
1593
- const strategy = await this.getNavigationStrategy(config.defaultNavigationStrategyId);
1594
- if (strategy) {
1595
- logger.debug(`Surfacing strategy ${strategy.name} from course config`);
1596
- return strategy;
1597
- }
1598
- } catch (e) {
1599
- logger.warn(
1600
- // @ts-expect-error tmp: defaultNavigationStrategyId property does not yet exist
1601
- `Failed to load strategy '${config.defaultNavigationStrategyId}' specified in course config. Falling back to ELO.`,
1602
- e
1603
- );
1604
- }
3339
+ const allStrategies = await this.getAllNavigationStrategies();
3340
+ if (allStrategies.length === 0) {
3341
+ logger.debug(
3342
+ "[courseDB] No strategy documents found, using default Pipeline(Composite(ELO, SRS), [eloDistanceFilter])"
3343
+ );
3344
+ return this.createDefaultPipeline(user);
1605
3345
  }
1606
- } catch (e) {
1607
- logger.warn(
1608
- "Could not retrieve course config to determine navigation strategy. Falling back to ELO.",
1609
- e
3346
+ const assembler = new PipelineAssembler();
3347
+ const { pipeline, generatorStrategies, filterStrategies, warnings } = await assembler.assemble({
3348
+ strategies: allStrategies,
3349
+ user,
3350
+ course: this
3351
+ });
3352
+ for (const warning of warnings) {
3353
+ logger.warn(`[PipelineAssembler] ${warning}`);
3354
+ }
3355
+ if (!pipeline) {
3356
+ logger.debug("[courseDB] Pipeline assembly failed, using default pipeline");
3357
+ return this.createDefaultPipeline(user);
3358
+ }
3359
+ logger.debug(
3360
+ `[courseDB] Using assembled pipeline with ${generatorStrategies.length} generator(s) and ${filterStrategies.length} filter(s)`
1610
3361
  );
3362
+ return pipeline;
3363
+ } catch (e) {
3364
+ logger.error(`[courseDB] Error creating navigator: ${e}`);
3365
+ throw e;
1611
3366
  }
1612
- logger.warn(`Returning hard-coded default ELO navigator`);
1613
- const ret = {
1614
- _id: "NAVIGATION_STRATEGY-ELO",
3367
+ }
3368
+ makeDefaultEloStrategy() {
3369
+ return {
3370
+ _id: "NAVIGATION_STRATEGY-ELO-default",
1615
3371
  docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
1616
- name: "ELO",
1617
- description: "ELO-based navigation strategy",
3372
+ name: "ELO (default)",
3373
+ description: "Default ELO-based navigation strategy for new cards",
1618
3374
  implementingClass: "elo" /* ELO */,
1619
3375
  course: this.id,
1620
3376
  serializedData: ""
1621
- // serde is a noop for ELO navigator.
1622
3377
  };
1623
- return Promise.resolve(ret);
3378
+ }
3379
+ makeDefaultSrsStrategy() {
3380
+ return {
3381
+ _id: "NAVIGATION_STRATEGY-SRS-default",
3382
+ docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
3383
+ name: "SRS (default)",
3384
+ description: "Default SRS-based navigation strategy for reviews",
3385
+ implementingClass: "srs" /* SRS */,
3386
+ course: this.id,
3387
+ serializedData: ""
3388
+ };
3389
+ }
3390
+ /**
3391
+ * Creates the default navigation pipeline for courses with no configured strategies.
3392
+ *
3393
+ * Default: Pipeline(Composite(ELO, SRS), [eloDistanceFilter])
3394
+ * - ELO generator: scores new cards by skill proximity
3395
+ * - SRS generator: scores reviews by overdueness and interval recency
3396
+ * - ELO distance filter: penalizes cards far from user's current level
3397
+ */
3398
+ createDefaultPipeline(user) {
3399
+ const eloNavigator = new ELONavigator(user, this, this.makeDefaultEloStrategy());
3400
+ const srsNavigator = new SRSNavigator(user, this, this.makeDefaultSrsStrategy());
3401
+ const compositeGenerator = new CompositeGenerator([eloNavigator, srsNavigator]);
3402
+ const eloDistanceFilter = createEloDistanceFilter();
3403
+ return new Pipeline(compositeGenerator, [eloDistanceFilter], user, this);
1624
3404
  }
1625
3405
  ////////////////////////////////////
1626
3406
  // END NavigationStrategyManager implementation
@@ -1631,22 +3411,39 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
1631
3411
  async getNewCards(limit = 99) {
1632
3412
  const u = await this._getCurrentUser();
1633
3413
  try {
1634
- const strategy = await this.surfaceNavigationStrategy();
1635
- const navigator = await ContentNavigator.create(u, this, strategy);
3414
+ const navigator = await this.createNavigator(u);
1636
3415
  return navigator.getNewCards(limit);
1637
3416
  } catch (e) {
1638
- logger.error(`[courseDB] Error surfacing a NavigationStrategy: ${e}`);
3417
+ logger.error(`[courseDB] Error in getNewCards: ${e}`);
1639
3418
  throw e;
1640
3419
  }
1641
3420
  }
1642
3421
  async getPendingReviews() {
1643
3422
  const u = await this._getCurrentUser();
1644
3423
  try {
1645
- const strategy = await this.surfaceNavigationStrategy();
1646
- const navigator = await ContentNavigator.create(u, this, strategy);
3424
+ const navigator = await this.createNavigator(u);
1647
3425
  return navigator.getPendingReviews();
1648
3426
  } catch (e) {
1649
- logger.error(`[courseDB] Error surfacing a NavigationStrategy: ${e}`);
3427
+ logger.error(`[courseDB] Error in getPendingReviews: ${e}`);
3428
+ throw e;
3429
+ }
3430
+ }
3431
+ /**
3432
+ * Get cards with suitability scores for presentation.
3433
+ *
3434
+ * This is the PRIMARY API for content sources going forward. Delegates to the
3435
+ * course's configured NavigationStrategy to get scored candidates.
3436
+ *
3437
+ * @param limit - Maximum number of cards to return
3438
+ * @returns Cards sorted by score descending
3439
+ */
3440
+ async getWeightedCards(limit) {
3441
+ const u = await this._getCurrentUser();
3442
+ try {
3443
+ const navigator = await this.createNavigator(u);
3444
+ return navigator.getWeightedCards(limit);
3445
+ } catch (e) {
3446
+ logger.error(`[courseDB] Error getting weighted cards: ${e}`);
1650
3447
  throw e;
1651
3448
  }
1652
3449
  }
@@ -1786,7 +3583,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
1786
3583
  });
1787
3584
 
1788
3585
  // src/impl/couch/classroomDB.ts
1789
- import moment3 from "moment";
3586
+ import moment4 from "moment";
1790
3587
  var classroomLookupDBTitle, CLASSROOM_CONFIG, ClassroomDBBase, StudentClassroomDB, TeacherClassroomDB, ClassroomLookupDB;
1791
3588
  var init_classroomDB2 = __esm({
1792
3589
  "src/impl/couch/classroomDB.ts"() {
@@ -1887,9 +3684,9 @@ var init_classroomDB2 = __esm({
1887
3684
  }
1888
3685
  async getNewCards() {
1889
3686
  const activeCards = await this._user.getActiveCards();
1890
- const now = moment3.utc();
3687
+ const now = moment4.utc();
1891
3688
  const assigned = await this.getAssignedContent();
1892
- const due = assigned.filter((c) => now.isAfter(moment3.utc(c.activeOn, REVIEW_TIME_FORMAT2)));
3689
+ const due = assigned.filter((c) => now.isAfter(moment4.utc(c.activeOn, REVIEW_TIME_FORMAT2)));
1893
3690
  logger.info(`Due content: ${JSON.stringify(due)}`);
1894
3691
  let ret = [];
1895
3692
  for (let i = 0; i < due.length; i++) {
@@ -1926,6 +3723,52 @@ var init_classroomDB2 = __esm({
1926
3723
  }
1927
3724
  });
1928
3725
  }
3726
+ /**
3727
+ * Get cards with suitability scores for presentation.
3728
+ *
3729
+ * This implementation wraps the legacy getNewCards/getPendingReviews methods,
3730
+ * assigning score=1.0 to all cards. StudentClassroomDB does not currently
3731
+ * support pluggable navigation strategies.
3732
+ *
3733
+ * @param limit - Maximum number of cards to return
3734
+ * @returns Cards sorted by score descending (all scores = 1.0)
3735
+ */
3736
+ async getWeightedCards(limit) {
3737
+ const [newCards, reviews] = await Promise.all([this.getNewCards(), this.getPendingReviews()]);
3738
+ const weighted = [
3739
+ ...newCards.map((c) => ({
3740
+ cardId: c.cardID,
3741
+ courseId: c.courseID,
3742
+ score: 1,
3743
+ provenance: [
3744
+ {
3745
+ strategy: "classroom",
3746
+ strategyName: "Classroom",
3747
+ strategyId: "CLASSROOM",
3748
+ action: "generated",
3749
+ score: 1,
3750
+ reason: "Classroom legacy getNewCards(), new card"
3751
+ }
3752
+ ]
3753
+ })),
3754
+ ...reviews.map((r) => ({
3755
+ cardId: r.cardID,
3756
+ courseId: r.courseID,
3757
+ score: 1,
3758
+ provenance: [
3759
+ {
3760
+ strategy: "classroom",
3761
+ strategyName: "Classroom",
3762
+ strategyId: "CLASSROOM",
3763
+ action: "generated",
3764
+ score: 1,
3765
+ reason: "Classroom legacy getPendingReviews(), review"
3766
+ }
3767
+ ]
3768
+ }))
3769
+ ];
3770
+ return weighted.slice(0, limit);
3771
+ }
1929
3772
  };
1930
3773
  TeacherClassroomDB = class _TeacherClassroomDB extends ClassroomDBBase {
1931
3774
  _stuDb;
@@ -1982,8 +3825,8 @@ var init_classroomDB2 = __esm({
1982
3825
  type: "tag",
1983
3826
  _id: id,
1984
3827
  assignedBy: content.assignedBy,
1985
- assignedOn: moment3.utc(),
1986
- activeOn: content.activeOn || moment3.utc()
3828
+ assignedOn: moment4.utc(),
3829
+ activeOn: content.activeOn || moment4.utc()
1987
3830
  });
1988
3831
  } else {
1989
3832
  put = await this._db.put({
@@ -1991,8 +3834,8 @@ var init_classroomDB2 = __esm({
1991
3834
  type: "course",
1992
3835
  _id: id,
1993
3836
  assignedBy: content.assignedBy,
1994
- assignedOn: moment3.utc(),
1995
- activeOn: content.activeOn || moment3.utc()
3837
+ assignedOn: moment4.utc(),
3838
+ activeOn: content.activeOn || moment4.utc()
1996
3839
  });
1997
3840
  }
1998
3841
  if (put.ok) {
@@ -2072,8 +3915,7 @@ var init_adminDB2 = __esm({
2072
3915
  }
2073
3916
  }
2074
3917
  }
2075
- const dbs = await Promise.all(promisedCRDbs);
2076
- return dbs.map((db) => {
3918
+ return promisedCRDbs.map((db) => {
2077
3919
  return {
2078
3920
  ...db.getConfig(),
2079
3921
  _id: db._id
@@ -2357,7 +4199,7 @@ var init_CouchDBSyncStrategy = __esm({
2357
4199
 
2358
4200
  // src/impl/couch/index.ts
2359
4201
  import fetch3 from "cross-fetch";
2360
- import moment4 from "moment";
4202
+ import moment5 from "moment";
2361
4203
  import process2 from "process";
2362
4204
  function createPouchDBConfig() {
2363
4205
  const hasExplicitCredentials = ENV.COUCHDB_USERNAME && ENV.COUCHDB_PASSWORD;
@@ -2442,11 +4284,13 @@ var init_couch = __esm({
2442
4284
 
2443
4285
  // src/impl/common/BaseUserDB.ts
2444
4286
  import { Status as Status3 } from "@vue-skuilder/common";
2445
- import moment5 from "moment";
4287
+ import moment6 from "moment";
2446
4288
  function accomodateGuest() {
2447
4289
  logger.log("[funnel] accomodateGuest() called");
2448
4290
  if (typeof localStorage === "undefined") {
2449
- logger.log("[funnel] localStorage not available (Node.js environment), returning default guest");
4291
+ logger.log(
4292
+ "[funnel] localStorage not available (Node.js environment), returning default guest"
4293
+ );
2450
4294
  return {
2451
4295
  username: GuestUsername + "nodejs-test",
2452
4296
  firstVisit: true
@@ -2880,7 +4724,7 @@ Currently logged-in as ${this._username}.`
2880
4724
  );
2881
4725
  return reviews.rows.filter((r) => {
2882
4726
  if (r.id.startsWith(DocTypePrefixes["SCHEDULED_CARD" /* SCHEDULED_CARD */])) {
2883
- const date = moment5.utc(
4727
+ const date = moment6.utc(
2884
4728
  r.id.substr(DocTypePrefixes["SCHEDULED_CARD" /* SCHEDULED_CARD */].length),
2885
4729
  REVIEW_TIME_FORMAT
2886
4730
  );
@@ -2893,11 +4737,11 @@ Currently logged-in as ${this._username}.`
2893
4737
  }).map((r) => r.doc);
2894
4738
  }
2895
4739
  async getReviewsForcast(daysCount) {
2896
- const time = moment5.utc().add(daysCount, "days");
4740
+ const time = moment6.utc().add(daysCount, "days");
2897
4741
  return this.getReviewstoDate(time);
2898
4742
  }
2899
4743
  async getPendingReviews(course_id) {
2900
- const now = moment5.utc();
4744
+ const now = moment6.utc();
2901
4745
  return this.getReviewstoDate(now, course_id);
2902
4746
  }
2903
4747
  async getScheduledReviewCount(course_id) {
@@ -3184,7 +5028,7 @@ Currently logged-in as ${this._username}.`
3184
5028
  */
3185
5029
  async putCardRecord(record) {
3186
5030
  const cardHistoryID = getCardHistoryID(record.courseID, record.cardID);
3187
- record.timeStamp = moment5.utc(record.timeStamp).toString();
5031
+ record.timeStamp = moment6.utc(record.timeStamp).toString();
3188
5032
  try {
3189
5033
  const cardHistory = await this.update(
3190
5034
  cardHistoryID,
@@ -3200,7 +5044,7 @@ Currently logged-in as ${this._username}.`
3200
5044
  const ret = {
3201
5045
  ...record2
3202
5046
  };
3203
- ret.timeStamp = moment5.utc(record2.timeStamp);
5047
+ ret.timeStamp = moment6.utc(record2.timeStamp);
3204
5048
  return ret;
3205
5049
  });
3206
5050
  return cardHistory;
@@ -3424,6 +5268,55 @@ Currently logged-in as ${this._username}.`
3424
5268
  async updateUserElo(courseId, elo) {
3425
5269
  return updateUserElo(this._username, courseId, elo);
3426
5270
  }
5271
+ async getStrategyState(courseId, strategyKey) {
5272
+ const docId = buildStrategyStateId(courseId, strategyKey);
5273
+ try {
5274
+ const doc = await this.localDB.get(docId);
5275
+ return doc.data;
5276
+ } catch (e) {
5277
+ const err = e;
5278
+ if (err.status === 404) {
5279
+ return null;
5280
+ }
5281
+ throw e;
5282
+ }
5283
+ }
5284
+ async putStrategyState(courseId, strategyKey, data) {
5285
+ const docId = buildStrategyStateId(courseId, strategyKey);
5286
+ let existingRev;
5287
+ try {
5288
+ const existing = await this.localDB.get(docId);
5289
+ existingRev = existing._rev;
5290
+ } catch (e) {
5291
+ const err = e;
5292
+ if (err.status !== 404) {
5293
+ throw e;
5294
+ }
5295
+ }
5296
+ const doc = {
5297
+ _id: docId,
5298
+ _rev: existingRev,
5299
+ docType: "STRATEGY_STATE" /* STRATEGY_STATE */,
5300
+ courseId,
5301
+ strategyKey,
5302
+ data,
5303
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
5304
+ };
5305
+ await this.localDB.put(doc);
5306
+ }
5307
+ async deleteStrategyState(courseId, strategyKey) {
5308
+ const docId = buildStrategyStateId(courseId, strategyKey);
5309
+ try {
5310
+ const doc = await this.localDB.get(docId);
5311
+ await this.localDB.remove(doc);
5312
+ } catch (e) {
5313
+ const err = e;
5314
+ if (err.status === 404) {
5315
+ return;
5316
+ }
5317
+ throw e;
5318
+ }
5319
+ }
3427
5320
  };
3428
5321
  userCoursesDoc = "CourseRegistrations";
3429
5322
  userClassroomsDoc = "ClassroomRegistrations";
@@ -4091,6 +5984,14 @@ var init_courseDB2 = __esm({
4091
5984
  };
4092
5985
  }
4093
5986
  }
5987
+ async getAppliedTagsBatch(cardIds) {
5988
+ const tagsIndex = await this.unpacker.getTagsIndex();
5989
+ const tagsByCard = /* @__PURE__ */ new Map();
5990
+ for (const cardId of cardIds) {
5991
+ tagsByCard.set(cardId, tagsIndex.byCard[cardId] || []);
5992
+ }
5993
+ return tagsByCard;
5994
+ }
4094
5995
  async addTagToCard(_cardId, _tagId) {
4095
5996
  throw new Error("Cannot modify tags in static mode");
4096
5997
  }
@@ -4204,9 +6105,6 @@ var init_courseDB2 = __esm({
4204
6105
  async updateNavigationStrategy(_id, _data) {
4205
6106
  throw new Error("Cannot update navigation strategies in static mode");
4206
6107
  }
4207
- async surfaceNavigationStrategy() {
4208
- return this.getNavigationStrategy("ELO");
4209
- }
4210
6108
  // Study Content Source implementation
4211
6109
  async getPendingReviews() {
4212
6110
  return [];
@@ -4527,7 +6425,215 @@ var init_factory = __esm({
4527
6425
  }
4528
6426
  });
4529
6427
 
6428
+ // src/study/TagFilteredContentSource.ts
6429
+ import { hasActiveFilter } from "@vue-skuilder/common";
6430
+ var TagFilteredContentSource;
6431
+ var init_TagFilteredContentSource = __esm({
6432
+ "src/study/TagFilteredContentSource.ts"() {
6433
+ "use strict";
6434
+ init_courseDB();
6435
+ init_logger();
6436
+ TagFilteredContentSource = class {
6437
+ courseId;
6438
+ filter;
6439
+ user;
6440
+ // Cache resolved card IDs to avoid repeated lookups within a session
6441
+ resolvedCardIds = null;
6442
+ constructor(courseId, filter, user) {
6443
+ this.courseId = courseId;
6444
+ this.filter = filter;
6445
+ this.user = user;
6446
+ logger.info(
6447
+ `[TagFilteredContentSource] Created for course "${courseId}" with filter:`,
6448
+ JSON.stringify(filter)
6449
+ );
6450
+ }
6451
+ /**
6452
+ * Resolves the TagFilter to a set of eligible card IDs.
6453
+ *
6454
+ * - Cards in `include` tags are OR'd together (card needs at least one)
6455
+ * - Cards in `exclude` tags are removed from the result
6456
+ */
6457
+ async resolveFilteredCardIds() {
6458
+ if (this.resolvedCardIds !== null) {
6459
+ return this.resolvedCardIds;
6460
+ }
6461
+ const includedCardIds = /* @__PURE__ */ new Set();
6462
+ if (this.filter.include.length > 0) {
6463
+ for (const tagName of this.filter.include) {
6464
+ try {
6465
+ const tagDoc = await getTag(this.courseId, tagName);
6466
+ tagDoc.taggedCards.forEach((cardId) => includedCardIds.add(cardId));
6467
+ } catch (error) {
6468
+ logger.warn(
6469
+ `[TagFilteredContentSource] Could not resolve tag "${tagName}" for inclusion:`,
6470
+ error
6471
+ );
6472
+ }
6473
+ }
6474
+ }
6475
+ if (includedCardIds.size === 0 && this.filter.include.length > 0) {
6476
+ logger.warn(
6477
+ `[TagFilteredContentSource] No cards found for include tags: ${this.filter.include.join(", ")}`
6478
+ );
6479
+ this.resolvedCardIds = /* @__PURE__ */ new Set();
6480
+ return this.resolvedCardIds;
6481
+ }
6482
+ const excludedCardIds = /* @__PURE__ */ new Set();
6483
+ if (this.filter.exclude.length > 0) {
6484
+ for (const tagName of this.filter.exclude) {
6485
+ try {
6486
+ const tagDoc = await getTag(this.courseId, tagName);
6487
+ tagDoc.taggedCards.forEach((cardId) => excludedCardIds.add(cardId));
6488
+ } catch (error) {
6489
+ logger.warn(
6490
+ `[TagFilteredContentSource] Could not resolve tag "${tagName}" for exclusion:`,
6491
+ error
6492
+ );
6493
+ }
6494
+ }
6495
+ }
6496
+ const finalCardIds = /* @__PURE__ */ new Set();
6497
+ for (const cardId of includedCardIds) {
6498
+ if (!excludedCardIds.has(cardId)) {
6499
+ finalCardIds.add(cardId);
6500
+ }
6501
+ }
6502
+ logger.info(
6503
+ `[TagFilteredContentSource] Resolved ${finalCardIds.size} cards (included: ${includedCardIds.size}, excluded: ${excludedCardIds.size})`
6504
+ );
6505
+ this.resolvedCardIds = finalCardIds;
6506
+ return finalCardIds;
6507
+ }
6508
+ /**
6509
+ * Gets new cards that match the tag filter and are not already active for the user.
6510
+ */
6511
+ async getNewCards(limit) {
6512
+ if (!hasActiveFilter(this.filter)) {
6513
+ logger.warn("[TagFilteredContentSource] getNewCards called with no active filter");
6514
+ return [];
6515
+ }
6516
+ const eligibleCardIds = await this.resolveFilteredCardIds();
6517
+ const activeCards = await this.user.getActiveCards();
6518
+ const activeCardIds = new Set(activeCards.map((c) => c.cardID));
6519
+ const newItems = [];
6520
+ for (const cardId of eligibleCardIds) {
6521
+ if (!activeCardIds.has(cardId)) {
6522
+ newItems.push({
6523
+ courseID: this.courseId,
6524
+ cardID: cardId,
6525
+ contentSourceType: "course",
6526
+ contentSourceID: this.courseId,
6527
+ status: "new"
6528
+ });
6529
+ }
6530
+ if (limit !== void 0 && newItems.length >= limit) {
6531
+ break;
6532
+ }
6533
+ }
6534
+ logger.info(`[TagFilteredContentSource] Found ${newItems.length} new cards matching filter`);
6535
+ return newItems;
6536
+ }
6537
+ /**
6538
+ * Gets pending reviews, filtered to only include cards that match the tag filter.
6539
+ */
6540
+ async getPendingReviews() {
6541
+ if (!hasActiveFilter(this.filter)) {
6542
+ logger.warn("[TagFilteredContentSource] getPendingReviews called with no active filter");
6543
+ return [];
6544
+ }
6545
+ const eligibleCardIds = await this.resolveFilteredCardIds();
6546
+ const allReviews = await this.user.getPendingReviews(this.courseId);
6547
+ const filteredReviews = allReviews.filter((review) => {
6548
+ return eligibleCardIds.has(review.cardId);
6549
+ });
6550
+ logger.info(
6551
+ `[TagFilteredContentSource] Found ${filteredReviews.length} pending reviews matching filter (of ${allReviews.length} total)`
6552
+ );
6553
+ return filteredReviews.map((r) => ({
6554
+ ...r,
6555
+ courseID: r.courseId,
6556
+ cardID: r.cardId,
6557
+ contentSourceType: "course",
6558
+ contentSourceID: this.courseId,
6559
+ reviewID: r._id,
6560
+ status: "review"
6561
+ }));
6562
+ }
6563
+ /**
6564
+ * Get cards with suitability scores for presentation.
6565
+ *
6566
+ * This implementation wraps the legacy getNewCards/getPendingReviews methods,
6567
+ * assigning score=1.0 to all cards. TagFilteredContentSource does not currently
6568
+ * support pluggable navigation strategies - it returns flat-scored candidates.
6569
+ *
6570
+ * @param limit - Maximum number of cards to return
6571
+ * @returns Cards sorted by score descending (all scores = 1.0)
6572
+ */
6573
+ async getWeightedCards(limit) {
6574
+ const [newCards, reviews] = await Promise.all([
6575
+ this.getNewCards(limit),
6576
+ this.getPendingReviews()
6577
+ ]);
6578
+ const weighted = [
6579
+ ...reviews.map((r) => ({
6580
+ cardId: r.cardID,
6581
+ courseId: r.courseID,
6582
+ score: 1,
6583
+ provenance: [
6584
+ {
6585
+ strategy: "tagFilter",
6586
+ strategyName: "Tag Filter",
6587
+ strategyId: "TAG_FILTER",
6588
+ action: "generated",
6589
+ score: 1,
6590
+ reason: `Tag-filtered review (tags: ${this.filter.include.join(", ")})`
6591
+ }
6592
+ ]
6593
+ })),
6594
+ ...newCards.map((c) => ({
6595
+ cardId: c.cardID,
6596
+ courseId: c.courseID,
6597
+ score: 1,
6598
+ provenance: [
6599
+ {
6600
+ strategy: "tagFilter",
6601
+ strategyName: "Tag Filter",
6602
+ strategyId: "TAG_FILTER",
6603
+ action: "generated",
6604
+ score: 1,
6605
+ reason: `Tag-filtered new card (tags: ${this.filter.include.join(", ")})`
6606
+ }
6607
+ ]
6608
+ }))
6609
+ ];
6610
+ return weighted.slice(0, limit);
6611
+ }
6612
+ /**
6613
+ * Clears the cached resolved card IDs.
6614
+ * Call this if the underlying tag data may have changed during a session.
6615
+ */
6616
+ clearCache() {
6617
+ this.resolvedCardIds = null;
6618
+ }
6619
+ /**
6620
+ * Returns the course ID this source is filtering.
6621
+ */
6622
+ getCourseId() {
6623
+ return this.courseId;
6624
+ }
6625
+ /**
6626
+ * Returns the active tag filter.
6627
+ */
6628
+ getFilter() {
6629
+ return this.filter;
6630
+ }
6631
+ };
6632
+ }
6633
+ });
6634
+
4530
6635
  // src/core/interfaces/contentSource.ts
6636
+ import { hasActiveFilter as hasActiveFilter2 } from "@vue-skuilder/common";
4531
6637
  function isReview(item) {
4532
6638
  const ret = item.status === "review" || item.status === "failed-review" || "reviewID" in item;
4533
6639
  return ret;
@@ -4536,6 +6642,9 @@ async function getStudySource(source, user) {
4536
6642
  if (source.type === "classroom") {
4537
6643
  return await StudentClassroomDB.factory(source.id, user);
4538
6644
  } else {
6645
+ if (hasActiveFilter2(source.tagFilter)) {
6646
+ return new TagFilteredContentSource(source.id, source.tagFilter, user);
6647
+ }
4539
6648
  return getDataLayer().getCourseDB(source.id);
4540
6649
  }
4541
6650
  }
@@ -4544,6 +6653,7 @@ var init_contentSource = __esm({
4544
6653
  "use strict";
4545
6654
  init_factory();
4546
6655
  init_classroomDB2();
6656
+ init_TagFilteredContentSource();
4547
6657
  }
4548
6658
  });
4549
6659
 
@@ -4588,6 +6698,16 @@ var init_user = __esm({
4588
6698
  }
4589
6699
  });
4590
6700
 
6701
+ // src/core/types/strategyState.ts
6702
+ function buildStrategyStateId(courseId, strategyKey) {
6703
+ return `STRATEGY_STATE::${courseId}::${strategyKey}`;
6704
+ }
6705
+ var init_strategyState = __esm({
6706
+ "src/core/types/strategyState.ts"() {
6707
+ "use strict";
6708
+ }
6709
+ });
6710
+
4591
6711
  // src/core/bulkImport/cardProcessor.ts
4592
6712
  import { Status as Status5 } from "@vue-skuilder/common";
4593
6713
  async function importParsedCards(parsedCards, courseDB, config) {
@@ -4709,7 +6829,7 @@ var init_cardProcessor = __esm({
4709
6829
  });
4710
6830
 
4711
6831
  // src/core/bulkImport/types.ts
4712
- var init_types = __esm({
6832
+ var init_types3 = __esm({
4713
6833
  "src/core/bulkImport/types.ts"() {
4714
6834
  "use strict";
4715
6835
  }
@@ -4720,7 +6840,7 @@ var init_bulkImport = __esm({
4720
6840
  "src/core/bulkImport/index.ts"() {
4721
6841
  "use strict";
4722
6842
  init_cardProcessor();
4723
- init_types();
6843
+ init_types3();
4724
6844
  }
4725
6845
  });
4726
6846
 
@@ -4731,6 +6851,7 @@ var init_core = __esm({
4731
6851
  init_interfaces();
4732
6852
  init_types_legacy();
4733
6853
  init_user();
6854
+ init_strategyState();
4734
6855
  init_Loggable();
4735
6856
  init_util();
4736
6857
  init_navigators();
@@ -4744,13 +6865,13 @@ init_courseLookupDB();
4744
6865
 
4745
6866
  // src/study/services/SrsService.ts
4746
6867
  init_couch();
4747
- import moment7 from "moment";
6868
+ import moment8 from "moment";
4748
6869
 
4749
6870
  // src/study/SpacedRepetition.ts
4750
6871
  init_util();
4751
6872
  init_logger();
4752
- import moment6 from "moment";
4753
- var duration = moment6.duration;
6873
+ import moment7 from "moment";
6874
+ var duration = moment7.duration;
4754
6875
  function newInterval(user, cardHistory) {
4755
6876
  if (areQuestionRecords(cardHistory)) {
4756
6877
  return newQuestionInterval(user, cardHistory);
@@ -4814,8 +6935,8 @@ function getInitialInterval(cardHistory) {
4814
6935
  return 60 * 60 * 24 * 3;
4815
6936
  }
4816
6937
  function secondsBetween(start, end) {
4817
- start = moment6(start);
4818
- end = moment6(end);
6938
+ start = moment7(start);
6939
+ end = moment7(end);
4819
6940
  const ret = duration(end.diff(start)).asSeconds();
4820
6941
  return ret;
4821
6942
  }
@@ -4835,7 +6956,7 @@ var SrsService = class {
4835
6956
  */
4836
6957
  async scheduleReview(history, item) {
4837
6958
  const nextInterval = newInterval(this.user, history);
4838
- const nextReviewTime = moment7.utc().add(nextInterval, "seconds");
6959
+ const nextReviewTime = moment8.utc().add(nextInterval, "seconds");
4839
6960
  if (isReview(item)) {
4840
6961
  logger.info(`[SrsService] Removing previously scheduled review for: ${item.cardID}`);
4841
6962
  void this.user.removeScheduledCardReview(item.reviewID);
@@ -4853,7 +6974,7 @@ var SrsService = class {
4853
6974
 
4854
6975
  // src/study/services/EloService.ts
4855
6976
  init_logger();
4856
- import { adjustCourseScores, toCourseElo as toCourseElo3 } from "@vue-skuilder/common";
6977
+ import { adjustCourseScores, toCourseElo as toCourseElo7 } from "@vue-skuilder/common";
4857
6978
  var EloService = class {
4858
6979
  dataLayer;
4859
6980
  user;
@@ -4875,7 +6996,7 @@ var EloService = class {
4875
6996
  logger.warn(`k value interpretation not currently implemented`);
4876
6997
  }
4877
6998
  const courseDB = this.dataLayer.getCourseDB(currentCard.card.course_id);
4878
- const userElo = toCourseElo3(userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo);
6999
+ const userElo = toCourseElo7(userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo);
4879
7000
  const cardElo = (await courseDB.getCardEloData([currentCard.card.card_id]))[0];
4880
7001
  if (cardElo && userElo) {
4881
7002
  const eloUpdate = adjustCourseScores(userElo, cardElo, userScore);
@@ -5084,7 +7205,7 @@ init_logger();
5084
7205
  import {
5085
7206
  displayableDataToViewData,
5086
7207
  isCourseElo,
5087
- toCourseElo as toCourseElo4
7208
+ toCourseElo as toCourseElo8
5088
7209
  } from "@vue-skuilder/common";
5089
7210
 
5090
7211
  // src/study/ItemQueue.ts
@@ -5209,7 +7330,7 @@ var CardHydrationService = class {
5209
7330
  const courseDB = this.getCourseDB(nextItem.courseID);
5210
7331
  const cardData = await courseDB.getCourseDoc(nextItem.cardID);
5211
7332
  if (!isCourseElo(cardData.elo)) {
5212
- cardData.elo = toCourseElo4(cardData.elo);
7333
+ cardData.elo = toCourseElo8(cardData.elo);
5213
7334
  }
5214
7335
  const view = this.getViewComponent(cardData.id_view);
5215
7336
  const dataDocs = await Promise.all(
@@ -6675,6 +8796,7 @@ init_dataDirectory();
6675
8796
  init_tuiLogger();
6676
8797
 
6677
8798
  // src/study/SessionController.ts
8799
+ init_navigators();
6678
8800
  function randomInt(min, max) {
6679
8801
  return Math.floor(Math.random() * (max - min + 1)) + min;
6680
8802
  }
@@ -6777,7 +8899,12 @@ var SessionController = class extends Loggable {
6777
8899
  }
6778
8900
  async prepareSession() {
6779
8901
  try {
6780
- await Promise.all([this.getScheduledReviews(), this.getNewCards()]);
8902
+ const hasWeightedCards = this.sources.some((s) => typeof s.getWeightedCards === "function");
8903
+ if (hasWeightedCards) {
8904
+ await this.getWeightedContent();
8905
+ } else {
8906
+ await Promise.all([this.getScheduledReviews(), this.getNewCards()]);
8907
+ }
6781
8908
  } catch (e) {
6782
8909
  this.error("Error preparing study session:", e);
6783
8910
  }
@@ -6803,6 +8930,9 @@ var SessionController = class extends Loggable {
6803
8930
  * Used by SessionControllerDebug component for runtime inspection.
6804
8931
  */
6805
8932
  getDebugInfo() {
8933
+ const supportsWeightedCards = this.sources.some(
8934
+ (s) => typeof s.getWeightedCards === "function"
8935
+ );
6806
8936
  const extractQueueItems = (queue, limit = 10) => {
6807
8937
  const items = [];
6808
8938
  for (let i = 0; i < Math.min(queue.length, limit); i++) {
@@ -6820,6 +8950,10 @@ var SessionController = class extends Loggable {
6820
8950
  return items;
6821
8951
  };
6822
8952
  return {
8953
+ api: {
8954
+ mode: supportsWeightedCards ? "weighted" : "legacy",
8955
+ description: supportsWeightedCards ? "Using getWeightedCards() API with scored candidates" : "Using legacy getNewCards()/getPendingReviews() API"
8956
+ },
6823
8957
  reviewQueue: {
6824
8958
  length: this.reviewQ.length,
6825
8959
  dequeueCount: this.reviewQ.dequeueCount,
@@ -6842,6 +8976,109 @@ var SessionController = class extends Loggable {
6842
8976
  }
6843
8977
  };
6844
8978
  }
8979
+ /**
8980
+ * Fetch content using the new getWeightedCards API.
8981
+ *
8982
+ * This method uses getWeightedCards() to get scored candidates, then uses the
8983
+ * scores to determine ordering. For reviews, we still need the full ScheduledCard
8984
+ * data from getPendingReviews(), so we fetch both and use scores for ordering.
8985
+ *
8986
+ * The hybrid approach:
8987
+ * 1. Fetch weighted cards to get scoring/ordering information
8988
+ * 2. Fetch full review data via legacy getPendingReviews()
8989
+ * 3. Order reviews by their weighted scores
8990
+ * 4. Add new cards ordered by their weighted scores
8991
+ */
8992
+ async getWeightedContent() {
8993
+ const limit = 20;
8994
+ const allWeighted = [];
8995
+ const allReviews = [];
8996
+ const allNewCards = [];
8997
+ for (const source of this.sources) {
8998
+ try {
8999
+ const reviews = await source.getPendingReviews().catch((error) => {
9000
+ this.error(`Failed to get reviews for source:`, error);
9001
+ return [];
9002
+ });
9003
+ allReviews.push(...reviews);
9004
+ if (typeof source.getWeightedCards === "function") {
9005
+ const weighted = await source.getWeightedCards(limit);
9006
+ allWeighted.push(...weighted);
9007
+ } else {
9008
+ const newCards = await source.getNewCards(limit);
9009
+ allNewCards.push(...newCards);
9010
+ allWeighted.push(
9011
+ ...newCards.map((c) => ({
9012
+ cardId: c.cardID,
9013
+ courseId: c.courseID,
9014
+ score: 1,
9015
+ provenance: [
9016
+ {
9017
+ strategy: "legacy",
9018
+ strategyName: "Legacy Fallback",
9019
+ strategyId: "legacy-fallback",
9020
+ action: "generated",
9021
+ score: 1,
9022
+ reason: "Fallback to legacy getNewCards(), new card"
9023
+ }
9024
+ ]
9025
+ })),
9026
+ ...reviews.map((r) => ({
9027
+ cardId: r.cardID,
9028
+ courseId: r.courseID,
9029
+ score: 1,
9030
+ provenance: [
9031
+ {
9032
+ strategy: "legacy",
9033
+ strategyName: "Legacy Fallback",
9034
+ strategyId: "legacy-fallback",
9035
+ action: "generated",
9036
+ score: 1,
9037
+ reason: "Fallback to legacy getPendingReviews(), review"
9038
+ }
9039
+ ]
9040
+ }))
9041
+ );
9042
+ }
9043
+ } catch (error) {
9044
+ this.error(`Failed to get content from source:`, error);
9045
+ }
9046
+ }
9047
+ const scoreMap = /* @__PURE__ */ new Map();
9048
+ for (const w of allWeighted) {
9049
+ const key = `${w.courseId}::${w.cardId}`;
9050
+ scoreMap.set(key, w.score);
9051
+ }
9052
+ const scoredReviews = allReviews.map((r) => ({
9053
+ review: r,
9054
+ score: scoreMap.get(`${r.courseID}::${r.cardID}`) ?? 1
9055
+ }));
9056
+ scoredReviews.sort((a, b) => b.score - a.score);
9057
+ let report = "Weighted content session created with:\n";
9058
+ for (const { review, score } of scoredReviews) {
9059
+ this.reviewQ.add(review, review.cardID);
9060
+ report += `Review: ${review.courseID}::${review.cardID} (score: ${score.toFixed(2)})
9061
+ `;
9062
+ }
9063
+ const newCardWeighted = allWeighted.filter((w) => getCardOrigin(w) === "new").sort((a, b) => b.score - a.score);
9064
+ for (const card of newCardWeighted) {
9065
+ const newItem = {
9066
+ cardID: card.cardId,
9067
+ courseID: card.courseId,
9068
+ contentSourceType: "course",
9069
+ contentSourceID: card.courseId,
9070
+ status: "new"
9071
+ };
9072
+ this.newQ.add(newItem, card.cardId);
9073
+ report += `New: ${card.courseId}::${card.cardId} (score: ${card.score.toFixed(2)})
9074
+ `;
9075
+ }
9076
+ this.log(report);
9077
+ }
9078
+ /**
9079
+ * @deprecated Use getWeightedContent() instead. This method is kept for backward
9080
+ * compatibility with sources that don't support getWeightedCards().
9081
+ */
6845
9082
  async getScheduledReviews() {
6846
9083
  const reviews = await Promise.all(
6847
9084
  this.sources.map(
@@ -6867,6 +9104,10 @@ var SessionController = class extends Loggable {
6867
9104
  report += dueCards.map((card) => `Card ${card.courseID}::${card.cardID} `).join("\n");
6868
9105
  this.log(report);
6869
9106
  }
9107
+ /**
9108
+ * @deprecated Use getWeightedContent() instead. This method is kept for backward
9109
+ * compatibility with sources that don't support getWeightedCards().
9110
+ */
6870
9111
  async getNewCards(n = 10) {
6871
9112
  const perCourse = Math.ceil(n / this.sources.length);
6872
9113
  const newContent = await Promise.all(this.sources.map((c) => c.getNewCards(perCourse)));
@@ -7031,6 +9272,9 @@ var SessionController = class extends Loggable {
7031
9272
  }
7032
9273
  };
7033
9274
 
9275
+ // src/study/index.ts
9276
+ init_TagFilteredContentSource();
9277
+
7034
9278
  // src/index.ts
7035
9279
  init_factory();
7036
9280
  export {
@@ -7044,15 +9288,20 @@ export {
7044
9288
  GuestUsername,
7045
9289
  Loggable,
7046
9290
  NOT_SET,
9291
+ NavigatorRole,
9292
+ NavigatorRoles,
7047
9293
  Navigators,
7048
9294
  SessionController,
7049
9295
  StaticToCouchDBMigrator,
9296
+ TagFilteredContentSource,
7050
9297
  _resetDataLayer,
7051
9298
  areQuestionRecords,
9299
+ buildStrategyStateId,
7052
9300
  docIsDeleted,
7053
9301
  ensureAppDataDirectory,
7054
9302
  getAppDataDirectory,
7055
9303
  getCardHistoryID,
9304
+ getCardOrigin,
7056
9305
  getDataLayer,
7057
9306
  getDbPath,
7058
9307
  getLogFilePath,
@@ -7061,6 +9310,8 @@ export {
7061
9310
  initializeDataDirectory,
7062
9311
  initializeDataLayer,
7063
9312
  initializeTuiLogging,
9313
+ isFilter,
9314
+ isGenerator,
7064
9315
  isQuestionRecord,
7065
9316
  isReview,
7066
9317
  log,