@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.mjs
CHANGED
|
@@ -922,23 +922,460 @@ var init_courseLookupDB = __esm({
|
|
|
922
922
|
}
|
|
923
923
|
});
|
|
924
924
|
|
|
925
|
+
// src/core/navigators/CompositeGenerator.ts
|
|
926
|
+
var CompositeGenerator_exports = {};
|
|
927
|
+
__export(CompositeGenerator_exports, {
|
|
928
|
+
AggregationMode: () => AggregationMode,
|
|
929
|
+
default: () => CompositeGenerator
|
|
930
|
+
});
|
|
931
|
+
var AggregationMode, DEFAULT_AGGREGATION_MODE, FREQUENCY_BOOST_FACTOR, CompositeGenerator;
|
|
932
|
+
var init_CompositeGenerator = __esm({
|
|
933
|
+
"src/core/navigators/CompositeGenerator.ts"() {
|
|
934
|
+
"use strict";
|
|
935
|
+
init_navigators();
|
|
936
|
+
init_logger();
|
|
937
|
+
AggregationMode = /* @__PURE__ */ ((AggregationMode2) => {
|
|
938
|
+
AggregationMode2["MAX"] = "max";
|
|
939
|
+
AggregationMode2["AVERAGE"] = "average";
|
|
940
|
+
AggregationMode2["FREQUENCY_BOOST"] = "frequencyBoost";
|
|
941
|
+
return AggregationMode2;
|
|
942
|
+
})(AggregationMode || {});
|
|
943
|
+
DEFAULT_AGGREGATION_MODE = "frequencyBoost" /* FREQUENCY_BOOST */;
|
|
944
|
+
FREQUENCY_BOOST_FACTOR = 0.1;
|
|
945
|
+
CompositeGenerator = class _CompositeGenerator extends ContentNavigator {
|
|
946
|
+
/** Human-readable name for CardGenerator interface */
|
|
947
|
+
name = "Composite Generator";
|
|
948
|
+
generators;
|
|
949
|
+
aggregationMode;
|
|
950
|
+
constructor(generators, aggregationMode = DEFAULT_AGGREGATION_MODE) {
|
|
951
|
+
super();
|
|
952
|
+
this.generators = generators;
|
|
953
|
+
this.aggregationMode = aggregationMode;
|
|
954
|
+
if (generators.length === 0) {
|
|
955
|
+
throw new Error("CompositeGenerator requires at least one generator");
|
|
956
|
+
}
|
|
957
|
+
logger.debug(
|
|
958
|
+
`[CompositeGenerator] Created with ${generators.length} generators, mode: ${aggregationMode}`
|
|
959
|
+
);
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
* Creates a CompositeGenerator from strategy data.
|
|
963
|
+
*
|
|
964
|
+
* This is a convenience factory for use by PipelineAssembler.
|
|
965
|
+
*/
|
|
966
|
+
static async fromStrategies(user, course, strategies, aggregationMode = DEFAULT_AGGREGATION_MODE) {
|
|
967
|
+
const generators = await Promise.all(
|
|
968
|
+
strategies.map((s) => ContentNavigator.create(user, course, s))
|
|
969
|
+
);
|
|
970
|
+
return new _CompositeGenerator(generators, aggregationMode);
|
|
971
|
+
}
|
|
972
|
+
/**
|
|
973
|
+
* Get weighted cards from all generators, merge and deduplicate.
|
|
974
|
+
*
|
|
975
|
+
* Cards appearing in multiple generators receive a score boost.
|
|
976
|
+
* Provenance tracks which generators produced each card and how scores were aggregated.
|
|
977
|
+
*
|
|
978
|
+
* This method supports both the legacy signature (limit only) and the
|
|
979
|
+
* CardGenerator interface signature (limit, context).
|
|
980
|
+
*
|
|
981
|
+
* @param limit - Maximum number of cards to return
|
|
982
|
+
* @param context - Optional GeneratorContext passed to child generators
|
|
983
|
+
*/
|
|
984
|
+
async getWeightedCards(limit, context) {
|
|
985
|
+
const results = await Promise.all(
|
|
986
|
+
this.generators.map((g) => g.getWeightedCards(limit, context))
|
|
987
|
+
);
|
|
988
|
+
const byCardId = /* @__PURE__ */ new Map();
|
|
989
|
+
for (const cards of results) {
|
|
990
|
+
for (const card of cards) {
|
|
991
|
+
const existing = byCardId.get(card.cardId) || [];
|
|
992
|
+
existing.push(card);
|
|
993
|
+
byCardId.set(card.cardId, existing);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
const merged = [];
|
|
997
|
+
for (const [, cards] of byCardId) {
|
|
998
|
+
const aggregatedScore = this.aggregateScores(cards);
|
|
999
|
+
const finalScore = Math.min(1, aggregatedScore);
|
|
1000
|
+
const mergedProvenance = cards.flatMap((c) => c.provenance);
|
|
1001
|
+
const initialScore = cards[0].score;
|
|
1002
|
+
const action = finalScore > initialScore ? "boosted" : finalScore < initialScore ? "penalized" : "passed";
|
|
1003
|
+
const reason = this.buildAggregationReason(cards, finalScore);
|
|
1004
|
+
merged.push({
|
|
1005
|
+
...cards[0],
|
|
1006
|
+
score: finalScore,
|
|
1007
|
+
provenance: [
|
|
1008
|
+
...mergedProvenance,
|
|
1009
|
+
{
|
|
1010
|
+
strategy: "composite",
|
|
1011
|
+
strategyName: "Composite Generator",
|
|
1012
|
+
strategyId: "COMPOSITE_GENERATOR",
|
|
1013
|
+
action,
|
|
1014
|
+
score: finalScore,
|
|
1015
|
+
reason
|
|
1016
|
+
}
|
|
1017
|
+
]
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
return merged.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
1021
|
+
}
|
|
1022
|
+
/**
|
|
1023
|
+
* Build human-readable reason for score aggregation.
|
|
1024
|
+
*/
|
|
1025
|
+
buildAggregationReason(cards, finalScore) {
|
|
1026
|
+
const count = cards.length;
|
|
1027
|
+
const scores = cards.map((c) => c.score.toFixed(2)).join(", ");
|
|
1028
|
+
if (count === 1) {
|
|
1029
|
+
return `Single generator, score ${finalScore.toFixed(2)}`;
|
|
1030
|
+
}
|
|
1031
|
+
const strategies = cards.map((c) => c.provenance[0]?.strategy || "unknown").join(", ");
|
|
1032
|
+
switch (this.aggregationMode) {
|
|
1033
|
+
case "max" /* MAX */:
|
|
1034
|
+
return `Max of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
|
|
1035
|
+
case "average" /* AVERAGE */:
|
|
1036
|
+
return `Average of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
|
|
1037
|
+
case "frequencyBoost" /* FREQUENCY_BOOST */: {
|
|
1038
|
+
const avg = cards.reduce((sum, c) => sum + c.score, 0) / count;
|
|
1039
|
+
const boost = 1 + FREQUENCY_BOOST_FACTOR * (count - 1);
|
|
1040
|
+
return `Frequency boost from ${count} generators (${strategies}): avg ${avg.toFixed(2)} \xD7 ${boost.toFixed(2)} \u2192 ${finalScore.toFixed(2)}`;
|
|
1041
|
+
}
|
|
1042
|
+
default:
|
|
1043
|
+
return `Aggregated from ${count} generators: ${finalScore.toFixed(2)}`;
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* Aggregate scores from multiple generators for the same card.
|
|
1048
|
+
*/
|
|
1049
|
+
aggregateScores(cards) {
|
|
1050
|
+
const scores = cards.map((c) => c.score);
|
|
1051
|
+
switch (this.aggregationMode) {
|
|
1052
|
+
case "max" /* MAX */:
|
|
1053
|
+
return Math.max(...scores);
|
|
1054
|
+
case "average" /* AVERAGE */:
|
|
1055
|
+
return scores.reduce((sum, s) => sum + s, 0) / scores.length;
|
|
1056
|
+
case "frequencyBoost" /* FREQUENCY_BOOST */: {
|
|
1057
|
+
const avg = scores.reduce((sum, s) => sum + s, 0) / scores.length;
|
|
1058
|
+
const frequencyBoost = 1 + FREQUENCY_BOOST_FACTOR * (cards.length - 1);
|
|
1059
|
+
return avg * frequencyBoost;
|
|
1060
|
+
}
|
|
1061
|
+
default:
|
|
1062
|
+
return scores[0];
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
/**
|
|
1066
|
+
* Get new cards from all generators, merged and deduplicated.
|
|
1067
|
+
*/
|
|
1068
|
+
async getNewCards(n) {
|
|
1069
|
+
const legacyGenerators = this.generators.filter(
|
|
1070
|
+
(g) => g instanceof ContentNavigator
|
|
1071
|
+
);
|
|
1072
|
+
const results = await Promise.all(legacyGenerators.map((g) => g.getNewCards(n)));
|
|
1073
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1074
|
+
const merged = [];
|
|
1075
|
+
for (const cards of results) {
|
|
1076
|
+
for (const card of cards) {
|
|
1077
|
+
if (!seen.has(card.cardID)) {
|
|
1078
|
+
seen.add(card.cardID);
|
|
1079
|
+
merged.push(card);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
return n ? merged.slice(0, n) : merged;
|
|
1084
|
+
}
|
|
1085
|
+
/**
|
|
1086
|
+
* Get pending reviews from all generators, merged and deduplicated.
|
|
1087
|
+
*/
|
|
1088
|
+
async getPendingReviews() {
|
|
1089
|
+
const legacyGenerators = this.generators.filter(
|
|
1090
|
+
(g) => g instanceof ContentNavigator
|
|
1091
|
+
);
|
|
1092
|
+
const results = await Promise.all(legacyGenerators.map((g) => g.getPendingReviews()));
|
|
1093
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1094
|
+
const merged = [];
|
|
1095
|
+
for (const reviews of results) {
|
|
1096
|
+
for (const review of reviews) {
|
|
1097
|
+
if (!seen.has(review.cardID)) {
|
|
1098
|
+
seen.add(review.cardID);
|
|
1099
|
+
merged.push(review);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
return merged;
|
|
1104
|
+
}
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
// src/core/navigators/Pipeline.ts
|
|
1110
|
+
var Pipeline_exports = {};
|
|
1111
|
+
__export(Pipeline_exports, {
|
|
1112
|
+
Pipeline: () => Pipeline
|
|
1113
|
+
});
|
|
1114
|
+
import { toCourseElo as toCourseElo2 } from "@vue-skuilder/common";
|
|
1115
|
+
var Pipeline;
|
|
1116
|
+
var init_Pipeline = __esm({
|
|
1117
|
+
"src/core/navigators/Pipeline.ts"() {
|
|
1118
|
+
"use strict";
|
|
1119
|
+
init_navigators();
|
|
1120
|
+
init_logger();
|
|
1121
|
+
Pipeline = class extends ContentNavigator {
|
|
1122
|
+
generator;
|
|
1123
|
+
filters;
|
|
1124
|
+
/**
|
|
1125
|
+
* Create a new pipeline.
|
|
1126
|
+
*
|
|
1127
|
+
* @param generator - The generator (or CompositeGenerator) that produces candidates
|
|
1128
|
+
* @param filters - Filters to apply sequentially (order doesn't matter for multipliers)
|
|
1129
|
+
* @param user - User database interface
|
|
1130
|
+
* @param course - Course database interface
|
|
1131
|
+
*/
|
|
1132
|
+
constructor(generator, filters, user, course) {
|
|
1133
|
+
super();
|
|
1134
|
+
this.generator = generator;
|
|
1135
|
+
this.filters = filters;
|
|
1136
|
+
this.user = user;
|
|
1137
|
+
this.course = course;
|
|
1138
|
+
logger.debug(
|
|
1139
|
+
`[Pipeline] Created with generator '${generator.name}' and ${filters.length} filters: ${filters.map((f) => f.name).join(", ")}`
|
|
1140
|
+
);
|
|
1141
|
+
}
|
|
1142
|
+
/**
|
|
1143
|
+
* Get weighted cards by running generator and applying filters.
|
|
1144
|
+
*
|
|
1145
|
+
* 1. Build shared context (user ELO, etc.)
|
|
1146
|
+
* 2. Get candidates from generator (passing context)
|
|
1147
|
+
* 3. Apply each filter sequentially
|
|
1148
|
+
* 4. Remove zero-score cards
|
|
1149
|
+
* 5. Sort by score descending
|
|
1150
|
+
* 6. Return top N
|
|
1151
|
+
*
|
|
1152
|
+
* @param limit - Maximum number of cards to return
|
|
1153
|
+
* @returns Cards sorted by score descending
|
|
1154
|
+
*/
|
|
1155
|
+
async getWeightedCards(limit) {
|
|
1156
|
+
const context = await this.buildContext();
|
|
1157
|
+
const overFetchMultiplier = 2 + this.filters.length * 0.5;
|
|
1158
|
+
const fetchLimit = Math.ceil(limit * overFetchMultiplier);
|
|
1159
|
+
logger.debug(
|
|
1160
|
+
`[Pipeline] Fetching ${fetchLimit} candidates from generator '${this.generator.name}'`
|
|
1161
|
+
);
|
|
1162
|
+
let cards = await this.generator.getWeightedCards(fetchLimit, context);
|
|
1163
|
+
logger.debug(`[Pipeline] Generator returned ${cards.length} candidates`);
|
|
1164
|
+
for (const filter of this.filters) {
|
|
1165
|
+
const beforeCount = cards.length;
|
|
1166
|
+
cards = await filter.transform(cards, context);
|
|
1167
|
+
logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeCount} \u2192 ${cards.length} cards`);
|
|
1168
|
+
}
|
|
1169
|
+
cards = cards.filter((c) => c.score > 0);
|
|
1170
|
+
cards.sort((a, b) => b.score - a.score);
|
|
1171
|
+
const result = cards.slice(0, limit);
|
|
1172
|
+
logger.debug(
|
|
1173
|
+
`[Pipeline] Returning ${result.length} cards (top scores: ${result.slice(0, 3).map((c) => c.score.toFixed(2)).join(", ")}...)`
|
|
1174
|
+
);
|
|
1175
|
+
return result;
|
|
1176
|
+
}
|
|
1177
|
+
/**
|
|
1178
|
+
* Build shared context for generator and filters.
|
|
1179
|
+
*
|
|
1180
|
+
* Called once per getWeightedCards() invocation.
|
|
1181
|
+
* Contains data that the generator and multiple filters might need.
|
|
1182
|
+
*
|
|
1183
|
+
* The context satisfies both GeneratorContext and FilterContext interfaces.
|
|
1184
|
+
*/
|
|
1185
|
+
async buildContext() {
|
|
1186
|
+
let userElo = 1e3;
|
|
1187
|
+
try {
|
|
1188
|
+
const courseReg = await this.user.getCourseRegDoc(this.course.getCourseID());
|
|
1189
|
+
const courseElo = toCourseElo2(courseReg.elo);
|
|
1190
|
+
userElo = courseElo.global.score;
|
|
1191
|
+
} catch (e) {
|
|
1192
|
+
logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`);
|
|
1193
|
+
}
|
|
1194
|
+
return {
|
|
1195
|
+
user: this.user,
|
|
1196
|
+
course: this.course,
|
|
1197
|
+
userElo
|
|
1198
|
+
};
|
|
1199
|
+
}
|
|
1200
|
+
// ===========================================================================
|
|
1201
|
+
// Legacy StudyContentSource methods
|
|
1202
|
+
// ===========================================================================
|
|
1203
|
+
//
|
|
1204
|
+
// These delegate to the generator for backward compatibility.
|
|
1205
|
+
// Eventually SessionController will use getWeightedCards() exclusively.
|
|
1206
|
+
//
|
|
1207
|
+
/**
|
|
1208
|
+
* Get new cards via legacy API.
|
|
1209
|
+
* Delegates to the generator if it supports the legacy interface.
|
|
1210
|
+
*/
|
|
1211
|
+
async getNewCards(n) {
|
|
1212
|
+
if ("getNewCards" in this.generator && typeof this.generator.getNewCards === "function") {
|
|
1213
|
+
return this.generator.getNewCards(n);
|
|
1214
|
+
}
|
|
1215
|
+
return [];
|
|
1216
|
+
}
|
|
1217
|
+
/**
|
|
1218
|
+
* Get pending reviews via legacy API.
|
|
1219
|
+
* Delegates to the generator if it supports the legacy interface.
|
|
1220
|
+
*/
|
|
1221
|
+
async getPendingReviews() {
|
|
1222
|
+
if ("getPendingReviews" in this.generator && typeof this.generator.getPendingReviews === "function") {
|
|
1223
|
+
return this.generator.getPendingReviews();
|
|
1224
|
+
}
|
|
1225
|
+
return [];
|
|
1226
|
+
}
|
|
1227
|
+
/**
|
|
1228
|
+
* Get the course ID for this pipeline.
|
|
1229
|
+
*/
|
|
1230
|
+
getCourseID() {
|
|
1231
|
+
return this.course.getCourseID();
|
|
1232
|
+
}
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
// src/core/navigators/PipelineAssembler.ts
|
|
1238
|
+
var PipelineAssembler_exports = {};
|
|
1239
|
+
__export(PipelineAssembler_exports, {
|
|
1240
|
+
PipelineAssembler: () => PipelineAssembler
|
|
1241
|
+
});
|
|
1242
|
+
var PipelineAssembler;
|
|
1243
|
+
var init_PipelineAssembler = __esm({
|
|
1244
|
+
"src/core/navigators/PipelineAssembler.ts"() {
|
|
1245
|
+
"use strict";
|
|
1246
|
+
init_navigators();
|
|
1247
|
+
init_Pipeline();
|
|
1248
|
+
init_types_legacy();
|
|
1249
|
+
init_logger();
|
|
1250
|
+
init_CompositeGenerator();
|
|
1251
|
+
PipelineAssembler = class {
|
|
1252
|
+
/**
|
|
1253
|
+
* Assembles a navigation pipeline from strategy documents.
|
|
1254
|
+
*
|
|
1255
|
+
* 1. Separates into generators and filters by role
|
|
1256
|
+
* 2. Validates at least one generator exists (or creates default ELO)
|
|
1257
|
+
* 3. Instantiates generators - wraps multiple in CompositeGenerator
|
|
1258
|
+
* 4. Instantiates filters
|
|
1259
|
+
* 5. Returns Pipeline(generator, filters)
|
|
1260
|
+
*
|
|
1261
|
+
* @param input - Strategy documents plus user/course interfaces
|
|
1262
|
+
* @returns Assembled pipeline and any warnings
|
|
1263
|
+
*/
|
|
1264
|
+
async assemble(input) {
|
|
1265
|
+
const { strategies, user, course } = input;
|
|
1266
|
+
const warnings = [];
|
|
1267
|
+
if (strategies.length === 0) {
|
|
1268
|
+
return {
|
|
1269
|
+
pipeline: null,
|
|
1270
|
+
generatorStrategies: [],
|
|
1271
|
+
filterStrategies: [],
|
|
1272
|
+
warnings
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
const generatorStrategies = [];
|
|
1276
|
+
const filterStrategies = [];
|
|
1277
|
+
for (const s of strategies) {
|
|
1278
|
+
if (isGenerator(s.implementingClass)) {
|
|
1279
|
+
generatorStrategies.push(s);
|
|
1280
|
+
} else if (isFilter(s.implementingClass)) {
|
|
1281
|
+
filterStrategies.push(s);
|
|
1282
|
+
} else {
|
|
1283
|
+
warnings.push(`Unknown strategy type '${s.implementingClass}', skipping: ${s.name}`);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
if (generatorStrategies.length === 0) {
|
|
1287
|
+
if (filterStrategies.length > 0) {
|
|
1288
|
+
logger.debug(
|
|
1289
|
+
"[PipelineAssembler] No generator found, using default ELO with configured filters"
|
|
1290
|
+
);
|
|
1291
|
+
generatorStrategies.push(this.makeDefaultEloStrategy(course.getCourseID()));
|
|
1292
|
+
} else {
|
|
1293
|
+
warnings.push("No generator strategy found");
|
|
1294
|
+
return {
|
|
1295
|
+
pipeline: null,
|
|
1296
|
+
generatorStrategies: [],
|
|
1297
|
+
filterStrategies: [],
|
|
1298
|
+
warnings
|
|
1299
|
+
};
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
let generator;
|
|
1303
|
+
if (generatorStrategies.length === 1) {
|
|
1304
|
+
const nav = await ContentNavigator.create(user, course, generatorStrategies[0]);
|
|
1305
|
+
generator = nav;
|
|
1306
|
+
logger.debug(`[PipelineAssembler] Using single generator: ${generatorStrategies[0].name}`);
|
|
1307
|
+
} else {
|
|
1308
|
+
logger.debug(
|
|
1309
|
+
`[PipelineAssembler] Using CompositeGenerator for ${generatorStrategies.length} generators: ${generatorStrategies.map((g) => g.name).join(", ")}`
|
|
1310
|
+
);
|
|
1311
|
+
generator = await CompositeGenerator.fromStrategies(user, course, generatorStrategies);
|
|
1312
|
+
}
|
|
1313
|
+
const filters = [];
|
|
1314
|
+
const sortedFilterStrategies = [...filterStrategies].sort(
|
|
1315
|
+
(a, b) => a.name.localeCompare(b.name)
|
|
1316
|
+
);
|
|
1317
|
+
for (const filterStrategy of sortedFilterStrategies) {
|
|
1318
|
+
try {
|
|
1319
|
+
const nav = await ContentNavigator.create(user, course, filterStrategy);
|
|
1320
|
+
if ("transform" in nav && typeof nav.transform === "function") {
|
|
1321
|
+
filters.push(nav);
|
|
1322
|
+
logger.debug(`[PipelineAssembler] Added filter: ${filterStrategy.name}`);
|
|
1323
|
+
} else {
|
|
1324
|
+
warnings.push(
|
|
1325
|
+
`Filter '${filterStrategy.name}' does not implement CardFilter.transform(), skipping`
|
|
1326
|
+
);
|
|
1327
|
+
}
|
|
1328
|
+
} catch (e) {
|
|
1329
|
+
warnings.push(`Failed to instantiate filter '${filterStrategy.name}': ${e}`);
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
const pipeline = new Pipeline(generator, filters, user, course);
|
|
1333
|
+
logger.debug(
|
|
1334
|
+
`[PipelineAssembler] Assembled pipeline with ${generatorStrategies.length} generator(s) and ${filters.length} filter(s)`
|
|
1335
|
+
);
|
|
1336
|
+
return {
|
|
1337
|
+
pipeline,
|
|
1338
|
+
generatorStrategies,
|
|
1339
|
+
filterStrategies: sortedFilterStrategies,
|
|
1340
|
+
warnings
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
/**
|
|
1344
|
+
* Creates a default ELO generator strategy.
|
|
1345
|
+
* Used when filters are configured but no generator is specified.
|
|
1346
|
+
*/
|
|
1347
|
+
makeDefaultEloStrategy(courseId) {
|
|
1348
|
+
return {
|
|
1349
|
+
_id: "NAVIGATION_STRATEGY-ELO-default",
|
|
1350
|
+
course: courseId,
|
|
1351
|
+
docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
|
|
1352
|
+
name: "ELO (default)",
|
|
1353
|
+
description: "Default ELO-based generator",
|
|
1354
|
+
implementingClass: "elo" /* ELO */,
|
|
1355
|
+
serializedData: ""
|
|
1356
|
+
};
|
|
1357
|
+
}
|
|
1358
|
+
};
|
|
1359
|
+
}
|
|
1360
|
+
});
|
|
1361
|
+
|
|
925
1362
|
// src/core/navigators/elo.ts
|
|
926
1363
|
var elo_exports = {};
|
|
927
1364
|
__export(elo_exports, {
|
|
928
1365
|
default: () => ELONavigator
|
|
929
1366
|
});
|
|
1367
|
+
import { toCourseElo as toCourseElo3 } from "@vue-skuilder/common";
|
|
930
1368
|
var ELONavigator;
|
|
931
1369
|
var init_elo = __esm({
|
|
932
1370
|
"src/core/navigators/elo.ts"() {
|
|
933
1371
|
"use strict";
|
|
934
1372
|
init_navigators();
|
|
935
1373
|
ELONavigator = class extends ContentNavigator {
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
constructor(user, course) {
|
|
939
|
-
super();
|
|
940
|
-
this.
|
|
941
|
-
this.course = course;
|
|
1374
|
+
/** Human-readable name for CardGenerator interface */
|
|
1375
|
+
name;
|
|
1376
|
+
constructor(user, course, strategyData) {
|
|
1377
|
+
super(user, course, strategyData);
|
|
1378
|
+
this.name = strategyData?.name || "ELO";
|
|
942
1379
|
}
|
|
943
1380
|
async getPendingReviews() {
|
|
944
1381
|
const reviews = await this.user.getPendingReviews(this.course.getCourseID());
|
|
@@ -984,10 +1421,154 @@ var init_elo = __esm({
|
|
|
984
1421
|
};
|
|
985
1422
|
});
|
|
986
1423
|
}
|
|
1424
|
+
/**
|
|
1425
|
+
* Get new cards with suitability scores based on ELO distance.
|
|
1426
|
+
*
|
|
1427
|
+
* Cards closer to user's ELO get higher scores.
|
|
1428
|
+
* Score formula: max(0, 1 - distance / 500)
|
|
1429
|
+
*
|
|
1430
|
+
* NOTE: This generator only handles NEW cards. Reviews are handled by
|
|
1431
|
+
* SRSNavigator. Use CompositeGenerator to combine both.
|
|
1432
|
+
*
|
|
1433
|
+
* This method supports both the legacy signature (limit only) and the
|
|
1434
|
+
* CardGenerator interface signature (limit, context).
|
|
1435
|
+
*
|
|
1436
|
+
* @param limit - Maximum number of cards to return
|
|
1437
|
+
* @param context - Optional GeneratorContext (used when called via Pipeline)
|
|
1438
|
+
*/
|
|
1439
|
+
async getWeightedCards(limit, context) {
|
|
1440
|
+
let userGlobalElo;
|
|
1441
|
+
if (context?.userElo !== void 0) {
|
|
1442
|
+
userGlobalElo = context.userElo;
|
|
1443
|
+
} else {
|
|
1444
|
+
const courseReg = await this.user.getCourseRegDoc(this.course.getCourseID());
|
|
1445
|
+
const userElo = toCourseElo3(courseReg.elo);
|
|
1446
|
+
userGlobalElo = userElo.global.score;
|
|
1447
|
+
}
|
|
1448
|
+
const newCards = await this.getNewCards(limit);
|
|
1449
|
+
const cardIds = newCards.map((c) => c.cardID);
|
|
1450
|
+
const cardEloData = await this.course.getCardEloData(cardIds);
|
|
1451
|
+
const scored = newCards.map((c, i) => {
|
|
1452
|
+
const cardElo = cardEloData[i]?.global?.score ?? 1e3;
|
|
1453
|
+
const distance = Math.abs(cardElo - userGlobalElo);
|
|
1454
|
+
const score = Math.max(0, 1 - distance / 500);
|
|
1455
|
+
return {
|
|
1456
|
+
cardId: c.cardID,
|
|
1457
|
+
courseId: c.courseID,
|
|
1458
|
+
score,
|
|
1459
|
+
provenance: [
|
|
1460
|
+
{
|
|
1461
|
+
strategy: "elo",
|
|
1462
|
+
strategyName: this.strategyName || this.name,
|
|
1463
|
+
strategyId: this.strategyId || "NAVIGATION_STRATEGY-ELO-default",
|
|
1464
|
+
action: "generated",
|
|
1465
|
+
score,
|
|
1466
|
+
reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userGlobalElo)}), new card`
|
|
1467
|
+
}
|
|
1468
|
+
]
|
|
1469
|
+
};
|
|
1470
|
+
});
|
|
1471
|
+
scored.sort((a, b) => b.score - a.score);
|
|
1472
|
+
return scored.slice(0, limit);
|
|
1473
|
+
}
|
|
987
1474
|
};
|
|
988
1475
|
}
|
|
989
1476
|
});
|
|
990
1477
|
|
|
1478
|
+
// src/core/navigators/filters/eloDistance.ts
|
|
1479
|
+
var eloDistance_exports = {};
|
|
1480
|
+
__export(eloDistance_exports, {
|
|
1481
|
+
DEFAULT_HALF_LIFE: () => DEFAULT_HALF_LIFE,
|
|
1482
|
+
DEFAULT_MAX_MULTIPLIER: () => DEFAULT_MAX_MULTIPLIER,
|
|
1483
|
+
DEFAULT_MIN_MULTIPLIER: () => DEFAULT_MIN_MULTIPLIER,
|
|
1484
|
+
createEloDistanceFilter: () => createEloDistanceFilter
|
|
1485
|
+
});
|
|
1486
|
+
function computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier) {
|
|
1487
|
+
const normalizedDistance = distance / halfLife;
|
|
1488
|
+
const decay = Math.exp(-(normalizedDistance * normalizedDistance));
|
|
1489
|
+
return minMultiplier + (maxMultiplier - minMultiplier) * decay;
|
|
1490
|
+
}
|
|
1491
|
+
function createEloDistanceFilter(config) {
|
|
1492
|
+
const halfLife = config?.halfLife ?? DEFAULT_HALF_LIFE;
|
|
1493
|
+
const minMultiplier = config?.minMultiplier ?? DEFAULT_MIN_MULTIPLIER;
|
|
1494
|
+
const maxMultiplier = config?.maxMultiplier ?? DEFAULT_MAX_MULTIPLIER;
|
|
1495
|
+
return {
|
|
1496
|
+
name: "ELO Distance Filter",
|
|
1497
|
+
async transform(cards, context) {
|
|
1498
|
+
const { course, userElo } = context;
|
|
1499
|
+
const cardIds = cards.map((c) => c.cardId);
|
|
1500
|
+
const cardElos = await course.getCardEloData(cardIds);
|
|
1501
|
+
return cards.map((card, i) => {
|
|
1502
|
+
const cardElo = cardElos[i]?.global?.score ?? 1e3;
|
|
1503
|
+
const distance = Math.abs(cardElo - userElo);
|
|
1504
|
+
const multiplier = computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier);
|
|
1505
|
+
const newScore = card.score * multiplier;
|
|
1506
|
+
const action = multiplier < maxMultiplier - 0.01 ? "penalized" : "passed";
|
|
1507
|
+
return {
|
|
1508
|
+
...card,
|
|
1509
|
+
score: newScore,
|
|
1510
|
+
provenance: [
|
|
1511
|
+
...card.provenance,
|
|
1512
|
+
{
|
|
1513
|
+
strategy: "eloDistance",
|
|
1514
|
+
strategyName: "ELO Distance Filter",
|
|
1515
|
+
strategyId: "ELO_DISTANCE_FILTER",
|
|
1516
|
+
action,
|
|
1517
|
+
score: newScore,
|
|
1518
|
+
reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userElo)}) \u2192 ${multiplier.toFixed(2)}x`
|
|
1519
|
+
}
|
|
1520
|
+
]
|
|
1521
|
+
};
|
|
1522
|
+
});
|
|
1523
|
+
}
|
|
1524
|
+
};
|
|
1525
|
+
}
|
|
1526
|
+
var DEFAULT_HALF_LIFE, DEFAULT_MIN_MULTIPLIER, DEFAULT_MAX_MULTIPLIER;
|
|
1527
|
+
var init_eloDistance = __esm({
|
|
1528
|
+
"src/core/navigators/filters/eloDistance.ts"() {
|
|
1529
|
+
"use strict";
|
|
1530
|
+
DEFAULT_HALF_LIFE = 200;
|
|
1531
|
+
DEFAULT_MIN_MULTIPLIER = 0.3;
|
|
1532
|
+
DEFAULT_MAX_MULTIPLIER = 1;
|
|
1533
|
+
}
|
|
1534
|
+
});
|
|
1535
|
+
|
|
1536
|
+
// src/core/navigators/filters/index.ts
|
|
1537
|
+
var filters_exports = {};
|
|
1538
|
+
__export(filters_exports, {
|
|
1539
|
+
createEloDistanceFilter: () => createEloDistanceFilter
|
|
1540
|
+
});
|
|
1541
|
+
var init_filters = __esm({
|
|
1542
|
+
"src/core/navigators/filters/index.ts"() {
|
|
1543
|
+
"use strict";
|
|
1544
|
+
init_eloDistance();
|
|
1545
|
+
}
|
|
1546
|
+
});
|
|
1547
|
+
|
|
1548
|
+
// src/core/navigators/filters/types.ts
|
|
1549
|
+
var types_exports = {};
|
|
1550
|
+
var init_types = __esm({
|
|
1551
|
+
"src/core/navigators/filters/types.ts"() {
|
|
1552
|
+
"use strict";
|
|
1553
|
+
}
|
|
1554
|
+
});
|
|
1555
|
+
|
|
1556
|
+
// src/core/navigators/generators/index.ts
|
|
1557
|
+
var generators_exports = {};
|
|
1558
|
+
var init_generators = __esm({
|
|
1559
|
+
"src/core/navigators/generators/index.ts"() {
|
|
1560
|
+
"use strict";
|
|
1561
|
+
}
|
|
1562
|
+
});
|
|
1563
|
+
|
|
1564
|
+
// src/core/navigators/generators/types.ts
|
|
1565
|
+
var types_exports2 = {};
|
|
1566
|
+
var init_types2 = __esm({
|
|
1567
|
+
"src/core/navigators/generators/types.ts"() {
|
|
1568
|
+
"use strict";
|
|
1569
|
+
}
|
|
1570
|
+
});
|
|
1571
|
+
|
|
991
1572
|
// src/core/navigators/hardcodedOrder.ts
|
|
992
1573
|
var hardcodedOrder_exports = {};
|
|
993
1574
|
__export(hardcodedOrder_exports, {
|
|
@@ -1000,13 +1581,12 @@ var init_hardcodedOrder = __esm({
|
|
|
1000
1581
|
init_navigators();
|
|
1001
1582
|
init_logger();
|
|
1002
1583
|
HardcodedOrderNavigator = class extends ContentNavigator {
|
|
1584
|
+
/** Human-readable name for CardGenerator interface */
|
|
1585
|
+
name;
|
|
1003
1586
|
orderedCardIds = [];
|
|
1004
|
-
user;
|
|
1005
|
-
course;
|
|
1006
1587
|
constructor(user, course, strategyData) {
|
|
1007
|
-
super();
|
|
1008
|
-
this.
|
|
1009
|
-
this.course = course;
|
|
1588
|
+
super(user, course, strategyData);
|
|
1589
|
+
this.name = strategyData.name || "Hardcoded Order";
|
|
1010
1590
|
if (strategyData.serializedData) {
|
|
1011
1591
|
try {
|
|
1012
1592
|
this.orderedCardIds = JSON.parse(strategyData.serializedData);
|
|
@@ -1014,36 +1594,792 @@ var init_hardcodedOrder = __esm({
|
|
|
1014
1594
|
logger.error("Failed to parse serializedData for HardcodedOrderNavigator", e);
|
|
1015
1595
|
}
|
|
1016
1596
|
}
|
|
1017
|
-
}
|
|
1018
|
-
async getPendingReviews() {
|
|
1597
|
+
}
|
|
1598
|
+
async getPendingReviews() {
|
|
1599
|
+
const reviews = await this.user.getPendingReviews(this.course.getCourseID());
|
|
1600
|
+
return reviews.map((r) => {
|
|
1601
|
+
return {
|
|
1602
|
+
...r,
|
|
1603
|
+
contentSourceType: "course",
|
|
1604
|
+
contentSourceID: this.course.getCourseID(),
|
|
1605
|
+
cardID: r.cardId,
|
|
1606
|
+
courseID: r.courseId,
|
|
1607
|
+
reviewID: r._id,
|
|
1608
|
+
status: "review"
|
|
1609
|
+
};
|
|
1610
|
+
});
|
|
1611
|
+
}
|
|
1612
|
+
async getNewCards(limit = 99) {
|
|
1613
|
+
const activeCardIds = (await this.user.getActiveCards()).map((c) => c.cardID);
|
|
1614
|
+
const newCardIds = this.orderedCardIds.filter((cardId) => !activeCardIds.includes(cardId));
|
|
1615
|
+
const cardsToReturn = newCardIds.slice(0, limit);
|
|
1616
|
+
return cardsToReturn.map((cardId) => {
|
|
1617
|
+
return {
|
|
1618
|
+
cardID: cardId,
|
|
1619
|
+
courseID: this.course.getCourseID(),
|
|
1620
|
+
contentSourceType: "course",
|
|
1621
|
+
contentSourceID: this.course.getCourseID(),
|
|
1622
|
+
status: "new"
|
|
1623
|
+
};
|
|
1624
|
+
});
|
|
1625
|
+
}
|
|
1626
|
+
/**
|
|
1627
|
+
* Get cards in hardcoded order with scores based on position.
|
|
1628
|
+
*
|
|
1629
|
+
* Earlier cards in the sequence get higher scores.
|
|
1630
|
+
* Score formula: 1.0 - (position / totalCards) * 0.5
|
|
1631
|
+
* This ensures scores range from 1.0 (first card) to 0.5+ (last card).
|
|
1632
|
+
*
|
|
1633
|
+
* This method supports both the legacy signature (limit only) and the
|
|
1634
|
+
* CardGenerator interface signature (limit, context).
|
|
1635
|
+
*
|
|
1636
|
+
* @param limit - Maximum number of cards to return
|
|
1637
|
+
* @param _context - Optional GeneratorContext (currently unused, but required for interface)
|
|
1638
|
+
*/
|
|
1639
|
+
async getWeightedCards(limit, _context) {
|
|
1640
|
+
const activeCardIds = (await this.user.getActiveCards()).map((c) => c.cardID);
|
|
1641
|
+
const reviews = await this.getPendingReviews();
|
|
1642
|
+
const newCardIds = this.orderedCardIds.filter((cardId) => !activeCardIds.includes(cardId));
|
|
1643
|
+
const totalCards = newCardIds.length;
|
|
1644
|
+
const scoredNew = newCardIds.slice(0, limit).map((cardId, index) => {
|
|
1645
|
+
const position = index + 1;
|
|
1646
|
+
const score = Math.max(0.5, 1 - index / totalCards * 0.5);
|
|
1647
|
+
return {
|
|
1648
|
+
cardId,
|
|
1649
|
+
courseId: this.course.getCourseID(),
|
|
1650
|
+
score,
|
|
1651
|
+
provenance: [
|
|
1652
|
+
{
|
|
1653
|
+
strategy: "hardcodedOrder",
|
|
1654
|
+
strategyName: this.strategyName || this.name,
|
|
1655
|
+
strategyId: this.strategyId || "NAVIGATION_STRATEGY-hardcoded",
|
|
1656
|
+
action: "generated",
|
|
1657
|
+
score,
|
|
1658
|
+
reason: `Position ${position} of ${totalCards} in fixed sequence, new card`
|
|
1659
|
+
}
|
|
1660
|
+
]
|
|
1661
|
+
};
|
|
1662
|
+
});
|
|
1663
|
+
const scoredReviews = reviews.map((r) => ({
|
|
1664
|
+
cardId: r.cardID,
|
|
1665
|
+
courseId: r.courseID,
|
|
1666
|
+
score: 1,
|
|
1667
|
+
provenance: [
|
|
1668
|
+
{
|
|
1669
|
+
strategy: "hardcodedOrder",
|
|
1670
|
+
strategyName: this.strategyName || this.name,
|
|
1671
|
+
strategyId: this.strategyId || "NAVIGATION_STRATEGY-hardcoded",
|
|
1672
|
+
action: "generated",
|
|
1673
|
+
score: 1,
|
|
1674
|
+
reason: "Scheduled review, highest priority"
|
|
1675
|
+
}
|
|
1676
|
+
]
|
|
1677
|
+
}));
|
|
1678
|
+
const all = [...scoredReviews, ...scoredNew];
|
|
1679
|
+
all.sort((a, b) => b.score - a.score);
|
|
1680
|
+
return all.slice(0, limit);
|
|
1681
|
+
}
|
|
1682
|
+
};
|
|
1683
|
+
}
|
|
1684
|
+
});
|
|
1685
|
+
|
|
1686
|
+
// src/core/navigators/hierarchyDefinition.ts
|
|
1687
|
+
var hierarchyDefinition_exports = {};
|
|
1688
|
+
__export(hierarchyDefinition_exports, {
|
|
1689
|
+
default: () => HierarchyDefinitionNavigator
|
|
1690
|
+
});
|
|
1691
|
+
import { toCourseElo as toCourseElo4 } from "@vue-skuilder/common";
|
|
1692
|
+
var DEFAULT_MIN_COUNT, HierarchyDefinitionNavigator;
|
|
1693
|
+
var init_hierarchyDefinition = __esm({
|
|
1694
|
+
"src/core/navigators/hierarchyDefinition.ts"() {
|
|
1695
|
+
"use strict";
|
|
1696
|
+
init_navigators();
|
|
1697
|
+
DEFAULT_MIN_COUNT = 3;
|
|
1698
|
+
HierarchyDefinitionNavigator = class extends ContentNavigator {
|
|
1699
|
+
config;
|
|
1700
|
+
_strategyData;
|
|
1701
|
+
/** Human-readable name for CardFilter interface */
|
|
1702
|
+
name;
|
|
1703
|
+
constructor(user, course, _strategyData) {
|
|
1704
|
+
super(user, course, _strategyData);
|
|
1705
|
+
this._strategyData = _strategyData;
|
|
1706
|
+
this.config = this.parseConfig(_strategyData.serializedData);
|
|
1707
|
+
this.name = _strategyData.name || "Hierarchy Definition";
|
|
1708
|
+
}
|
|
1709
|
+
parseConfig(serializedData) {
|
|
1710
|
+
try {
|
|
1711
|
+
const parsed = JSON.parse(serializedData);
|
|
1712
|
+
return {
|
|
1713
|
+
prerequisites: parsed.prerequisites || {}
|
|
1714
|
+
};
|
|
1715
|
+
} catch {
|
|
1716
|
+
return {
|
|
1717
|
+
prerequisites: {}
|
|
1718
|
+
};
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
/**
|
|
1722
|
+
* Check if a specific prerequisite is satisfied
|
|
1723
|
+
*/
|
|
1724
|
+
isPrerequisiteMet(prereq, userTagElo, userGlobalElo) {
|
|
1725
|
+
if (!userTagElo) return false;
|
|
1726
|
+
const minCount = prereq.masteryThreshold?.minCount ?? DEFAULT_MIN_COUNT;
|
|
1727
|
+
if (userTagElo.count < minCount) return false;
|
|
1728
|
+
if (prereq.masteryThreshold?.minElo !== void 0) {
|
|
1729
|
+
return userTagElo.score >= prereq.masteryThreshold.minElo;
|
|
1730
|
+
} else {
|
|
1731
|
+
return userTagElo.score >= userGlobalElo;
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
/**
|
|
1735
|
+
* Get the set of tags the user has mastered.
|
|
1736
|
+
* A tag is "mastered" if it appears as a prerequisite somewhere and meets its threshold.
|
|
1737
|
+
*/
|
|
1738
|
+
async getMasteredTags(context) {
|
|
1739
|
+
const mastered = /* @__PURE__ */ new Set();
|
|
1740
|
+
try {
|
|
1741
|
+
const courseReg = await context.user.getCourseRegDoc(context.course.getCourseID());
|
|
1742
|
+
const userElo = toCourseElo4(courseReg.elo);
|
|
1743
|
+
for (const prereqs of Object.values(this.config.prerequisites)) {
|
|
1744
|
+
for (const prereq of prereqs) {
|
|
1745
|
+
const tagElo = userElo.tags[prereq.tag];
|
|
1746
|
+
if (this.isPrerequisiteMet(prereq, tagElo, userElo.global.score)) {
|
|
1747
|
+
mastered.add(prereq.tag);
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
} catch {
|
|
1752
|
+
}
|
|
1753
|
+
return mastered;
|
|
1754
|
+
}
|
|
1755
|
+
/**
|
|
1756
|
+
* Get the set of tags that are unlocked (prerequisites met)
|
|
1757
|
+
*/
|
|
1758
|
+
getUnlockedTags(masteredTags) {
|
|
1759
|
+
const unlocked = /* @__PURE__ */ new Set();
|
|
1760
|
+
for (const [tagId, prereqs] of Object.entries(this.config.prerequisites)) {
|
|
1761
|
+
const allPrereqsMet = prereqs.every((prereq) => masteredTags.has(prereq.tag));
|
|
1762
|
+
if (allPrereqsMet) {
|
|
1763
|
+
unlocked.add(tagId);
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
return unlocked;
|
|
1767
|
+
}
|
|
1768
|
+
/**
|
|
1769
|
+
* Check if a tag has prerequisites defined in config
|
|
1770
|
+
*/
|
|
1771
|
+
hasPrerequisites(tagId) {
|
|
1772
|
+
return tagId in this.config.prerequisites;
|
|
1773
|
+
}
|
|
1774
|
+
/**
|
|
1775
|
+
* Check if a card is unlocked and generate reason.
|
|
1776
|
+
*/
|
|
1777
|
+
async checkCardUnlock(cardId, course, unlockedTags, masteredTags) {
|
|
1778
|
+
try {
|
|
1779
|
+
const tagResponse = await course.getAppliedTags(cardId);
|
|
1780
|
+
const cardTags = tagResponse.rows.map((row) => row.value?.name || row.key);
|
|
1781
|
+
const lockedTags = cardTags.filter(
|
|
1782
|
+
(tag) => this.hasPrerequisites(tag) && !unlockedTags.has(tag)
|
|
1783
|
+
);
|
|
1784
|
+
if (lockedTags.length === 0) {
|
|
1785
|
+
const tagList = cardTags.length > 0 ? cardTags.join(", ") : "none";
|
|
1786
|
+
return {
|
|
1787
|
+
isUnlocked: true,
|
|
1788
|
+
reason: `Prerequisites met, tags: ${tagList}`
|
|
1789
|
+
};
|
|
1790
|
+
}
|
|
1791
|
+
const missingPrereqs = lockedTags.flatMap((tag) => {
|
|
1792
|
+
const prereqs = this.config.prerequisites[tag] || [];
|
|
1793
|
+
return prereqs.filter((p) => !masteredTags.has(p.tag)).map((p) => p.tag);
|
|
1794
|
+
});
|
|
1795
|
+
return {
|
|
1796
|
+
isUnlocked: false,
|
|
1797
|
+
reason: `Blocked: missing prerequisites ${missingPrereqs.join(", ")} for tags ${lockedTags.join(", ")}`
|
|
1798
|
+
};
|
|
1799
|
+
} catch {
|
|
1800
|
+
return {
|
|
1801
|
+
isUnlocked: true,
|
|
1802
|
+
reason: "Prerequisites check skipped (tag lookup failed)"
|
|
1803
|
+
};
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
/**
|
|
1807
|
+
* CardFilter.transform implementation.
|
|
1808
|
+
*
|
|
1809
|
+
* Apply prerequisite gating to cards. Cards with locked tags receive score: 0.
|
|
1810
|
+
*/
|
|
1811
|
+
async transform(cards, context) {
|
|
1812
|
+
const masteredTags = await this.getMasteredTags(context);
|
|
1813
|
+
const unlockedTags = this.getUnlockedTags(masteredTags);
|
|
1814
|
+
const gated = [];
|
|
1815
|
+
for (const card of cards) {
|
|
1816
|
+
const { isUnlocked, reason } = await this.checkCardUnlock(
|
|
1817
|
+
card.cardId,
|
|
1818
|
+
context.course,
|
|
1819
|
+
unlockedTags,
|
|
1820
|
+
masteredTags
|
|
1821
|
+
);
|
|
1822
|
+
const finalScore = isUnlocked ? card.score : 0;
|
|
1823
|
+
const action = isUnlocked ? "passed" : "penalized";
|
|
1824
|
+
gated.push({
|
|
1825
|
+
...card,
|
|
1826
|
+
score: finalScore,
|
|
1827
|
+
provenance: [
|
|
1828
|
+
...card.provenance,
|
|
1829
|
+
{
|
|
1830
|
+
strategy: "hierarchyDefinition",
|
|
1831
|
+
strategyName: this.strategyName || this.name,
|
|
1832
|
+
strategyId: this.strategyId || "NAVIGATION_STRATEGY-hierarchy",
|
|
1833
|
+
action,
|
|
1834
|
+
score: finalScore,
|
|
1835
|
+
reason
|
|
1836
|
+
}
|
|
1837
|
+
]
|
|
1838
|
+
});
|
|
1839
|
+
}
|
|
1840
|
+
return gated;
|
|
1841
|
+
}
|
|
1842
|
+
/**
|
|
1843
|
+
* Legacy getWeightedCards - now throws as filters should not be used as generators.
|
|
1844
|
+
*
|
|
1845
|
+
* Use transform() via Pipeline instead.
|
|
1846
|
+
*/
|
|
1847
|
+
async getWeightedCards(_limit) {
|
|
1848
|
+
throw new Error(
|
|
1849
|
+
"HierarchyDefinitionNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
|
|
1850
|
+
);
|
|
1851
|
+
}
|
|
1852
|
+
// Legacy methods - stub implementations since filters don't generate cards
|
|
1853
|
+
async getNewCards(_n) {
|
|
1854
|
+
return [];
|
|
1855
|
+
}
|
|
1856
|
+
async getPendingReviews() {
|
|
1857
|
+
return [];
|
|
1858
|
+
}
|
|
1859
|
+
};
|
|
1860
|
+
}
|
|
1861
|
+
});
|
|
1862
|
+
|
|
1863
|
+
// src/core/navigators/interferenceMitigator.ts
|
|
1864
|
+
var interferenceMitigator_exports = {};
|
|
1865
|
+
__export(interferenceMitigator_exports, {
|
|
1866
|
+
default: () => InterferenceMitigatorNavigator
|
|
1867
|
+
});
|
|
1868
|
+
import { toCourseElo as toCourseElo5 } from "@vue-skuilder/common";
|
|
1869
|
+
var DEFAULT_MIN_COUNT2, DEFAULT_MIN_ELAPSED_DAYS, DEFAULT_INTERFERENCE_DECAY, InterferenceMitigatorNavigator;
|
|
1870
|
+
var init_interferenceMitigator = __esm({
|
|
1871
|
+
"src/core/navigators/interferenceMitigator.ts"() {
|
|
1872
|
+
"use strict";
|
|
1873
|
+
init_navigators();
|
|
1874
|
+
DEFAULT_MIN_COUNT2 = 10;
|
|
1875
|
+
DEFAULT_MIN_ELAPSED_DAYS = 3;
|
|
1876
|
+
DEFAULT_INTERFERENCE_DECAY = 0.8;
|
|
1877
|
+
InterferenceMitigatorNavigator = class extends ContentNavigator {
|
|
1878
|
+
config;
|
|
1879
|
+
_strategyData;
|
|
1880
|
+
/** Human-readable name for CardFilter interface */
|
|
1881
|
+
name;
|
|
1882
|
+
/** Precomputed map: tag -> set of { partner, decay } it interferes with */
|
|
1883
|
+
interferenceMap;
|
|
1884
|
+
constructor(user, course, _strategyData) {
|
|
1885
|
+
super(user, course, _strategyData);
|
|
1886
|
+
this._strategyData = _strategyData;
|
|
1887
|
+
this.config = this.parseConfig(_strategyData.serializedData);
|
|
1888
|
+
this.interferenceMap = this.buildInterferenceMap();
|
|
1889
|
+
this.name = _strategyData.name || "Interference Mitigator";
|
|
1890
|
+
}
|
|
1891
|
+
parseConfig(serializedData) {
|
|
1892
|
+
try {
|
|
1893
|
+
const parsed = JSON.parse(serializedData);
|
|
1894
|
+
let sets = parsed.interferenceSets || [];
|
|
1895
|
+
if (sets.length > 0 && Array.isArray(sets[0])) {
|
|
1896
|
+
sets = sets.map((tags) => ({ tags }));
|
|
1897
|
+
}
|
|
1898
|
+
return {
|
|
1899
|
+
interferenceSets: sets,
|
|
1900
|
+
maturityThreshold: {
|
|
1901
|
+
minCount: parsed.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT2,
|
|
1902
|
+
minElo: parsed.maturityThreshold?.minElo,
|
|
1903
|
+
minElapsedDays: parsed.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS
|
|
1904
|
+
},
|
|
1905
|
+
defaultDecay: parsed.defaultDecay ?? DEFAULT_INTERFERENCE_DECAY
|
|
1906
|
+
};
|
|
1907
|
+
} catch {
|
|
1908
|
+
return {
|
|
1909
|
+
interferenceSets: [],
|
|
1910
|
+
maturityThreshold: {
|
|
1911
|
+
minCount: DEFAULT_MIN_COUNT2,
|
|
1912
|
+
minElapsedDays: DEFAULT_MIN_ELAPSED_DAYS
|
|
1913
|
+
},
|
|
1914
|
+
defaultDecay: DEFAULT_INTERFERENCE_DECAY
|
|
1915
|
+
};
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
/**
|
|
1919
|
+
* Build a map from each tag to its interference partners with decay coefficients.
|
|
1920
|
+
* If tags A, B, C are in an interference group with decay 0.8, then:
|
|
1921
|
+
* - A interferes with B (decay 0.8) and C (decay 0.8)
|
|
1922
|
+
* - B interferes with A (decay 0.8) and C (decay 0.8)
|
|
1923
|
+
* - etc.
|
|
1924
|
+
*/
|
|
1925
|
+
buildInterferenceMap() {
|
|
1926
|
+
const map = /* @__PURE__ */ new Map();
|
|
1927
|
+
for (const group of this.config.interferenceSets) {
|
|
1928
|
+
const decay = group.decay ?? this.config.defaultDecay ?? DEFAULT_INTERFERENCE_DECAY;
|
|
1929
|
+
for (const tag of group.tags) {
|
|
1930
|
+
if (!map.has(tag)) {
|
|
1931
|
+
map.set(tag, []);
|
|
1932
|
+
}
|
|
1933
|
+
const partners = map.get(tag);
|
|
1934
|
+
for (const other of group.tags) {
|
|
1935
|
+
if (other !== tag) {
|
|
1936
|
+
const existing = partners.find((p) => p.partner === other);
|
|
1937
|
+
if (existing) {
|
|
1938
|
+
existing.decay = Math.max(existing.decay, decay);
|
|
1939
|
+
} else {
|
|
1940
|
+
partners.push({ partner: other, decay });
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
return map;
|
|
1947
|
+
}
|
|
1948
|
+
/**
|
|
1949
|
+
* Get the set of tags that are currently immature for this user.
|
|
1950
|
+
* A tag is immature if the user has interacted with it but hasn't
|
|
1951
|
+
* reached the maturity threshold.
|
|
1952
|
+
*/
|
|
1953
|
+
async getImmatureTags(context) {
|
|
1954
|
+
const immature = /* @__PURE__ */ new Set();
|
|
1955
|
+
try {
|
|
1956
|
+
const courseReg = await context.user.getCourseRegDoc(context.course.getCourseID());
|
|
1957
|
+
const userElo = toCourseElo5(courseReg.elo);
|
|
1958
|
+
const minCount = this.config.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT2;
|
|
1959
|
+
const minElo = this.config.maturityThreshold?.minElo;
|
|
1960
|
+
const minElapsedDays = this.config.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS;
|
|
1961
|
+
const minCountForElapsed = minElapsedDays * 2;
|
|
1962
|
+
for (const [tagId, tagElo] of Object.entries(userElo.tags)) {
|
|
1963
|
+
if (tagElo.count === 0) continue;
|
|
1964
|
+
const belowCount = tagElo.count < minCount;
|
|
1965
|
+
const belowElo = minElo !== void 0 && tagElo.score < minElo;
|
|
1966
|
+
const belowElapsed = tagElo.count < minCountForElapsed;
|
|
1967
|
+
if (belowCount || belowElo || belowElapsed) {
|
|
1968
|
+
immature.add(tagId);
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
} catch {
|
|
1972
|
+
}
|
|
1973
|
+
return immature;
|
|
1974
|
+
}
|
|
1975
|
+
/**
|
|
1976
|
+
* Get all tags that interfere with any immature tag, along with their decay coefficients.
|
|
1977
|
+
* These are the tags we want to avoid introducing.
|
|
1978
|
+
*/
|
|
1979
|
+
getTagsToAvoid(immatureTags) {
|
|
1980
|
+
const avoid = /* @__PURE__ */ new Map();
|
|
1981
|
+
for (const immatureTag of immatureTags) {
|
|
1982
|
+
const partners = this.interferenceMap.get(immatureTag);
|
|
1983
|
+
if (partners) {
|
|
1984
|
+
for (const { partner, decay } of partners) {
|
|
1985
|
+
if (!immatureTags.has(partner)) {
|
|
1986
|
+
const existing = avoid.get(partner) ?? 0;
|
|
1987
|
+
avoid.set(partner, Math.max(existing, decay));
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
return avoid;
|
|
1993
|
+
}
|
|
1994
|
+
/**
|
|
1995
|
+
* Get tags for a single card
|
|
1996
|
+
*/
|
|
1997
|
+
async getCardTags(cardId, course) {
|
|
1998
|
+
try {
|
|
1999
|
+
const tagResponse = await course.getAppliedTags(cardId);
|
|
2000
|
+
return tagResponse.rows.map((row) => row.value?.name || row.key).filter(Boolean);
|
|
2001
|
+
} catch {
|
|
2002
|
+
return [];
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
/**
|
|
2006
|
+
* Compute interference score reduction for a card.
|
|
2007
|
+
* Returns: { multiplier, interfering tags, reason }
|
|
2008
|
+
*/
|
|
2009
|
+
computeInterferenceEffect(cardTags, tagsToAvoid, immatureTags) {
|
|
2010
|
+
if (tagsToAvoid.size === 0) {
|
|
2011
|
+
return {
|
|
2012
|
+
multiplier: 1,
|
|
2013
|
+
interferingTags: [],
|
|
2014
|
+
reason: "No interference detected"
|
|
2015
|
+
};
|
|
2016
|
+
}
|
|
2017
|
+
let multiplier = 1;
|
|
2018
|
+
const interferingTags = [];
|
|
2019
|
+
for (const tag of cardTags) {
|
|
2020
|
+
const decay = tagsToAvoid.get(tag);
|
|
2021
|
+
if (decay !== void 0) {
|
|
2022
|
+
interferingTags.push(tag);
|
|
2023
|
+
multiplier *= 1 - decay;
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
if (interferingTags.length === 0) {
|
|
2027
|
+
return {
|
|
2028
|
+
multiplier: 1,
|
|
2029
|
+
interferingTags: [],
|
|
2030
|
+
reason: "No interference detected"
|
|
2031
|
+
};
|
|
2032
|
+
}
|
|
2033
|
+
const causingTags = /* @__PURE__ */ new Set();
|
|
2034
|
+
for (const tag of interferingTags) {
|
|
2035
|
+
for (const immatureTag of immatureTags) {
|
|
2036
|
+
const partners = this.interferenceMap.get(immatureTag);
|
|
2037
|
+
if (partners?.some((p) => p.partner === tag)) {
|
|
2038
|
+
causingTags.add(immatureTag);
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
const reason = `Interferes with immature tags ${Array.from(causingTags).join(", ")} (tags: ${interferingTags.join(", ")}, multiplier: ${multiplier.toFixed(2)})`;
|
|
2043
|
+
return { multiplier, interferingTags, reason };
|
|
2044
|
+
}
|
|
2045
|
+
/**
|
|
2046
|
+
* CardFilter.transform implementation.
|
|
2047
|
+
*
|
|
2048
|
+
* Apply interference-aware scoring. Cards with tags that interfere with
|
|
2049
|
+
* immature learnings get reduced scores.
|
|
2050
|
+
*/
|
|
2051
|
+
async transform(cards, context) {
|
|
2052
|
+
const immatureTags = await this.getImmatureTags(context);
|
|
2053
|
+
const tagsToAvoid = this.getTagsToAvoid(immatureTags);
|
|
2054
|
+
const adjusted = [];
|
|
2055
|
+
for (const card of cards) {
|
|
2056
|
+
const cardTags = await this.getCardTags(card.cardId, context.course);
|
|
2057
|
+
const { multiplier, reason } = this.computeInterferenceEffect(
|
|
2058
|
+
cardTags,
|
|
2059
|
+
tagsToAvoid,
|
|
2060
|
+
immatureTags
|
|
2061
|
+
);
|
|
2062
|
+
const finalScore = card.score * multiplier;
|
|
2063
|
+
const action = multiplier < 1 ? "penalized" : multiplier > 1 ? "boosted" : "passed";
|
|
2064
|
+
adjusted.push({
|
|
2065
|
+
...card,
|
|
2066
|
+
score: finalScore,
|
|
2067
|
+
provenance: [
|
|
2068
|
+
...card.provenance,
|
|
2069
|
+
{
|
|
2070
|
+
strategy: "interferenceMitigator",
|
|
2071
|
+
strategyName: this.strategyName || this.name,
|
|
2072
|
+
strategyId: this.strategyId || "NAVIGATION_STRATEGY-interference",
|
|
2073
|
+
action,
|
|
2074
|
+
score: finalScore,
|
|
2075
|
+
reason
|
|
2076
|
+
}
|
|
2077
|
+
]
|
|
2078
|
+
});
|
|
2079
|
+
}
|
|
2080
|
+
return adjusted;
|
|
2081
|
+
}
|
|
2082
|
+
/**
|
|
2083
|
+
* Legacy getWeightedCards - now throws as filters should not be used as generators.
|
|
2084
|
+
*
|
|
2085
|
+
* Use transform() via Pipeline instead.
|
|
2086
|
+
*/
|
|
2087
|
+
async getWeightedCards(_limit) {
|
|
2088
|
+
throw new Error(
|
|
2089
|
+
"InterferenceMitigatorNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
|
|
2090
|
+
);
|
|
2091
|
+
}
|
|
2092
|
+
// Legacy methods - stub implementations since filters don't generate cards
|
|
2093
|
+
async getNewCards(_n) {
|
|
2094
|
+
return [];
|
|
2095
|
+
}
|
|
2096
|
+
async getPendingReviews() {
|
|
2097
|
+
return [];
|
|
2098
|
+
}
|
|
2099
|
+
};
|
|
2100
|
+
}
|
|
2101
|
+
});
|
|
2102
|
+
|
|
2103
|
+
// src/core/navigators/relativePriority.ts
|
|
2104
|
+
var relativePriority_exports = {};
|
|
2105
|
+
__export(relativePriority_exports, {
|
|
2106
|
+
default: () => RelativePriorityNavigator
|
|
2107
|
+
});
|
|
2108
|
+
var DEFAULT_PRIORITY, DEFAULT_PRIORITY_INFLUENCE, DEFAULT_COMBINE_MODE, RelativePriorityNavigator;
|
|
2109
|
+
var init_relativePriority = __esm({
|
|
2110
|
+
"src/core/navigators/relativePriority.ts"() {
|
|
2111
|
+
"use strict";
|
|
2112
|
+
init_navigators();
|
|
2113
|
+
DEFAULT_PRIORITY = 0.5;
|
|
2114
|
+
DEFAULT_PRIORITY_INFLUENCE = 0.5;
|
|
2115
|
+
DEFAULT_COMBINE_MODE = "max";
|
|
2116
|
+
RelativePriorityNavigator = class extends ContentNavigator {
|
|
2117
|
+
config;
|
|
2118
|
+
_strategyData;
|
|
2119
|
+
/** Human-readable name for CardFilter interface */
|
|
2120
|
+
name;
|
|
2121
|
+
constructor(user, course, _strategyData) {
|
|
2122
|
+
super(user, course, _strategyData);
|
|
2123
|
+
this._strategyData = _strategyData;
|
|
2124
|
+
this.config = this.parseConfig(_strategyData.serializedData);
|
|
2125
|
+
this.name = _strategyData.name || "Relative Priority";
|
|
2126
|
+
}
|
|
2127
|
+
parseConfig(serializedData) {
|
|
2128
|
+
try {
|
|
2129
|
+
const parsed = JSON.parse(serializedData);
|
|
2130
|
+
return {
|
|
2131
|
+
tagPriorities: parsed.tagPriorities || {},
|
|
2132
|
+
defaultPriority: parsed.defaultPriority ?? DEFAULT_PRIORITY,
|
|
2133
|
+
combineMode: parsed.combineMode ?? DEFAULT_COMBINE_MODE,
|
|
2134
|
+
priorityInfluence: parsed.priorityInfluence ?? DEFAULT_PRIORITY_INFLUENCE
|
|
2135
|
+
};
|
|
2136
|
+
} catch {
|
|
2137
|
+
return {
|
|
2138
|
+
tagPriorities: {},
|
|
2139
|
+
defaultPriority: DEFAULT_PRIORITY,
|
|
2140
|
+
combineMode: DEFAULT_COMBINE_MODE,
|
|
2141
|
+
priorityInfluence: DEFAULT_PRIORITY_INFLUENCE
|
|
2142
|
+
};
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
/**
|
|
2146
|
+
* Look up the priority for a tag.
|
|
2147
|
+
*/
|
|
2148
|
+
getTagPriority(tagId) {
|
|
2149
|
+
return this.config.tagPriorities[tagId] ?? this.config.defaultPriority ?? DEFAULT_PRIORITY;
|
|
2150
|
+
}
|
|
2151
|
+
/**
|
|
2152
|
+
* Compute combined priority for a card based on its tags.
|
|
2153
|
+
*/
|
|
2154
|
+
computeCardPriority(cardTags) {
|
|
2155
|
+
if (cardTags.length === 0) {
|
|
2156
|
+
return this.config.defaultPriority ?? DEFAULT_PRIORITY;
|
|
2157
|
+
}
|
|
2158
|
+
const priorities = cardTags.map((tag) => this.getTagPriority(tag));
|
|
2159
|
+
switch (this.config.combineMode) {
|
|
2160
|
+
case "max":
|
|
2161
|
+
return Math.max(...priorities);
|
|
2162
|
+
case "min":
|
|
2163
|
+
return Math.min(...priorities);
|
|
2164
|
+
case "average":
|
|
2165
|
+
return priorities.reduce((sum, p) => sum + p, 0) / priorities.length;
|
|
2166
|
+
default:
|
|
2167
|
+
return Math.max(...priorities);
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
/**
|
|
2171
|
+
* Compute boost factor based on priority.
|
|
2172
|
+
*
|
|
2173
|
+
* The formula: 1 + (priority - 0.5) * priorityInfluence
|
|
2174
|
+
*
|
|
2175
|
+
* This creates a multiplier centered around 1.0:
|
|
2176
|
+
* - Priority 1.0 with influence 0.5 → 1.25 (25% boost)
|
|
2177
|
+
* - Priority 0.5 with any influence → 1.00 (neutral)
|
|
2178
|
+
* - Priority 0.0 with influence 0.5 → 0.75 (25% reduction)
|
|
2179
|
+
*/
|
|
2180
|
+
computeBoostFactor(priority) {
|
|
2181
|
+
const influence = this.config.priorityInfluence ?? DEFAULT_PRIORITY_INFLUENCE;
|
|
2182
|
+
return 1 + (priority - 0.5) * influence;
|
|
2183
|
+
}
|
|
2184
|
+
/**
|
|
2185
|
+
* Build human-readable reason for priority adjustment.
|
|
2186
|
+
*/
|
|
2187
|
+
buildPriorityReason(cardTags, priority, boostFactor, finalScore) {
|
|
2188
|
+
if (cardTags.length === 0) {
|
|
2189
|
+
return `No tags, neutral priority (${priority.toFixed(2)})`;
|
|
2190
|
+
}
|
|
2191
|
+
const tagList = cardTags.slice(0, 3).join(", ");
|
|
2192
|
+
const more = cardTags.length > 3 ? ` (+${cardTags.length - 3} more)` : "";
|
|
2193
|
+
if (boostFactor === 1) {
|
|
2194
|
+
return `Neutral priority (${priority.toFixed(2)}) for tags: ${tagList}${more}`;
|
|
2195
|
+
} else if (boostFactor > 1) {
|
|
2196
|
+
return `High-priority tags: ${tagList}${more} (priority ${priority.toFixed(2)} \u2192 boost ${boostFactor.toFixed(2)}x \u2192 ${finalScore.toFixed(2)})`;
|
|
2197
|
+
} else {
|
|
2198
|
+
return `Low-priority tags: ${tagList}${more} (priority ${priority.toFixed(2)} \u2192 reduce ${boostFactor.toFixed(2)}x \u2192 ${finalScore.toFixed(2)})`;
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
/**
|
|
2202
|
+
* Get tags for a single card.
|
|
2203
|
+
*/
|
|
2204
|
+
async getCardTags(cardId, course) {
|
|
2205
|
+
try {
|
|
2206
|
+
const tagResponse = await course.getAppliedTags(cardId);
|
|
2207
|
+
return tagResponse.rows.map((r) => r.doc?.name).filter((x) => !!x);
|
|
2208
|
+
} catch {
|
|
2209
|
+
return [];
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
/**
|
|
2213
|
+
* CardFilter.transform implementation.
|
|
2214
|
+
*
|
|
2215
|
+
* Apply priority-adjusted scoring. Cards with high-priority tags get boosted,
|
|
2216
|
+
* cards with low-priority tags get reduced scores.
|
|
2217
|
+
*/
|
|
2218
|
+
async transform(cards, context) {
|
|
2219
|
+
const adjusted = await Promise.all(
|
|
2220
|
+
cards.map(async (card) => {
|
|
2221
|
+
const cardTags = await this.getCardTags(card.cardId, context.course);
|
|
2222
|
+
const priority = this.computeCardPriority(cardTags);
|
|
2223
|
+
const boostFactor = this.computeBoostFactor(priority);
|
|
2224
|
+
const finalScore = Math.max(0, Math.min(1, card.score * boostFactor));
|
|
2225
|
+
const action = boostFactor > 1 ? "boosted" : boostFactor < 1 ? "penalized" : "passed";
|
|
2226
|
+
const reason = this.buildPriorityReason(cardTags, priority, boostFactor, finalScore);
|
|
2227
|
+
return {
|
|
2228
|
+
...card,
|
|
2229
|
+
score: finalScore,
|
|
2230
|
+
provenance: [
|
|
2231
|
+
...card.provenance,
|
|
2232
|
+
{
|
|
2233
|
+
strategy: "relativePriority",
|
|
2234
|
+
strategyName: this.strategyName || this.name,
|
|
2235
|
+
strategyId: this.strategyId || "NAVIGATION_STRATEGY-priority",
|
|
2236
|
+
action,
|
|
2237
|
+
score: finalScore,
|
|
2238
|
+
reason
|
|
2239
|
+
}
|
|
2240
|
+
]
|
|
2241
|
+
};
|
|
2242
|
+
})
|
|
2243
|
+
);
|
|
2244
|
+
return adjusted;
|
|
2245
|
+
}
|
|
2246
|
+
/**
|
|
2247
|
+
* Legacy getWeightedCards - now throws as filters should not be used as generators.
|
|
2248
|
+
*
|
|
2249
|
+
* Use transform() via Pipeline instead.
|
|
2250
|
+
*/
|
|
2251
|
+
async getWeightedCards(_limit) {
|
|
2252
|
+
throw new Error(
|
|
2253
|
+
"RelativePriorityNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
|
|
2254
|
+
);
|
|
2255
|
+
}
|
|
2256
|
+
// Legacy methods - stub implementations since filters don't generate cards
|
|
2257
|
+
async getNewCards(_n) {
|
|
2258
|
+
return [];
|
|
2259
|
+
}
|
|
2260
|
+
async getPendingReviews() {
|
|
2261
|
+
return [];
|
|
2262
|
+
}
|
|
2263
|
+
};
|
|
2264
|
+
}
|
|
2265
|
+
});
|
|
2266
|
+
|
|
2267
|
+
// src/core/navigators/srs.ts
|
|
2268
|
+
var srs_exports = {};
|
|
2269
|
+
__export(srs_exports, {
|
|
2270
|
+
default: () => SRSNavigator
|
|
2271
|
+
});
|
|
2272
|
+
import moment3 from "moment";
|
|
2273
|
+
var SRSNavigator;
|
|
2274
|
+
var init_srs = __esm({
|
|
2275
|
+
"src/core/navigators/srs.ts"() {
|
|
2276
|
+
"use strict";
|
|
2277
|
+
init_navigators();
|
|
2278
|
+
SRSNavigator = class extends ContentNavigator {
|
|
2279
|
+
/** Human-readable name for CardGenerator interface */
|
|
2280
|
+
name;
|
|
2281
|
+
constructor(user, course, strategyData) {
|
|
2282
|
+
super(user, course, strategyData);
|
|
2283
|
+
this.name = strategyData?.name || "SRS";
|
|
2284
|
+
}
|
|
2285
|
+
/**
|
|
2286
|
+
* Get review cards scored by urgency.
|
|
2287
|
+
*
|
|
2288
|
+
* Score formula combines:
|
|
2289
|
+
* - Relative overdueness: hoursOverdue / intervalHours
|
|
2290
|
+
* - Interval recency: exponential decay favoring shorter intervals
|
|
2291
|
+
*
|
|
2292
|
+
* Cards not yet due are excluded (not scored as 0).
|
|
2293
|
+
*
|
|
2294
|
+
* This method supports both the legacy signature (limit only) and the
|
|
2295
|
+
* CardGenerator interface signature (limit, context).
|
|
2296
|
+
*
|
|
2297
|
+
* @param limit - Maximum number of cards to return
|
|
2298
|
+
* @param _context - Optional GeneratorContext (currently unused, but required for interface)
|
|
2299
|
+
*/
|
|
2300
|
+
async getWeightedCards(limit, _context) {
|
|
2301
|
+
if (!this.user || !this.course) {
|
|
2302
|
+
throw new Error("SRSNavigator requires user and course to be set");
|
|
2303
|
+
}
|
|
1019
2304
|
const reviews = await this.user.getPendingReviews(this.course.getCourseID());
|
|
1020
|
-
|
|
2305
|
+
const now = moment3.utc();
|
|
2306
|
+
const dueReviews = reviews.filter((r) => now.isAfter(moment3.utc(r.reviewTime)));
|
|
2307
|
+
const scored = dueReviews.map((review) => {
|
|
2308
|
+
const { score, reason } = this.computeUrgencyScore(review, now);
|
|
1021
2309
|
return {
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
2310
|
+
cardId: review.cardId,
|
|
2311
|
+
courseId: review.courseId,
|
|
2312
|
+
score,
|
|
2313
|
+
provenance: [
|
|
2314
|
+
{
|
|
2315
|
+
strategy: "srs",
|
|
2316
|
+
strategyName: this.strategyName || this.name,
|
|
2317
|
+
strategyId: this.strategyId || "NAVIGATION_STRATEGY-SRS-default",
|
|
2318
|
+
action: "generated",
|
|
2319
|
+
score,
|
|
2320
|
+
reason
|
|
2321
|
+
}
|
|
2322
|
+
]
|
|
1029
2323
|
};
|
|
1030
2324
|
});
|
|
2325
|
+
return scored.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
1031
2326
|
}
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
2327
|
+
/**
|
|
2328
|
+
* Compute urgency score for a review card.
|
|
2329
|
+
*
|
|
2330
|
+
* Two factors:
|
|
2331
|
+
* 1. Relative overdueness = hoursOverdue / intervalHours
|
|
2332
|
+
* - 2 days overdue on 3-day interval = 0.67 (urgent)
|
|
2333
|
+
* - 2 days overdue on 180-day interval = 0.01 (not urgent)
|
|
2334
|
+
*
|
|
2335
|
+
* 2. Interval recency factor = 0.3 + 0.7 * exp(-intervalHours / 720)
|
|
2336
|
+
* - 24h interval → ~1.0 (very recent learning)
|
|
2337
|
+
* - 30 days (720h) → ~0.56
|
|
2338
|
+
* - 180 days → ~0.30
|
|
2339
|
+
*
|
|
2340
|
+
* Combined: base 0.5 + weighted average of factors * 0.45
|
|
2341
|
+
* Result range: approximately 0.5 to 0.95
|
|
2342
|
+
*/
|
|
2343
|
+
computeUrgencyScore(review, now) {
|
|
2344
|
+
const scheduledAt = moment3.utc(review.scheduledAt);
|
|
2345
|
+
const due = moment3.utc(review.reviewTime);
|
|
2346
|
+
const intervalHours = Math.max(1, due.diff(scheduledAt, "hours"));
|
|
2347
|
+
const hoursOverdue = now.diff(due, "hours");
|
|
2348
|
+
const relativeOverdue = hoursOverdue / intervalHours;
|
|
2349
|
+
const recencyFactor = 0.3 + 0.7 * Math.exp(-intervalHours / 720);
|
|
2350
|
+
const overdueContribution = Math.min(1, Math.max(0, relativeOverdue));
|
|
2351
|
+
const urgency = overdueContribution * 0.5 + recencyFactor * 0.5;
|
|
2352
|
+
const score = Math.min(0.95, 0.5 + urgency * 0.45);
|
|
2353
|
+
const reason = `${Math.round(hoursOverdue)}h overdue (interval: ${Math.round(intervalHours)}h, relative: ${relativeOverdue.toFixed(2)}), recency: ${recencyFactor.toFixed(2)}, review`;
|
|
2354
|
+
return { score, reason };
|
|
2355
|
+
}
|
|
2356
|
+
/**
|
|
2357
|
+
* Get pending reviews in legacy format.
|
|
2358
|
+
*
|
|
2359
|
+
* Returns all pending reviews for the course, enriched with session item fields.
|
|
2360
|
+
*/
|
|
2361
|
+
async getPendingReviews() {
|
|
2362
|
+
if (!this.user || !this.course) {
|
|
2363
|
+
throw new Error("SRSNavigator requires user and course to be set");
|
|
2364
|
+
}
|
|
2365
|
+
const reviews = await this.user.getPendingReviews(this.course.getCourseID());
|
|
2366
|
+
return reviews.map((r) => ({
|
|
2367
|
+
...r,
|
|
2368
|
+
contentSourceType: "course",
|
|
2369
|
+
contentSourceID: this.course.getCourseID(),
|
|
2370
|
+
cardID: r.cardId,
|
|
2371
|
+
courseID: r.courseId,
|
|
2372
|
+
qualifiedID: `${r.courseId}-${r.cardId}`,
|
|
2373
|
+
reviewID: r._id,
|
|
2374
|
+
status: "review"
|
|
2375
|
+
}));
|
|
2376
|
+
}
|
|
2377
|
+
/**
|
|
2378
|
+
* SRS does not generate new cards.
|
|
2379
|
+
* Use ELONavigator or another generator for new cards.
|
|
2380
|
+
*/
|
|
2381
|
+
async getNewCards(_n) {
|
|
2382
|
+
return [];
|
|
1047
2383
|
}
|
|
1048
2384
|
};
|
|
1049
2385
|
}
|
|
@@ -1054,9 +2390,21 @@ var globImport;
|
|
|
1054
2390
|
var init_ = __esm({
|
|
1055
2391
|
'import("./**/*") in src/core/navigators/index.ts'() {
|
|
1056
2392
|
globImport = __glob({
|
|
2393
|
+
"./CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
|
|
2394
|
+
"./Pipeline.ts": () => Promise.resolve().then(() => (init_Pipeline(), Pipeline_exports)),
|
|
2395
|
+
"./PipelineAssembler.ts": () => Promise.resolve().then(() => (init_PipelineAssembler(), PipelineAssembler_exports)),
|
|
1057
2396
|
"./elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
|
|
2397
|
+
"./filters/eloDistance.ts": () => Promise.resolve().then(() => (init_eloDistance(), eloDistance_exports)),
|
|
2398
|
+
"./filters/index.ts": () => Promise.resolve().then(() => (init_filters(), filters_exports)),
|
|
2399
|
+
"./filters/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports)),
|
|
2400
|
+
"./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
|
|
2401
|
+
"./generators/types.ts": () => Promise.resolve().then(() => (init_types2(), types_exports2)),
|
|
1058
2402
|
"./hardcodedOrder.ts": () => Promise.resolve().then(() => (init_hardcodedOrder(), hardcodedOrder_exports)),
|
|
1059
|
-
"./
|
|
2403
|
+
"./hierarchyDefinition.ts": () => Promise.resolve().then(() => (init_hierarchyDefinition(), hierarchyDefinition_exports)),
|
|
2404
|
+
"./index.ts": () => Promise.resolve().then(() => (init_navigators(), navigators_exports)),
|
|
2405
|
+
"./interferenceMitigator.ts": () => Promise.resolve().then(() => (init_interferenceMitigator(), interferenceMitigator_exports)),
|
|
2406
|
+
"./relativePriority.ts": () => Promise.resolve().then(() => (init_relativePriority(), relativePriority_exports)),
|
|
2407
|
+
"./srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports))
|
|
1060
2408
|
});
|
|
1061
2409
|
}
|
|
1062
2410
|
});
|
|
@@ -1065,9 +2413,34 @@ var init_ = __esm({
|
|
|
1065
2413
|
var navigators_exports = {};
|
|
1066
2414
|
__export(navigators_exports, {
|
|
1067
2415
|
ContentNavigator: () => ContentNavigator,
|
|
1068
|
-
|
|
2416
|
+
NavigatorRole: () => NavigatorRole,
|
|
2417
|
+
NavigatorRoles: () => NavigatorRoles,
|
|
2418
|
+
Navigators: () => Navigators,
|
|
2419
|
+
getCardOrigin: () => getCardOrigin,
|
|
2420
|
+
isFilter: () => isFilter,
|
|
2421
|
+
isGenerator: () => isGenerator
|
|
1069
2422
|
});
|
|
1070
|
-
|
|
2423
|
+
function getCardOrigin(card) {
|
|
2424
|
+
if (card.provenance.length === 0) {
|
|
2425
|
+
throw new Error("Card has no provenance - cannot determine origin");
|
|
2426
|
+
}
|
|
2427
|
+
const firstEntry = card.provenance[0];
|
|
2428
|
+
const reason = firstEntry.reason.toLowerCase();
|
|
2429
|
+
if (reason.includes("failed")) {
|
|
2430
|
+
return "failed";
|
|
2431
|
+
}
|
|
2432
|
+
if (reason.includes("review")) {
|
|
2433
|
+
return "review";
|
|
2434
|
+
}
|
|
2435
|
+
return "new";
|
|
2436
|
+
}
|
|
2437
|
+
function isGenerator(impl) {
|
|
2438
|
+
return NavigatorRoles[impl] === "generator" /* GENERATOR */;
|
|
2439
|
+
}
|
|
2440
|
+
function isFilter(impl) {
|
|
2441
|
+
return NavigatorRoles[impl] === "filter" /* FILTER */;
|
|
2442
|
+
}
|
|
2443
|
+
var Navigators, NavigatorRole, NavigatorRoles, ContentNavigator;
|
|
1071
2444
|
var init_navigators = __esm({
|
|
1072
2445
|
"src/core/navigators/index.ts"() {
|
|
1073
2446
|
"use strict";
|
|
@@ -1075,14 +2448,55 @@ var init_navigators = __esm({
|
|
|
1075
2448
|
init_();
|
|
1076
2449
|
Navigators = /* @__PURE__ */ ((Navigators2) => {
|
|
1077
2450
|
Navigators2["ELO"] = "elo";
|
|
2451
|
+
Navigators2["SRS"] = "srs";
|
|
1078
2452
|
Navigators2["HARDCODED"] = "hardcodedOrder";
|
|
2453
|
+
Navigators2["HIERARCHY"] = "hierarchyDefinition";
|
|
2454
|
+
Navigators2["INTERFERENCE"] = "interferenceMitigator";
|
|
2455
|
+
Navigators2["RELATIVE_PRIORITY"] = "relativePriority";
|
|
1079
2456
|
return Navigators2;
|
|
1080
2457
|
})(Navigators || {});
|
|
2458
|
+
NavigatorRole = /* @__PURE__ */ ((NavigatorRole2) => {
|
|
2459
|
+
NavigatorRole2["GENERATOR"] = "generator";
|
|
2460
|
+
NavigatorRole2["FILTER"] = "filter";
|
|
2461
|
+
return NavigatorRole2;
|
|
2462
|
+
})(NavigatorRole || {});
|
|
2463
|
+
NavigatorRoles = {
|
|
2464
|
+
["elo" /* ELO */]: "generator" /* GENERATOR */,
|
|
2465
|
+
["srs" /* SRS */]: "generator" /* GENERATOR */,
|
|
2466
|
+
["hardcodedOrder" /* HARDCODED */]: "generator" /* GENERATOR */,
|
|
2467
|
+
["hierarchyDefinition" /* HIERARCHY */]: "filter" /* FILTER */,
|
|
2468
|
+
["interferenceMitigator" /* INTERFERENCE */]: "filter" /* FILTER */,
|
|
2469
|
+
["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */
|
|
2470
|
+
};
|
|
1081
2471
|
ContentNavigator = class {
|
|
2472
|
+
/** User interface for this navigation session */
|
|
2473
|
+
user;
|
|
2474
|
+
/** Course interface for this navigation session */
|
|
2475
|
+
course;
|
|
2476
|
+
/** Human-readable name for this strategy instance (from ContentNavigationStrategyData.name) */
|
|
2477
|
+
strategyName;
|
|
2478
|
+
/** Unique document ID for this strategy instance (from ContentNavigationStrategyData._id) */
|
|
2479
|
+
strategyId;
|
|
2480
|
+
/**
|
|
2481
|
+
* Constructor for standard navigators.
|
|
2482
|
+
* Call this from subclass constructors to initialize common fields.
|
|
2483
|
+
*
|
|
2484
|
+
* Note: CompositeGenerator doesn't use this pattern and should call super() without args.
|
|
2485
|
+
*/
|
|
2486
|
+
constructor(user, course, strategyData) {
|
|
2487
|
+
if (user && course && strategyData) {
|
|
2488
|
+
this.user = user;
|
|
2489
|
+
this.course = course;
|
|
2490
|
+
this.strategyName = strategyData.name;
|
|
2491
|
+
this.strategyId = strategyData._id;
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
1082
2494
|
/**
|
|
2495
|
+
* Factory method to create navigator instances dynamically.
|
|
1083
2496
|
*
|
|
1084
|
-
* @param user
|
|
1085
|
-
* @param
|
|
2497
|
+
* @param user - User interface
|
|
2498
|
+
* @param course - Course interface
|
|
2499
|
+
* @param strategyData - Strategy configuration document
|
|
1086
2500
|
* @returns the runtime object used to steer a study session.
|
|
1087
2501
|
*/
|
|
1088
2502
|
static async create(user, course, strategyData) {
|
|
@@ -1103,6 +2517,70 @@ var init_navigators = __esm({
|
|
|
1103
2517
|
}
|
|
1104
2518
|
return new NavigatorImpl(user, course, strategyData);
|
|
1105
2519
|
}
|
|
2520
|
+
/**
|
|
2521
|
+
* Get cards with suitability scores and provenance trails.
|
|
2522
|
+
*
|
|
2523
|
+
* **This is the PRIMARY API for navigation strategies.**
|
|
2524
|
+
*
|
|
2525
|
+
* Returns cards ranked by suitability score (0-1). Higher scores indicate
|
|
2526
|
+
* better candidates for presentation. Each card includes a provenance trail
|
|
2527
|
+
* documenting how strategies contributed to the final score.
|
|
2528
|
+
*
|
|
2529
|
+
* ## For Generators
|
|
2530
|
+
* Override this method to generate candidates and compute scores based on
|
|
2531
|
+
* your strategy's logic (e.g., ELO proximity, review urgency). Create the
|
|
2532
|
+
* initial provenance entry with action='generated'.
|
|
2533
|
+
*
|
|
2534
|
+
* ## Default Implementation
|
|
2535
|
+
* The base class provides a backward-compatible default that:
|
|
2536
|
+
* 1. Calls legacy getNewCards() and getPendingReviews()
|
|
2537
|
+
* 2. Assigns score=1.0 to all cards
|
|
2538
|
+
* 3. Creates minimal provenance from legacy methods
|
|
2539
|
+
* 4. Returns combined results up to limit
|
|
2540
|
+
*
|
|
2541
|
+
* This allows existing strategies to work without modification while
|
|
2542
|
+
* new strategies can override with proper scoring and provenance.
|
|
2543
|
+
*
|
|
2544
|
+
* @param limit - Maximum cards to return
|
|
2545
|
+
* @returns Cards sorted by score descending, with provenance trails
|
|
2546
|
+
*/
|
|
2547
|
+
async getWeightedCards(limit) {
|
|
2548
|
+
const newCards = await this.getNewCards(limit);
|
|
2549
|
+
const reviews = await this.getPendingReviews();
|
|
2550
|
+
const weighted = [
|
|
2551
|
+
...newCards.map((c) => ({
|
|
2552
|
+
cardId: c.cardID,
|
|
2553
|
+
courseId: c.courseID,
|
|
2554
|
+
score: 1,
|
|
2555
|
+
provenance: [
|
|
2556
|
+
{
|
|
2557
|
+
strategy: "legacy",
|
|
2558
|
+
strategyName: this.strategyName || "Legacy API",
|
|
2559
|
+
strategyId: this.strategyId || "legacy-fallback",
|
|
2560
|
+
action: "generated",
|
|
2561
|
+
score: 1,
|
|
2562
|
+
reason: "Generated via legacy getNewCards(), new card"
|
|
2563
|
+
}
|
|
2564
|
+
]
|
|
2565
|
+
})),
|
|
2566
|
+
...reviews.map((r) => ({
|
|
2567
|
+
cardId: r.cardID,
|
|
2568
|
+
courseId: r.courseID,
|
|
2569
|
+
score: 1,
|
|
2570
|
+
provenance: [
|
|
2571
|
+
{
|
|
2572
|
+
strategy: "legacy",
|
|
2573
|
+
strategyName: this.strategyName || "Legacy API",
|
|
2574
|
+
strategyId: this.strategyId || "legacy-fallback",
|
|
2575
|
+
action: "generated",
|
|
2576
|
+
score: 1,
|
|
2577
|
+
reason: "Generated via legacy getPendingReviews(), review"
|
|
2578
|
+
}
|
|
2579
|
+
]
|
|
2580
|
+
}))
|
|
2581
|
+
];
|
|
2582
|
+
return weighted.slice(0, limit);
|
|
2583
|
+
}
|
|
1106
2584
|
};
|
|
1107
2585
|
}
|
|
1108
2586
|
});
|
|
@@ -1112,7 +2590,7 @@ import {
|
|
|
1112
2590
|
EloToNumber,
|
|
1113
2591
|
Status,
|
|
1114
2592
|
blankCourseElo as blankCourseElo2,
|
|
1115
|
-
toCourseElo as
|
|
2593
|
+
toCourseElo as toCourseElo6
|
|
1116
2594
|
} from "@vue-skuilder/common";
|
|
1117
2595
|
function randIntWeightedTowardZero(n) {
|
|
1118
2596
|
return Math.floor(Math.random() * Math.random() * Math.random() * n);
|
|
@@ -1201,6 +2679,12 @@ var init_courseDB = __esm({
|
|
|
1201
2679
|
init_courseAPI();
|
|
1202
2680
|
init_courseLookupDB();
|
|
1203
2681
|
init_navigators();
|
|
2682
|
+
init_Pipeline();
|
|
2683
|
+
init_PipelineAssembler();
|
|
2684
|
+
init_CompositeGenerator();
|
|
2685
|
+
init_elo();
|
|
2686
|
+
init_srs();
|
|
2687
|
+
init_eloDistance();
|
|
1204
2688
|
CoursesDB = class {
|
|
1205
2689
|
_courseIDs;
|
|
1206
2690
|
constructor(courseIDs) {
|
|
@@ -1312,7 +2796,7 @@ var init_courseDB = __esm({
|
|
|
1312
2796
|
docs.rows.forEach((r) => {
|
|
1313
2797
|
if (isSuccessRow(r)) {
|
|
1314
2798
|
if (r.doc && r.doc.elo) {
|
|
1315
|
-
ret.push(
|
|
2799
|
+
ret.push(toCourseElo6(r.doc.elo));
|
|
1316
2800
|
} else {
|
|
1317
2801
|
logger.warn("no elo data for card: " + r.id);
|
|
1318
2802
|
ret.push(blankCourseElo2());
|
|
@@ -1585,42 +3069,82 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
1585
3069
|
logger.debug(JSON.stringify(data));
|
|
1586
3070
|
return Promise.resolve();
|
|
1587
3071
|
}
|
|
1588
|
-
|
|
3072
|
+
/**
|
|
3073
|
+
* Creates an instantiated navigator for this course.
|
|
3074
|
+
*
|
|
3075
|
+
* Handles multiple generators by wrapping them in CompositeGenerator.
|
|
3076
|
+
* This is the preferred method for getting a ready-to-use navigator.
|
|
3077
|
+
*
|
|
3078
|
+
* @param user - User database interface
|
|
3079
|
+
* @returns Instantiated ContentNavigator ready for use
|
|
3080
|
+
*/
|
|
3081
|
+
async createNavigator(user) {
|
|
1589
3082
|
try {
|
|
1590
|
-
const
|
|
1591
|
-
if (
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
return strategy;
|
|
1597
|
-
}
|
|
1598
|
-
} catch (e) {
|
|
1599
|
-
logger.warn(
|
|
1600
|
-
// @ts-expect-error tmp: defaultNavigationStrategyId property does not yet exist
|
|
1601
|
-
`Failed to load strategy '${config.defaultNavigationStrategyId}' specified in course config. Falling back to ELO.`,
|
|
1602
|
-
e
|
|
1603
|
-
);
|
|
1604
|
-
}
|
|
3083
|
+
const allStrategies = await this.getAllNavigationStrategies();
|
|
3084
|
+
if (allStrategies.length === 0) {
|
|
3085
|
+
logger.debug(
|
|
3086
|
+
"[courseDB] No strategy documents found, using default Pipeline(Composite(ELO, SRS), [eloDistanceFilter])"
|
|
3087
|
+
);
|
|
3088
|
+
return this.createDefaultPipeline(user);
|
|
1605
3089
|
}
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
3090
|
+
const assembler = new PipelineAssembler();
|
|
3091
|
+
const { pipeline, generatorStrategies, filterStrategies, warnings } = await assembler.assemble({
|
|
3092
|
+
strategies: allStrategies,
|
|
3093
|
+
user,
|
|
3094
|
+
course: this
|
|
3095
|
+
});
|
|
3096
|
+
for (const warning of warnings) {
|
|
3097
|
+
logger.warn(`[PipelineAssembler] ${warning}`);
|
|
3098
|
+
}
|
|
3099
|
+
if (!pipeline) {
|
|
3100
|
+
logger.debug("[courseDB] Pipeline assembly failed, using default pipeline");
|
|
3101
|
+
return this.createDefaultPipeline(user);
|
|
3102
|
+
}
|
|
3103
|
+
logger.debug(
|
|
3104
|
+
`[courseDB] Using assembled pipeline with ${generatorStrategies.length} generator(s) and ${filterStrategies.length} filter(s)`
|
|
1610
3105
|
);
|
|
3106
|
+
return pipeline;
|
|
3107
|
+
} catch (e) {
|
|
3108
|
+
logger.error(`[courseDB] Error creating navigator: ${e}`);
|
|
3109
|
+
throw e;
|
|
1611
3110
|
}
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
3111
|
+
}
|
|
3112
|
+
makeDefaultEloStrategy() {
|
|
3113
|
+
return {
|
|
3114
|
+
_id: "NAVIGATION_STRATEGY-ELO-default",
|
|
1615
3115
|
docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
|
|
1616
|
-
name: "ELO",
|
|
1617
|
-
description: "ELO-based navigation strategy",
|
|
3116
|
+
name: "ELO (default)",
|
|
3117
|
+
description: "Default ELO-based navigation strategy for new cards",
|
|
1618
3118
|
implementingClass: "elo" /* ELO */,
|
|
1619
3119
|
course: this.id,
|
|
1620
3120
|
serializedData: ""
|
|
1621
|
-
// serde is a noop for ELO navigator.
|
|
1622
3121
|
};
|
|
1623
|
-
|
|
3122
|
+
}
|
|
3123
|
+
makeDefaultSrsStrategy() {
|
|
3124
|
+
return {
|
|
3125
|
+
_id: "NAVIGATION_STRATEGY-SRS-default",
|
|
3126
|
+
docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
|
|
3127
|
+
name: "SRS (default)",
|
|
3128
|
+
description: "Default SRS-based navigation strategy for reviews",
|
|
3129
|
+
implementingClass: "srs" /* SRS */,
|
|
3130
|
+
course: this.id,
|
|
3131
|
+
serializedData: ""
|
|
3132
|
+
};
|
|
3133
|
+
}
|
|
3134
|
+
/**
|
|
3135
|
+
* Creates the default navigation pipeline for courses with no configured strategies.
|
|
3136
|
+
*
|
|
3137
|
+
* Default: Pipeline(Composite(ELO, SRS), [eloDistanceFilter])
|
|
3138
|
+
* - ELO generator: scores new cards by skill proximity
|
|
3139
|
+
* - SRS generator: scores reviews by overdueness and interval recency
|
|
3140
|
+
* - ELO distance filter: penalizes cards far from user's current level
|
|
3141
|
+
*/
|
|
3142
|
+
createDefaultPipeline(user) {
|
|
3143
|
+
const eloNavigator = new ELONavigator(user, this, this.makeDefaultEloStrategy());
|
|
3144
|
+
const srsNavigator = new SRSNavigator(user, this, this.makeDefaultSrsStrategy());
|
|
3145
|
+
const compositeGenerator = new CompositeGenerator([eloNavigator, srsNavigator]);
|
|
3146
|
+
const eloDistanceFilter = createEloDistanceFilter();
|
|
3147
|
+
return new Pipeline(compositeGenerator, [eloDistanceFilter], user, this);
|
|
1624
3148
|
}
|
|
1625
3149
|
////////////////////////////////////
|
|
1626
3150
|
// END NavigationStrategyManager implementation
|
|
@@ -1631,22 +3155,39 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
1631
3155
|
async getNewCards(limit = 99) {
|
|
1632
3156
|
const u = await this._getCurrentUser();
|
|
1633
3157
|
try {
|
|
1634
|
-
const
|
|
1635
|
-
const navigator = await ContentNavigator.create(u, this, strategy);
|
|
3158
|
+
const navigator = await this.createNavigator(u);
|
|
1636
3159
|
return navigator.getNewCards(limit);
|
|
1637
3160
|
} catch (e) {
|
|
1638
|
-
logger.error(`[courseDB] Error
|
|
3161
|
+
logger.error(`[courseDB] Error in getNewCards: ${e}`);
|
|
1639
3162
|
throw e;
|
|
1640
3163
|
}
|
|
1641
3164
|
}
|
|
1642
3165
|
async getPendingReviews() {
|
|
1643
3166
|
const u = await this._getCurrentUser();
|
|
1644
3167
|
try {
|
|
1645
|
-
const
|
|
1646
|
-
const navigator = await ContentNavigator.create(u, this, strategy);
|
|
3168
|
+
const navigator = await this.createNavigator(u);
|
|
1647
3169
|
return navigator.getPendingReviews();
|
|
1648
3170
|
} catch (e) {
|
|
1649
|
-
logger.error(`[courseDB] Error
|
|
3171
|
+
logger.error(`[courseDB] Error in getPendingReviews: ${e}`);
|
|
3172
|
+
throw e;
|
|
3173
|
+
}
|
|
3174
|
+
}
|
|
3175
|
+
/**
|
|
3176
|
+
* Get cards with suitability scores for presentation.
|
|
3177
|
+
*
|
|
3178
|
+
* This is the PRIMARY API for content sources going forward. Delegates to the
|
|
3179
|
+
* course's configured NavigationStrategy to get scored candidates.
|
|
3180
|
+
*
|
|
3181
|
+
* @param limit - Maximum number of cards to return
|
|
3182
|
+
* @returns Cards sorted by score descending
|
|
3183
|
+
*/
|
|
3184
|
+
async getWeightedCards(limit) {
|
|
3185
|
+
const u = await this._getCurrentUser();
|
|
3186
|
+
try {
|
|
3187
|
+
const navigator = await this.createNavigator(u);
|
|
3188
|
+
return navigator.getWeightedCards(limit);
|
|
3189
|
+
} catch (e) {
|
|
3190
|
+
logger.error(`[courseDB] Error getting weighted cards: ${e}`);
|
|
1650
3191
|
throw e;
|
|
1651
3192
|
}
|
|
1652
3193
|
}
|
|
@@ -1786,7 +3327,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
1786
3327
|
});
|
|
1787
3328
|
|
|
1788
3329
|
// src/impl/couch/classroomDB.ts
|
|
1789
|
-
import
|
|
3330
|
+
import moment4 from "moment";
|
|
1790
3331
|
var classroomLookupDBTitle, CLASSROOM_CONFIG, ClassroomDBBase, StudentClassroomDB, TeacherClassroomDB, ClassroomLookupDB;
|
|
1791
3332
|
var init_classroomDB2 = __esm({
|
|
1792
3333
|
"src/impl/couch/classroomDB.ts"() {
|
|
@@ -1887,9 +3428,9 @@ var init_classroomDB2 = __esm({
|
|
|
1887
3428
|
}
|
|
1888
3429
|
async getNewCards() {
|
|
1889
3430
|
const activeCards = await this._user.getActiveCards();
|
|
1890
|
-
const now =
|
|
3431
|
+
const now = moment4.utc();
|
|
1891
3432
|
const assigned = await this.getAssignedContent();
|
|
1892
|
-
const due = assigned.filter((c) => now.isAfter(
|
|
3433
|
+
const due = assigned.filter((c) => now.isAfter(moment4.utc(c.activeOn, REVIEW_TIME_FORMAT2)));
|
|
1893
3434
|
logger.info(`Due content: ${JSON.stringify(due)}`);
|
|
1894
3435
|
let ret = [];
|
|
1895
3436
|
for (let i = 0; i < due.length; i++) {
|
|
@@ -1926,6 +3467,52 @@ var init_classroomDB2 = __esm({
|
|
|
1926
3467
|
}
|
|
1927
3468
|
});
|
|
1928
3469
|
}
|
|
3470
|
+
/**
|
|
3471
|
+
* Get cards with suitability scores for presentation.
|
|
3472
|
+
*
|
|
3473
|
+
* This implementation wraps the legacy getNewCards/getPendingReviews methods,
|
|
3474
|
+
* assigning score=1.0 to all cards. StudentClassroomDB does not currently
|
|
3475
|
+
* support pluggable navigation strategies.
|
|
3476
|
+
*
|
|
3477
|
+
* @param limit - Maximum number of cards to return
|
|
3478
|
+
* @returns Cards sorted by score descending (all scores = 1.0)
|
|
3479
|
+
*/
|
|
3480
|
+
async getWeightedCards(limit) {
|
|
3481
|
+
const [newCards, reviews] = await Promise.all([this.getNewCards(), this.getPendingReviews()]);
|
|
3482
|
+
const weighted = [
|
|
3483
|
+
...newCards.map((c) => ({
|
|
3484
|
+
cardId: c.cardID,
|
|
3485
|
+
courseId: c.courseID,
|
|
3486
|
+
score: 1,
|
|
3487
|
+
provenance: [
|
|
3488
|
+
{
|
|
3489
|
+
strategy: "classroom",
|
|
3490
|
+
strategyName: "Classroom",
|
|
3491
|
+
strategyId: "CLASSROOM",
|
|
3492
|
+
action: "generated",
|
|
3493
|
+
score: 1,
|
|
3494
|
+
reason: "Classroom legacy getNewCards(), new card"
|
|
3495
|
+
}
|
|
3496
|
+
]
|
|
3497
|
+
})),
|
|
3498
|
+
...reviews.map((r) => ({
|
|
3499
|
+
cardId: r.cardID,
|
|
3500
|
+
courseId: r.courseID,
|
|
3501
|
+
score: 1,
|
|
3502
|
+
provenance: [
|
|
3503
|
+
{
|
|
3504
|
+
strategy: "classroom",
|
|
3505
|
+
strategyName: "Classroom",
|
|
3506
|
+
strategyId: "CLASSROOM",
|
|
3507
|
+
action: "generated",
|
|
3508
|
+
score: 1,
|
|
3509
|
+
reason: "Classroom legacy getPendingReviews(), review"
|
|
3510
|
+
}
|
|
3511
|
+
]
|
|
3512
|
+
}))
|
|
3513
|
+
];
|
|
3514
|
+
return weighted.slice(0, limit);
|
|
3515
|
+
}
|
|
1929
3516
|
};
|
|
1930
3517
|
TeacherClassroomDB = class _TeacherClassroomDB extends ClassroomDBBase {
|
|
1931
3518
|
_stuDb;
|
|
@@ -1982,8 +3569,8 @@ var init_classroomDB2 = __esm({
|
|
|
1982
3569
|
type: "tag",
|
|
1983
3570
|
_id: id,
|
|
1984
3571
|
assignedBy: content.assignedBy,
|
|
1985
|
-
assignedOn:
|
|
1986
|
-
activeOn: content.activeOn ||
|
|
3572
|
+
assignedOn: moment4.utc(),
|
|
3573
|
+
activeOn: content.activeOn || moment4.utc()
|
|
1987
3574
|
});
|
|
1988
3575
|
} else {
|
|
1989
3576
|
put = await this._db.put({
|
|
@@ -1991,8 +3578,8 @@ var init_classroomDB2 = __esm({
|
|
|
1991
3578
|
type: "course",
|
|
1992
3579
|
_id: id,
|
|
1993
3580
|
assignedBy: content.assignedBy,
|
|
1994
|
-
assignedOn:
|
|
1995
|
-
activeOn: content.activeOn ||
|
|
3581
|
+
assignedOn: moment4.utc(),
|
|
3582
|
+
activeOn: content.activeOn || moment4.utc()
|
|
1996
3583
|
});
|
|
1997
3584
|
}
|
|
1998
3585
|
if (put.ok) {
|
|
@@ -2357,7 +3944,7 @@ var init_CouchDBSyncStrategy = __esm({
|
|
|
2357
3944
|
|
|
2358
3945
|
// src/impl/couch/index.ts
|
|
2359
3946
|
import fetch3 from "cross-fetch";
|
|
2360
|
-
import
|
|
3947
|
+
import moment5 from "moment";
|
|
2361
3948
|
import process2 from "process";
|
|
2362
3949
|
function createPouchDBConfig() {
|
|
2363
3950
|
const hasExplicitCredentials = ENV.COUCHDB_USERNAME && ENV.COUCHDB_PASSWORD;
|
|
@@ -2442,7 +4029,7 @@ var init_couch = __esm({
|
|
|
2442
4029
|
|
|
2443
4030
|
// src/impl/common/BaseUserDB.ts
|
|
2444
4031
|
import { Status as Status3 } from "@vue-skuilder/common";
|
|
2445
|
-
import
|
|
4032
|
+
import moment6 from "moment";
|
|
2446
4033
|
function accomodateGuest() {
|
|
2447
4034
|
logger.log("[funnel] accomodateGuest() called");
|
|
2448
4035
|
if (typeof localStorage === "undefined") {
|
|
@@ -2880,7 +4467,7 @@ Currently logged-in as ${this._username}.`
|
|
|
2880
4467
|
);
|
|
2881
4468
|
return reviews.rows.filter((r) => {
|
|
2882
4469
|
if (r.id.startsWith(DocTypePrefixes["SCHEDULED_CARD" /* SCHEDULED_CARD */])) {
|
|
2883
|
-
const date =
|
|
4470
|
+
const date = moment6.utc(
|
|
2884
4471
|
r.id.substr(DocTypePrefixes["SCHEDULED_CARD" /* SCHEDULED_CARD */].length),
|
|
2885
4472
|
REVIEW_TIME_FORMAT
|
|
2886
4473
|
);
|
|
@@ -2893,11 +4480,11 @@ Currently logged-in as ${this._username}.`
|
|
|
2893
4480
|
}).map((r) => r.doc);
|
|
2894
4481
|
}
|
|
2895
4482
|
async getReviewsForcast(daysCount) {
|
|
2896
|
-
const time =
|
|
4483
|
+
const time = moment6.utc().add(daysCount, "days");
|
|
2897
4484
|
return this.getReviewstoDate(time);
|
|
2898
4485
|
}
|
|
2899
4486
|
async getPendingReviews(course_id) {
|
|
2900
|
-
const now =
|
|
4487
|
+
const now = moment6.utc();
|
|
2901
4488
|
return this.getReviewstoDate(now, course_id);
|
|
2902
4489
|
}
|
|
2903
4490
|
async getScheduledReviewCount(course_id) {
|
|
@@ -3184,7 +4771,7 @@ Currently logged-in as ${this._username}.`
|
|
|
3184
4771
|
*/
|
|
3185
4772
|
async putCardRecord(record) {
|
|
3186
4773
|
const cardHistoryID = getCardHistoryID(record.courseID, record.cardID);
|
|
3187
|
-
record.timeStamp =
|
|
4774
|
+
record.timeStamp = moment6.utc(record.timeStamp).toString();
|
|
3188
4775
|
try {
|
|
3189
4776
|
const cardHistory = await this.update(
|
|
3190
4777
|
cardHistoryID,
|
|
@@ -3200,7 +4787,7 @@ Currently logged-in as ${this._username}.`
|
|
|
3200
4787
|
const ret = {
|
|
3201
4788
|
...record2
|
|
3202
4789
|
};
|
|
3203
|
-
ret.timeStamp =
|
|
4790
|
+
ret.timeStamp = moment6.utc(record2.timeStamp);
|
|
3204
4791
|
return ret;
|
|
3205
4792
|
});
|
|
3206
4793
|
return cardHistory;
|
|
@@ -4204,9 +5791,6 @@ var init_courseDB2 = __esm({
|
|
|
4204
5791
|
async updateNavigationStrategy(_id, _data) {
|
|
4205
5792
|
throw new Error("Cannot update navigation strategies in static mode");
|
|
4206
5793
|
}
|
|
4207
|
-
async surfaceNavigationStrategy() {
|
|
4208
|
-
return this.getNavigationStrategy("ELO");
|
|
4209
|
-
}
|
|
4210
5794
|
// Study Content Source implementation
|
|
4211
5795
|
async getPendingReviews() {
|
|
4212
5796
|
return [];
|
|
@@ -4527,7 +6111,215 @@ var init_factory = __esm({
|
|
|
4527
6111
|
}
|
|
4528
6112
|
});
|
|
4529
6113
|
|
|
6114
|
+
// src/study/TagFilteredContentSource.ts
|
|
6115
|
+
import { hasActiveFilter } from "@vue-skuilder/common";
|
|
6116
|
+
var TagFilteredContentSource;
|
|
6117
|
+
var init_TagFilteredContentSource = __esm({
|
|
6118
|
+
"src/study/TagFilteredContentSource.ts"() {
|
|
6119
|
+
"use strict";
|
|
6120
|
+
init_courseDB();
|
|
6121
|
+
init_logger();
|
|
6122
|
+
TagFilteredContentSource = class {
|
|
6123
|
+
courseId;
|
|
6124
|
+
filter;
|
|
6125
|
+
user;
|
|
6126
|
+
// Cache resolved card IDs to avoid repeated lookups within a session
|
|
6127
|
+
resolvedCardIds = null;
|
|
6128
|
+
constructor(courseId, filter, user) {
|
|
6129
|
+
this.courseId = courseId;
|
|
6130
|
+
this.filter = filter;
|
|
6131
|
+
this.user = user;
|
|
6132
|
+
logger.info(
|
|
6133
|
+
`[TagFilteredContentSource] Created for course "${courseId}" with filter:`,
|
|
6134
|
+
JSON.stringify(filter)
|
|
6135
|
+
);
|
|
6136
|
+
}
|
|
6137
|
+
/**
|
|
6138
|
+
* Resolves the TagFilter to a set of eligible card IDs.
|
|
6139
|
+
*
|
|
6140
|
+
* - Cards in `include` tags are OR'd together (card needs at least one)
|
|
6141
|
+
* - Cards in `exclude` tags are removed from the result
|
|
6142
|
+
*/
|
|
6143
|
+
async resolveFilteredCardIds() {
|
|
6144
|
+
if (this.resolvedCardIds !== null) {
|
|
6145
|
+
return this.resolvedCardIds;
|
|
6146
|
+
}
|
|
6147
|
+
const includedCardIds = /* @__PURE__ */ new Set();
|
|
6148
|
+
if (this.filter.include.length > 0) {
|
|
6149
|
+
for (const tagName of this.filter.include) {
|
|
6150
|
+
try {
|
|
6151
|
+
const tagDoc = await getTag(this.courseId, tagName);
|
|
6152
|
+
tagDoc.taggedCards.forEach((cardId) => includedCardIds.add(cardId));
|
|
6153
|
+
} catch (error) {
|
|
6154
|
+
logger.warn(
|
|
6155
|
+
`[TagFilteredContentSource] Could not resolve tag "${tagName}" for inclusion:`,
|
|
6156
|
+
error
|
|
6157
|
+
);
|
|
6158
|
+
}
|
|
6159
|
+
}
|
|
6160
|
+
}
|
|
6161
|
+
if (includedCardIds.size === 0 && this.filter.include.length > 0) {
|
|
6162
|
+
logger.warn(
|
|
6163
|
+
`[TagFilteredContentSource] No cards found for include tags: ${this.filter.include.join(", ")}`
|
|
6164
|
+
);
|
|
6165
|
+
this.resolvedCardIds = /* @__PURE__ */ new Set();
|
|
6166
|
+
return this.resolvedCardIds;
|
|
6167
|
+
}
|
|
6168
|
+
const excludedCardIds = /* @__PURE__ */ new Set();
|
|
6169
|
+
if (this.filter.exclude.length > 0) {
|
|
6170
|
+
for (const tagName of this.filter.exclude) {
|
|
6171
|
+
try {
|
|
6172
|
+
const tagDoc = await getTag(this.courseId, tagName);
|
|
6173
|
+
tagDoc.taggedCards.forEach((cardId) => excludedCardIds.add(cardId));
|
|
6174
|
+
} catch (error) {
|
|
6175
|
+
logger.warn(
|
|
6176
|
+
`[TagFilteredContentSource] Could not resolve tag "${tagName}" for exclusion:`,
|
|
6177
|
+
error
|
|
6178
|
+
);
|
|
6179
|
+
}
|
|
6180
|
+
}
|
|
6181
|
+
}
|
|
6182
|
+
const finalCardIds = /* @__PURE__ */ new Set();
|
|
6183
|
+
for (const cardId of includedCardIds) {
|
|
6184
|
+
if (!excludedCardIds.has(cardId)) {
|
|
6185
|
+
finalCardIds.add(cardId);
|
|
6186
|
+
}
|
|
6187
|
+
}
|
|
6188
|
+
logger.info(
|
|
6189
|
+
`[TagFilteredContentSource] Resolved ${finalCardIds.size} cards (included: ${includedCardIds.size}, excluded: ${excludedCardIds.size})`
|
|
6190
|
+
);
|
|
6191
|
+
this.resolvedCardIds = finalCardIds;
|
|
6192
|
+
return finalCardIds;
|
|
6193
|
+
}
|
|
6194
|
+
/**
|
|
6195
|
+
* Gets new cards that match the tag filter and are not already active for the user.
|
|
6196
|
+
*/
|
|
6197
|
+
async getNewCards(limit) {
|
|
6198
|
+
if (!hasActiveFilter(this.filter)) {
|
|
6199
|
+
logger.warn("[TagFilteredContentSource] getNewCards called with no active filter");
|
|
6200
|
+
return [];
|
|
6201
|
+
}
|
|
6202
|
+
const eligibleCardIds = await this.resolveFilteredCardIds();
|
|
6203
|
+
const activeCards = await this.user.getActiveCards();
|
|
6204
|
+
const activeCardIds = new Set(activeCards.map((c) => c.cardID));
|
|
6205
|
+
const newItems = [];
|
|
6206
|
+
for (const cardId of eligibleCardIds) {
|
|
6207
|
+
if (!activeCardIds.has(cardId)) {
|
|
6208
|
+
newItems.push({
|
|
6209
|
+
courseID: this.courseId,
|
|
6210
|
+
cardID: cardId,
|
|
6211
|
+
contentSourceType: "course",
|
|
6212
|
+
contentSourceID: this.courseId,
|
|
6213
|
+
status: "new"
|
|
6214
|
+
});
|
|
6215
|
+
}
|
|
6216
|
+
if (limit !== void 0 && newItems.length >= limit) {
|
|
6217
|
+
break;
|
|
6218
|
+
}
|
|
6219
|
+
}
|
|
6220
|
+
logger.info(`[TagFilteredContentSource] Found ${newItems.length} new cards matching filter`);
|
|
6221
|
+
return newItems;
|
|
6222
|
+
}
|
|
6223
|
+
/**
|
|
6224
|
+
* Gets pending reviews, filtered to only include cards that match the tag filter.
|
|
6225
|
+
*/
|
|
6226
|
+
async getPendingReviews() {
|
|
6227
|
+
if (!hasActiveFilter(this.filter)) {
|
|
6228
|
+
logger.warn("[TagFilteredContentSource] getPendingReviews called with no active filter");
|
|
6229
|
+
return [];
|
|
6230
|
+
}
|
|
6231
|
+
const eligibleCardIds = await this.resolveFilteredCardIds();
|
|
6232
|
+
const allReviews = await this.user.getPendingReviews(this.courseId);
|
|
6233
|
+
const filteredReviews = allReviews.filter((review) => {
|
|
6234
|
+
return eligibleCardIds.has(review.cardId);
|
|
6235
|
+
});
|
|
6236
|
+
logger.info(
|
|
6237
|
+
`[TagFilteredContentSource] Found ${filteredReviews.length} pending reviews matching filter (of ${allReviews.length} total)`
|
|
6238
|
+
);
|
|
6239
|
+
return filteredReviews.map((r) => ({
|
|
6240
|
+
...r,
|
|
6241
|
+
courseID: r.courseId,
|
|
6242
|
+
cardID: r.cardId,
|
|
6243
|
+
contentSourceType: "course",
|
|
6244
|
+
contentSourceID: this.courseId,
|
|
6245
|
+
reviewID: r._id,
|
|
6246
|
+
status: "review"
|
|
6247
|
+
}));
|
|
6248
|
+
}
|
|
6249
|
+
/**
|
|
6250
|
+
* Get cards with suitability scores for presentation.
|
|
6251
|
+
*
|
|
6252
|
+
* This implementation wraps the legacy getNewCards/getPendingReviews methods,
|
|
6253
|
+
* assigning score=1.0 to all cards. TagFilteredContentSource does not currently
|
|
6254
|
+
* support pluggable navigation strategies - it returns flat-scored candidates.
|
|
6255
|
+
*
|
|
6256
|
+
* @param limit - Maximum number of cards to return
|
|
6257
|
+
* @returns Cards sorted by score descending (all scores = 1.0)
|
|
6258
|
+
*/
|
|
6259
|
+
async getWeightedCards(limit) {
|
|
6260
|
+
const [newCards, reviews] = await Promise.all([
|
|
6261
|
+
this.getNewCards(limit),
|
|
6262
|
+
this.getPendingReviews()
|
|
6263
|
+
]);
|
|
6264
|
+
const weighted = [
|
|
6265
|
+
...reviews.map((r) => ({
|
|
6266
|
+
cardId: r.cardID,
|
|
6267
|
+
courseId: r.courseID,
|
|
6268
|
+
score: 1,
|
|
6269
|
+
provenance: [
|
|
6270
|
+
{
|
|
6271
|
+
strategy: "tagFilter",
|
|
6272
|
+
strategyName: "Tag Filter",
|
|
6273
|
+
strategyId: "TAG_FILTER",
|
|
6274
|
+
action: "generated",
|
|
6275
|
+
score: 1,
|
|
6276
|
+
reason: `Tag-filtered review (tags: ${this.filter.include.join(", ")})`
|
|
6277
|
+
}
|
|
6278
|
+
]
|
|
6279
|
+
})),
|
|
6280
|
+
...newCards.map((c) => ({
|
|
6281
|
+
cardId: c.cardID,
|
|
6282
|
+
courseId: c.courseID,
|
|
6283
|
+
score: 1,
|
|
6284
|
+
provenance: [
|
|
6285
|
+
{
|
|
6286
|
+
strategy: "tagFilter",
|
|
6287
|
+
strategyName: "Tag Filter",
|
|
6288
|
+
strategyId: "TAG_FILTER",
|
|
6289
|
+
action: "generated",
|
|
6290
|
+
score: 1,
|
|
6291
|
+
reason: `Tag-filtered new card (tags: ${this.filter.include.join(", ")})`
|
|
6292
|
+
}
|
|
6293
|
+
]
|
|
6294
|
+
}))
|
|
6295
|
+
];
|
|
6296
|
+
return weighted.slice(0, limit);
|
|
6297
|
+
}
|
|
6298
|
+
/**
|
|
6299
|
+
* Clears the cached resolved card IDs.
|
|
6300
|
+
* Call this if the underlying tag data may have changed during a session.
|
|
6301
|
+
*/
|
|
6302
|
+
clearCache() {
|
|
6303
|
+
this.resolvedCardIds = null;
|
|
6304
|
+
}
|
|
6305
|
+
/**
|
|
6306
|
+
* Returns the course ID this source is filtering.
|
|
6307
|
+
*/
|
|
6308
|
+
getCourseId() {
|
|
6309
|
+
return this.courseId;
|
|
6310
|
+
}
|
|
6311
|
+
/**
|
|
6312
|
+
* Returns the active tag filter.
|
|
6313
|
+
*/
|
|
6314
|
+
getFilter() {
|
|
6315
|
+
return this.filter;
|
|
6316
|
+
}
|
|
6317
|
+
};
|
|
6318
|
+
}
|
|
6319
|
+
});
|
|
6320
|
+
|
|
4530
6321
|
// src/core/interfaces/contentSource.ts
|
|
6322
|
+
import { hasActiveFilter as hasActiveFilter2 } from "@vue-skuilder/common";
|
|
4531
6323
|
function isReview(item) {
|
|
4532
6324
|
const ret = item.status === "review" || item.status === "failed-review" || "reviewID" in item;
|
|
4533
6325
|
return ret;
|
|
@@ -4536,6 +6328,9 @@ async function getStudySource(source, user) {
|
|
|
4536
6328
|
if (source.type === "classroom") {
|
|
4537
6329
|
return await StudentClassroomDB.factory(source.id, user);
|
|
4538
6330
|
} else {
|
|
6331
|
+
if (hasActiveFilter2(source.tagFilter)) {
|
|
6332
|
+
return new TagFilteredContentSource(source.id, source.tagFilter, user);
|
|
6333
|
+
}
|
|
4539
6334
|
return getDataLayer().getCourseDB(source.id);
|
|
4540
6335
|
}
|
|
4541
6336
|
}
|
|
@@ -4544,6 +6339,7 @@ var init_contentSource = __esm({
|
|
|
4544
6339
|
"use strict";
|
|
4545
6340
|
init_factory();
|
|
4546
6341
|
init_classroomDB2();
|
|
6342
|
+
init_TagFilteredContentSource();
|
|
4547
6343
|
}
|
|
4548
6344
|
});
|
|
4549
6345
|
|
|
@@ -4709,7 +6505,7 @@ var init_cardProcessor = __esm({
|
|
|
4709
6505
|
});
|
|
4710
6506
|
|
|
4711
6507
|
// src/core/bulkImport/types.ts
|
|
4712
|
-
var
|
|
6508
|
+
var init_types3 = __esm({
|
|
4713
6509
|
"src/core/bulkImport/types.ts"() {
|
|
4714
6510
|
"use strict";
|
|
4715
6511
|
}
|
|
@@ -4720,7 +6516,7 @@ var init_bulkImport = __esm({
|
|
|
4720
6516
|
"src/core/bulkImport/index.ts"() {
|
|
4721
6517
|
"use strict";
|
|
4722
6518
|
init_cardProcessor();
|
|
4723
|
-
|
|
6519
|
+
init_types3();
|
|
4724
6520
|
}
|
|
4725
6521
|
});
|
|
4726
6522
|
|
|
@@ -4744,13 +6540,13 @@ init_courseLookupDB();
|
|
|
4744
6540
|
|
|
4745
6541
|
// src/study/services/SrsService.ts
|
|
4746
6542
|
init_couch();
|
|
4747
|
-
import
|
|
6543
|
+
import moment8 from "moment";
|
|
4748
6544
|
|
|
4749
6545
|
// src/study/SpacedRepetition.ts
|
|
4750
6546
|
init_util();
|
|
4751
6547
|
init_logger();
|
|
4752
|
-
import
|
|
4753
|
-
var duration =
|
|
6548
|
+
import moment7 from "moment";
|
|
6549
|
+
var duration = moment7.duration;
|
|
4754
6550
|
function newInterval(user, cardHistory) {
|
|
4755
6551
|
if (areQuestionRecords(cardHistory)) {
|
|
4756
6552
|
return newQuestionInterval(user, cardHistory);
|
|
@@ -4814,8 +6610,8 @@ function getInitialInterval(cardHistory) {
|
|
|
4814
6610
|
return 60 * 60 * 24 * 3;
|
|
4815
6611
|
}
|
|
4816
6612
|
function secondsBetween(start, end) {
|
|
4817
|
-
start =
|
|
4818
|
-
end =
|
|
6613
|
+
start = moment7(start);
|
|
6614
|
+
end = moment7(end);
|
|
4819
6615
|
const ret = duration(end.diff(start)).asSeconds();
|
|
4820
6616
|
return ret;
|
|
4821
6617
|
}
|
|
@@ -4835,7 +6631,7 @@ var SrsService = class {
|
|
|
4835
6631
|
*/
|
|
4836
6632
|
async scheduleReview(history, item) {
|
|
4837
6633
|
const nextInterval = newInterval(this.user, history);
|
|
4838
|
-
const nextReviewTime =
|
|
6634
|
+
const nextReviewTime = moment8.utc().add(nextInterval, "seconds");
|
|
4839
6635
|
if (isReview(item)) {
|
|
4840
6636
|
logger.info(`[SrsService] Removing previously scheduled review for: ${item.cardID}`);
|
|
4841
6637
|
void this.user.removeScheduledCardReview(item.reviewID);
|
|
@@ -4853,7 +6649,7 @@ var SrsService = class {
|
|
|
4853
6649
|
|
|
4854
6650
|
// src/study/services/EloService.ts
|
|
4855
6651
|
init_logger();
|
|
4856
|
-
import { adjustCourseScores, toCourseElo as
|
|
6652
|
+
import { adjustCourseScores, toCourseElo as toCourseElo7 } from "@vue-skuilder/common";
|
|
4857
6653
|
var EloService = class {
|
|
4858
6654
|
dataLayer;
|
|
4859
6655
|
user;
|
|
@@ -4875,7 +6671,7 @@ var EloService = class {
|
|
|
4875
6671
|
logger.warn(`k value interpretation not currently implemented`);
|
|
4876
6672
|
}
|
|
4877
6673
|
const courseDB = this.dataLayer.getCourseDB(currentCard.card.course_id);
|
|
4878
|
-
const userElo =
|
|
6674
|
+
const userElo = toCourseElo7(userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo);
|
|
4879
6675
|
const cardElo = (await courseDB.getCardEloData([currentCard.card.card_id]))[0];
|
|
4880
6676
|
if (cardElo && userElo) {
|
|
4881
6677
|
const eloUpdate = adjustCourseScores(userElo, cardElo, userScore);
|
|
@@ -5084,7 +6880,7 @@ init_logger();
|
|
|
5084
6880
|
import {
|
|
5085
6881
|
displayableDataToViewData,
|
|
5086
6882
|
isCourseElo,
|
|
5087
|
-
toCourseElo as
|
|
6883
|
+
toCourseElo as toCourseElo8
|
|
5088
6884
|
} from "@vue-skuilder/common";
|
|
5089
6885
|
|
|
5090
6886
|
// src/study/ItemQueue.ts
|
|
@@ -5209,7 +7005,7 @@ var CardHydrationService = class {
|
|
|
5209
7005
|
const courseDB = this.getCourseDB(nextItem.courseID);
|
|
5210
7006
|
const cardData = await courseDB.getCourseDoc(nextItem.cardID);
|
|
5211
7007
|
if (!isCourseElo(cardData.elo)) {
|
|
5212
|
-
cardData.elo =
|
|
7008
|
+
cardData.elo = toCourseElo8(cardData.elo);
|
|
5213
7009
|
}
|
|
5214
7010
|
const view = this.getViewComponent(cardData.id_view);
|
|
5215
7011
|
const dataDocs = await Promise.all(
|
|
@@ -6675,6 +8471,7 @@ init_dataDirectory();
|
|
|
6675
8471
|
init_tuiLogger();
|
|
6676
8472
|
|
|
6677
8473
|
// src/study/SessionController.ts
|
|
8474
|
+
init_navigators();
|
|
6678
8475
|
function randomInt(min, max) {
|
|
6679
8476
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
6680
8477
|
}
|
|
@@ -6777,7 +8574,12 @@ var SessionController = class extends Loggable {
|
|
|
6777
8574
|
}
|
|
6778
8575
|
async prepareSession() {
|
|
6779
8576
|
try {
|
|
6780
|
-
|
|
8577
|
+
const hasWeightedCards = this.sources.some((s) => typeof s.getWeightedCards === "function");
|
|
8578
|
+
if (hasWeightedCards) {
|
|
8579
|
+
await this.getWeightedContent();
|
|
8580
|
+
} else {
|
|
8581
|
+
await Promise.all([this.getScheduledReviews(), this.getNewCards()]);
|
|
8582
|
+
}
|
|
6781
8583
|
} catch (e) {
|
|
6782
8584
|
this.error("Error preparing study session:", e);
|
|
6783
8585
|
}
|
|
@@ -6803,6 +8605,9 @@ var SessionController = class extends Loggable {
|
|
|
6803
8605
|
* Used by SessionControllerDebug component for runtime inspection.
|
|
6804
8606
|
*/
|
|
6805
8607
|
getDebugInfo() {
|
|
8608
|
+
const supportsWeightedCards = this.sources.some(
|
|
8609
|
+
(s) => typeof s.getWeightedCards === "function"
|
|
8610
|
+
);
|
|
6806
8611
|
const extractQueueItems = (queue, limit = 10) => {
|
|
6807
8612
|
const items = [];
|
|
6808
8613
|
for (let i = 0; i < Math.min(queue.length, limit); i++) {
|
|
@@ -6820,6 +8625,10 @@ var SessionController = class extends Loggable {
|
|
|
6820
8625
|
return items;
|
|
6821
8626
|
};
|
|
6822
8627
|
return {
|
|
8628
|
+
api: {
|
|
8629
|
+
mode: supportsWeightedCards ? "weighted" : "legacy",
|
|
8630
|
+
description: supportsWeightedCards ? "Using getWeightedCards() API with scored candidates" : "Using legacy getNewCards()/getPendingReviews() API"
|
|
8631
|
+
},
|
|
6823
8632
|
reviewQueue: {
|
|
6824
8633
|
length: this.reviewQ.length,
|
|
6825
8634
|
dequeueCount: this.reviewQ.dequeueCount,
|
|
@@ -6842,6 +8651,109 @@ var SessionController = class extends Loggable {
|
|
|
6842
8651
|
}
|
|
6843
8652
|
};
|
|
6844
8653
|
}
|
|
8654
|
+
/**
|
|
8655
|
+
* Fetch content using the new getWeightedCards API.
|
|
8656
|
+
*
|
|
8657
|
+
* This method uses getWeightedCards() to get scored candidates, then uses the
|
|
8658
|
+
* scores to determine ordering. For reviews, we still need the full ScheduledCard
|
|
8659
|
+
* data from getPendingReviews(), so we fetch both and use scores for ordering.
|
|
8660
|
+
*
|
|
8661
|
+
* The hybrid approach:
|
|
8662
|
+
* 1. Fetch weighted cards to get scoring/ordering information
|
|
8663
|
+
* 2. Fetch full review data via legacy getPendingReviews()
|
|
8664
|
+
* 3. Order reviews by their weighted scores
|
|
8665
|
+
* 4. Add new cards ordered by their weighted scores
|
|
8666
|
+
*/
|
|
8667
|
+
async getWeightedContent() {
|
|
8668
|
+
const limit = 20;
|
|
8669
|
+
const allWeighted = [];
|
|
8670
|
+
const allReviews = [];
|
|
8671
|
+
const allNewCards = [];
|
|
8672
|
+
for (const source of this.sources) {
|
|
8673
|
+
try {
|
|
8674
|
+
const reviews = await source.getPendingReviews().catch((error) => {
|
|
8675
|
+
this.error(`Failed to get reviews for source:`, error);
|
|
8676
|
+
return [];
|
|
8677
|
+
});
|
|
8678
|
+
allReviews.push(...reviews);
|
|
8679
|
+
if (typeof source.getWeightedCards === "function") {
|
|
8680
|
+
const weighted = await source.getWeightedCards(limit);
|
|
8681
|
+
allWeighted.push(...weighted);
|
|
8682
|
+
} else {
|
|
8683
|
+
const newCards = await source.getNewCards(limit);
|
|
8684
|
+
allNewCards.push(...newCards);
|
|
8685
|
+
allWeighted.push(
|
|
8686
|
+
...newCards.map((c) => ({
|
|
8687
|
+
cardId: c.cardID,
|
|
8688
|
+
courseId: c.courseID,
|
|
8689
|
+
score: 1,
|
|
8690
|
+
provenance: [
|
|
8691
|
+
{
|
|
8692
|
+
strategy: "legacy",
|
|
8693
|
+
strategyName: "Legacy Fallback",
|
|
8694
|
+
strategyId: "legacy-fallback",
|
|
8695
|
+
action: "generated",
|
|
8696
|
+
score: 1,
|
|
8697
|
+
reason: "Fallback to legacy getNewCards(), new card"
|
|
8698
|
+
}
|
|
8699
|
+
]
|
|
8700
|
+
})),
|
|
8701
|
+
...reviews.map((r) => ({
|
|
8702
|
+
cardId: r.cardID,
|
|
8703
|
+
courseId: r.courseID,
|
|
8704
|
+
score: 1,
|
|
8705
|
+
provenance: [
|
|
8706
|
+
{
|
|
8707
|
+
strategy: "legacy",
|
|
8708
|
+
strategyName: "Legacy Fallback",
|
|
8709
|
+
strategyId: "legacy-fallback",
|
|
8710
|
+
action: "generated",
|
|
8711
|
+
score: 1,
|
|
8712
|
+
reason: "Fallback to legacy getPendingReviews(), review"
|
|
8713
|
+
}
|
|
8714
|
+
]
|
|
8715
|
+
}))
|
|
8716
|
+
);
|
|
8717
|
+
}
|
|
8718
|
+
} catch (error) {
|
|
8719
|
+
this.error(`Failed to get content from source:`, error);
|
|
8720
|
+
}
|
|
8721
|
+
}
|
|
8722
|
+
const scoreMap = /* @__PURE__ */ new Map();
|
|
8723
|
+
for (const w of allWeighted) {
|
|
8724
|
+
const key = `${w.courseId}::${w.cardId}`;
|
|
8725
|
+
scoreMap.set(key, w.score);
|
|
8726
|
+
}
|
|
8727
|
+
const scoredReviews = allReviews.map((r) => ({
|
|
8728
|
+
review: r,
|
|
8729
|
+
score: scoreMap.get(`${r.courseID}::${r.cardID}`) ?? 1
|
|
8730
|
+
}));
|
|
8731
|
+
scoredReviews.sort((a, b) => b.score - a.score);
|
|
8732
|
+
let report = "Weighted content session created with:\n";
|
|
8733
|
+
for (const { review, score } of scoredReviews) {
|
|
8734
|
+
this.reviewQ.add(review, review.cardID);
|
|
8735
|
+
report += `Review: ${review.courseID}::${review.cardID} (score: ${score.toFixed(2)})
|
|
8736
|
+
`;
|
|
8737
|
+
}
|
|
8738
|
+
const newCardWeighted = allWeighted.filter((w) => getCardOrigin(w) === "new").sort((a, b) => b.score - a.score);
|
|
8739
|
+
for (const card of newCardWeighted) {
|
|
8740
|
+
const newItem = {
|
|
8741
|
+
cardID: card.cardId,
|
|
8742
|
+
courseID: card.courseId,
|
|
8743
|
+
contentSourceType: "course",
|
|
8744
|
+
contentSourceID: card.courseId,
|
|
8745
|
+
status: "new"
|
|
8746
|
+
};
|
|
8747
|
+
this.newQ.add(newItem, card.cardId);
|
|
8748
|
+
report += `New: ${card.courseId}::${card.cardId} (score: ${card.score.toFixed(2)})
|
|
8749
|
+
`;
|
|
8750
|
+
}
|
|
8751
|
+
this.log(report);
|
|
8752
|
+
}
|
|
8753
|
+
/**
|
|
8754
|
+
* @deprecated Use getWeightedContent() instead. This method is kept for backward
|
|
8755
|
+
* compatibility with sources that don't support getWeightedCards().
|
|
8756
|
+
*/
|
|
6845
8757
|
async getScheduledReviews() {
|
|
6846
8758
|
const reviews = await Promise.all(
|
|
6847
8759
|
this.sources.map(
|
|
@@ -6867,6 +8779,10 @@ var SessionController = class extends Loggable {
|
|
|
6867
8779
|
report += dueCards.map((card) => `Card ${card.courseID}::${card.cardID} `).join("\n");
|
|
6868
8780
|
this.log(report);
|
|
6869
8781
|
}
|
|
8782
|
+
/**
|
|
8783
|
+
* @deprecated Use getWeightedContent() instead. This method is kept for backward
|
|
8784
|
+
* compatibility with sources that don't support getWeightedCards().
|
|
8785
|
+
*/
|
|
6870
8786
|
async getNewCards(n = 10) {
|
|
6871
8787
|
const perCourse = Math.ceil(n / this.sources.length);
|
|
6872
8788
|
const newContent = await Promise.all(this.sources.map((c) => c.getNewCards(perCourse)));
|
|
@@ -7031,6 +8947,9 @@ var SessionController = class extends Loggable {
|
|
|
7031
8947
|
}
|
|
7032
8948
|
};
|
|
7033
8949
|
|
|
8950
|
+
// src/study/index.ts
|
|
8951
|
+
init_TagFilteredContentSource();
|
|
8952
|
+
|
|
7034
8953
|
// src/index.ts
|
|
7035
8954
|
init_factory();
|
|
7036
8955
|
export {
|
|
@@ -7044,15 +8963,19 @@ export {
|
|
|
7044
8963
|
GuestUsername,
|
|
7045
8964
|
Loggable,
|
|
7046
8965
|
NOT_SET,
|
|
8966
|
+
NavigatorRole,
|
|
8967
|
+
NavigatorRoles,
|
|
7047
8968
|
Navigators,
|
|
7048
8969
|
SessionController,
|
|
7049
8970
|
StaticToCouchDBMigrator,
|
|
8971
|
+
TagFilteredContentSource,
|
|
7050
8972
|
_resetDataLayer,
|
|
7051
8973
|
areQuestionRecords,
|
|
7052
8974
|
docIsDeleted,
|
|
7053
8975
|
ensureAppDataDirectory,
|
|
7054
8976
|
getAppDataDirectory,
|
|
7055
8977
|
getCardHistoryID,
|
|
8978
|
+
getCardOrigin,
|
|
7056
8979
|
getDataLayer,
|
|
7057
8980
|
getDbPath,
|
|
7058
8981
|
getLogFilePath,
|
|
@@ -7061,6 +8984,8 @@ export {
|
|
|
7061
8984
|
initializeDataDirectory,
|
|
7062
8985
|
initializeDataLayer,
|
|
7063
8986
|
initializeTuiLogging,
|
|
8987
|
+
isFilter,
|
|
8988
|
+
isGenerator,
|
|
7064
8989
|
isQuestionRecord,
|
|
7065
8990
|
isReview,
|
|
7066
8991
|
log,
|