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