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