@vue-skuilder/db 0.1.23 → 0.1.24
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/{contentSource-BP9hznNV.d.ts → contentSource-BotbOOfX.d.ts} +227 -3
- package/dist/{contentSource-DsJadoBU.d.cts → contentSource-C90LH-OH.d.cts} +227 -3
- package/dist/core/index.d.cts +220 -6
- package/dist/core/index.d.ts +220 -6
- package/dist/core/index.js +2052 -559
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +2035 -555
- package/dist/core/index.mjs.map +1 -1
- package/dist/{dataLayerProvider-CHYrQ5pB.d.cts → dataLayerProvider-DGKp4zFB.d.cts} +1 -1
- package/dist/{dataLayerProvider-MDTxXq2l.d.ts → dataLayerProvider-SBpz9jQf.d.ts} +1 -1
- package/dist/impl/couch/index.d.cts +11 -3
- package/dist/impl/couch/index.d.ts +11 -3
- package/dist/impl/couch/index.js +1811 -574
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +1792 -550
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.d.cts +4 -4
- package/dist/impl/static/index.d.ts +4 -4
- package/dist/impl/static/index.js +1797 -560
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +1789 -547
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/{index-Dj0SEgk3.d.ts → index-BWvO-_rJ.d.ts} +1 -1
- package/dist/{index-B_j6u5E4.d.cts → index-Ba7hYbHj.d.cts} +1 -1
- package/dist/index.d.cts +36 -11
- package/dist/index.d.ts +36 -11
- package/dist/index.js +2410 -806
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2112 -529
- package/dist/index.mjs.map +1 -1
- package/dist/{types-DQaXnuoc.d.ts → types-CJrLM1Ew.d.ts} +1 -1
- package/dist/{types-Bn0itutr.d.cts → types-W8n-B6HG.d.cts} +1 -1
- package/dist/{types-legacy-DDY4N-Uq.d.cts → types-legacy-JXDxinpU.d.cts} +5 -1
- package/dist/{types-legacy-DDY4N-Uq.d.ts → types-legacy-JXDxinpU.d.ts} +5 -1
- package/dist/util/packer/index.d.cts +3 -3
- package/dist/util/packer/index.d.ts +3 -3
- package/docs/brainstorm-navigation-paradigm.md +40 -34
- package/docs/future-orchestration-vision.md +216 -0
- package/docs/navigators-architecture.md +188 -5
- package/docs/todo-strategy-authoring.md +8 -6
- package/package.json +3 -3
- package/src/core/index.ts +2 -0
- package/src/core/interfaces/contentSource.ts +7 -0
- package/src/core/interfaces/userDB.ts +6 -0
- package/src/core/navigators/Pipeline.ts +46 -0
- package/src/core/navigators/PipelineAssembler.ts +14 -1
- package/src/core/navigators/filters/WeightedFilter.ts +141 -0
- package/src/core/navigators/filters/types.ts +4 -0
- package/src/core/navigators/generators/CompositeGenerator.ts +61 -19
- package/src/core/navigators/generators/types.ts +4 -0
- package/src/core/navigators/index.ts +194 -13
- package/src/core/orchestration/gradient.ts +133 -0
- package/src/core/orchestration/index.ts +210 -0
- package/src/core/orchestration/learning.ts +250 -0
- package/src/core/orchestration/recording.ts +92 -0
- package/src/core/orchestration/signal.ts +67 -0
- package/src/core/types/contentNavigationStrategy.ts +38 -0
- package/src/core/types/learningState.ts +77 -0
- package/src/core/types/types-legacy.ts +4 -0
- package/src/core/types/userOutcome.ts +51 -0
- package/src/courseConfigRegistration.ts +107 -0
- package/src/factory.ts +6 -0
- package/src/impl/common/BaseUserDB.ts +16 -0
- package/src/study/SessionController.ts +64 -1
- package/tests/core/navigators/Pipeline.test.ts +2 -0
- package/docs/todo-evolutionary-orchestration.md +0 -310
package/dist/index.js
CHANGED
|
@@ -5,6 +5,11 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
|
5
5
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
6
|
var __getProtoOf = Object.getPrototypeOf;
|
|
7
7
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __glob = (map) => (path2) => {
|
|
9
|
+
var fn = map[path2];
|
|
10
|
+
if (fn) return fn();
|
|
11
|
+
throw new Error("Module not found in bundle: " + path2);
|
|
12
|
+
};
|
|
8
13
|
var __esm = (fn, res) => function __init() {
|
|
9
14
|
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
10
15
|
};
|
|
@@ -118,6 +123,8 @@ var init_types_legacy = __esm({
|
|
|
118
123
|
DocType3["TAG"] = "TAG";
|
|
119
124
|
DocType3["NAVIGATION_STRATEGY"] = "NAVIGATION_STRATEGY";
|
|
120
125
|
DocType3["STRATEGY_STATE"] = "STRATEGY_STATE";
|
|
126
|
+
DocType3["USER_OUTCOME"] = "USER_OUTCOME";
|
|
127
|
+
DocType3["STRATEGY_LEARNING_STATE"] = "STRATEGY_LEARNING_STATE";
|
|
121
128
|
return DocType3;
|
|
122
129
|
})(DocType || {});
|
|
123
130
|
DocTypePrefixes = {
|
|
@@ -132,7 +139,9 @@ var init_types_legacy = __esm({
|
|
|
132
139
|
["VIEW" /* VIEW */]: "VIEW",
|
|
133
140
|
["PEDAGOGY" /* PEDAGOGY */]: "PEDAGOGY",
|
|
134
141
|
["NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */]: "NAVIGATION_STRATEGY",
|
|
135
|
-
["STRATEGY_STATE" /* STRATEGY_STATE */]: "STRATEGY_STATE"
|
|
142
|
+
["STRATEGY_STATE" /* STRATEGY_STATE */]: "STRATEGY_STATE",
|
|
143
|
+
["USER_OUTCOME" /* USER_OUTCOME */]: "USER_OUTCOME",
|
|
144
|
+
["STRATEGY_LEARNING_STATE" /* STRATEGY_LEARNING_STATE */]: "STRATEGY_LEARNING_STATE"
|
|
136
145
|
};
|
|
137
146
|
}
|
|
138
147
|
});
|
|
@@ -854,870 +863,2295 @@ var init_courseLookupDB = __esm({
|
|
|
854
863
|
}
|
|
855
864
|
});
|
|
856
865
|
|
|
857
|
-
// src/core/navigators/
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
}
|
|
867
|
-
if (reason.includes("review")) {
|
|
868
|
-
return "review";
|
|
869
|
-
}
|
|
870
|
-
return "new";
|
|
871
|
-
}
|
|
872
|
-
function isGenerator(impl) {
|
|
873
|
-
return NavigatorRoles[impl] === "generator" /* GENERATOR */;
|
|
874
|
-
}
|
|
875
|
-
function isFilter(impl) {
|
|
876
|
-
return NavigatorRoles[impl] === "filter" /* FILTER */;
|
|
877
|
-
}
|
|
878
|
-
var Navigators, NavigatorRole, NavigatorRoles, ContentNavigator;
|
|
879
|
-
var init_navigators = __esm({
|
|
880
|
-
"src/core/navigators/index.ts"() {
|
|
866
|
+
// src/core/navigators/generators/CompositeGenerator.ts
|
|
867
|
+
var CompositeGenerator_exports = {};
|
|
868
|
+
__export(CompositeGenerator_exports, {
|
|
869
|
+
AggregationMode: () => AggregationMode,
|
|
870
|
+
default: () => CompositeGenerator
|
|
871
|
+
});
|
|
872
|
+
var AggregationMode, DEFAULT_AGGREGATION_MODE, FREQUENCY_BOOST_FACTOR, CompositeGenerator;
|
|
873
|
+
var init_CompositeGenerator = __esm({
|
|
874
|
+
"src/core/navigators/generators/CompositeGenerator.ts"() {
|
|
881
875
|
"use strict";
|
|
876
|
+
init_navigators();
|
|
882
877
|
init_logger();
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */,
|
|
903
|
-
["userTagPreference" /* USER_TAG_PREFERENCE */]: "filter" /* FILTER */
|
|
904
|
-
};
|
|
905
|
-
ContentNavigator = class {
|
|
906
|
-
/** User interface for this navigation session */
|
|
907
|
-
user;
|
|
908
|
-
/** Course interface for this navigation session */
|
|
909
|
-
course;
|
|
910
|
-
/** Human-readable name for this strategy instance (from ContentNavigationStrategyData.name) */
|
|
911
|
-
strategyName;
|
|
912
|
-
/** Unique document ID for this strategy instance (from ContentNavigationStrategyData._id) */
|
|
913
|
-
strategyId;
|
|
914
|
-
/**
|
|
915
|
-
* Constructor for standard navigators.
|
|
916
|
-
* Call this from subclass constructors to initialize common fields.
|
|
917
|
-
*
|
|
918
|
-
* Note: CompositeGenerator and Pipeline call super() without args, then set
|
|
919
|
-
* user/course fields directly if needed.
|
|
920
|
-
*/
|
|
921
|
-
constructor(user, course, strategyData) {
|
|
922
|
-
this.user = user;
|
|
923
|
-
this.course = course;
|
|
924
|
-
if (strategyData) {
|
|
925
|
-
this.strategyName = strategyData.name;
|
|
926
|
-
this.strategyId = strategyData._id;
|
|
878
|
+
AggregationMode = /* @__PURE__ */ ((AggregationMode2) => {
|
|
879
|
+
AggregationMode2["MAX"] = "max";
|
|
880
|
+
AggregationMode2["AVERAGE"] = "average";
|
|
881
|
+
AggregationMode2["FREQUENCY_BOOST"] = "frequencyBoost";
|
|
882
|
+
return AggregationMode2;
|
|
883
|
+
})(AggregationMode || {});
|
|
884
|
+
DEFAULT_AGGREGATION_MODE = "frequencyBoost" /* FREQUENCY_BOOST */;
|
|
885
|
+
FREQUENCY_BOOST_FACTOR = 0.1;
|
|
886
|
+
CompositeGenerator = class _CompositeGenerator extends ContentNavigator {
|
|
887
|
+
/** Human-readable name for CardGenerator interface */
|
|
888
|
+
name = "Composite Generator";
|
|
889
|
+
generators;
|
|
890
|
+
aggregationMode;
|
|
891
|
+
constructor(generators, aggregationMode = DEFAULT_AGGREGATION_MODE) {
|
|
892
|
+
super();
|
|
893
|
+
this.generators = generators;
|
|
894
|
+
this.aggregationMode = aggregationMode;
|
|
895
|
+
if (generators.length === 0) {
|
|
896
|
+
throw new Error("CompositeGenerator requires at least one generator");
|
|
927
897
|
}
|
|
898
|
+
logger.debug(
|
|
899
|
+
`[CompositeGenerator] Created with ${generators.length} generators, mode: ${aggregationMode}`
|
|
900
|
+
);
|
|
928
901
|
}
|
|
929
|
-
// ============================================================================
|
|
930
|
-
// STRATEGY STATE HELPERS
|
|
931
|
-
// ============================================================================
|
|
932
|
-
//
|
|
933
|
-
// These methods allow strategies to persist their own state (user preferences,
|
|
934
|
-
// learned patterns, temporal tracking) in the user database.
|
|
935
|
-
//
|
|
936
|
-
// ============================================================================
|
|
937
902
|
/**
|
|
938
|
-
*
|
|
903
|
+
* Creates a CompositeGenerator from strategy data.
|
|
939
904
|
*
|
|
940
|
-
*
|
|
941
|
-
* Override in subclasses if multiple instances of the same strategy type
|
|
942
|
-
* need separate state storage.
|
|
905
|
+
* This is a convenience factory for use by PipelineAssembler.
|
|
943
906
|
*/
|
|
944
|
-
|
|
945
|
-
|
|
907
|
+
static async fromStrategies(user, course, strategies, aggregationMode = DEFAULT_AGGREGATION_MODE) {
|
|
908
|
+
const generators = await Promise.all(
|
|
909
|
+
strategies.map((s) => ContentNavigator.create(user, course, s))
|
|
910
|
+
);
|
|
911
|
+
return new _CompositeGenerator(generators, aggregationMode);
|
|
946
912
|
}
|
|
947
913
|
/**
|
|
948
|
-
* Get
|
|
914
|
+
* Get weighted cards from all generators, merge and deduplicate.
|
|
949
915
|
*
|
|
950
|
-
*
|
|
951
|
-
*
|
|
916
|
+
* Cards appearing in multiple generators receive a score boost.
|
|
917
|
+
* Provenance tracks which generators produced each card and how scores were aggregated.
|
|
918
|
+
*
|
|
919
|
+
* This method supports both the legacy signature (limit only) and the
|
|
920
|
+
* CardGenerator interface signature (limit, context).
|
|
921
|
+
*
|
|
922
|
+
* @param limit - Maximum number of cards to return
|
|
923
|
+
* @param context - GeneratorContext passed to child generators (required when called via Pipeline)
|
|
952
924
|
*/
|
|
953
|
-
async
|
|
954
|
-
if (!
|
|
925
|
+
async getWeightedCards(limit, context) {
|
|
926
|
+
if (!context) {
|
|
955
927
|
throw new Error(
|
|
956
|
-
|
|
928
|
+
"CompositeGenerator.getWeightedCards requires a GeneratorContext. It should be called via Pipeline, not directly."
|
|
957
929
|
);
|
|
958
930
|
}
|
|
959
|
-
|
|
931
|
+
const results = await Promise.all(
|
|
932
|
+
this.generators.map((g) => g.getWeightedCards(limit, context))
|
|
933
|
+
);
|
|
934
|
+
const byCardId = /* @__PURE__ */ new Map();
|
|
935
|
+
results.forEach((cards, index) => {
|
|
936
|
+
const gen = this.generators[index];
|
|
937
|
+
let weight = gen.learnable?.weight ?? 1;
|
|
938
|
+
let deviation;
|
|
939
|
+
if (gen.learnable && !gen.staticWeight && context.orchestration) {
|
|
940
|
+
const strategyId = gen.strategyId;
|
|
941
|
+
if (strategyId) {
|
|
942
|
+
weight = context.orchestration.getEffectiveWeight(strategyId, gen.learnable);
|
|
943
|
+
deviation = context.orchestration.getDeviation(strategyId);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
for (const card of cards) {
|
|
947
|
+
if (card.provenance.length > 0) {
|
|
948
|
+
card.provenance[0].effectiveWeight = weight;
|
|
949
|
+
card.provenance[0].deviation = deviation;
|
|
950
|
+
}
|
|
951
|
+
const existing = byCardId.get(card.cardId) || [];
|
|
952
|
+
existing.push({ card, weight });
|
|
953
|
+
byCardId.set(card.cardId, existing);
|
|
954
|
+
}
|
|
955
|
+
});
|
|
956
|
+
const merged = [];
|
|
957
|
+
for (const [, items] of byCardId) {
|
|
958
|
+
const cards = items.map((i) => i.card);
|
|
959
|
+
const aggregatedScore = this.aggregateScores(items);
|
|
960
|
+
const finalScore = Math.min(1, aggregatedScore);
|
|
961
|
+
const mergedProvenance = cards.flatMap((c) => c.provenance);
|
|
962
|
+
const initialScore = cards[0].score;
|
|
963
|
+
const action = finalScore > initialScore ? "boosted" : finalScore < initialScore ? "penalized" : "passed";
|
|
964
|
+
const reason = this.buildAggregationReason(items, finalScore);
|
|
965
|
+
merged.push({
|
|
966
|
+
...cards[0],
|
|
967
|
+
score: finalScore,
|
|
968
|
+
provenance: [
|
|
969
|
+
...mergedProvenance,
|
|
970
|
+
{
|
|
971
|
+
strategy: "composite",
|
|
972
|
+
strategyName: "Composite Generator",
|
|
973
|
+
strategyId: "COMPOSITE_GENERATOR",
|
|
974
|
+
action,
|
|
975
|
+
score: finalScore,
|
|
976
|
+
reason
|
|
977
|
+
}
|
|
978
|
+
]
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
return merged.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
960
982
|
}
|
|
961
983
|
/**
|
|
962
|
-
*
|
|
963
|
-
*
|
|
964
|
-
* @param data - The strategy's data payload to store
|
|
965
|
-
* @throws Error if user or course is not initialized
|
|
984
|
+
* Build human-readable reason for score aggregation.
|
|
966
985
|
*/
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
986
|
+
buildAggregationReason(items, finalScore) {
|
|
987
|
+
const cards = items.map((i) => i.card);
|
|
988
|
+
const count = cards.length;
|
|
989
|
+
const scores = cards.map((c) => c.score.toFixed(2)).join(", ");
|
|
990
|
+
if (count === 1) {
|
|
991
|
+
const weightMsg = Math.abs(items[0].weight - 1) > 1e-3 ? ` (w=${items[0].weight.toFixed(2)})` : "";
|
|
992
|
+
return `Single generator, score ${finalScore.toFixed(2)}${weightMsg}`;
|
|
993
|
+
}
|
|
994
|
+
const strategies = cards.map((c) => c.provenance[0]?.strategy || "unknown").join(", ");
|
|
995
|
+
switch (this.aggregationMode) {
|
|
996
|
+
case "max" /* MAX */:
|
|
997
|
+
return `Max of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
|
|
998
|
+
case "average" /* AVERAGE */:
|
|
999
|
+
return `Weighted Avg of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
|
|
1000
|
+
case "frequencyBoost" /* FREQUENCY_BOOST */: {
|
|
1001
|
+
const totalWeight = items.reduce((sum, i) => sum + i.weight, 0);
|
|
1002
|
+
const weightedSum = items.reduce((sum, i) => sum + i.card.score * i.weight, 0);
|
|
1003
|
+
const avg = totalWeight > 0 ? weightedSum / totalWeight : 0;
|
|
1004
|
+
const boost = 1 + FREQUENCY_BOOST_FACTOR * (count - 1);
|
|
1005
|
+
return `Frequency boost from ${count} generators (${strategies}): w-avg ${avg.toFixed(2)} \xD7 ${boost.toFixed(2)} \u2192 ${finalScore.toFixed(2)}`;
|
|
1006
|
+
}
|
|
1007
|
+
default:
|
|
1008
|
+
return `Aggregated from ${count} generators: ${finalScore.toFixed(2)}`;
|
|
972
1009
|
}
|
|
973
|
-
return this.user.putStrategyState(this.course.getCourseID(), this.strategyKey, data);
|
|
974
1010
|
}
|
|
975
1011
|
/**
|
|
976
|
-
*
|
|
977
|
-
*
|
|
978
|
-
* @param user - User interface
|
|
979
|
-
* @param course - Course interface
|
|
980
|
-
* @param strategyData - Strategy configuration document
|
|
981
|
-
* @returns the runtime object used to steer a study session.
|
|
1012
|
+
* Aggregate scores from multiple generators for the same card.
|
|
982
1013
|
*/
|
|
983
|
-
|
|
984
|
-
const
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
NavigatorImpl = module2.default;
|
|
994
|
-
break;
|
|
995
|
-
} catch (e) {
|
|
996
|
-
logger.debug(`Failed to load extension from ${loadFrom}:`, e);
|
|
997
|
-
}
|
|
1014
|
+
aggregateScores(items) {
|
|
1015
|
+
const scores = items.map((i) => i.card.score);
|
|
1016
|
+
switch (this.aggregationMode) {
|
|
1017
|
+
case "max" /* MAX */:
|
|
1018
|
+
return Math.max(...scores);
|
|
1019
|
+
case "average" /* AVERAGE */: {
|
|
1020
|
+
const totalWeight = items.reduce((sum, i) => sum + i.weight, 0);
|
|
1021
|
+
if (totalWeight === 0) return 0;
|
|
1022
|
+
const weightedSum = items.reduce((sum, i) => sum + i.card.score * i.weight, 0);
|
|
1023
|
+
return weightedSum / totalWeight;
|
|
998
1024
|
}
|
|
1025
|
+
case "frequencyBoost" /* FREQUENCY_BOOST */: {
|
|
1026
|
+
const totalWeight = items.reduce((sum, i) => sum + i.weight, 0);
|
|
1027
|
+
const weightedSum = items.reduce((sum, i) => sum + i.card.score * i.weight, 0);
|
|
1028
|
+
const avg = totalWeight > 0 ? weightedSum / totalWeight : 0;
|
|
1029
|
+
const frequencyBoost = 1 + FREQUENCY_BOOST_FACTOR * (items.length - 1);
|
|
1030
|
+
return avg * frequencyBoost;
|
|
1031
|
+
}
|
|
1032
|
+
default:
|
|
1033
|
+
return scores[0];
|
|
999
1034
|
}
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1035
|
+
}
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
// src/core/navigators/generators/elo.ts
|
|
1041
|
+
var elo_exports = {};
|
|
1042
|
+
__export(elo_exports, {
|
|
1043
|
+
default: () => ELONavigator
|
|
1044
|
+
});
|
|
1045
|
+
var import_common5, ELONavigator;
|
|
1046
|
+
var init_elo = __esm({
|
|
1047
|
+
"src/core/navigators/generators/elo.ts"() {
|
|
1048
|
+
"use strict";
|
|
1049
|
+
init_navigators();
|
|
1050
|
+
import_common5 = require("@vue-skuilder/common");
|
|
1051
|
+
ELONavigator = class extends ContentNavigator {
|
|
1052
|
+
/** Human-readable name for CardGenerator interface */
|
|
1053
|
+
name;
|
|
1054
|
+
constructor(user, course, strategyData) {
|
|
1055
|
+
super(user, course, strategyData);
|
|
1056
|
+
this.name = strategyData?.name || "ELO";
|
|
1004
1057
|
}
|
|
1005
1058
|
/**
|
|
1006
|
-
* Get cards with suitability scores
|
|
1007
|
-
*
|
|
1008
|
-
* **This is the PRIMARY API for navigation strategies.**
|
|
1009
|
-
*
|
|
1010
|
-
* Returns cards ranked by suitability score (0-1). Higher scores indicate
|
|
1011
|
-
* better candidates for presentation. Each card includes a provenance trail
|
|
1012
|
-
* documenting how strategies contributed to the final score.
|
|
1059
|
+
* Get new cards with suitability scores based on ELO distance.
|
|
1013
1060
|
*
|
|
1014
|
-
*
|
|
1015
|
-
*
|
|
1016
|
-
* not provide a default implementation.
|
|
1061
|
+
* Cards closer to user's ELO get higher scores.
|
|
1062
|
+
* Score formula: max(0, 1 - distance / 500)
|
|
1017
1063
|
*
|
|
1018
|
-
*
|
|
1019
|
-
*
|
|
1020
|
-
* your strategy's logic (e.g., ELO proximity, review urgency). Create the
|
|
1021
|
-
* initial provenance entry with action='generated'.
|
|
1064
|
+
* NOTE: This generator only handles NEW cards. Reviews are handled by
|
|
1065
|
+
* SRSNavigator. Use CompositeGenerator to combine both.
|
|
1022
1066
|
*
|
|
1023
|
-
*
|
|
1024
|
-
*
|
|
1025
|
-
* via Pipeline. Filters do not directly implement getWeightedCards().
|
|
1067
|
+
* This method supports both the legacy signature (limit only) and the
|
|
1068
|
+
* CardGenerator interface signature (limit, context).
|
|
1026
1069
|
*
|
|
1027
|
-
* @param limit - Maximum cards to return
|
|
1028
|
-
* @
|
|
1070
|
+
* @param limit - Maximum number of cards to return
|
|
1071
|
+
* @param context - Optional GeneratorContext (used when called via Pipeline)
|
|
1029
1072
|
*/
|
|
1030
|
-
async getWeightedCards(
|
|
1031
|
-
|
|
1073
|
+
async getWeightedCards(limit, context) {
|
|
1074
|
+
let userGlobalElo;
|
|
1075
|
+
if (context?.userElo !== void 0) {
|
|
1076
|
+
userGlobalElo = context.userElo;
|
|
1077
|
+
} else {
|
|
1078
|
+
const courseReg = await this.user.getCourseRegDoc(this.course.getCourseID());
|
|
1079
|
+
const userElo = (0, import_common5.toCourseElo)(courseReg.elo);
|
|
1080
|
+
userGlobalElo = userElo.global.score;
|
|
1081
|
+
}
|
|
1082
|
+
const activeCards = await this.user.getActiveCards();
|
|
1083
|
+
const newCards = (await this.course.getCardsCenteredAtELO(
|
|
1084
|
+
{ limit, elo: "user" },
|
|
1085
|
+
(c) => !activeCards.some((ac) => c.cardID === ac.cardID)
|
|
1086
|
+
)).map((c) => ({ ...c, status: "new" }));
|
|
1087
|
+
const cardIds = newCards.map((c) => c.cardID);
|
|
1088
|
+
const cardEloData = await this.course.getCardEloData(cardIds);
|
|
1089
|
+
const scored = newCards.map((c, i) => {
|
|
1090
|
+
const cardElo = cardEloData[i]?.global?.score ?? 1e3;
|
|
1091
|
+
const distance = Math.abs(cardElo - userGlobalElo);
|
|
1092
|
+
const score = Math.max(0, 1 - distance / 500);
|
|
1093
|
+
return {
|
|
1094
|
+
cardId: c.cardID,
|
|
1095
|
+
courseId: c.courseID,
|
|
1096
|
+
score,
|
|
1097
|
+
provenance: [
|
|
1098
|
+
{
|
|
1099
|
+
strategy: "elo",
|
|
1100
|
+
strategyName: this.strategyName || this.name,
|
|
1101
|
+
strategyId: this.strategyId || "NAVIGATION_STRATEGY-ELO-default",
|
|
1102
|
+
action: "generated",
|
|
1103
|
+
score,
|
|
1104
|
+
reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userGlobalElo)}), new card`
|
|
1105
|
+
}
|
|
1106
|
+
]
|
|
1107
|
+
};
|
|
1108
|
+
});
|
|
1109
|
+
scored.sort((a, b) => b.score - a.score);
|
|
1110
|
+
return scored.slice(0, limit);
|
|
1032
1111
|
}
|
|
1033
1112
|
};
|
|
1034
1113
|
}
|
|
1035
1114
|
});
|
|
1036
1115
|
|
|
1037
|
-
// src/core/navigators/
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
`[Pipeline] Configuration:
|
|
1042
|
-
Generator: ${generator.name}
|
|
1043
|
-
Filters:${filterList}`
|
|
1044
|
-
);
|
|
1045
|
-
}
|
|
1046
|
-
function logTagHydration(cards, tagsByCard) {
|
|
1047
|
-
const totalTags = Array.from(tagsByCard.values()).reduce((sum, tags) => sum + tags.length, 0);
|
|
1048
|
-
const cardsWithTags = Array.from(tagsByCard.values()).filter((tags) => tags.length > 0).length;
|
|
1049
|
-
logger.debug(
|
|
1050
|
-
`[Pipeline] Tag hydration: ${cards.length} cards, ${cardsWithTags} have tags (${totalTags} total tags) - single batch query`
|
|
1051
|
-
);
|
|
1052
|
-
}
|
|
1053
|
-
function logExecutionSummary(generatorName, generatedCount, filterCount, finalCount, topScores) {
|
|
1054
|
-
const scoreDisplay = topScores.length > 0 ? topScores.map((s) => s.toFixed(2)).join(", ") : "none";
|
|
1055
|
-
logger.info(
|
|
1056
|
-
`[Pipeline] Execution: ${generatorName} produced ${generatedCount} \u2192 ${filterCount} filters \u2192 ${finalCount} results (top scores: ${scoreDisplay})`
|
|
1057
|
-
);
|
|
1058
|
-
}
|
|
1059
|
-
function logCardProvenance(cards, maxCards = 3) {
|
|
1060
|
-
const cardsToLog = cards.slice(0, maxCards);
|
|
1061
|
-
logger.debug(`[Pipeline] Provenance for top ${cardsToLog.length} cards:`);
|
|
1062
|
-
for (const card of cardsToLog) {
|
|
1063
|
-
logger.debug(`[Pipeline] ${card.cardId} (final score: ${card.score.toFixed(3)}):`);
|
|
1064
|
-
for (const entry of card.provenance) {
|
|
1065
|
-
const scoreChange = entry.score.toFixed(3);
|
|
1066
|
-
const action = entry.action.padEnd(9);
|
|
1067
|
-
logger.debug(
|
|
1068
|
-
`[Pipeline] ${action} ${scoreChange} - ${entry.strategyName}: ${entry.reason}`
|
|
1069
|
-
);
|
|
1070
|
-
}
|
|
1071
|
-
}
|
|
1072
|
-
}
|
|
1073
|
-
var import_common5, Pipeline;
|
|
1074
|
-
var init_Pipeline = __esm({
|
|
1075
|
-
"src/core/navigators/Pipeline.ts"() {
|
|
1116
|
+
// src/core/navigators/generators/index.ts
|
|
1117
|
+
var generators_exports = {};
|
|
1118
|
+
var init_generators = __esm({
|
|
1119
|
+
"src/core/navigators/generators/index.ts"() {
|
|
1076
1120
|
"use strict";
|
|
1077
|
-
import_common5 = require("@vue-skuilder/common");
|
|
1078
|
-
init_navigators();
|
|
1079
|
-
init_logger();
|
|
1080
|
-
Pipeline = class extends ContentNavigator {
|
|
1081
|
-
generator;
|
|
1082
|
-
filters;
|
|
1083
|
-
/**
|
|
1084
|
-
* Create a new pipeline.
|
|
1085
|
-
*
|
|
1086
|
-
* @param generator - The generator (or CompositeGenerator) that produces candidates
|
|
1087
|
-
* @param filters - Filters to apply sequentially (order doesn't matter for multipliers)
|
|
1088
|
-
* @param user - User database interface
|
|
1089
|
-
* @param course - Course database interface
|
|
1090
|
-
*/
|
|
1091
|
-
constructor(generator, filters, user, course) {
|
|
1092
|
-
super();
|
|
1093
|
-
this.generator = generator;
|
|
1094
|
-
this.filters = filters;
|
|
1095
|
-
this.user = user;
|
|
1096
|
-
this.course = course;
|
|
1097
|
-
course.getCourseConfig().then((cfg) => {
|
|
1098
|
-
logger.debug(`[pipeline] Crated pipeline for ${cfg.name}`);
|
|
1099
|
-
}).catch((e) => {
|
|
1100
|
-
logger.error(`[pipeline] Failed to lookup courseCfg: ${e}`);
|
|
1101
|
-
});
|
|
1102
|
-
logPipelineConfig(generator, filters);
|
|
1103
|
-
}
|
|
1104
|
-
/**
|
|
1105
|
-
* Get weighted cards by running generator and applying filters.
|
|
1106
|
-
*
|
|
1107
|
-
* 1. Build shared context (user ELO, etc.)
|
|
1108
|
-
* 2. Get candidates from generator (passing context)
|
|
1109
|
-
* 3. Batch hydrate tags for all candidates
|
|
1110
|
-
* 4. Apply each filter sequentially
|
|
1111
|
-
* 5. Remove zero-score cards
|
|
1112
|
-
* 6. Sort by score descending
|
|
1113
|
-
* 7. Return top N
|
|
1114
|
-
*
|
|
1115
|
-
* @param limit - Maximum number of cards to return
|
|
1116
|
-
* @returns Cards sorted by score descending
|
|
1117
|
-
*/
|
|
1118
|
-
async getWeightedCards(limit) {
|
|
1119
|
-
const context = await this.buildContext();
|
|
1120
|
-
const overFetchMultiplier = 2 + this.filters.length * 0.5;
|
|
1121
|
-
const fetchLimit = Math.ceil(limit * overFetchMultiplier);
|
|
1122
|
-
logger.debug(
|
|
1123
|
-
`[Pipeline] Fetching ${fetchLimit} candidates from generator '${this.generator.name}'`
|
|
1124
|
-
);
|
|
1125
|
-
let cards = await this.generator.getWeightedCards(fetchLimit, context);
|
|
1126
|
-
const generatedCount = cards.length;
|
|
1127
|
-
logger.debug(`[Pipeline] Generator returned ${generatedCount} candidates`);
|
|
1128
|
-
cards = await this.hydrateTags(cards);
|
|
1129
|
-
for (const filter of this.filters) {
|
|
1130
|
-
const beforeCount = cards.length;
|
|
1131
|
-
cards = await filter.transform(cards, context);
|
|
1132
|
-
logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeCount} \u2192 ${cards.length} cards`);
|
|
1133
|
-
}
|
|
1134
|
-
cards = cards.filter((c) => c.score > 0);
|
|
1135
|
-
cards.sort((a, b) => b.score - a.score);
|
|
1136
|
-
const result = cards.slice(0, limit);
|
|
1137
|
-
const topScores = result.slice(0, 3).map((c) => c.score);
|
|
1138
|
-
logExecutionSummary(
|
|
1139
|
-
this.generator.name,
|
|
1140
|
-
generatedCount,
|
|
1141
|
-
this.filters.length,
|
|
1142
|
-
result.length,
|
|
1143
|
-
topScores
|
|
1144
|
-
);
|
|
1145
|
-
logCardProvenance(result, 3);
|
|
1146
|
-
return result;
|
|
1147
|
-
}
|
|
1148
|
-
/**
|
|
1149
|
-
* Batch hydrate tags for all cards.
|
|
1150
|
-
*
|
|
1151
|
-
* Fetches tags for all cards in a single database query and attaches them
|
|
1152
|
-
* to the WeightedCard objects. Filters can then use card.tags instead of
|
|
1153
|
-
* making individual getAppliedTags() calls.
|
|
1154
|
-
*
|
|
1155
|
-
* @param cards - Cards to hydrate
|
|
1156
|
-
* @returns Cards with tags populated
|
|
1157
|
-
*/
|
|
1158
|
-
async hydrateTags(cards) {
|
|
1159
|
-
if (cards.length === 0) {
|
|
1160
|
-
return cards;
|
|
1161
|
-
}
|
|
1162
|
-
const cardIds = cards.map((c) => c.cardId);
|
|
1163
|
-
const tagsByCard = await this.course.getAppliedTagsBatch(cardIds);
|
|
1164
|
-
logTagHydration(cards, tagsByCard);
|
|
1165
|
-
return cards.map((card) => ({
|
|
1166
|
-
...card,
|
|
1167
|
-
tags: tagsByCard.get(card.cardId) ?? []
|
|
1168
|
-
}));
|
|
1169
|
-
}
|
|
1170
|
-
/**
|
|
1171
|
-
* Build shared context for generator and filters.
|
|
1172
|
-
*
|
|
1173
|
-
* Called once per getWeightedCards() invocation.
|
|
1174
|
-
* Contains data that the generator and multiple filters might need.
|
|
1175
|
-
*
|
|
1176
|
-
* The context satisfies both GeneratorContext and FilterContext interfaces.
|
|
1177
|
-
*/
|
|
1178
|
-
async buildContext() {
|
|
1179
|
-
let userElo = 1e3;
|
|
1180
|
-
try {
|
|
1181
|
-
const courseReg = await this.user.getCourseRegDoc(this.course.getCourseID());
|
|
1182
|
-
const courseElo = (0, import_common5.toCourseElo)(courseReg.elo);
|
|
1183
|
-
userElo = courseElo.global.score;
|
|
1184
|
-
} catch (e) {
|
|
1185
|
-
logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`);
|
|
1186
|
-
}
|
|
1187
|
-
return {
|
|
1188
|
-
user: this.user,
|
|
1189
|
-
course: this.course,
|
|
1190
|
-
userElo
|
|
1191
|
-
};
|
|
1192
|
-
}
|
|
1193
|
-
/**
|
|
1194
|
-
* Get the course ID for this pipeline.
|
|
1195
|
-
*/
|
|
1196
|
-
getCourseID() {
|
|
1197
|
-
return this.course.getCourseID();
|
|
1198
|
-
}
|
|
1199
|
-
};
|
|
1200
1121
|
}
|
|
1201
1122
|
});
|
|
1202
1123
|
|
|
1203
|
-
// src/core/navigators/generators/
|
|
1204
|
-
var
|
|
1205
|
-
|
|
1206
|
-
|
|
1124
|
+
// src/core/navigators/generators/srs.ts
|
|
1125
|
+
var srs_exports = {};
|
|
1126
|
+
__export(srs_exports, {
|
|
1127
|
+
default: () => SRSNavigator
|
|
1128
|
+
});
|
|
1129
|
+
var import_moment3, SRSNavigator;
|
|
1130
|
+
var init_srs = __esm({
|
|
1131
|
+
"src/core/navigators/generators/srs.ts"() {
|
|
1207
1132
|
"use strict";
|
|
1133
|
+
import_moment3 = __toESM(require("moment"), 1);
|
|
1208
1134
|
init_navigators();
|
|
1209
1135
|
init_logger();
|
|
1210
|
-
|
|
1211
|
-
FREQUENCY_BOOST_FACTOR = 0.1;
|
|
1212
|
-
CompositeGenerator = class _CompositeGenerator extends ContentNavigator {
|
|
1136
|
+
SRSNavigator = class extends ContentNavigator {
|
|
1213
1137
|
/** Human-readable name for CardGenerator interface */
|
|
1214
|
-
name
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
super();
|
|
1219
|
-
this.generators = generators;
|
|
1220
|
-
this.aggregationMode = aggregationMode;
|
|
1221
|
-
if (generators.length === 0) {
|
|
1222
|
-
throw new Error("CompositeGenerator requires at least one generator");
|
|
1223
|
-
}
|
|
1224
|
-
logger.debug(
|
|
1225
|
-
`[CompositeGenerator] Created with ${generators.length} generators, mode: ${aggregationMode}`
|
|
1226
|
-
);
|
|
1138
|
+
name;
|
|
1139
|
+
constructor(user, course, strategyData) {
|
|
1140
|
+
super(user, course, strategyData);
|
|
1141
|
+
this.name = strategyData?.name || "SRS";
|
|
1227
1142
|
}
|
|
1228
1143
|
/**
|
|
1229
|
-
*
|
|
1144
|
+
* Get review cards scored by urgency.
|
|
1230
1145
|
*
|
|
1231
|
-
*
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
const generators = await Promise.all(
|
|
1235
|
-
strategies.map((s) => ContentNavigator.create(user, course, s))
|
|
1236
|
-
);
|
|
1237
|
-
return new _CompositeGenerator(generators, aggregationMode);
|
|
1238
|
-
}
|
|
1239
|
-
/**
|
|
1240
|
-
* Get weighted cards from all generators, merge and deduplicate.
|
|
1146
|
+
* Score formula combines:
|
|
1147
|
+
* - Relative overdueness: hoursOverdue / intervalHours
|
|
1148
|
+
* - Interval recency: exponential decay favoring shorter intervals
|
|
1241
1149
|
*
|
|
1242
|
-
* Cards
|
|
1243
|
-
* Provenance tracks which generators produced each card and how scores were aggregated.
|
|
1150
|
+
* Cards not yet due are excluded (not scored as 0).
|
|
1244
1151
|
*
|
|
1245
1152
|
* This method supports both the legacy signature (limit only) and the
|
|
1246
1153
|
* CardGenerator interface signature (limit, context).
|
|
1247
1154
|
*
|
|
1248
1155
|
* @param limit - Maximum number of cards to return
|
|
1249
|
-
* @param
|
|
1156
|
+
* @param _context - Optional GeneratorContext (currently unused, but required for interface)
|
|
1250
1157
|
*/
|
|
1251
|
-
async getWeightedCards(limit,
|
|
1252
|
-
if (!
|
|
1253
|
-
throw new Error(
|
|
1254
|
-
"CompositeGenerator.getWeightedCards requires a GeneratorContext. It should be called via Pipeline, not directly."
|
|
1255
|
-
);
|
|
1256
|
-
}
|
|
1257
|
-
const results = await Promise.all(
|
|
1258
|
-
this.generators.map((g) => g.getWeightedCards(limit, context))
|
|
1259
|
-
);
|
|
1260
|
-
const byCardId = /* @__PURE__ */ new Map();
|
|
1261
|
-
for (const cards of results) {
|
|
1262
|
-
for (const card of cards) {
|
|
1263
|
-
const existing = byCardId.get(card.cardId) || [];
|
|
1264
|
-
existing.push(card);
|
|
1265
|
-
byCardId.set(card.cardId, existing);
|
|
1266
|
-
}
|
|
1158
|
+
async getWeightedCards(limit, _context) {
|
|
1159
|
+
if (!this.user || !this.course) {
|
|
1160
|
+
throw new Error("SRSNavigator requires user and course to be set");
|
|
1267
1161
|
}
|
|
1268
|
-
const
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
const
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
score: finalScore,
|
|
1162
|
+
const reviews = await this.user.getPendingReviews(this.course.getCourseID());
|
|
1163
|
+
const now = import_moment3.default.utc();
|
|
1164
|
+
const dueReviews = reviews.filter((r) => now.isAfter(import_moment3.default.utc(r.reviewTime)));
|
|
1165
|
+
const scored = dueReviews.map((review) => {
|
|
1166
|
+
const { score, reason } = this.computeUrgencyScore(review, now);
|
|
1167
|
+
return {
|
|
1168
|
+
cardId: review.cardId,
|
|
1169
|
+
courseId: review.courseId,
|
|
1170
|
+
score,
|
|
1171
|
+
reviewID: review._id,
|
|
1279
1172
|
provenance: [
|
|
1280
|
-
...mergedProvenance,
|
|
1281
1173
|
{
|
|
1282
|
-
strategy: "
|
|
1283
|
-
strategyName:
|
|
1284
|
-
strategyId: "
|
|
1285
|
-
action,
|
|
1286
|
-
score
|
|
1174
|
+
strategy: "srs",
|
|
1175
|
+
strategyName: this.strategyName || this.name,
|
|
1176
|
+
strategyId: this.strategyId || "NAVIGATION_STRATEGY-SRS-default",
|
|
1177
|
+
action: "generated",
|
|
1178
|
+
score,
|
|
1287
1179
|
reason
|
|
1288
1180
|
}
|
|
1289
1181
|
]
|
|
1290
|
-
}
|
|
1291
|
-
}
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
/**
|
|
1295
|
-
* Build human-readable reason for score aggregation.
|
|
1296
|
-
*/
|
|
1297
|
-
buildAggregationReason(cards, finalScore) {
|
|
1298
|
-
const count = cards.length;
|
|
1299
|
-
const scores = cards.map((c) => c.score.toFixed(2)).join(", ");
|
|
1300
|
-
if (count === 1) {
|
|
1301
|
-
return `Single generator, score ${finalScore.toFixed(2)}`;
|
|
1302
|
-
}
|
|
1303
|
-
const strategies = cards.map((c) => c.provenance[0]?.strategy || "unknown").join(", ");
|
|
1304
|
-
switch (this.aggregationMode) {
|
|
1305
|
-
case "max" /* MAX */:
|
|
1306
|
-
return `Max of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
|
|
1307
|
-
case "average" /* AVERAGE */:
|
|
1308
|
-
return `Average of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
|
|
1309
|
-
case "frequencyBoost" /* FREQUENCY_BOOST */: {
|
|
1310
|
-
const avg = cards.reduce((sum, c) => sum + c.score, 0) / count;
|
|
1311
|
-
const boost = 1 + FREQUENCY_BOOST_FACTOR * (count - 1);
|
|
1312
|
-
return `Frequency boost from ${count} generators (${strategies}): avg ${avg.toFixed(2)} \xD7 ${boost.toFixed(2)} \u2192 ${finalScore.toFixed(2)}`;
|
|
1313
|
-
}
|
|
1314
|
-
default:
|
|
1315
|
-
return `Aggregated from ${count} generators: ${finalScore.toFixed(2)}`;
|
|
1316
|
-
}
|
|
1182
|
+
};
|
|
1183
|
+
});
|
|
1184
|
+
logger.debug(`[srsNav] got ${scored.length} weighted cards`);
|
|
1185
|
+
return scored.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
1317
1186
|
}
|
|
1318
1187
|
/**
|
|
1319
|
-
*
|
|
1188
|
+
* Compute urgency score for a review card.
|
|
1189
|
+
*
|
|
1190
|
+
* Two factors:
|
|
1191
|
+
* 1. Relative overdueness = hoursOverdue / intervalHours
|
|
1192
|
+
* - 2 days overdue on 3-day interval = 0.67 (urgent)
|
|
1193
|
+
* - 2 days overdue on 180-day interval = 0.01 (not urgent)
|
|
1194
|
+
*
|
|
1195
|
+
* 2. Interval recency factor = 0.3 + 0.7 * exp(-intervalHours / 720)
|
|
1196
|
+
* - 24h interval → ~1.0 (very recent learning)
|
|
1197
|
+
* - 30 days (720h) → ~0.56
|
|
1198
|
+
* - 180 days → ~0.30
|
|
1199
|
+
*
|
|
1200
|
+
* Combined: base 0.5 + weighted average of factors * 0.45
|
|
1201
|
+
* Result range: approximately 0.5 to 0.95
|
|
1320
1202
|
*/
|
|
1321
|
-
|
|
1322
|
-
const
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
default:
|
|
1334
|
-
return scores[0];
|
|
1335
|
-
}
|
|
1203
|
+
computeUrgencyScore(review, now) {
|
|
1204
|
+
const scheduledAt = import_moment3.default.utc(review.scheduledAt);
|
|
1205
|
+
const due = import_moment3.default.utc(review.reviewTime);
|
|
1206
|
+
const intervalHours = Math.max(1, due.diff(scheduledAt, "hours"));
|
|
1207
|
+
const hoursOverdue = now.diff(due, "hours");
|
|
1208
|
+
const relativeOverdue = hoursOverdue / intervalHours;
|
|
1209
|
+
const recencyFactor = 0.3 + 0.7 * Math.exp(-intervalHours / 720);
|
|
1210
|
+
const overdueContribution = Math.min(1, Math.max(0, relativeOverdue));
|
|
1211
|
+
const urgency = overdueContribution * 0.5 + recencyFactor * 0.5;
|
|
1212
|
+
const score = Math.min(0.95, 0.5 + urgency * 0.45);
|
|
1213
|
+
const reason = `${Math.round(hoursOverdue)}h overdue (interval: ${Math.round(intervalHours)}h, relative: ${relativeOverdue.toFixed(2)}), recency: ${recencyFactor.toFixed(2)}, review`;
|
|
1214
|
+
return { score, reason };
|
|
1336
1215
|
}
|
|
1337
1216
|
};
|
|
1338
1217
|
}
|
|
1339
1218
|
});
|
|
1340
1219
|
|
|
1341
|
-
// src/core/navigators/
|
|
1342
|
-
var
|
|
1343
|
-
var
|
|
1344
|
-
"src/core/navigators/
|
|
1220
|
+
// src/core/navigators/generators/types.ts
|
|
1221
|
+
var types_exports = {};
|
|
1222
|
+
var init_types = __esm({
|
|
1223
|
+
"src/core/navigators/generators/types.ts"() {
|
|
1345
1224
|
"use strict";
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1225
|
+
}
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
// import("./generators/**/*") in src/core/navigators/index.ts
|
|
1229
|
+
var globImport_generators;
|
|
1230
|
+
var init_ = __esm({
|
|
1231
|
+
'import("./generators/**/*") in src/core/navigators/index.ts'() {
|
|
1232
|
+
globImport_generators = __glob({
|
|
1233
|
+
"./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
|
|
1234
|
+
"./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
|
|
1235
|
+
"./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
|
|
1236
|
+
"./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
|
|
1237
|
+
"./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports))
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
});
|
|
1241
|
+
|
|
1242
|
+
// src/core/types/contentNavigationStrategy.ts
|
|
1243
|
+
var DEFAULT_LEARNABLE_WEIGHT;
|
|
1244
|
+
var init_contentNavigationStrategy = __esm({
|
|
1245
|
+
"src/core/types/contentNavigationStrategy.ts"() {
|
|
1246
|
+
"use strict";
|
|
1247
|
+
DEFAULT_LEARNABLE_WEIGHT = {
|
|
1248
|
+
weight: 1,
|
|
1249
|
+
confidence: 0.1,
|
|
1250
|
+
// Low confidence initially = wide exploration
|
|
1251
|
+
sampleSize: 0
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
});
|
|
1255
|
+
|
|
1256
|
+
// src/core/navigators/filters/WeightedFilter.ts
|
|
1257
|
+
var WeightedFilter_exports = {};
|
|
1258
|
+
__export(WeightedFilter_exports, {
|
|
1259
|
+
WeightedFilter: () => WeightedFilter
|
|
1260
|
+
});
|
|
1261
|
+
var WeightedFilter;
|
|
1262
|
+
var init_WeightedFilter = __esm({
|
|
1263
|
+
"src/core/navigators/filters/WeightedFilter.ts"() {
|
|
1264
|
+
"use strict";
|
|
1265
|
+
init_contentNavigationStrategy();
|
|
1266
|
+
WeightedFilter = class {
|
|
1267
|
+
name;
|
|
1268
|
+
inner;
|
|
1269
|
+
learnable;
|
|
1270
|
+
staticWeight;
|
|
1271
|
+
strategyId;
|
|
1272
|
+
constructor(inner, learnable = DEFAULT_LEARNABLE_WEIGHT, staticWeight = false, strategyId) {
|
|
1273
|
+
this.inner = inner;
|
|
1274
|
+
this.name = inner.name;
|
|
1275
|
+
this.learnable = learnable;
|
|
1276
|
+
this.staticWeight = staticWeight;
|
|
1277
|
+
this.strategyId = strategyId;
|
|
1278
|
+
}
|
|
1352
1279
|
/**
|
|
1353
|
-
*
|
|
1354
|
-
*
|
|
1355
|
-
* 1. Separates into generators and filters by role
|
|
1356
|
-
* 2. Validates at least one generator exists (or creates default ELO)
|
|
1357
|
-
* 3. Instantiates generators - wraps multiple in CompositeGenerator
|
|
1358
|
-
* 4. Instantiates filters
|
|
1359
|
-
* 5. Returns Pipeline(generator, filters)
|
|
1360
|
-
*
|
|
1361
|
-
* @param input - Strategy documents plus user/course interfaces
|
|
1362
|
-
* @returns Assembled pipeline and any warnings
|
|
1280
|
+
* Apply the inner filter, then scale its effect by the configured weight.
|
|
1363
1281
|
*/
|
|
1364
|
-
async
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
if (
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
}
|
|
1375
|
-
const
|
|
1376
|
-
const
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1282
|
+
async transform(cards, context) {
|
|
1283
|
+
let effectiveWeight = this.learnable.weight;
|
|
1284
|
+
let deviation;
|
|
1285
|
+
if (!this.staticWeight && context.orchestration) {
|
|
1286
|
+
const strategyId = this.strategyId || this.inner.strategyId || this.name;
|
|
1287
|
+
effectiveWeight = context.orchestration.getEffectiveWeight(strategyId, this.learnable);
|
|
1288
|
+
deviation = context.orchestration.getDeviation(strategyId);
|
|
1289
|
+
}
|
|
1290
|
+
if (Math.abs(effectiveWeight - 1) < 1e-3) {
|
|
1291
|
+
return this.inner.transform(cards, context);
|
|
1292
|
+
}
|
|
1293
|
+
const originalScores = /* @__PURE__ */ new Map();
|
|
1294
|
+
for (const card of cards) {
|
|
1295
|
+
originalScores.set(card.cardId, card.score);
|
|
1296
|
+
}
|
|
1297
|
+
const transformedCards = await this.inner.transform(cards, context);
|
|
1298
|
+
return transformedCards.map((card) => {
|
|
1299
|
+
const originalScore = originalScores.get(card.cardId);
|
|
1300
|
+
if (originalScore === void 0 || originalScore === 0 || card.score === 0) {
|
|
1301
|
+
return card;
|
|
1384
1302
|
}
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1303
|
+
const rawEffect = card.score / originalScore;
|
|
1304
|
+
if (Math.abs(rawEffect - 1) < 1e-4) {
|
|
1305
|
+
return card;
|
|
1306
|
+
}
|
|
1307
|
+
const weightedEffect = Math.pow(rawEffect, effectiveWeight);
|
|
1308
|
+
const newScore = originalScore * weightedEffect;
|
|
1309
|
+
const lastProvIndex = card.provenance.length - 1;
|
|
1310
|
+
const lastProv = card.provenance[lastProvIndex];
|
|
1311
|
+
if (lastProv) {
|
|
1312
|
+
const updatedProvenance = [...card.provenance];
|
|
1313
|
+
updatedProvenance[lastProvIndex] = {
|
|
1314
|
+
...lastProv,
|
|
1315
|
+
score: newScore,
|
|
1316
|
+
effectiveWeight,
|
|
1317
|
+
deviation
|
|
1318
|
+
// We can optionally append to the reason, but the structured field is key
|
|
1319
|
+
};
|
|
1394
1320
|
return {
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
warnings
|
|
1321
|
+
...card,
|
|
1322
|
+
score: newScore,
|
|
1323
|
+
provenance: updatedProvenance
|
|
1399
1324
|
};
|
|
1400
1325
|
}
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
logger.debug(`[PipelineAssembler] Using single generator: ${generatorStrategies[0].name}`);
|
|
1407
|
-
} else {
|
|
1408
|
-
logger.debug(
|
|
1409
|
-
`[PipelineAssembler] Using CompositeGenerator for ${generatorStrategies.length} generators: ${generatorStrategies.map((g) => g.name).join(", ")}`
|
|
1410
|
-
);
|
|
1411
|
-
generator = await CompositeGenerator.fromStrategies(user, course, generatorStrategies);
|
|
1412
|
-
}
|
|
1413
|
-
const filters = [];
|
|
1414
|
-
const sortedFilterStrategies = [...filterStrategies].sort(
|
|
1415
|
-
(a, b) => a.name.localeCompare(b.name)
|
|
1416
|
-
);
|
|
1417
|
-
for (const filterStrategy of sortedFilterStrategies) {
|
|
1418
|
-
try {
|
|
1419
|
-
const nav = await ContentNavigator.create(user, course, filterStrategy);
|
|
1420
|
-
if ("transform" in nav && typeof nav.transform === "function") {
|
|
1421
|
-
filters.push(nav);
|
|
1422
|
-
logger.debug(`[PipelineAssembler] Added filter: ${filterStrategy.name}`);
|
|
1423
|
-
} else {
|
|
1424
|
-
warnings.push(
|
|
1425
|
-
`Filter '${filterStrategy.name}' does not implement CardFilter.transform(), skipping`
|
|
1426
|
-
);
|
|
1427
|
-
}
|
|
1428
|
-
} catch (e) {
|
|
1429
|
-
warnings.push(`Failed to instantiate filter '${filterStrategy.name}': ${e}`);
|
|
1430
|
-
}
|
|
1431
|
-
}
|
|
1432
|
-
const pipeline = new Pipeline(generator, filters, user, course);
|
|
1433
|
-
logger.debug(
|
|
1434
|
-
`[PipelineAssembler] Assembled pipeline with ${generatorStrategies.length} generator(s) and ${filters.length} filter(s)`
|
|
1435
|
-
);
|
|
1436
|
-
return {
|
|
1437
|
-
pipeline,
|
|
1438
|
-
generatorStrategies,
|
|
1439
|
-
filterStrategies: sortedFilterStrategies,
|
|
1440
|
-
warnings
|
|
1441
|
-
};
|
|
1442
|
-
}
|
|
1443
|
-
/**
|
|
1444
|
-
* Creates a default ELO generator strategy.
|
|
1445
|
-
* Used when filters are configured but no generator is specified.
|
|
1446
|
-
*/
|
|
1447
|
-
makeDefaultEloStrategy(courseId) {
|
|
1448
|
-
return {
|
|
1449
|
-
_id: "NAVIGATION_STRATEGY-ELO-default",
|
|
1450
|
-
course: courseId,
|
|
1451
|
-
docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
|
|
1452
|
-
name: "ELO (default)",
|
|
1453
|
-
description: "Default ELO-based generator",
|
|
1454
|
-
implementingClass: "elo" /* ELO */,
|
|
1455
|
-
serializedData: ""
|
|
1456
|
-
};
|
|
1326
|
+
return {
|
|
1327
|
+
...card,
|
|
1328
|
+
score: newScore
|
|
1329
|
+
};
|
|
1330
|
+
});
|
|
1457
1331
|
}
|
|
1458
1332
|
};
|
|
1459
1333
|
}
|
|
1460
1334
|
});
|
|
1461
1335
|
|
|
1462
|
-
// src/core/navigators/
|
|
1463
|
-
var
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1336
|
+
// src/core/navigators/filters/eloDistance.ts
|
|
1337
|
+
var eloDistance_exports = {};
|
|
1338
|
+
__export(eloDistance_exports, {
|
|
1339
|
+
DEFAULT_HALF_LIFE: () => DEFAULT_HALF_LIFE,
|
|
1340
|
+
DEFAULT_MAX_MULTIPLIER: () => DEFAULT_MAX_MULTIPLIER,
|
|
1341
|
+
DEFAULT_MIN_MULTIPLIER: () => DEFAULT_MIN_MULTIPLIER,
|
|
1342
|
+
createEloDistanceFilter: () => createEloDistanceFilter
|
|
1343
|
+
});
|
|
1344
|
+
function computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier) {
|
|
1345
|
+
const normalizedDistance = distance / halfLife;
|
|
1346
|
+
const decay = Math.exp(-(normalizedDistance * normalizedDistance));
|
|
1347
|
+
return minMultiplier + (maxMultiplier - minMultiplier) * decay;
|
|
1348
|
+
}
|
|
1349
|
+
function createEloDistanceFilter(config) {
|
|
1350
|
+
const halfLife = config?.halfLife ?? DEFAULT_HALF_LIFE;
|
|
1351
|
+
const minMultiplier = config?.minMultiplier ?? DEFAULT_MIN_MULTIPLIER;
|
|
1352
|
+
const maxMultiplier = config?.maxMultiplier ?? DEFAULT_MAX_MULTIPLIER;
|
|
1353
|
+
return {
|
|
1354
|
+
name: "ELO Distance Filter",
|
|
1355
|
+
async transform(cards, context) {
|
|
1356
|
+
const { course, userElo } = context;
|
|
1357
|
+
const cardIds = cards.map((c) => c.cardId);
|
|
1358
|
+
const cardElos = await course.getCardEloData(cardIds);
|
|
1359
|
+
return cards.map((card, i) => {
|
|
1360
|
+
const cardElo = cardElos[i]?.global?.score ?? 1e3;
|
|
1361
|
+
const distance = Math.abs(cardElo - userElo);
|
|
1362
|
+
const multiplier = computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier);
|
|
1363
|
+
const newScore = card.score * multiplier;
|
|
1364
|
+
const action = multiplier < maxMultiplier - 0.01 ? "penalized" : "passed";
|
|
1365
|
+
return {
|
|
1366
|
+
...card,
|
|
1367
|
+
score: newScore,
|
|
1368
|
+
provenance: [
|
|
1369
|
+
...card.provenance,
|
|
1370
|
+
{
|
|
1371
|
+
strategy: "eloDistance",
|
|
1372
|
+
strategyName: "ELO Distance Filter",
|
|
1373
|
+
strategyId: "ELO_DISTANCE_FILTER",
|
|
1374
|
+
action,
|
|
1375
|
+
score: newScore,
|
|
1376
|
+
reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userElo)}) \u2192 ${multiplier.toFixed(2)}x`
|
|
1377
|
+
}
|
|
1378
|
+
]
|
|
1379
|
+
};
|
|
1380
|
+
});
|
|
1381
|
+
}
|
|
1382
|
+
};
|
|
1383
|
+
}
|
|
1384
|
+
var DEFAULT_HALF_LIFE, DEFAULT_MIN_MULTIPLIER, DEFAULT_MAX_MULTIPLIER;
|
|
1385
|
+
var init_eloDistance = __esm({
|
|
1386
|
+
"src/core/navigators/filters/eloDistance.ts"() {
|
|
1387
|
+
"use strict";
|
|
1388
|
+
DEFAULT_HALF_LIFE = 200;
|
|
1389
|
+
DEFAULT_MIN_MULTIPLIER = 0.3;
|
|
1390
|
+
DEFAULT_MAX_MULTIPLIER = 1;
|
|
1391
|
+
}
|
|
1392
|
+
});
|
|
1393
|
+
|
|
1394
|
+
// src/core/navigators/filters/hierarchyDefinition.ts
|
|
1395
|
+
var hierarchyDefinition_exports = {};
|
|
1396
|
+
__export(hierarchyDefinition_exports, {
|
|
1397
|
+
default: () => HierarchyDefinitionNavigator
|
|
1398
|
+
});
|
|
1399
|
+
var import_common6, DEFAULT_MIN_COUNT, HierarchyDefinitionNavigator;
|
|
1400
|
+
var init_hierarchyDefinition = __esm({
|
|
1401
|
+
"src/core/navigators/filters/hierarchyDefinition.ts"() {
|
|
1402
|
+
"use strict";
|
|
1403
|
+
init_navigators();
|
|
1404
|
+
import_common6 = require("@vue-skuilder/common");
|
|
1405
|
+
DEFAULT_MIN_COUNT = 3;
|
|
1406
|
+
HierarchyDefinitionNavigator = class extends ContentNavigator {
|
|
1407
|
+
config;
|
|
1408
|
+
/** Human-readable name for CardFilter interface */
|
|
1409
|
+
name;
|
|
1410
|
+
constructor(user, course, strategyData) {
|
|
1411
|
+
super(user, course, strategyData);
|
|
1412
|
+
this.config = this.parseConfig(strategyData.serializedData);
|
|
1413
|
+
this.name = strategyData.name || "Hierarchy Definition";
|
|
1414
|
+
}
|
|
1415
|
+
parseConfig(serializedData) {
|
|
1416
|
+
try {
|
|
1417
|
+
const parsed = JSON.parse(serializedData);
|
|
1418
|
+
return {
|
|
1419
|
+
prerequisites: parsed.prerequisites || {}
|
|
1420
|
+
};
|
|
1421
|
+
} catch {
|
|
1422
|
+
return {
|
|
1423
|
+
prerequisites: {}
|
|
1424
|
+
};
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
/**
|
|
1428
|
+
* Check if a specific prerequisite is satisfied
|
|
1490
1429
|
*/
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1430
|
+
isPrerequisiteMet(prereq, userTagElo, userGlobalElo) {
|
|
1431
|
+
if (!userTagElo) return false;
|
|
1432
|
+
const minCount = prereq.masteryThreshold?.minCount ?? DEFAULT_MIN_COUNT;
|
|
1433
|
+
if (userTagElo.count < minCount) return false;
|
|
1434
|
+
if (prereq.masteryThreshold?.minElo !== void 0) {
|
|
1435
|
+
return userTagElo.score >= prereq.masteryThreshold.minElo;
|
|
1495
1436
|
} else {
|
|
1496
|
-
|
|
1437
|
+
return userTagElo.score >= userGlobalElo;
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
/**
|
|
1441
|
+
* Get the set of tags the user has mastered.
|
|
1442
|
+
* A tag is "mastered" if it appears as a prerequisite somewhere and meets its threshold.
|
|
1443
|
+
*/
|
|
1444
|
+
async getMasteredTags(context) {
|
|
1445
|
+
const mastered = /* @__PURE__ */ new Set();
|
|
1446
|
+
try {
|
|
1447
|
+
const courseReg = await context.user.getCourseRegDoc(context.course.getCourseID());
|
|
1497
1448
|
const userElo = (0, import_common6.toCourseElo)(courseReg.elo);
|
|
1498
|
-
|
|
1449
|
+
for (const prereqs of Object.values(this.config.prerequisites)) {
|
|
1450
|
+
for (const prereq of prereqs) {
|
|
1451
|
+
const tagElo = userElo.tags[prereq.tag];
|
|
1452
|
+
if (this.isPrerequisiteMet(prereq, tagElo, userElo.global.score)) {
|
|
1453
|
+
mastered.add(prereq.tag);
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
} catch {
|
|
1499
1458
|
}
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
const
|
|
1507
|
-
const
|
|
1508
|
-
const
|
|
1509
|
-
|
|
1510
|
-
|
|
1459
|
+
return mastered;
|
|
1460
|
+
}
|
|
1461
|
+
/**
|
|
1462
|
+
* Get the set of tags that are unlocked (prerequisites met)
|
|
1463
|
+
*/
|
|
1464
|
+
getUnlockedTags(masteredTags) {
|
|
1465
|
+
const unlocked = /* @__PURE__ */ new Set();
|
|
1466
|
+
for (const [tagId, prereqs] of Object.entries(this.config.prerequisites)) {
|
|
1467
|
+
const allPrereqsMet = prereqs.every((prereq) => masteredTags.has(prereq.tag));
|
|
1468
|
+
if (allPrereqsMet) {
|
|
1469
|
+
unlocked.add(tagId);
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
return unlocked;
|
|
1473
|
+
}
|
|
1474
|
+
/**
|
|
1475
|
+
* Check if a tag has prerequisites defined in config
|
|
1476
|
+
*/
|
|
1477
|
+
hasPrerequisites(tagId) {
|
|
1478
|
+
return tagId in this.config.prerequisites;
|
|
1479
|
+
}
|
|
1480
|
+
/**
|
|
1481
|
+
* Check if a card is unlocked and generate reason.
|
|
1482
|
+
*/
|
|
1483
|
+
async checkCardUnlock(card, _course, unlockedTags, masteredTags) {
|
|
1484
|
+
try {
|
|
1485
|
+
const cardTags = card.tags ?? [];
|
|
1486
|
+
const lockedTags = cardTags.filter(
|
|
1487
|
+
(tag) => this.hasPrerequisites(tag) && !unlockedTags.has(tag)
|
|
1488
|
+
);
|
|
1489
|
+
if (lockedTags.length === 0) {
|
|
1490
|
+
const tagList = cardTags.length > 0 ? cardTags.join(", ") : "none";
|
|
1491
|
+
return {
|
|
1492
|
+
isUnlocked: true,
|
|
1493
|
+
reason: `Prerequisites met, tags: ${tagList}`
|
|
1494
|
+
};
|
|
1495
|
+
}
|
|
1496
|
+
const missingPrereqs = lockedTags.flatMap((tag) => {
|
|
1497
|
+
const prereqs = this.config.prerequisites[tag] || [];
|
|
1498
|
+
return prereqs.filter((p) => !masteredTags.has(p.tag)).map((p) => p.tag);
|
|
1499
|
+
});
|
|
1511
1500
|
return {
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1501
|
+
isUnlocked: false,
|
|
1502
|
+
reason: `Blocked: missing prerequisites ${missingPrereqs.join(", ")} for tags ${lockedTags.join(", ")}`
|
|
1503
|
+
};
|
|
1504
|
+
} catch {
|
|
1505
|
+
return {
|
|
1506
|
+
isUnlocked: true,
|
|
1507
|
+
reason: "Prerequisites check skipped (tag lookup failed)"
|
|
1508
|
+
};
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
/**
|
|
1512
|
+
* CardFilter.transform implementation.
|
|
1513
|
+
*
|
|
1514
|
+
* Apply prerequisite gating to cards. Cards with locked tags receive score: 0.
|
|
1515
|
+
*/
|
|
1516
|
+
async transform(cards, context) {
|
|
1517
|
+
const masteredTags = await this.getMasteredTags(context);
|
|
1518
|
+
const unlockedTags = this.getUnlockedTags(masteredTags);
|
|
1519
|
+
const gated = [];
|
|
1520
|
+
for (const card of cards) {
|
|
1521
|
+
const { isUnlocked, reason } = await this.checkCardUnlock(
|
|
1522
|
+
card,
|
|
1523
|
+
context.course,
|
|
1524
|
+
unlockedTags,
|
|
1525
|
+
masteredTags
|
|
1526
|
+
);
|
|
1527
|
+
const finalScore = isUnlocked ? card.score : 0;
|
|
1528
|
+
const action = isUnlocked ? "passed" : "penalized";
|
|
1529
|
+
gated.push({
|
|
1530
|
+
...card,
|
|
1531
|
+
score: finalScore,
|
|
1515
1532
|
provenance: [
|
|
1533
|
+
...card.provenance,
|
|
1516
1534
|
{
|
|
1517
|
-
strategy: "
|
|
1535
|
+
strategy: "hierarchyDefinition",
|
|
1518
1536
|
strategyName: this.strategyName || this.name,
|
|
1519
|
-
strategyId: this.strategyId || "NAVIGATION_STRATEGY-
|
|
1520
|
-
action
|
|
1521
|
-
score,
|
|
1522
|
-
reason
|
|
1537
|
+
strategyId: this.strategyId || "NAVIGATION_STRATEGY-hierarchy",
|
|
1538
|
+
action,
|
|
1539
|
+
score: finalScore,
|
|
1540
|
+
reason
|
|
1523
1541
|
}
|
|
1524
1542
|
]
|
|
1525
|
-
};
|
|
1526
|
-
}
|
|
1527
|
-
|
|
1528
|
-
|
|
1543
|
+
});
|
|
1544
|
+
}
|
|
1545
|
+
return gated;
|
|
1546
|
+
}
|
|
1547
|
+
/**
|
|
1548
|
+
* Legacy getWeightedCards - now throws as filters should not be used as generators.
|
|
1549
|
+
*
|
|
1550
|
+
* Use transform() via Pipeline instead.
|
|
1551
|
+
*/
|
|
1552
|
+
async getWeightedCards(_limit) {
|
|
1553
|
+
throw new Error(
|
|
1554
|
+
"HierarchyDefinitionNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
|
|
1555
|
+
);
|
|
1529
1556
|
}
|
|
1530
1557
|
};
|
|
1531
1558
|
}
|
|
1532
1559
|
});
|
|
1533
1560
|
|
|
1534
|
-
// src/core/navigators/
|
|
1535
|
-
var
|
|
1536
|
-
|
|
1537
|
-
|
|
1561
|
+
// src/core/navigators/filters/userTagPreference.ts
|
|
1562
|
+
var userTagPreference_exports = {};
|
|
1563
|
+
__export(userTagPreference_exports, {
|
|
1564
|
+
default: () => UserTagPreferenceFilter
|
|
1565
|
+
});
|
|
1566
|
+
var UserTagPreferenceFilter;
|
|
1567
|
+
var init_userTagPreference = __esm({
|
|
1568
|
+
"src/core/navigators/filters/userTagPreference.ts"() {
|
|
1538
1569
|
"use strict";
|
|
1539
|
-
import_moment3 = __toESM(require("moment"), 1);
|
|
1540
1570
|
init_navigators();
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
/** Human-readable name for
|
|
1571
|
+
UserTagPreferenceFilter = class extends ContentNavigator {
|
|
1572
|
+
_strategyData;
|
|
1573
|
+
/** Human-readable name for CardFilter interface */
|
|
1544
1574
|
name;
|
|
1545
1575
|
constructor(user, course, strategyData) {
|
|
1546
1576
|
super(user, course, strategyData);
|
|
1547
|
-
this.
|
|
1577
|
+
this._strategyData = strategyData;
|
|
1578
|
+
this.name = strategyData.name || "User Tag Preferences";
|
|
1579
|
+
}
|
|
1580
|
+
/**
|
|
1581
|
+
* Compute multiplier for a card based on its tags and user preferences.
|
|
1582
|
+
* Returns the maximum multiplier among all matching tags, or 1.0 if no matches.
|
|
1583
|
+
*/
|
|
1584
|
+
computeMultiplier(cardTags, boostMap) {
|
|
1585
|
+
const multipliers = cardTags.map((tag) => boostMap[tag]).filter((val) => val !== void 0);
|
|
1586
|
+
if (multipliers.length === 0) {
|
|
1587
|
+
return 1;
|
|
1588
|
+
}
|
|
1589
|
+
return Math.max(...multipliers);
|
|
1590
|
+
}
|
|
1591
|
+
/**
|
|
1592
|
+
* Build human-readable reason for the filter's decision.
|
|
1593
|
+
*/
|
|
1594
|
+
buildReason(cardTags, boostMap, multiplier) {
|
|
1595
|
+
const matchingTags = cardTags.filter((tag) => boostMap[tag] === multiplier);
|
|
1596
|
+
if (multiplier === 0) {
|
|
1597
|
+
return `Excluded by user preference: ${matchingTags.join(", ")} (${multiplier}x)`;
|
|
1598
|
+
}
|
|
1599
|
+
if (multiplier < 1) {
|
|
1600
|
+
return `Penalized by user preference: ${matchingTags.join(", ")} (${multiplier.toFixed(2)}x)`;
|
|
1601
|
+
}
|
|
1602
|
+
if (multiplier > 1) {
|
|
1603
|
+
return `Boosted by user preference: ${matchingTags.join(", ")} (${multiplier.toFixed(2)}x)`;
|
|
1604
|
+
}
|
|
1605
|
+
return "No matching user preferences";
|
|
1606
|
+
}
|
|
1607
|
+
/**
|
|
1608
|
+
* CardFilter.transform implementation.
|
|
1609
|
+
*
|
|
1610
|
+
* Apply user tag preferences:
|
|
1611
|
+
* 1. Read preferences from strategy state
|
|
1612
|
+
* 2. If no preferences, pass through unchanged
|
|
1613
|
+
* 3. For each card:
|
|
1614
|
+
* - Look up tag in boost record
|
|
1615
|
+
* - If tag found: apply multiplier (0 = exclude, 1 = neutral, >1 = boost)
|
|
1616
|
+
* - If multiple tags match: use max multiplier
|
|
1617
|
+
* - Append provenance with clear reason
|
|
1618
|
+
*/
|
|
1619
|
+
async transform(cards, _context) {
|
|
1620
|
+
const prefs = await this.getStrategyState();
|
|
1621
|
+
if (!prefs || Object.keys(prefs.boost).length === 0) {
|
|
1622
|
+
return cards.map((card) => ({
|
|
1623
|
+
...card,
|
|
1624
|
+
provenance: [
|
|
1625
|
+
...card.provenance,
|
|
1626
|
+
{
|
|
1627
|
+
strategy: "userTagPreference",
|
|
1628
|
+
strategyName: this.strategyName || this.name,
|
|
1629
|
+
strategyId: this.strategyId || this._strategyData._id,
|
|
1630
|
+
action: "passed",
|
|
1631
|
+
score: card.score,
|
|
1632
|
+
reason: "No user tag preferences configured"
|
|
1633
|
+
}
|
|
1634
|
+
]
|
|
1635
|
+
}));
|
|
1636
|
+
}
|
|
1637
|
+
const adjusted = await Promise.all(
|
|
1638
|
+
cards.map(async (card) => {
|
|
1639
|
+
const cardTags = card.tags ?? [];
|
|
1640
|
+
const multiplier = this.computeMultiplier(cardTags, prefs.boost);
|
|
1641
|
+
const finalScore = Math.min(1, card.score * multiplier);
|
|
1642
|
+
let action;
|
|
1643
|
+
if (multiplier === 0 || multiplier < 1) {
|
|
1644
|
+
action = "penalized";
|
|
1645
|
+
} else if (multiplier > 1) {
|
|
1646
|
+
action = "boosted";
|
|
1647
|
+
} else {
|
|
1648
|
+
action = "passed";
|
|
1649
|
+
}
|
|
1650
|
+
return {
|
|
1651
|
+
...card,
|
|
1652
|
+
score: finalScore,
|
|
1653
|
+
provenance: [
|
|
1654
|
+
...card.provenance,
|
|
1655
|
+
{
|
|
1656
|
+
strategy: "userTagPreference",
|
|
1657
|
+
strategyName: this.strategyName || this.name,
|
|
1658
|
+
strategyId: this.strategyId || this._strategyData._id,
|
|
1659
|
+
action,
|
|
1660
|
+
score: finalScore,
|
|
1661
|
+
reason: this.buildReason(cardTags, prefs.boost, multiplier)
|
|
1662
|
+
}
|
|
1663
|
+
]
|
|
1664
|
+
};
|
|
1665
|
+
})
|
|
1666
|
+
);
|
|
1667
|
+
return adjusted;
|
|
1668
|
+
}
|
|
1669
|
+
/**
|
|
1670
|
+
* Legacy getWeightedCards - throws as filters should not be used as generators.
|
|
1671
|
+
*/
|
|
1672
|
+
async getWeightedCards(_limit) {
|
|
1673
|
+
throw new Error(
|
|
1674
|
+
"UserTagPreferenceFilter is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
|
|
1675
|
+
);
|
|
1676
|
+
}
|
|
1677
|
+
};
|
|
1678
|
+
}
|
|
1679
|
+
});
|
|
1680
|
+
|
|
1681
|
+
// src/core/navigators/filters/index.ts
|
|
1682
|
+
var filters_exports = {};
|
|
1683
|
+
__export(filters_exports, {
|
|
1684
|
+
UserTagPreferenceFilter: () => UserTagPreferenceFilter,
|
|
1685
|
+
createEloDistanceFilter: () => createEloDistanceFilter
|
|
1686
|
+
});
|
|
1687
|
+
var init_filters = __esm({
|
|
1688
|
+
"src/core/navigators/filters/index.ts"() {
|
|
1689
|
+
"use strict";
|
|
1690
|
+
init_eloDistance();
|
|
1691
|
+
init_userTagPreference();
|
|
1692
|
+
}
|
|
1693
|
+
});
|
|
1694
|
+
|
|
1695
|
+
// src/core/navigators/filters/inferredPreferenceStub.ts
|
|
1696
|
+
var inferredPreferenceStub_exports = {};
|
|
1697
|
+
__export(inferredPreferenceStub_exports, {
|
|
1698
|
+
INFERRED_PREFERENCE_NAVIGATOR_STUB: () => INFERRED_PREFERENCE_NAVIGATOR_STUB
|
|
1699
|
+
});
|
|
1700
|
+
var INFERRED_PREFERENCE_NAVIGATOR_STUB;
|
|
1701
|
+
var init_inferredPreferenceStub = __esm({
|
|
1702
|
+
"src/core/navigators/filters/inferredPreferenceStub.ts"() {
|
|
1703
|
+
"use strict";
|
|
1704
|
+
INFERRED_PREFERENCE_NAVIGATOR_STUB = true;
|
|
1705
|
+
}
|
|
1706
|
+
});
|
|
1707
|
+
|
|
1708
|
+
// src/core/navigators/filters/interferenceMitigator.ts
|
|
1709
|
+
var interferenceMitigator_exports = {};
|
|
1710
|
+
__export(interferenceMitigator_exports, {
|
|
1711
|
+
default: () => InterferenceMitigatorNavigator
|
|
1712
|
+
});
|
|
1713
|
+
var import_common7, DEFAULT_MIN_COUNT2, DEFAULT_MIN_ELAPSED_DAYS, DEFAULT_INTERFERENCE_DECAY, InterferenceMitigatorNavigator;
|
|
1714
|
+
var init_interferenceMitigator = __esm({
|
|
1715
|
+
"src/core/navigators/filters/interferenceMitigator.ts"() {
|
|
1716
|
+
"use strict";
|
|
1717
|
+
init_navigators();
|
|
1718
|
+
import_common7 = require("@vue-skuilder/common");
|
|
1719
|
+
DEFAULT_MIN_COUNT2 = 10;
|
|
1720
|
+
DEFAULT_MIN_ELAPSED_DAYS = 3;
|
|
1721
|
+
DEFAULT_INTERFERENCE_DECAY = 0.8;
|
|
1722
|
+
InterferenceMitigatorNavigator = class extends ContentNavigator {
|
|
1723
|
+
config;
|
|
1724
|
+
/** Human-readable name for CardFilter interface */
|
|
1725
|
+
name;
|
|
1726
|
+
/** Precomputed map: tag -> set of { partner, decay } it interferes with */
|
|
1727
|
+
interferenceMap;
|
|
1728
|
+
constructor(user, course, strategyData) {
|
|
1729
|
+
super(user, course, strategyData);
|
|
1730
|
+
this.config = this.parseConfig(strategyData.serializedData);
|
|
1731
|
+
this.interferenceMap = this.buildInterferenceMap();
|
|
1732
|
+
this.name = strategyData.name || "Interference Mitigator";
|
|
1733
|
+
}
|
|
1734
|
+
parseConfig(serializedData) {
|
|
1735
|
+
try {
|
|
1736
|
+
const parsed = JSON.parse(serializedData);
|
|
1737
|
+
let sets = parsed.interferenceSets || [];
|
|
1738
|
+
if (sets.length > 0 && Array.isArray(sets[0])) {
|
|
1739
|
+
sets = sets.map((tags) => ({ tags }));
|
|
1740
|
+
}
|
|
1741
|
+
return {
|
|
1742
|
+
interferenceSets: sets,
|
|
1743
|
+
maturityThreshold: {
|
|
1744
|
+
minCount: parsed.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT2,
|
|
1745
|
+
minElo: parsed.maturityThreshold?.minElo,
|
|
1746
|
+
minElapsedDays: parsed.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS
|
|
1747
|
+
},
|
|
1748
|
+
defaultDecay: parsed.defaultDecay ?? DEFAULT_INTERFERENCE_DECAY
|
|
1749
|
+
};
|
|
1750
|
+
} catch {
|
|
1751
|
+
return {
|
|
1752
|
+
interferenceSets: [],
|
|
1753
|
+
maturityThreshold: {
|
|
1754
|
+
minCount: DEFAULT_MIN_COUNT2,
|
|
1755
|
+
minElapsedDays: DEFAULT_MIN_ELAPSED_DAYS
|
|
1756
|
+
},
|
|
1757
|
+
defaultDecay: DEFAULT_INTERFERENCE_DECAY
|
|
1758
|
+
};
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
/**
|
|
1762
|
+
* Build a map from each tag to its interference partners with decay coefficients.
|
|
1763
|
+
* If tags A, B, C are in an interference group with decay 0.8, then:
|
|
1764
|
+
* - A interferes with B (decay 0.8) and C (decay 0.8)
|
|
1765
|
+
* - B interferes with A (decay 0.8) and C (decay 0.8)
|
|
1766
|
+
* - etc.
|
|
1767
|
+
*/
|
|
1768
|
+
buildInterferenceMap() {
|
|
1769
|
+
const map = /* @__PURE__ */ new Map();
|
|
1770
|
+
for (const group of this.config.interferenceSets) {
|
|
1771
|
+
const decay = group.decay ?? this.config.defaultDecay ?? DEFAULT_INTERFERENCE_DECAY;
|
|
1772
|
+
for (const tag of group.tags) {
|
|
1773
|
+
if (!map.has(tag)) {
|
|
1774
|
+
map.set(tag, []);
|
|
1775
|
+
}
|
|
1776
|
+
const partners = map.get(tag);
|
|
1777
|
+
for (const other of group.tags) {
|
|
1778
|
+
if (other !== tag) {
|
|
1779
|
+
const existing = partners.find((p) => p.partner === other);
|
|
1780
|
+
if (existing) {
|
|
1781
|
+
existing.decay = Math.max(existing.decay, decay);
|
|
1782
|
+
} else {
|
|
1783
|
+
partners.push({ partner: other, decay });
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
return map;
|
|
1790
|
+
}
|
|
1791
|
+
/**
|
|
1792
|
+
* Get the set of tags that are currently immature for this user.
|
|
1793
|
+
* A tag is immature if the user has interacted with it but hasn't
|
|
1794
|
+
* reached the maturity threshold.
|
|
1795
|
+
*/
|
|
1796
|
+
async getImmatureTags(context) {
|
|
1797
|
+
const immature = /* @__PURE__ */ new Set();
|
|
1798
|
+
try {
|
|
1799
|
+
const courseReg = await context.user.getCourseRegDoc(context.course.getCourseID());
|
|
1800
|
+
const userElo = (0, import_common7.toCourseElo)(courseReg.elo);
|
|
1801
|
+
const minCount = this.config.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT2;
|
|
1802
|
+
const minElo = this.config.maturityThreshold?.minElo;
|
|
1803
|
+
const minElapsedDays = this.config.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS;
|
|
1804
|
+
const minCountForElapsed = minElapsedDays * 2;
|
|
1805
|
+
for (const [tagId, tagElo] of Object.entries(userElo.tags)) {
|
|
1806
|
+
if (tagElo.count === 0) continue;
|
|
1807
|
+
const belowCount = tagElo.count < minCount;
|
|
1808
|
+
const belowElo = minElo !== void 0 && tagElo.score < minElo;
|
|
1809
|
+
const belowElapsed = tagElo.count < minCountForElapsed;
|
|
1810
|
+
if (belowCount || belowElo || belowElapsed) {
|
|
1811
|
+
immature.add(tagId);
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
} catch {
|
|
1815
|
+
}
|
|
1816
|
+
return immature;
|
|
1817
|
+
}
|
|
1818
|
+
/**
|
|
1819
|
+
* Get all tags that interfere with any immature tag, along with their decay coefficients.
|
|
1820
|
+
* These are the tags we want to avoid introducing.
|
|
1821
|
+
*/
|
|
1822
|
+
getTagsToAvoid(immatureTags) {
|
|
1823
|
+
const avoid = /* @__PURE__ */ new Map();
|
|
1824
|
+
for (const immatureTag of immatureTags) {
|
|
1825
|
+
const partners = this.interferenceMap.get(immatureTag);
|
|
1826
|
+
if (partners) {
|
|
1827
|
+
for (const { partner, decay } of partners) {
|
|
1828
|
+
if (!immatureTags.has(partner)) {
|
|
1829
|
+
const existing = avoid.get(partner) ?? 0;
|
|
1830
|
+
avoid.set(partner, Math.max(existing, decay));
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
return avoid;
|
|
1836
|
+
}
|
|
1837
|
+
/**
|
|
1838
|
+
* Compute interference score reduction for a card.
|
|
1839
|
+
* Returns: { multiplier, interfering tags, reason }
|
|
1840
|
+
*/
|
|
1841
|
+
computeInterferenceEffect(cardTags, tagsToAvoid, immatureTags) {
|
|
1842
|
+
if (tagsToAvoid.size === 0) {
|
|
1843
|
+
return {
|
|
1844
|
+
multiplier: 1,
|
|
1845
|
+
interferingTags: [],
|
|
1846
|
+
reason: "No interference detected"
|
|
1847
|
+
};
|
|
1848
|
+
}
|
|
1849
|
+
let multiplier = 1;
|
|
1850
|
+
const interferingTags = [];
|
|
1851
|
+
for (const tag of cardTags) {
|
|
1852
|
+
const decay = tagsToAvoid.get(tag);
|
|
1853
|
+
if (decay !== void 0) {
|
|
1854
|
+
interferingTags.push(tag);
|
|
1855
|
+
multiplier *= 1 - decay;
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
if (interferingTags.length === 0) {
|
|
1859
|
+
return {
|
|
1860
|
+
multiplier: 1,
|
|
1861
|
+
interferingTags: [],
|
|
1862
|
+
reason: "No interference detected"
|
|
1863
|
+
};
|
|
1864
|
+
}
|
|
1865
|
+
const causingTags = /* @__PURE__ */ new Set();
|
|
1866
|
+
for (const tag of interferingTags) {
|
|
1867
|
+
for (const immatureTag of immatureTags) {
|
|
1868
|
+
const partners = this.interferenceMap.get(immatureTag);
|
|
1869
|
+
if (partners?.some((p) => p.partner === tag)) {
|
|
1870
|
+
causingTags.add(immatureTag);
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
const reason = `Interferes with immature tags ${Array.from(causingTags).join(", ")} (tags: ${interferingTags.join(", ")}, multiplier: ${multiplier.toFixed(2)})`;
|
|
1875
|
+
return { multiplier, interferingTags, reason };
|
|
1876
|
+
}
|
|
1877
|
+
/**
|
|
1878
|
+
* CardFilter.transform implementation.
|
|
1879
|
+
*
|
|
1880
|
+
* Apply interference-aware scoring. Cards with tags that interfere with
|
|
1881
|
+
* immature learnings get reduced scores.
|
|
1882
|
+
*/
|
|
1883
|
+
async transform(cards, context) {
|
|
1884
|
+
const immatureTags = await this.getImmatureTags(context);
|
|
1885
|
+
const tagsToAvoid = this.getTagsToAvoid(immatureTags);
|
|
1886
|
+
const adjusted = [];
|
|
1887
|
+
for (const card of cards) {
|
|
1888
|
+
const cardTags = card.tags ?? [];
|
|
1889
|
+
const { multiplier, reason } = this.computeInterferenceEffect(
|
|
1890
|
+
cardTags,
|
|
1891
|
+
tagsToAvoid,
|
|
1892
|
+
immatureTags
|
|
1893
|
+
);
|
|
1894
|
+
const finalScore = card.score * multiplier;
|
|
1895
|
+
const action = multiplier < 1 ? "penalized" : multiplier > 1 ? "boosted" : "passed";
|
|
1896
|
+
adjusted.push({
|
|
1897
|
+
...card,
|
|
1898
|
+
score: finalScore,
|
|
1899
|
+
provenance: [
|
|
1900
|
+
...card.provenance,
|
|
1901
|
+
{
|
|
1902
|
+
strategy: "interferenceMitigator",
|
|
1903
|
+
strategyName: this.strategyName || this.name,
|
|
1904
|
+
strategyId: this.strategyId || "NAVIGATION_STRATEGY-interference",
|
|
1905
|
+
action,
|
|
1906
|
+
score: finalScore,
|
|
1907
|
+
reason
|
|
1908
|
+
}
|
|
1909
|
+
]
|
|
1910
|
+
});
|
|
1911
|
+
}
|
|
1912
|
+
return adjusted;
|
|
1913
|
+
}
|
|
1914
|
+
/**
|
|
1915
|
+
* Legacy getWeightedCards - now throws as filters should not be used as generators.
|
|
1916
|
+
*
|
|
1917
|
+
* Use transform() via Pipeline instead.
|
|
1918
|
+
*/
|
|
1919
|
+
async getWeightedCards(_limit) {
|
|
1920
|
+
throw new Error(
|
|
1921
|
+
"InterferenceMitigatorNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
|
|
1922
|
+
);
|
|
1923
|
+
}
|
|
1924
|
+
};
|
|
1925
|
+
}
|
|
1926
|
+
});
|
|
1927
|
+
|
|
1928
|
+
// src/core/navigators/filters/relativePriority.ts
|
|
1929
|
+
var relativePriority_exports = {};
|
|
1930
|
+
__export(relativePriority_exports, {
|
|
1931
|
+
default: () => RelativePriorityNavigator
|
|
1932
|
+
});
|
|
1933
|
+
var DEFAULT_PRIORITY, DEFAULT_PRIORITY_INFLUENCE, DEFAULT_COMBINE_MODE, RelativePriorityNavigator;
|
|
1934
|
+
var init_relativePriority = __esm({
|
|
1935
|
+
"src/core/navigators/filters/relativePriority.ts"() {
|
|
1936
|
+
"use strict";
|
|
1937
|
+
init_navigators();
|
|
1938
|
+
DEFAULT_PRIORITY = 0.5;
|
|
1939
|
+
DEFAULT_PRIORITY_INFLUENCE = 0.5;
|
|
1940
|
+
DEFAULT_COMBINE_MODE = "max";
|
|
1941
|
+
RelativePriorityNavigator = class extends ContentNavigator {
|
|
1942
|
+
config;
|
|
1943
|
+
/** Human-readable name for CardFilter interface */
|
|
1944
|
+
name;
|
|
1945
|
+
constructor(user, course, strategyData) {
|
|
1946
|
+
super(user, course, strategyData);
|
|
1947
|
+
this.config = this.parseConfig(strategyData.serializedData);
|
|
1948
|
+
this.name = strategyData.name || "Relative Priority";
|
|
1949
|
+
}
|
|
1950
|
+
parseConfig(serializedData) {
|
|
1951
|
+
try {
|
|
1952
|
+
const parsed = JSON.parse(serializedData);
|
|
1953
|
+
return {
|
|
1954
|
+
tagPriorities: parsed.tagPriorities || {},
|
|
1955
|
+
defaultPriority: parsed.defaultPriority ?? DEFAULT_PRIORITY,
|
|
1956
|
+
combineMode: parsed.combineMode ?? DEFAULT_COMBINE_MODE,
|
|
1957
|
+
priorityInfluence: parsed.priorityInfluence ?? DEFAULT_PRIORITY_INFLUENCE
|
|
1958
|
+
};
|
|
1959
|
+
} catch {
|
|
1960
|
+
return {
|
|
1961
|
+
tagPriorities: {},
|
|
1962
|
+
defaultPriority: DEFAULT_PRIORITY,
|
|
1963
|
+
combineMode: DEFAULT_COMBINE_MODE,
|
|
1964
|
+
priorityInfluence: DEFAULT_PRIORITY_INFLUENCE
|
|
1965
|
+
};
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
/**
|
|
1969
|
+
* Look up the priority for a tag.
|
|
1970
|
+
*/
|
|
1971
|
+
getTagPriority(tagId) {
|
|
1972
|
+
return this.config.tagPriorities[tagId] ?? this.config.defaultPriority ?? DEFAULT_PRIORITY;
|
|
1973
|
+
}
|
|
1974
|
+
/**
|
|
1975
|
+
* Compute combined priority for a card based on its tags.
|
|
1976
|
+
*/
|
|
1977
|
+
computeCardPriority(cardTags) {
|
|
1978
|
+
if (cardTags.length === 0) {
|
|
1979
|
+
return this.config.defaultPriority ?? DEFAULT_PRIORITY;
|
|
1980
|
+
}
|
|
1981
|
+
const priorities = cardTags.map((tag) => this.getTagPriority(tag));
|
|
1982
|
+
switch (this.config.combineMode) {
|
|
1983
|
+
case "max":
|
|
1984
|
+
return Math.max(...priorities);
|
|
1985
|
+
case "min":
|
|
1986
|
+
return Math.min(...priorities);
|
|
1987
|
+
case "average":
|
|
1988
|
+
return priorities.reduce((sum, p) => sum + p, 0) / priorities.length;
|
|
1989
|
+
default:
|
|
1990
|
+
return Math.max(...priorities);
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
/**
|
|
1994
|
+
* Compute boost factor based on priority.
|
|
1995
|
+
*
|
|
1996
|
+
* The formula: 1 + (priority - 0.5) * priorityInfluence
|
|
1997
|
+
*
|
|
1998
|
+
* This creates a multiplier centered around 1.0:
|
|
1999
|
+
* - Priority 1.0 with influence 0.5 → 1.25 (25% boost)
|
|
2000
|
+
* - Priority 0.5 with any influence → 1.00 (neutral)
|
|
2001
|
+
* - Priority 0.0 with influence 0.5 → 0.75 (25% reduction)
|
|
2002
|
+
*/
|
|
2003
|
+
computeBoostFactor(priority) {
|
|
2004
|
+
const influence = this.config.priorityInfluence ?? DEFAULT_PRIORITY_INFLUENCE;
|
|
2005
|
+
return 1 + (priority - 0.5) * influence;
|
|
2006
|
+
}
|
|
2007
|
+
/**
|
|
2008
|
+
* Build human-readable reason for priority adjustment.
|
|
2009
|
+
*/
|
|
2010
|
+
buildPriorityReason(cardTags, priority, boostFactor, finalScore) {
|
|
2011
|
+
if (cardTags.length === 0) {
|
|
2012
|
+
return `No tags, neutral priority (${priority.toFixed(2)})`;
|
|
2013
|
+
}
|
|
2014
|
+
const tagList = cardTags.slice(0, 3).join(", ");
|
|
2015
|
+
const more = cardTags.length > 3 ? ` (+${cardTags.length - 3} more)` : "";
|
|
2016
|
+
if (boostFactor === 1) {
|
|
2017
|
+
return `Neutral priority (${priority.toFixed(2)}) for tags: ${tagList}${more}`;
|
|
2018
|
+
} else if (boostFactor > 1) {
|
|
2019
|
+
return `High-priority tags: ${tagList}${more} (priority ${priority.toFixed(2)} \u2192 boost ${boostFactor.toFixed(2)}x \u2192 ${finalScore.toFixed(2)})`;
|
|
2020
|
+
} else {
|
|
2021
|
+
return `Low-priority tags: ${tagList}${more} (priority ${priority.toFixed(2)} \u2192 reduce ${boostFactor.toFixed(2)}x \u2192 ${finalScore.toFixed(2)})`;
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
/**
|
|
2025
|
+
* CardFilter.transform implementation.
|
|
2026
|
+
*
|
|
2027
|
+
* Apply priority-adjusted scoring. Cards with high-priority tags get boosted,
|
|
2028
|
+
* cards with low-priority tags get reduced scores.
|
|
2029
|
+
*/
|
|
2030
|
+
async transform(cards, _context) {
|
|
2031
|
+
const adjusted = await Promise.all(
|
|
2032
|
+
cards.map(async (card) => {
|
|
2033
|
+
const cardTags = card.tags ?? [];
|
|
2034
|
+
const priority = this.computeCardPriority(cardTags);
|
|
2035
|
+
const boostFactor = this.computeBoostFactor(priority);
|
|
2036
|
+
const finalScore = Math.max(0, Math.min(1, card.score * boostFactor));
|
|
2037
|
+
const action = boostFactor > 1 ? "boosted" : boostFactor < 1 ? "penalized" : "passed";
|
|
2038
|
+
const reason = this.buildPriorityReason(cardTags, priority, boostFactor, finalScore);
|
|
2039
|
+
return {
|
|
2040
|
+
...card,
|
|
2041
|
+
score: finalScore,
|
|
2042
|
+
provenance: [
|
|
2043
|
+
...card.provenance,
|
|
2044
|
+
{
|
|
2045
|
+
strategy: "relativePriority",
|
|
2046
|
+
strategyName: this.strategyName || this.name,
|
|
2047
|
+
strategyId: this.strategyId || "NAVIGATION_STRATEGY-priority",
|
|
2048
|
+
action,
|
|
2049
|
+
score: finalScore,
|
|
2050
|
+
reason
|
|
2051
|
+
}
|
|
2052
|
+
]
|
|
2053
|
+
};
|
|
2054
|
+
})
|
|
2055
|
+
);
|
|
2056
|
+
return adjusted;
|
|
2057
|
+
}
|
|
2058
|
+
/**
|
|
2059
|
+
* Legacy getWeightedCards - now throws as filters should not be used as generators.
|
|
2060
|
+
*
|
|
2061
|
+
* Use transform() via Pipeline instead.
|
|
2062
|
+
*/
|
|
2063
|
+
async getWeightedCards(_limit) {
|
|
2064
|
+
throw new Error(
|
|
2065
|
+
"RelativePriorityNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
|
|
2066
|
+
);
|
|
2067
|
+
}
|
|
2068
|
+
};
|
|
2069
|
+
}
|
|
2070
|
+
});
|
|
2071
|
+
|
|
2072
|
+
// src/core/navigators/filters/types.ts
|
|
2073
|
+
var types_exports2 = {};
|
|
2074
|
+
var init_types2 = __esm({
|
|
2075
|
+
"src/core/navigators/filters/types.ts"() {
|
|
2076
|
+
"use strict";
|
|
2077
|
+
}
|
|
2078
|
+
});
|
|
2079
|
+
|
|
2080
|
+
// src/core/navigators/filters/userGoalStub.ts
|
|
2081
|
+
var userGoalStub_exports = {};
|
|
2082
|
+
__export(userGoalStub_exports, {
|
|
2083
|
+
USER_GOAL_NAVIGATOR_STUB: () => USER_GOAL_NAVIGATOR_STUB
|
|
2084
|
+
});
|
|
2085
|
+
var USER_GOAL_NAVIGATOR_STUB;
|
|
2086
|
+
var init_userGoalStub = __esm({
|
|
2087
|
+
"src/core/navigators/filters/userGoalStub.ts"() {
|
|
2088
|
+
"use strict";
|
|
2089
|
+
USER_GOAL_NAVIGATOR_STUB = true;
|
|
2090
|
+
}
|
|
2091
|
+
});
|
|
2092
|
+
|
|
2093
|
+
// import("./filters/**/*") in src/core/navigators/index.ts
|
|
2094
|
+
var globImport_filters;
|
|
2095
|
+
var init_2 = __esm({
|
|
2096
|
+
'import("./filters/**/*") in src/core/navigators/index.ts'() {
|
|
2097
|
+
globImport_filters = __glob({
|
|
2098
|
+
"./filters/WeightedFilter.ts": () => Promise.resolve().then(() => (init_WeightedFilter(), WeightedFilter_exports)),
|
|
2099
|
+
"./filters/eloDistance.ts": () => Promise.resolve().then(() => (init_eloDistance(), eloDistance_exports)),
|
|
2100
|
+
"./filters/hierarchyDefinition.ts": () => Promise.resolve().then(() => (init_hierarchyDefinition(), hierarchyDefinition_exports)),
|
|
2101
|
+
"./filters/index.ts": () => Promise.resolve().then(() => (init_filters(), filters_exports)),
|
|
2102
|
+
"./filters/inferredPreferenceStub.ts": () => Promise.resolve().then(() => (init_inferredPreferenceStub(), inferredPreferenceStub_exports)),
|
|
2103
|
+
"./filters/interferenceMitigator.ts": () => Promise.resolve().then(() => (init_interferenceMitigator(), interferenceMitigator_exports)),
|
|
2104
|
+
"./filters/relativePriority.ts": () => Promise.resolve().then(() => (init_relativePriority(), relativePriority_exports)),
|
|
2105
|
+
"./filters/types.ts": () => Promise.resolve().then(() => (init_types2(), types_exports2)),
|
|
2106
|
+
"./filters/userGoalStub.ts": () => Promise.resolve().then(() => (init_userGoalStub(), userGoalStub_exports)),
|
|
2107
|
+
"./filters/userTagPreference.ts": () => Promise.resolve().then(() => (init_userTagPreference(), userTagPreference_exports))
|
|
2108
|
+
});
|
|
2109
|
+
}
|
|
2110
|
+
});
|
|
2111
|
+
|
|
2112
|
+
// src/core/orchestration/gradient.ts
|
|
2113
|
+
function aggregateOutcomesForGradient(outcomes, strategyId) {
|
|
2114
|
+
const observations = [];
|
|
2115
|
+
for (const outcome of outcomes) {
|
|
2116
|
+
const deviation = outcome.deviations[strategyId];
|
|
2117
|
+
if (deviation === void 0) {
|
|
2118
|
+
continue;
|
|
2119
|
+
}
|
|
2120
|
+
observations.push({
|
|
2121
|
+
deviation,
|
|
2122
|
+
outcomeValue: outcome.outcomeValue,
|
|
2123
|
+
weight: 1
|
|
2124
|
+
});
|
|
2125
|
+
}
|
|
2126
|
+
logger.debug(
|
|
2127
|
+
`[Orchestration] Aggregated ${observations.length} observations for strategy ${strategyId}`
|
|
2128
|
+
);
|
|
2129
|
+
return observations;
|
|
2130
|
+
}
|
|
2131
|
+
function computeStrategyGradient(observations) {
|
|
2132
|
+
const n = observations.length;
|
|
2133
|
+
if (n < 3) {
|
|
2134
|
+
logger.debug(`[Orchestration] Insufficient observations for gradient (${n} < 3)`);
|
|
2135
|
+
return null;
|
|
2136
|
+
}
|
|
2137
|
+
let sumX = 0;
|
|
2138
|
+
let sumY = 0;
|
|
2139
|
+
let sumW = 0;
|
|
2140
|
+
for (const obs of observations) {
|
|
2141
|
+
const w = obs.weight ?? 1;
|
|
2142
|
+
sumX += obs.deviation * w;
|
|
2143
|
+
sumY += obs.outcomeValue * w;
|
|
2144
|
+
sumW += w;
|
|
2145
|
+
}
|
|
2146
|
+
const meanX = sumX / sumW;
|
|
2147
|
+
const meanY = sumY / sumW;
|
|
2148
|
+
let numerator = 0;
|
|
2149
|
+
let denominator = 0;
|
|
2150
|
+
let ssTotal = 0;
|
|
2151
|
+
for (const obs of observations) {
|
|
2152
|
+
const w = obs.weight ?? 1;
|
|
2153
|
+
const dx = obs.deviation - meanX;
|
|
2154
|
+
const dy = obs.outcomeValue - meanY;
|
|
2155
|
+
numerator += w * dx * dy;
|
|
2156
|
+
denominator += w * dx * dx;
|
|
2157
|
+
ssTotal += w * dy * dy;
|
|
2158
|
+
}
|
|
2159
|
+
if (denominator < 1e-10) {
|
|
2160
|
+
logger.debug(`[Orchestration] No variance in deviations, cannot compute gradient`);
|
|
2161
|
+
return {
|
|
2162
|
+
gradient: 0,
|
|
2163
|
+
intercept: meanY,
|
|
2164
|
+
rSquared: 0,
|
|
2165
|
+
sampleSize: n
|
|
2166
|
+
};
|
|
2167
|
+
}
|
|
2168
|
+
const gradient = numerator / denominator;
|
|
2169
|
+
const intercept = meanY - gradient * meanX;
|
|
2170
|
+
let ssResidual = 0;
|
|
2171
|
+
for (const obs of observations) {
|
|
2172
|
+
const w = obs.weight ?? 1;
|
|
2173
|
+
const predicted = gradient * obs.deviation + intercept;
|
|
2174
|
+
const residual = obs.outcomeValue - predicted;
|
|
2175
|
+
ssResidual += w * residual * residual;
|
|
2176
|
+
}
|
|
2177
|
+
const rSquared = ssTotal > 1e-10 ? 1 - ssResidual / ssTotal : 0;
|
|
2178
|
+
logger.debug(
|
|
2179
|
+
`[Orchestration] Computed gradient: ${gradient.toFixed(4)}, intercept: ${intercept.toFixed(4)}, R\xB2: ${rSquared.toFixed(4)}, n=${n}`
|
|
2180
|
+
);
|
|
2181
|
+
return {
|
|
2182
|
+
gradient,
|
|
2183
|
+
intercept,
|
|
2184
|
+
rSquared: Math.max(0, Math.min(1, rSquared)),
|
|
2185
|
+
// Clamp to [0,1]
|
|
2186
|
+
sampleSize: n
|
|
2187
|
+
};
|
|
2188
|
+
}
|
|
2189
|
+
var init_gradient = __esm({
|
|
2190
|
+
"src/core/orchestration/gradient.ts"() {
|
|
2191
|
+
"use strict";
|
|
2192
|
+
init_logger();
|
|
2193
|
+
}
|
|
2194
|
+
});
|
|
2195
|
+
|
|
2196
|
+
// src/core/orchestration/learning.ts
|
|
2197
|
+
function updateStrategyWeight(current, gradient) {
|
|
2198
|
+
if (gradient.sampleSize < MIN_OBSERVATIONS_FOR_UPDATE) {
|
|
2199
|
+
logger.debug(
|
|
2200
|
+
`[Orchestration] Insufficient samples (${gradient.sampleSize} < ${MIN_OBSERVATIONS_FOR_UPDATE}), keeping current weight`
|
|
2201
|
+
);
|
|
2202
|
+
return {
|
|
2203
|
+
...current,
|
|
2204
|
+
sampleSize: current.sampleSize + gradient.sampleSize
|
|
2205
|
+
};
|
|
2206
|
+
}
|
|
2207
|
+
const isReliable = gradient.rSquared >= MIN_R_SQUARED_FOR_GRADIENT;
|
|
2208
|
+
const isFlat = Math.abs(gradient.gradient) < FLAT_GRADIENT_THRESHOLD;
|
|
2209
|
+
let newWeight = current.weight;
|
|
2210
|
+
let newConfidence = current.confidence;
|
|
2211
|
+
if (!isReliable || isFlat) {
|
|
2212
|
+
const confidenceGain = 0.05 * (1 - current.confidence);
|
|
2213
|
+
newConfidence = Math.min(1, current.confidence + confidenceGain);
|
|
2214
|
+
logger.debug(
|
|
2215
|
+
`[Orchestration] Flat/unreliable gradient (|g|=${Math.abs(gradient.gradient).toFixed(4)}, R\xB2=${gradient.rSquared.toFixed(4)}). Increasing confidence: ${current.confidence.toFixed(3)} \u2192 ${newConfidence.toFixed(3)}`
|
|
2216
|
+
);
|
|
2217
|
+
} else {
|
|
2218
|
+
let delta = gradient.gradient * LEARNING_RATE;
|
|
2219
|
+
delta = Math.max(-MAX_WEIGHT_DELTA, Math.min(MAX_WEIGHT_DELTA, delta));
|
|
2220
|
+
newWeight = current.weight + delta;
|
|
2221
|
+
newWeight = Math.max(0.1, Math.min(3, newWeight));
|
|
2222
|
+
const confidenceGain = 0.02 * (1 - current.confidence);
|
|
2223
|
+
newConfidence = Math.min(1, current.confidence + confidenceGain);
|
|
2224
|
+
logger.debug(
|
|
2225
|
+
`[Orchestration] Adjusting weight: ${current.weight.toFixed(3)} \u2192 ${newWeight.toFixed(3)} (gradient=${gradient.gradient.toFixed(4)}, delta=${delta.toFixed(4)})`
|
|
2226
|
+
);
|
|
2227
|
+
}
|
|
2228
|
+
return {
|
|
2229
|
+
weight: newWeight,
|
|
2230
|
+
confidence: newConfidence,
|
|
2231
|
+
sampleSize: current.sampleSize + gradient.sampleSize
|
|
2232
|
+
};
|
|
2233
|
+
}
|
|
2234
|
+
function updateLearningState(courseId, strategyId, currentWeight, gradient, existing) {
|
|
2235
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2236
|
+
const id = `STRATEGY_LEARNING_STATE::${courseId}::${strategyId}`;
|
|
2237
|
+
const historyEntry = {
|
|
2238
|
+
timestamp: now,
|
|
2239
|
+
weight: currentWeight.weight,
|
|
2240
|
+
confidence: currentWeight.confidence,
|
|
2241
|
+
gradient: gradient.gradient
|
|
2242
|
+
};
|
|
2243
|
+
let history = existing?.history ?? [];
|
|
2244
|
+
history = [...history, historyEntry];
|
|
2245
|
+
if (history.length > MAX_HISTORY_LENGTH) {
|
|
2246
|
+
history = history.slice(history.length - MAX_HISTORY_LENGTH);
|
|
2247
|
+
}
|
|
2248
|
+
const state = {
|
|
2249
|
+
_id: id,
|
|
2250
|
+
_rev: existing?._rev,
|
|
2251
|
+
docType: "STRATEGY_LEARNING_STATE" /* STRATEGY_LEARNING_STATE */,
|
|
2252
|
+
courseId,
|
|
2253
|
+
strategyId,
|
|
2254
|
+
currentWeight,
|
|
2255
|
+
regression: {
|
|
2256
|
+
gradient: gradient.gradient,
|
|
2257
|
+
intercept: gradient.intercept,
|
|
2258
|
+
rSquared: gradient.rSquared,
|
|
2259
|
+
sampleSize: gradient.sampleSize,
|
|
2260
|
+
computedAt: now
|
|
2261
|
+
},
|
|
2262
|
+
history,
|
|
2263
|
+
updatedAt: now
|
|
2264
|
+
};
|
|
2265
|
+
return state;
|
|
2266
|
+
}
|
|
2267
|
+
function runPeriodUpdate(input) {
|
|
2268
|
+
const { courseId, strategyId, currentWeight, gradient, existingState } = input;
|
|
2269
|
+
logger.info(
|
|
2270
|
+
`[Orchestration] Running period update for strategy ${strategyId} (${gradient.sampleSize} observations)`
|
|
2271
|
+
);
|
|
2272
|
+
const newWeight = updateStrategyWeight(currentWeight, gradient);
|
|
2273
|
+
const updated = newWeight.weight !== currentWeight.weight;
|
|
2274
|
+
const learningState = updateLearningState(
|
|
2275
|
+
courseId,
|
|
2276
|
+
strategyId,
|
|
2277
|
+
newWeight,
|
|
2278
|
+
gradient,
|
|
2279
|
+
existingState
|
|
2280
|
+
);
|
|
2281
|
+
logger.info(
|
|
2282
|
+
`[Orchestration] Period update complete for ${strategyId}: weight ${currentWeight.weight.toFixed(3)} \u2192 ${newWeight.weight.toFixed(3)}, confidence ${currentWeight.confidence.toFixed(3)} \u2192 ${newWeight.confidence.toFixed(3)}`
|
|
2283
|
+
);
|
|
2284
|
+
return {
|
|
2285
|
+
strategyId,
|
|
2286
|
+
previousWeight: currentWeight,
|
|
2287
|
+
newWeight,
|
|
2288
|
+
gradient,
|
|
2289
|
+
learningState,
|
|
2290
|
+
updated
|
|
2291
|
+
};
|
|
2292
|
+
}
|
|
2293
|
+
function getDefaultLearnableWeight() {
|
|
2294
|
+
return { ...DEFAULT_LEARNABLE_WEIGHT };
|
|
2295
|
+
}
|
|
2296
|
+
var MIN_OBSERVATIONS_FOR_UPDATE, LEARNING_RATE, MAX_WEIGHT_DELTA, MIN_R_SQUARED_FOR_GRADIENT, FLAT_GRADIENT_THRESHOLD, MAX_HISTORY_LENGTH;
|
|
2297
|
+
var init_learning = __esm({
|
|
2298
|
+
"src/core/orchestration/learning.ts"() {
|
|
2299
|
+
"use strict";
|
|
2300
|
+
init_contentNavigationStrategy();
|
|
2301
|
+
init_types_legacy();
|
|
2302
|
+
init_logger();
|
|
2303
|
+
MIN_OBSERVATIONS_FOR_UPDATE = 10;
|
|
2304
|
+
LEARNING_RATE = 0.1;
|
|
2305
|
+
MAX_WEIGHT_DELTA = 0.3;
|
|
2306
|
+
MIN_R_SQUARED_FOR_GRADIENT = 0.05;
|
|
2307
|
+
FLAT_GRADIENT_THRESHOLD = 0.02;
|
|
2308
|
+
MAX_HISTORY_LENGTH = 100;
|
|
2309
|
+
}
|
|
2310
|
+
});
|
|
2311
|
+
|
|
2312
|
+
// src/core/orchestration/signal.ts
|
|
2313
|
+
function computeOutcomeSignal(records, config = {}) {
|
|
2314
|
+
if (!records || records.length === 0) {
|
|
2315
|
+
return null;
|
|
2316
|
+
}
|
|
2317
|
+
const target = config.targetAccuracy ?? 0.85;
|
|
2318
|
+
const tolerance = config.tolerance ?? 0.05;
|
|
2319
|
+
let correct = 0;
|
|
2320
|
+
for (const r of records) {
|
|
2321
|
+
if (r.isCorrect) correct++;
|
|
2322
|
+
}
|
|
2323
|
+
const accuracy = correct / records.length;
|
|
2324
|
+
return scoreAccuracyInZone(accuracy, target, tolerance);
|
|
2325
|
+
}
|
|
2326
|
+
function scoreAccuracyInZone(accuracy, target, tolerance) {
|
|
2327
|
+
const dist = Math.abs(accuracy - target);
|
|
2328
|
+
if (dist <= tolerance) {
|
|
2329
|
+
return 1;
|
|
2330
|
+
}
|
|
2331
|
+
const excess = dist - tolerance;
|
|
2332
|
+
const slope = 2.5;
|
|
2333
|
+
return Math.max(0, 1 - excess * slope);
|
|
2334
|
+
}
|
|
2335
|
+
var init_signal = __esm({
|
|
2336
|
+
"src/core/orchestration/signal.ts"() {
|
|
2337
|
+
"use strict";
|
|
2338
|
+
}
|
|
2339
|
+
});
|
|
2340
|
+
|
|
2341
|
+
// src/core/orchestration/recording.ts
|
|
2342
|
+
async function recordUserOutcome(context, periodStart, periodEnd, records, activeStrategyIds, eloStart = 0, eloEnd = 0, config) {
|
|
2343
|
+
const { user, course, userId } = context;
|
|
2344
|
+
const courseId = course.getCourseID();
|
|
2345
|
+
const outcomeValue = computeOutcomeSignal(records, config);
|
|
2346
|
+
if (outcomeValue === null) {
|
|
2347
|
+
logger.debug(
|
|
2348
|
+
`[Orchestration] No outcome signal computed for ${userId} (insufficient data). Skipping record.`
|
|
2349
|
+
);
|
|
2350
|
+
return;
|
|
2351
|
+
}
|
|
2352
|
+
const deviations = {};
|
|
2353
|
+
for (const strategyId of activeStrategyIds) {
|
|
2354
|
+
deviations[strategyId] = context.getDeviation(strategyId);
|
|
2355
|
+
}
|
|
2356
|
+
const id = `USER_OUTCOME::${courseId}::${userId}::${periodEnd}`;
|
|
2357
|
+
const record = {
|
|
2358
|
+
_id: id,
|
|
2359
|
+
docType: "USER_OUTCOME" /* USER_OUTCOME */,
|
|
2360
|
+
courseId,
|
|
2361
|
+
userId,
|
|
2362
|
+
periodStart,
|
|
2363
|
+
periodEnd,
|
|
2364
|
+
outcomeValue,
|
|
2365
|
+
deviations,
|
|
2366
|
+
metadata: {
|
|
2367
|
+
sessionsCount: 1,
|
|
2368
|
+
// Assumes recording is triggered per-session currently
|
|
2369
|
+
cardsSeen: records.length,
|
|
2370
|
+
eloStart,
|
|
2371
|
+
eloEnd,
|
|
2372
|
+
signalType: "accuracy_in_zone"
|
|
2373
|
+
}
|
|
2374
|
+
};
|
|
2375
|
+
try {
|
|
2376
|
+
await user.putUserOutcome(record);
|
|
2377
|
+
logger.debug(
|
|
2378
|
+
`[Orchestration] Recorded outcome ${outcomeValue.toFixed(3)} for ${userId} (doc: ${id})`
|
|
2379
|
+
);
|
|
2380
|
+
} catch (e) {
|
|
2381
|
+
logger.error(`[Orchestration] Failed to record outcome: ${e}`);
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
var init_recording = __esm({
|
|
2385
|
+
"src/core/orchestration/recording.ts"() {
|
|
2386
|
+
"use strict";
|
|
2387
|
+
init_signal();
|
|
2388
|
+
init_types_legacy();
|
|
2389
|
+
init_logger();
|
|
2390
|
+
}
|
|
2391
|
+
});
|
|
2392
|
+
|
|
2393
|
+
// src/core/orchestration/index.ts
|
|
2394
|
+
function fnv1a(str) {
|
|
2395
|
+
let hash = 2166136261;
|
|
2396
|
+
for (let i = 0; i < str.length; i++) {
|
|
2397
|
+
hash ^= str.charCodeAt(i);
|
|
2398
|
+
hash = Math.imul(hash, 16777619);
|
|
2399
|
+
}
|
|
2400
|
+
return hash >>> 0;
|
|
2401
|
+
}
|
|
2402
|
+
function computeDeviation(userId, strategyId, salt) {
|
|
2403
|
+
const input = `${userId}:${strategyId}:${salt}`;
|
|
2404
|
+
const hash = fnv1a(input);
|
|
2405
|
+
const normalized = hash / 4294967296;
|
|
2406
|
+
return normalized * 2 - 1;
|
|
2407
|
+
}
|
|
2408
|
+
function computeSpread(confidence) {
|
|
2409
|
+
const clampedConfidence = Math.max(0, Math.min(1, confidence));
|
|
2410
|
+
return MAX_SPREAD - clampedConfidence * (MAX_SPREAD - MIN_SPREAD);
|
|
2411
|
+
}
|
|
2412
|
+
function computeEffectiveWeight(learnable, userId, strategyId, salt) {
|
|
2413
|
+
const deviation = computeDeviation(userId, strategyId, salt);
|
|
2414
|
+
const spread = computeSpread(learnable.confidence);
|
|
2415
|
+
const adjustment = deviation * spread * learnable.weight;
|
|
2416
|
+
const effective = learnable.weight + adjustment;
|
|
2417
|
+
return Math.max(MIN_WEIGHT, Math.min(MAX_WEIGHT, effective));
|
|
2418
|
+
}
|
|
2419
|
+
async function createOrchestrationContext(user, course) {
|
|
2420
|
+
let courseConfig;
|
|
2421
|
+
try {
|
|
2422
|
+
courseConfig = await course.getCourseConfig();
|
|
2423
|
+
} catch (e) {
|
|
2424
|
+
logger.error(`[Orchestration] Failed to load course config: ${e}`);
|
|
2425
|
+
courseConfig = {
|
|
2426
|
+
name: "Unknown",
|
|
2427
|
+
description: "",
|
|
2428
|
+
public: false,
|
|
2429
|
+
deleted: false,
|
|
2430
|
+
creator: "",
|
|
2431
|
+
admins: [],
|
|
2432
|
+
moderators: [],
|
|
2433
|
+
dataShapes: [],
|
|
2434
|
+
questionTypes: [],
|
|
2435
|
+
orchestration: { salt: "default" }
|
|
2436
|
+
};
|
|
2437
|
+
}
|
|
2438
|
+
const userId = user.getUsername();
|
|
2439
|
+
const salt = courseConfig.orchestration?.salt || "default_salt";
|
|
2440
|
+
return {
|
|
2441
|
+
user,
|
|
2442
|
+
course,
|
|
2443
|
+
userId,
|
|
2444
|
+
courseConfig,
|
|
2445
|
+
getEffectiveWeight(strategyId, learnable) {
|
|
2446
|
+
return computeEffectiveWeight(learnable, userId, strategyId, salt);
|
|
2447
|
+
},
|
|
2448
|
+
getDeviation(strategyId) {
|
|
2449
|
+
return computeDeviation(userId, strategyId, salt);
|
|
2450
|
+
}
|
|
2451
|
+
};
|
|
2452
|
+
}
|
|
2453
|
+
var MIN_SPREAD, MAX_SPREAD, MIN_WEIGHT, MAX_WEIGHT;
|
|
2454
|
+
var init_orchestration = __esm({
|
|
2455
|
+
"src/core/orchestration/index.ts"() {
|
|
2456
|
+
"use strict";
|
|
2457
|
+
init_logger();
|
|
2458
|
+
init_gradient();
|
|
2459
|
+
init_learning();
|
|
2460
|
+
init_signal();
|
|
2461
|
+
init_recording();
|
|
2462
|
+
MIN_SPREAD = 0.1;
|
|
2463
|
+
MAX_SPREAD = 0.5;
|
|
2464
|
+
MIN_WEIGHT = 0.1;
|
|
2465
|
+
MAX_WEIGHT = 3;
|
|
2466
|
+
}
|
|
2467
|
+
});
|
|
2468
|
+
|
|
2469
|
+
// src/core/navigators/Pipeline.ts
|
|
2470
|
+
var Pipeline_exports = {};
|
|
2471
|
+
__export(Pipeline_exports, {
|
|
2472
|
+
Pipeline: () => Pipeline
|
|
2473
|
+
});
|
|
2474
|
+
function logPipelineConfig(generator, filters) {
|
|
2475
|
+
const filterList = filters.length > 0 ? "\n - " + filters.map((f) => f.name).join("\n - ") : " none";
|
|
2476
|
+
logger.info(
|
|
2477
|
+
`[Pipeline] Configuration:
|
|
2478
|
+
Generator: ${generator.name}
|
|
2479
|
+
Filters:${filterList}`
|
|
2480
|
+
);
|
|
2481
|
+
}
|
|
2482
|
+
function logTagHydration(cards, tagsByCard) {
|
|
2483
|
+
const totalTags = Array.from(tagsByCard.values()).reduce((sum, tags) => sum + tags.length, 0);
|
|
2484
|
+
const cardsWithTags = Array.from(tagsByCard.values()).filter((tags) => tags.length > 0).length;
|
|
2485
|
+
logger.debug(
|
|
2486
|
+
`[Pipeline] Tag hydration: ${cards.length} cards, ${cardsWithTags} have tags (${totalTags} total tags) - single batch query`
|
|
2487
|
+
);
|
|
2488
|
+
}
|
|
2489
|
+
function logExecutionSummary(generatorName, generatedCount, filterCount, finalCount, topScores) {
|
|
2490
|
+
const scoreDisplay = topScores.length > 0 ? topScores.map((s) => s.toFixed(2)).join(", ") : "none";
|
|
2491
|
+
logger.info(
|
|
2492
|
+
`[Pipeline] Execution: ${generatorName} produced ${generatedCount} \u2192 ${filterCount} filters \u2192 ${finalCount} results (top scores: ${scoreDisplay})`
|
|
2493
|
+
);
|
|
2494
|
+
}
|
|
2495
|
+
function logCardProvenance(cards, maxCards = 3) {
|
|
2496
|
+
const cardsToLog = cards.slice(0, maxCards);
|
|
2497
|
+
logger.debug(`[Pipeline] Provenance for top ${cardsToLog.length} cards:`);
|
|
2498
|
+
for (const card of cardsToLog) {
|
|
2499
|
+
logger.debug(`[Pipeline] ${card.cardId} (final score: ${card.score.toFixed(3)}):`);
|
|
2500
|
+
for (const entry of card.provenance) {
|
|
2501
|
+
const scoreChange = entry.score.toFixed(3);
|
|
2502
|
+
const action = entry.action.padEnd(9);
|
|
2503
|
+
logger.debug(
|
|
2504
|
+
`[Pipeline] ${action} ${scoreChange} - ${entry.strategyName}: ${entry.reason}`
|
|
2505
|
+
);
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
var import_common8, Pipeline;
|
|
2510
|
+
var init_Pipeline = __esm({
|
|
2511
|
+
"src/core/navigators/Pipeline.ts"() {
|
|
2512
|
+
"use strict";
|
|
2513
|
+
import_common8 = require("@vue-skuilder/common");
|
|
2514
|
+
init_navigators();
|
|
2515
|
+
init_logger();
|
|
2516
|
+
init_orchestration();
|
|
2517
|
+
Pipeline = class extends ContentNavigator {
|
|
2518
|
+
generator;
|
|
2519
|
+
filters;
|
|
2520
|
+
/**
|
|
2521
|
+
* Create a new pipeline.
|
|
2522
|
+
*
|
|
2523
|
+
* @param generator - The generator (or CompositeGenerator) that produces candidates
|
|
2524
|
+
* @param filters - Filters to apply sequentially (order doesn't matter for multipliers)
|
|
2525
|
+
* @param user - User database interface
|
|
2526
|
+
* @param course - Course database interface
|
|
2527
|
+
*/
|
|
2528
|
+
constructor(generator, filters, user, course) {
|
|
2529
|
+
super();
|
|
2530
|
+
this.generator = generator;
|
|
2531
|
+
this.filters = filters;
|
|
2532
|
+
this.user = user;
|
|
2533
|
+
this.course = course;
|
|
2534
|
+
course.getCourseConfig().then((cfg) => {
|
|
2535
|
+
logger.debug(`[pipeline] Crated pipeline for ${cfg.name}`);
|
|
2536
|
+
}).catch((e) => {
|
|
2537
|
+
logger.error(`[pipeline] Failed to lookup courseCfg: ${e}`);
|
|
2538
|
+
});
|
|
2539
|
+
logPipelineConfig(generator, filters);
|
|
2540
|
+
}
|
|
2541
|
+
/**
|
|
2542
|
+
* Get weighted cards by running generator and applying filters.
|
|
2543
|
+
*
|
|
2544
|
+
* 1. Build shared context (user ELO, etc.)
|
|
2545
|
+
* 2. Get candidates from generator (passing context)
|
|
2546
|
+
* 3. Batch hydrate tags for all candidates
|
|
2547
|
+
* 4. Apply each filter sequentially
|
|
2548
|
+
* 5. Remove zero-score cards
|
|
2549
|
+
* 6. Sort by score descending
|
|
2550
|
+
* 7. Return top N
|
|
2551
|
+
*
|
|
2552
|
+
* @param limit - Maximum number of cards to return
|
|
2553
|
+
* @returns Cards sorted by score descending
|
|
2554
|
+
*/
|
|
2555
|
+
async getWeightedCards(limit) {
|
|
2556
|
+
const context = await this.buildContext();
|
|
2557
|
+
const overFetchMultiplier = 2 + this.filters.length * 0.5;
|
|
2558
|
+
const fetchLimit = Math.ceil(limit * overFetchMultiplier);
|
|
2559
|
+
logger.debug(
|
|
2560
|
+
`[Pipeline] Fetching ${fetchLimit} candidates from generator '${this.generator.name}'`
|
|
2561
|
+
);
|
|
2562
|
+
let cards = await this.generator.getWeightedCards(fetchLimit, context);
|
|
2563
|
+
const generatedCount = cards.length;
|
|
2564
|
+
logger.debug(`[Pipeline] Generator returned ${generatedCount} candidates`);
|
|
2565
|
+
cards = await this.hydrateTags(cards);
|
|
2566
|
+
for (const filter of this.filters) {
|
|
2567
|
+
const beforeCount = cards.length;
|
|
2568
|
+
cards = await filter.transform(cards, context);
|
|
2569
|
+
logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeCount} \u2192 ${cards.length} cards`);
|
|
2570
|
+
}
|
|
2571
|
+
cards = cards.filter((c) => c.score > 0);
|
|
2572
|
+
cards.sort((a, b) => b.score - a.score);
|
|
2573
|
+
const result = cards.slice(0, limit);
|
|
2574
|
+
const topScores = result.slice(0, 3).map((c) => c.score);
|
|
2575
|
+
logExecutionSummary(
|
|
2576
|
+
this.generator.name,
|
|
2577
|
+
generatedCount,
|
|
2578
|
+
this.filters.length,
|
|
2579
|
+
result.length,
|
|
2580
|
+
topScores
|
|
2581
|
+
);
|
|
2582
|
+
logCardProvenance(result, 3);
|
|
2583
|
+
return result;
|
|
2584
|
+
}
|
|
2585
|
+
/**
|
|
2586
|
+
* Batch hydrate tags for all cards.
|
|
2587
|
+
*
|
|
2588
|
+
* Fetches tags for all cards in a single database query and attaches them
|
|
2589
|
+
* to the WeightedCard objects. Filters can then use card.tags instead of
|
|
2590
|
+
* making individual getAppliedTags() calls.
|
|
2591
|
+
*
|
|
2592
|
+
* @param cards - Cards to hydrate
|
|
2593
|
+
* @returns Cards with tags populated
|
|
2594
|
+
*/
|
|
2595
|
+
async hydrateTags(cards) {
|
|
2596
|
+
if (cards.length === 0) {
|
|
2597
|
+
return cards;
|
|
2598
|
+
}
|
|
2599
|
+
const cardIds = cards.map((c) => c.cardId);
|
|
2600
|
+
const tagsByCard = await this.course.getAppliedTagsBatch(cardIds);
|
|
2601
|
+
logTagHydration(cards, tagsByCard);
|
|
2602
|
+
return cards.map((card) => ({
|
|
2603
|
+
...card,
|
|
2604
|
+
tags: tagsByCard.get(card.cardId) ?? []
|
|
2605
|
+
}));
|
|
2606
|
+
}
|
|
2607
|
+
/**
|
|
2608
|
+
* Build shared context for generator and filters.
|
|
2609
|
+
*
|
|
2610
|
+
* Called once per getWeightedCards() invocation.
|
|
2611
|
+
* Contains data that the generator and multiple filters might need.
|
|
2612
|
+
*
|
|
2613
|
+
* The context satisfies both GeneratorContext and FilterContext interfaces.
|
|
2614
|
+
*/
|
|
2615
|
+
async buildContext() {
|
|
2616
|
+
let userElo = 1e3;
|
|
2617
|
+
try {
|
|
2618
|
+
const courseReg = await this.user.getCourseRegDoc(this.course.getCourseID());
|
|
2619
|
+
const courseElo = (0, import_common8.toCourseElo)(courseReg.elo);
|
|
2620
|
+
userElo = courseElo.global.score;
|
|
2621
|
+
} catch (e) {
|
|
2622
|
+
logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`);
|
|
2623
|
+
}
|
|
2624
|
+
const orchestration = await createOrchestrationContext(this.user, this.course);
|
|
2625
|
+
return {
|
|
2626
|
+
user: this.user,
|
|
2627
|
+
course: this.course,
|
|
2628
|
+
userElo,
|
|
2629
|
+
orchestration
|
|
2630
|
+
};
|
|
2631
|
+
}
|
|
2632
|
+
/**
|
|
2633
|
+
* Get the course ID for this pipeline.
|
|
2634
|
+
*/
|
|
2635
|
+
getCourseID() {
|
|
2636
|
+
return this.course.getCourseID();
|
|
2637
|
+
}
|
|
2638
|
+
/**
|
|
2639
|
+
* Get orchestration context for outcome recording.
|
|
2640
|
+
*/
|
|
2641
|
+
async getOrchestrationContext() {
|
|
2642
|
+
return createOrchestrationContext(this.user, this.course);
|
|
2643
|
+
}
|
|
2644
|
+
/**
|
|
2645
|
+
* Get IDs of all strategies in this pipeline.
|
|
2646
|
+
* Used to record which strategies contributed to an outcome.
|
|
2647
|
+
*/
|
|
2648
|
+
getStrategyIds() {
|
|
2649
|
+
const ids = [];
|
|
2650
|
+
const extractId = (obj) => {
|
|
2651
|
+
if (obj.strategyId) return obj.strategyId;
|
|
2652
|
+
return null;
|
|
2653
|
+
};
|
|
2654
|
+
const genId = extractId(this.generator);
|
|
2655
|
+
if (genId) ids.push(genId);
|
|
2656
|
+
if (this.generator.generators && Array.isArray(this.generator.generators)) {
|
|
2657
|
+
this.generator.generators.forEach((g) => {
|
|
2658
|
+
const subId = extractId(g);
|
|
2659
|
+
if (subId) ids.push(subId);
|
|
2660
|
+
});
|
|
2661
|
+
}
|
|
2662
|
+
for (const filter of this.filters) {
|
|
2663
|
+
const fId = extractId(filter);
|
|
2664
|
+
if (fId) ids.push(fId);
|
|
2665
|
+
}
|
|
2666
|
+
return [...new Set(ids)];
|
|
2667
|
+
}
|
|
2668
|
+
};
|
|
2669
|
+
}
|
|
2670
|
+
});
|
|
2671
|
+
|
|
2672
|
+
// src/core/navigators/PipelineAssembler.ts
|
|
2673
|
+
var PipelineAssembler_exports = {};
|
|
2674
|
+
__export(PipelineAssembler_exports, {
|
|
2675
|
+
PipelineAssembler: () => PipelineAssembler
|
|
2676
|
+
});
|
|
2677
|
+
var PipelineAssembler;
|
|
2678
|
+
var init_PipelineAssembler = __esm({
|
|
2679
|
+
"src/core/navigators/PipelineAssembler.ts"() {
|
|
2680
|
+
"use strict";
|
|
2681
|
+
init_navigators();
|
|
2682
|
+
init_WeightedFilter();
|
|
2683
|
+
init_Pipeline();
|
|
2684
|
+
init_types_legacy();
|
|
2685
|
+
init_logger();
|
|
2686
|
+
init_CompositeGenerator();
|
|
2687
|
+
PipelineAssembler = class {
|
|
2688
|
+
/**
|
|
2689
|
+
* Assembles a navigation pipeline from strategy documents.
|
|
2690
|
+
*
|
|
2691
|
+
* 1. Separates into generators and filters by role
|
|
2692
|
+
* 2. Validates at least one generator exists (or creates default ELO)
|
|
2693
|
+
* 3. Instantiates generators - wraps multiple in CompositeGenerator
|
|
2694
|
+
* 4. Instantiates filters
|
|
2695
|
+
* 5. Returns Pipeline(generator, filters)
|
|
2696
|
+
*
|
|
2697
|
+
* @param input - Strategy documents plus user/course interfaces
|
|
2698
|
+
* @returns Assembled pipeline and any warnings
|
|
2699
|
+
*/
|
|
2700
|
+
async assemble(input) {
|
|
2701
|
+
const { strategies, user, course } = input;
|
|
2702
|
+
const warnings = [];
|
|
2703
|
+
if (strategies.length === 0) {
|
|
2704
|
+
return {
|
|
2705
|
+
pipeline: null,
|
|
2706
|
+
generatorStrategies: [],
|
|
2707
|
+
filterStrategies: [],
|
|
2708
|
+
warnings
|
|
2709
|
+
};
|
|
2710
|
+
}
|
|
2711
|
+
const generatorStrategies = [];
|
|
2712
|
+
const filterStrategies = [];
|
|
2713
|
+
for (const s of strategies) {
|
|
2714
|
+
if (isGenerator(s.implementingClass)) {
|
|
2715
|
+
generatorStrategies.push(s);
|
|
2716
|
+
} else if (isFilter(s.implementingClass)) {
|
|
2717
|
+
filterStrategies.push(s);
|
|
2718
|
+
} else {
|
|
2719
|
+
warnings.push(`Unknown strategy type '${s.implementingClass}', skipping: ${s.name}`);
|
|
2720
|
+
}
|
|
2721
|
+
}
|
|
2722
|
+
if (generatorStrategies.length === 0) {
|
|
2723
|
+
if (filterStrategies.length > 0) {
|
|
2724
|
+
logger.debug(
|
|
2725
|
+
"[PipelineAssembler] No generator found, using default ELO with configured filters"
|
|
2726
|
+
);
|
|
2727
|
+
generatorStrategies.push(this.makeDefaultEloStrategy(course.getCourseID()));
|
|
2728
|
+
} else {
|
|
2729
|
+
warnings.push("No generator strategy found");
|
|
2730
|
+
return {
|
|
2731
|
+
pipeline: null,
|
|
2732
|
+
generatorStrategies: [],
|
|
2733
|
+
filterStrategies: [],
|
|
2734
|
+
warnings
|
|
2735
|
+
};
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2738
|
+
let generator;
|
|
2739
|
+
if (generatorStrategies.length === 1) {
|
|
2740
|
+
const nav = await ContentNavigator.create(user, course, generatorStrategies[0]);
|
|
2741
|
+
generator = nav;
|
|
2742
|
+
logger.debug(`[PipelineAssembler] Using single generator: ${generatorStrategies[0].name}`);
|
|
2743
|
+
} else {
|
|
2744
|
+
logger.debug(
|
|
2745
|
+
`[PipelineAssembler] Using CompositeGenerator for ${generatorStrategies.length} generators: ${generatorStrategies.map((g) => g.name).join(", ")}`
|
|
2746
|
+
);
|
|
2747
|
+
generator = await CompositeGenerator.fromStrategies(user, course, generatorStrategies);
|
|
2748
|
+
}
|
|
2749
|
+
const filters = [];
|
|
2750
|
+
const sortedFilterStrategies = [...filterStrategies].sort(
|
|
2751
|
+
(a, b) => a.name.localeCompare(b.name)
|
|
2752
|
+
);
|
|
2753
|
+
for (const filterStrategy of sortedFilterStrategies) {
|
|
2754
|
+
try {
|
|
2755
|
+
const nav = await ContentNavigator.create(user, course, filterStrategy);
|
|
2756
|
+
if ("transform" in nav && typeof nav.transform === "function") {
|
|
2757
|
+
let filter = nav;
|
|
2758
|
+
if (filterStrategy.learnable) {
|
|
2759
|
+
filter = new WeightedFilter(
|
|
2760
|
+
filter,
|
|
2761
|
+
filterStrategy.learnable,
|
|
2762
|
+
filterStrategy.staticWeight,
|
|
2763
|
+
filterStrategy._id
|
|
2764
|
+
);
|
|
2765
|
+
}
|
|
2766
|
+
filters.push(filter);
|
|
2767
|
+
logger.debug(`[PipelineAssembler] Added filter: ${filterStrategy.name}`);
|
|
2768
|
+
} else {
|
|
2769
|
+
warnings.push(
|
|
2770
|
+
`Filter '${filterStrategy.name}' does not implement CardFilter.transform(), skipping`
|
|
2771
|
+
);
|
|
2772
|
+
}
|
|
2773
|
+
} catch (e) {
|
|
2774
|
+
warnings.push(`Failed to instantiate filter '${filterStrategy.name}': ${e}`);
|
|
2775
|
+
}
|
|
2776
|
+
}
|
|
2777
|
+
const pipeline = new Pipeline(generator, filters, user, course);
|
|
2778
|
+
logger.debug(
|
|
2779
|
+
`[PipelineAssembler] Assembled pipeline with ${generatorStrategies.length} generator(s) and ${filters.length} filter(s)`
|
|
2780
|
+
);
|
|
2781
|
+
return {
|
|
2782
|
+
pipeline,
|
|
2783
|
+
generatorStrategies,
|
|
2784
|
+
filterStrategies: sortedFilterStrategies,
|
|
2785
|
+
warnings
|
|
2786
|
+
};
|
|
2787
|
+
}
|
|
2788
|
+
/**
|
|
2789
|
+
* Creates a default ELO generator strategy.
|
|
2790
|
+
* Used when filters are configured but no generator is specified.
|
|
2791
|
+
*/
|
|
2792
|
+
makeDefaultEloStrategy(courseId) {
|
|
2793
|
+
return {
|
|
2794
|
+
_id: "NAVIGATION_STRATEGY-ELO-default",
|
|
2795
|
+
course: courseId,
|
|
2796
|
+
docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
|
|
2797
|
+
name: "ELO (default)",
|
|
2798
|
+
description: "Default ELO-based generator",
|
|
2799
|
+
implementingClass: "elo" /* ELO */,
|
|
2800
|
+
serializedData: ""
|
|
2801
|
+
};
|
|
2802
|
+
}
|
|
2803
|
+
};
|
|
2804
|
+
}
|
|
2805
|
+
});
|
|
2806
|
+
|
|
2807
|
+
// src/core/navigators/defaults.ts
|
|
2808
|
+
var defaults_exports = {};
|
|
2809
|
+
__export(defaults_exports, {
|
|
2810
|
+
createDefaultEloStrategy: () => createDefaultEloStrategy,
|
|
2811
|
+
createDefaultPipeline: () => createDefaultPipeline,
|
|
2812
|
+
createDefaultSrsStrategy: () => createDefaultSrsStrategy
|
|
2813
|
+
});
|
|
2814
|
+
function createDefaultEloStrategy(courseId) {
|
|
2815
|
+
return {
|
|
2816
|
+
_id: "NAVIGATION_STRATEGY-ELO-default",
|
|
2817
|
+
docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
|
|
2818
|
+
name: "ELO (default)",
|
|
2819
|
+
description: "Default ELO-based navigation strategy for new cards",
|
|
2820
|
+
implementingClass: "elo" /* ELO */,
|
|
2821
|
+
course: courseId,
|
|
2822
|
+
serializedData: ""
|
|
2823
|
+
};
|
|
2824
|
+
}
|
|
2825
|
+
function createDefaultSrsStrategy(courseId) {
|
|
2826
|
+
return {
|
|
2827
|
+
_id: "NAVIGATION_STRATEGY-SRS-default",
|
|
2828
|
+
docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
|
|
2829
|
+
name: "SRS (default)",
|
|
2830
|
+
description: "Default SRS-based navigation strategy for reviews",
|
|
2831
|
+
implementingClass: "srs" /* SRS */,
|
|
2832
|
+
course: courseId,
|
|
2833
|
+
serializedData: ""
|
|
2834
|
+
};
|
|
2835
|
+
}
|
|
2836
|
+
function createDefaultPipeline(user, course) {
|
|
2837
|
+
const courseId = course.getCourseID();
|
|
2838
|
+
const eloNavigator = new ELONavigator(user, course, createDefaultEloStrategy(courseId));
|
|
2839
|
+
const srsNavigator = new SRSNavigator(user, course, createDefaultSrsStrategy(courseId));
|
|
2840
|
+
const compositeGenerator = new CompositeGenerator([eloNavigator, srsNavigator]);
|
|
2841
|
+
const eloDistanceFilter = createEloDistanceFilter();
|
|
2842
|
+
return new Pipeline(compositeGenerator, [eloDistanceFilter], user, course);
|
|
2843
|
+
}
|
|
2844
|
+
var init_defaults = __esm({
|
|
2845
|
+
"src/core/navigators/defaults.ts"() {
|
|
2846
|
+
"use strict";
|
|
2847
|
+
init_navigators();
|
|
2848
|
+
init_Pipeline();
|
|
2849
|
+
init_CompositeGenerator();
|
|
2850
|
+
init_elo();
|
|
2851
|
+
init_srs();
|
|
2852
|
+
init_eloDistance();
|
|
2853
|
+
init_types_legacy();
|
|
2854
|
+
}
|
|
2855
|
+
});
|
|
2856
|
+
|
|
2857
|
+
// import("./**/*") in src/core/navigators/index.ts
|
|
2858
|
+
var globImport;
|
|
2859
|
+
var init_3 = __esm({
|
|
2860
|
+
'import("./**/*") in src/core/navigators/index.ts'() {
|
|
2861
|
+
globImport = __glob({
|
|
2862
|
+
"./Pipeline.ts": () => Promise.resolve().then(() => (init_Pipeline(), Pipeline_exports)),
|
|
2863
|
+
"./PipelineAssembler.ts": () => Promise.resolve().then(() => (init_PipelineAssembler(), PipelineAssembler_exports)),
|
|
2864
|
+
"./defaults.ts": () => Promise.resolve().then(() => (init_defaults(), defaults_exports)),
|
|
2865
|
+
"./filters/WeightedFilter.ts": () => Promise.resolve().then(() => (init_WeightedFilter(), WeightedFilter_exports)),
|
|
2866
|
+
"./filters/eloDistance.ts": () => Promise.resolve().then(() => (init_eloDistance(), eloDistance_exports)),
|
|
2867
|
+
"./filters/hierarchyDefinition.ts": () => Promise.resolve().then(() => (init_hierarchyDefinition(), hierarchyDefinition_exports)),
|
|
2868
|
+
"./filters/index.ts": () => Promise.resolve().then(() => (init_filters(), filters_exports)),
|
|
2869
|
+
"./filters/inferredPreferenceStub.ts": () => Promise.resolve().then(() => (init_inferredPreferenceStub(), inferredPreferenceStub_exports)),
|
|
2870
|
+
"./filters/interferenceMitigator.ts": () => Promise.resolve().then(() => (init_interferenceMitigator(), interferenceMitigator_exports)),
|
|
2871
|
+
"./filters/relativePriority.ts": () => Promise.resolve().then(() => (init_relativePriority(), relativePriority_exports)),
|
|
2872
|
+
"./filters/types.ts": () => Promise.resolve().then(() => (init_types2(), types_exports2)),
|
|
2873
|
+
"./filters/userGoalStub.ts": () => Promise.resolve().then(() => (init_userGoalStub(), userGoalStub_exports)),
|
|
2874
|
+
"./filters/userTagPreference.ts": () => Promise.resolve().then(() => (init_userTagPreference(), userTagPreference_exports)),
|
|
2875
|
+
"./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
|
|
2876
|
+
"./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
|
|
2877
|
+
"./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
|
|
2878
|
+
"./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
|
|
2879
|
+
"./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports)),
|
|
2880
|
+
"./index.ts": () => Promise.resolve().then(() => (init_navigators(), navigators_exports))
|
|
2881
|
+
});
|
|
2882
|
+
}
|
|
2883
|
+
});
|
|
2884
|
+
|
|
2885
|
+
// src/core/navigators/index.ts
|
|
2886
|
+
var navigators_exports = {};
|
|
2887
|
+
__export(navigators_exports, {
|
|
2888
|
+
ContentNavigator: () => ContentNavigator,
|
|
2889
|
+
NavigatorRole: () => NavigatorRole,
|
|
2890
|
+
NavigatorRoles: () => NavigatorRoles,
|
|
2891
|
+
Navigators: () => Navigators,
|
|
2892
|
+
getCardOrigin: () => getCardOrigin,
|
|
2893
|
+
getRegisteredNavigator: () => getRegisteredNavigator,
|
|
2894
|
+
getRegisteredNavigatorNames: () => getRegisteredNavigatorNames,
|
|
2895
|
+
hasRegisteredNavigator: () => hasRegisteredNavigator,
|
|
2896
|
+
initializeNavigatorRegistry: () => initializeNavigatorRegistry,
|
|
2897
|
+
isFilter: () => isFilter,
|
|
2898
|
+
isGenerator: () => isGenerator,
|
|
2899
|
+
registerNavigator: () => registerNavigator
|
|
2900
|
+
});
|
|
2901
|
+
function registerNavigator(implementingClass, constructor) {
|
|
2902
|
+
navigatorRegistry.set(implementingClass, constructor);
|
|
2903
|
+
logger.debug(`[NavigatorRegistry] Registered: ${implementingClass}`);
|
|
2904
|
+
}
|
|
2905
|
+
function getRegisteredNavigator(implementingClass) {
|
|
2906
|
+
return navigatorRegistry.get(implementingClass);
|
|
2907
|
+
}
|
|
2908
|
+
function hasRegisteredNavigator(implementingClass) {
|
|
2909
|
+
return navigatorRegistry.has(implementingClass);
|
|
2910
|
+
}
|
|
2911
|
+
function getRegisteredNavigatorNames() {
|
|
2912
|
+
return Array.from(navigatorRegistry.keys());
|
|
2913
|
+
}
|
|
2914
|
+
async function initializeNavigatorRegistry() {
|
|
2915
|
+
logger.debug("[NavigatorRegistry] Initializing built-in navigators...");
|
|
2916
|
+
const [eloModule, srsModule] = await Promise.all([
|
|
2917
|
+
Promise.resolve().then(() => (init_elo(), elo_exports)),
|
|
2918
|
+
Promise.resolve().then(() => (init_srs(), srs_exports))
|
|
2919
|
+
]);
|
|
2920
|
+
registerNavigator("elo", eloModule.default);
|
|
2921
|
+
registerNavigator("srs", srsModule.default);
|
|
2922
|
+
const [
|
|
2923
|
+
hierarchyModule,
|
|
2924
|
+
interferenceModule,
|
|
2925
|
+
relativePriorityModule,
|
|
2926
|
+
userTagPreferenceModule
|
|
2927
|
+
] = await Promise.all([
|
|
2928
|
+
Promise.resolve().then(() => (init_hierarchyDefinition(), hierarchyDefinition_exports)),
|
|
2929
|
+
Promise.resolve().then(() => (init_interferenceMitigator(), interferenceMitigator_exports)),
|
|
2930
|
+
Promise.resolve().then(() => (init_relativePriority(), relativePriority_exports)),
|
|
2931
|
+
Promise.resolve().then(() => (init_userTagPreference(), userTagPreference_exports))
|
|
2932
|
+
]);
|
|
2933
|
+
registerNavigator("hierarchyDefinition", hierarchyModule.default);
|
|
2934
|
+
registerNavigator("interferenceMitigator", interferenceModule.default);
|
|
2935
|
+
registerNavigator("relativePriority", relativePriorityModule.default);
|
|
2936
|
+
registerNavigator("userTagPreference", userTagPreferenceModule.default);
|
|
2937
|
+
logger.debug(
|
|
2938
|
+
`[NavigatorRegistry] Initialized ${navigatorRegistry.size} navigators: ${getRegisteredNavigatorNames().join(", ")}`
|
|
2939
|
+
);
|
|
2940
|
+
}
|
|
2941
|
+
function getCardOrigin(card) {
|
|
2942
|
+
if (card.provenance.length === 0) {
|
|
2943
|
+
throw new Error("Card has no provenance - cannot determine origin");
|
|
2944
|
+
}
|
|
2945
|
+
const firstEntry = card.provenance[0];
|
|
2946
|
+
const reason = firstEntry.reason.toLowerCase();
|
|
2947
|
+
if (reason.includes("failed")) {
|
|
2948
|
+
return "failed";
|
|
2949
|
+
}
|
|
2950
|
+
if (reason.includes("review")) {
|
|
2951
|
+
return "review";
|
|
2952
|
+
}
|
|
2953
|
+
return "new";
|
|
2954
|
+
}
|
|
2955
|
+
function isGenerator(impl) {
|
|
2956
|
+
return NavigatorRoles[impl] === "generator" /* GENERATOR */;
|
|
2957
|
+
}
|
|
2958
|
+
function isFilter(impl) {
|
|
2959
|
+
return NavigatorRoles[impl] === "filter" /* FILTER */;
|
|
2960
|
+
}
|
|
2961
|
+
var navigatorRegistry, Navigators, NavigatorRole, NavigatorRoles, ContentNavigator;
|
|
2962
|
+
var init_navigators = __esm({
|
|
2963
|
+
"src/core/navigators/index.ts"() {
|
|
2964
|
+
"use strict";
|
|
2965
|
+
init_logger();
|
|
2966
|
+
init_();
|
|
2967
|
+
init_2();
|
|
2968
|
+
init_3();
|
|
2969
|
+
navigatorRegistry = /* @__PURE__ */ new Map();
|
|
2970
|
+
Navigators = /* @__PURE__ */ ((Navigators2) => {
|
|
2971
|
+
Navigators2["ELO"] = "elo";
|
|
2972
|
+
Navigators2["SRS"] = "srs";
|
|
2973
|
+
Navigators2["HIERARCHY"] = "hierarchyDefinition";
|
|
2974
|
+
Navigators2["INTERFERENCE"] = "interferenceMitigator";
|
|
2975
|
+
Navigators2["RELATIVE_PRIORITY"] = "relativePriority";
|
|
2976
|
+
Navigators2["USER_TAG_PREFERENCE"] = "userTagPreference";
|
|
2977
|
+
return Navigators2;
|
|
2978
|
+
})(Navigators || {});
|
|
2979
|
+
NavigatorRole = /* @__PURE__ */ ((NavigatorRole2) => {
|
|
2980
|
+
NavigatorRole2["GENERATOR"] = "generator";
|
|
2981
|
+
NavigatorRole2["FILTER"] = "filter";
|
|
2982
|
+
return NavigatorRole2;
|
|
2983
|
+
})(NavigatorRole || {});
|
|
2984
|
+
NavigatorRoles = {
|
|
2985
|
+
["elo" /* ELO */]: "generator" /* GENERATOR */,
|
|
2986
|
+
["srs" /* SRS */]: "generator" /* GENERATOR */,
|
|
2987
|
+
["hierarchyDefinition" /* HIERARCHY */]: "filter" /* FILTER */,
|
|
2988
|
+
["interferenceMitigator" /* INTERFERENCE */]: "filter" /* FILTER */,
|
|
2989
|
+
["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */,
|
|
2990
|
+
["userTagPreference" /* USER_TAG_PREFERENCE */]: "filter" /* FILTER */
|
|
2991
|
+
};
|
|
2992
|
+
ContentNavigator = class {
|
|
2993
|
+
/** User interface for this navigation session */
|
|
2994
|
+
user;
|
|
2995
|
+
/** Course interface for this navigation session */
|
|
2996
|
+
course;
|
|
2997
|
+
/** Human-readable name for this strategy instance (from ContentNavigationStrategyData.name) */
|
|
2998
|
+
strategyName;
|
|
2999
|
+
/** Unique document ID for this strategy instance (from ContentNavigationStrategyData._id) */
|
|
3000
|
+
strategyId;
|
|
3001
|
+
/** Evolutionary weighting configuration */
|
|
3002
|
+
learnable;
|
|
3003
|
+
/** Whether to bypass deviation (manual/static weighting) */
|
|
3004
|
+
staticWeight;
|
|
3005
|
+
/**
|
|
3006
|
+
* Constructor for standard navigators.
|
|
3007
|
+
* Call this from subclass constructors to initialize common fields.
|
|
3008
|
+
*
|
|
3009
|
+
* Note: CompositeGenerator and Pipeline call super() without args, then set
|
|
3010
|
+
* user/course fields directly if needed.
|
|
3011
|
+
*/
|
|
3012
|
+
constructor(user, course, strategyData) {
|
|
3013
|
+
this.user = user;
|
|
3014
|
+
this.course = course;
|
|
3015
|
+
if (strategyData) {
|
|
3016
|
+
this.strategyName = strategyData.name;
|
|
3017
|
+
this.strategyId = strategyData._id;
|
|
3018
|
+
this.learnable = strategyData.learnable;
|
|
3019
|
+
this.staticWeight = strategyData.staticWeight;
|
|
3020
|
+
}
|
|
3021
|
+
}
|
|
3022
|
+
// ============================================================================
|
|
3023
|
+
// STRATEGY STATE HELPERS
|
|
3024
|
+
// ============================================================================
|
|
3025
|
+
//
|
|
3026
|
+
// These methods allow strategies to persist their own state (user preferences,
|
|
3027
|
+
// learned patterns, temporal tracking) in the user database.
|
|
3028
|
+
//
|
|
3029
|
+
// ============================================================================
|
|
3030
|
+
/**
|
|
3031
|
+
* Unique key identifying this strategy for state storage.
|
|
3032
|
+
*
|
|
3033
|
+
* Defaults to the constructor name (e.g., "UserTagPreferenceFilter").
|
|
3034
|
+
* Override in subclasses if multiple instances of the same strategy type
|
|
3035
|
+
* need separate state storage.
|
|
3036
|
+
*/
|
|
3037
|
+
get strategyKey() {
|
|
3038
|
+
return this.constructor.name;
|
|
3039
|
+
}
|
|
3040
|
+
/**
|
|
3041
|
+
* Get this strategy's persisted state for the current course.
|
|
3042
|
+
*
|
|
3043
|
+
* @returns The strategy's data payload, or null if no state exists
|
|
3044
|
+
* @throws Error if user or course is not initialized
|
|
3045
|
+
*/
|
|
3046
|
+
async getStrategyState() {
|
|
3047
|
+
if (!this.user || !this.course) {
|
|
3048
|
+
throw new Error(
|
|
3049
|
+
`Cannot get strategy state: navigator not properly initialized. Ensure user and course are provided to constructor.`
|
|
3050
|
+
);
|
|
3051
|
+
}
|
|
3052
|
+
return this.user.getStrategyState(this.course.getCourseID(), this.strategyKey);
|
|
3053
|
+
}
|
|
3054
|
+
/**
|
|
3055
|
+
* Persist this strategy's state for the current course.
|
|
3056
|
+
*
|
|
3057
|
+
* @param data - The strategy's data payload to store
|
|
3058
|
+
* @throws Error if user or course is not initialized
|
|
3059
|
+
*/
|
|
3060
|
+
async putStrategyState(data) {
|
|
3061
|
+
if (!this.user || !this.course) {
|
|
3062
|
+
throw new Error(
|
|
3063
|
+
`Cannot put strategy state: navigator not properly initialized. Ensure user and course are provided to constructor.`
|
|
3064
|
+
);
|
|
3065
|
+
}
|
|
3066
|
+
return this.user.putStrategyState(this.course.getCourseID(), this.strategyKey, data);
|
|
3067
|
+
}
|
|
3068
|
+
/**
|
|
3069
|
+
* Factory method to create navigator instances.
|
|
3070
|
+
*
|
|
3071
|
+
* First checks the navigator registry for a pre-registered constructor.
|
|
3072
|
+
* If not found, falls back to dynamic import (for custom navigators).
|
|
3073
|
+
*
|
|
3074
|
+
* For reliable operation in test environments, call initializeNavigatorRegistry()
|
|
3075
|
+
* before using this method.
|
|
3076
|
+
*
|
|
3077
|
+
* @param user - User interface
|
|
3078
|
+
* @param course - Course interface
|
|
3079
|
+
* @param strategyData - Strategy configuration document
|
|
3080
|
+
* @returns the runtime object used to steer a study session.
|
|
3081
|
+
*/
|
|
3082
|
+
static async create(user, course, strategyData) {
|
|
3083
|
+
const implementingClass = strategyData.implementingClass;
|
|
3084
|
+
const RegisteredImpl = getRegisteredNavigator(implementingClass);
|
|
3085
|
+
if (RegisteredImpl) {
|
|
3086
|
+
logger.debug(`[ContentNavigator.create] Using registered navigator: ${implementingClass}`);
|
|
3087
|
+
return new RegisteredImpl(user, course, strategyData);
|
|
3088
|
+
}
|
|
3089
|
+
logger.debug(
|
|
3090
|
+
`[ContentNavigator.create] Navigator not in registry, attempting dynamic import: ${implementingClass}`
|
|
3091
|
+
);
|
|
3092
|
+
let NavigatorImpl;
|
|
3093
|
+
const variations = [".ts", ".js", ""];
|
|
3094
|
+
for (const ext of variations) {
|
|
3095
|
+
try {
|
|
3096
|
+
const module2 = await globImport_generators(`./generators/${implementingClass}${ext}`);
|
|
3097
|
+
NavigatorImpl = module2.default;
|
|
3098
|
+
if (NavigatorImpl) break;
|
|
3099
|
+
} catch (e) {
|
|
3100
|
+
logger.debug(`Failed to load generator ${implementingClass}${ext}:`, e);
|
|
3101
|
+
}
|
|
3102
|
+
try {
|
|
3103
|
+
const module2 = await globImport_filters(`./filters/${implementingClass}${ext}`);
|
|
3104
|
+
NavigatorImpl = module2.default;
|
|
3105
|
+
if (NavigatorImpl) break;
|
|
3106
|
+
} catch (e) {
|
|
3107
|
+
logger.debug(`Failed to load filter ${implementingClass}${ext}:`, e);
|
|
3108
|
+
}
|
|
3109
|
+
try {
|
|
3110
|
+
const module2 = await globImport(`./${implementingClass}${ext}`);
|
|
3111
|
+
NavigatorImpl = module2.default;
|
|
3112
|
+
if (NavigatorImpl) break;
|
|
3113
|
+
} catch (e) {
|
|
3114
|
+
logger.debug(`Failed to load legacy ${implementingClass}${ext}:`, e);
|
|
3115
|
+
}
|
|
3116
|
+
if (NavigatorImpl) break;
|
|
3117
|
+
}
|
|
3118
|
+
if (!NavigatorImpl) {
|
|
3119
|
+
throw new Error(`Could not load navigator implementation for: ${implementingClass}`);
|
|
3120
|
+
}
|
|
3121
|
+
return new NavigatorImpl(user, course, strategyData);
|
|
1548
3122
|
}
|
|
1549
3123
|
/**
|
|
1550
|
-
* Get
|
|
1551
|
-
*
|
|
1552
|
-
* Score formula combines:
|
|
1553
|
-
* - Relative overdueness: hoursOverdue / intervalHours
|
|
1554
|
-
* - Interval recency: exponential decay favoring shorter intervals
|
|
3124
|
+
* Get cards with suitability scores and provenance trails.
|
|
1555
3125
|
*
|
|
1556
|
-
*
|
|
3126
|
+
* **This is the PRIMARY API for navigation strategies.**
|
|
1557
3127
|
*
|
|
1558
|
-
*
|
|
1559
|
-
*
|
|
3128
|
+
* Returns cards ranked by suitability score (0-1). Higher scores indicate
|
|
3129
|
+
* better candidates for presentation. Each card includes a provenance trail
|
|
3130
|
+
* documenting how strategies contributed to the final score.
|
|
1560
3131
|
*
|
|
1561
|
-
*
|
|
1562
|
-
*
|
|
1563
|
-
|
|
1564
|
-
async getWeightedCards(limit, _context) {
|
|
1565
|
-
if (!this.user || !this.course) {
|
|
1566
|
-
throw new Error("SRSNavigator requires user and course to be set");
|
|
1567
|
-
}
|
|
1568
|
-
const reviews = await this.user.getPendingReviews(this.course.getCourseID());
|
|
1569
|
-
const now = import_moment3.default.utc();
|
|
1570
|
-
const dueReviews = reviews.filter((r) => now.isAfter(import_moment3.default.utc(r.reviewTime)));
|
|
1571
|
-
const scored = dueReviews.map((review) => {
|
|
1572
|
-
const { score, reason } = this.computeUrgencyScore(review, now);
|
|
1573
|
-
return {
|
|
1574
|
-
cardId: review.cardId,
|
|
1575
|
-
courseId: review.courseId,
|
|
1576
|
-
score,
|
|
1577
|
-
reviewID: review._id,
|
|
1578
|
-
provenance: [
|
|
1579
|
-
{
|
|
1580
|
-
strategy: "srs",
|
|
1581
|
-
strategyName: this.strategyName || this.name,
|
|
1582
|
-
strategyId: this.strategyId || "NAVIGATION_STRATEGY-SRS-default",
|
|
1583
|
-
action: "generated",
|
|
1584
|
-
score,
|
|
1585
|
-
reason
|
|
1586
|
-
}
|
|
1587
|
-
]
|
|
1588
|
-
};
|
|
1589
|
-
});
|
|
1590
|
-
logger.debug(`[srsNav] got ${scored.length} weighted cards`);
|
|
1591
|
-
return scored.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
1592
|
-
}
|
|
1593
|
-
/**
|
|
1594
|
-
* Compute urgency score for a review card.
|
|
3132
|
+
* ## Implementation Required
|
|
3133
|
+
* All navigation strategies MUST override this method. The base class does
|
|
3134
|
+
* not provide a default implementation.
|
|
1595
3135
|
*
|
|
1596
|
-
*
|
|
1597
|
-
*
|
|
1598
|
-
*
|
|
1599
|
-
*
|
|
3136
|
+
* ## For Generators
|
|
3137
|
+
* Override this method to generate candidates and compute scores based on
|
|
3138
|
+
* your strategy's logic (e.g., ELO proximity, review urgency). Create the
|
|
3139
|
+
* initial provenance entry with action='generated'.
|
|
1600
3140
|
*
|
|
1601
|
-
*
|
|
1602
|
-
*
|
|
1603
|
-
*
|
|
1604
|
-
* - 180 days → ~0.30
|
|
3141
|
+
* ## For Filters
|
|
3142
|
+
* Filters should implement the CardFilter interface instead and be composed
|
|
3143
|
+
* via Pipeline. Filters do not directly implement getWeightedCards().
|
|
1605
3144
|
*
|
|
1606
|
-
*
|
|
1607
|
-
*
|
|
3145
|
+
* @param limit - Maximum cards to return
|
|
3146
|
+
* @returns Cards sorted by score descending, with provenance trails
|
|
1608
3147
|
*/
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
const due = import_moment3.default.utc(review.reviewTime);
|
|
1612
|
-
const intervalHours = Math.max(1, due.diff(scheduledAt, "hours"));
|
|
1613
|
-
const hoursOverdue = now.diff(due, "hours");
|
|
1614
|
-
const relativeOverdue = hoursOverdue / intervalHours;
|
|
1615
|
-
const recencyFactor = 0.3 + 0.7 * Math.exp(-intervalHours / 720);
|
|
1616
|
-
const overdueContribution = Math.min(1, Math.max(0, relativeOverdue));
|
|
1617
|
-
const urgency = overdueContribution * 0.5 + recencyFactor * 0.5;
|
|
1618
|
-
const score = Math.min(0.95, 0.5 + urgency * 0.45);
|
|
1619
|
-
const reason = `${Math.round(hoursOverdue)}h overdue (interval: ${Math.round(intervalHours)}h, relative: ${relativeOverdue.toFixed(2)}), recency: ${recencyFactor.toFixed(2)}, review`;
|
|
1620
|
-
return { score, reason };
|
|
3148
|
+
async getWeightedCards(_limit) {
|
|
3149
|
+
throw new Error(`${this.constructor.name} must implement getWeightedCards(). `);
|
|
1621
3150
|
}
|
|
1622
3151
|
};
|
|
1623
3152
|
}
|
|
1624
3153
|
});
|
|
1625
3154
|
|
|
1626
|
-
// src/core/navigators/filters/eloDistance.ts
|
|
1627
|
-
function computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier) {
|
|
1628
|
-
const normalizedDistance = distance / halfLife;
|
|
1629
|
-
const decay = Math.exp(-(normalizedDistance * normalizedDistance));
|
|
1630
|
-
return minMultiplier + (maxMultiplier - minMultiplier) * decay;
|
|
1631
|
-
}
|
|
1632
|
-
function createEloDistanceFilter(config) {
|
|
1633
|
-
const halfLife = config?.halfLife ?? DEFAULT_HALF_LIFE;
|
|
1634
|
-
const minMultiplier = config?.minMultiplier ?? DEFAULT_MIN_MULTIPLIER;
|
|
1635
|
-
const maxMultiplier = config?.maxMultiplier ?? DEFAULT_MAX_MULTIPLIER;
|
|
1636
|
-
return {
|
|
1637
|
-
name: "ELO Distance Filter",
|
|
1638
|
-
async transform(cards, context) {
|
|
1639
|
-
const { course, userElo } = context;
|
|
1640
|
-
const cardIds = cards.map((c) => c.cardId);
|
|
1641
|
-
const cardElos = await course.getCardEloData(cardIds);
|
|
1642
|
-
return cards.map((card, i) => {
|
|
1643
|
-
const cardElo = cardElos[i]?.global?.score ?? 1e3;
|
|
1644
|
-
const distance = Math.abs(cardElo - userElo);
|
|
1645
|
-
const multiplier = computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier);
|
|
1646
|
-
const newScore = card.score * multiplier;
|
|
1647
|
-
const action = multiplier < maxMultiplier - 0.01 ? "penalized" : "passed";
|
|
1648
|
-
return {
|
|
1649
|
-
...card,
|
|
1650
|
-
score: newScore,
|
|
1651
|
-
provenance: [
|
|
1652
|
-
...card.provenance,
|
|
1653
|
-
{
|
|
1654
|
-
strategy: "eloDistance",
|
|
1655
|
-
strategyName: "ELO Distance Filter",
|
|
1656
|
-
strategyId: "ELO_DISTANCE_FILTER",
|
|
1657
|
-
action,
|
|
1658
|
-
score: newScore,
|
|
1659
|
-
reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userElo)}) \u2192 ${multiplier.toFixed(2)}x`
|
|
1660
|
-
}
|
|
1661
|
-
]
|
|
1662
|
-
};
|
|
1663
|
-
});
|
|
1664
|
-
}
|
|
1665
|
-
};
|
|
1666
|
-
}
|
|
1667
|
-
var DEFAULT_HALF_LIFE, DEFAULT_MIN_MULTIPLIER, DEFAULT_MAX_MULTIPLIER;
|
|
1668
|
-
var init_eloDistance = __esm({
|
|
1669
|
-
"src/core/navigators/filters/eloDistance.ts"() {
|
|
1670
|
-
"use strict";
|
|
1671
|
-
DEFAULT_HALF_LIFE = 200;
|
|
1672
|
-
DEFAULT_MIN_MULTIPLIER = 0.3;
|
|
1673
|
-
DEFAULT_MAX_MULTIPLIER = 1;
|
|
1674
|
-
}
|
|
1675
|
-
});
|
|
1676
|
-
|
|
1677
|
-
// src/core/navigators/defaults.ts
|
|
1678
|
-
function createDefaultEloStrategy(courseId) {
|
|
1679
|
-
return {
|
|
1680
|
-
_id: "NAVIGATION_STRATEGY-ELO-default",
|
|
1681
|
-
docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
|
|
1682
|
-
name: "ELO (default)",
|
|
1683
|
-
description: "Default ELO-based navigation strategy for new cards",
|
|
1684
|
-
implementingClass: "elo" /* ELO */,
|
|
1685
|
-
course: courseId,
|
|
1686
|
-
serializedData: ""
|
|
1687
|
-
};
|
|
1688
|
-
}
|
|
1689
|
-
function createDefaultSrsStrategy(courseId) {
|
|
1690
|
-
return {
|
|
1691
|
-
_id: "NAVIGATION_STRATEGY-SRS-default",
|
|
1692
|
-
docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
|
|
1693
|
-
name: "SRS (default)",
|
|
1694
|
-
description: "Default SRS-based navigation strategy for reviews",
|
|
1695
|
-
implementingClass: "srs" /* SRS */,
|
|
1696
|
-
course: courseId,
|
|
1697
|
-
serializedData: ""
|
|
1698
|
-
};
|
|
1699
|
-
}
|
|
1700
|
-
function createDefaultPipeline(user, course) {
|
|
1701
|
-
const courseId = course.getCourseID();
|
|
1702
|
-
const eloNavigator = new ELONavigator(user, course, createDefaultEloStrategy(courseId));
|
|
1703
|
-
const srsNavigator = new SRSNavigator(user, course, createDefaultSrsStrategy(courseId));
|
|
1704
|
-
const compositeGenerator = new CompositeGenerator([eloNavigator, srsNavigator]);
|
|
1705
|
-
const eloDistanceFilter = createEloDistanceFilter();
|
|
1706
|
-
return new Pipeline(compositeGenerator, [eloDistanceFilter], user, course);
|
|
1707
|
-
}
|
|
1708
|
-
var init_defaults = __esm({
|
|
1709
|
-
"src/core/navigators/defaults.ts"() {
|
|
1710
|
-
"use strict";
|
|
1711
|
-
init_navigators();
|
|
1712
|
-
init_Pipeline();
|
|
1713
|
-
init_CompositeGenerator();
|
|
1714
|
-
init_elo();
|
|
1715
|
-
init_srs();
|
|
1716
|
-
init_eloDistance();
|
|
1717
|
-
init_types_legacy();
|
|
1718
|
-
}
|
|
1719
|
-
});
|
|
1720
|
-
|
|
1721
3155
|
// src/impl/couch/courseDB.ts
|
|
1722
3156
|
function randIntWeightedTowardZero(n) {
|
|
1723
3157
|
return Math.floor(Math.random() * Math.random() * Math.random() * n);
|
|
@@ -1794,11 +3228,11 @@ ${JSON.stringify(config)}
|
|
|
1794
3228
|
function isSuccessRow(row) {
|
|
1795
3229
|
return "doc" in row && row.doc !== null && row.doc !== void 0;
|
|
1796
3230
|
}
|
|
1797
|
-
var
|
|
3231
|
+
var import_common9, CoursesDB, CourseDB;
|
|
1798
3232
|
var init_courseDB = __esm({
|
|
1799
3233
|
"src/impl/couch/courseDB.ts"() {
|
|
1800
3234
|
"use strict";
|
|
1801
|
-
|
|
3235
|
+
import_common9 = require("@vue-skuilder/common");
|
|
1802
3236
|
init_couch();
|
|
1803
3237
|
init_updateQueue();
|
|
1804
3238
|
init_types_legacy();
|
|
@@ -1920,14 +3354,14 @@ var init_courseDB = __esm({
|
|
|
1920
3354
|
docs.rows.forEach((r) => {
|
|
1921
3355
|
if (isSuccessRow(r)) {
|
|
1922
3356
|
if (r.doc && r.doc.elo) {
|
|
1923
|
-
ret.push((0,
|
|
3357
|
+
ret.push((0, import_common9.toCourseElo)(r.doc.elo));
|
|
1924
3358
|
} else {
|
|
1925
3359
|
logger.warn("no elo data for card: " + r.id);
|
|
1926
|
-
ret.push((0,
|
|
3360
|
+
ret.push((0, import_common9.blankCourseElo)());
|
|
1927
3361
|
}
|
|
1928
3362
|
} else {
|
|
1929
3363
|
logger.warn("no elo data for card: " + JSON.stringify(r));
|
|
1930
|
-
ret.push((0,
|
|
3364
|
+
ret.push((0, import_common9.blankCourseElo)());
|
|
1931
3365
|
}
|
|
1932
3366
|
});
|
|
1933
3367
|
return ret;
|
|
@@ -2122,7 +3556,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
2122
3556
|
async getCourseTagStubs() {
|
|
2123
3557
|
return getCourseTagStubs(this.id);
|
|
2124
3558
|
}
|
|
2125
|
-
async addNote(codeCourse, shape, data, author, tags, uploads, elo = (0,
|
|
3559
|
+
async addNote(codeCourse, shape, data, author, tags, uploads, elo = (0, import_common9.blankCourseElo)()) {
|
|
2126
3560
|
try {
|
|
2127
3561
|
const resp = await addNote55(this.id, codeCourse, shape, data, author, tags, uploads, elo);
|
|
2128
3562
|
if (resp.ok) {
|
|
@@ -2131,19 +3565,19 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
2131
3565
|
`[courseDB.addNote] Note added but card creation failed: ${resp.cardCreationError}`
|
|
2132
3566
|
);
|
|
2133
3567
|
return {
|
|
2134
|
-
status:
|
|
3568
|
+
status: import_common9.Status.error,
|
|
2135
3569
|
message: `Note was added but no cards were created: ${resp.cardCreationError}`,
|
|
2136
3570
|
id: resp.id
|
|
2137
3571
|
};
|
|
2138
3572
|
}
|
|
2139
3573
|
return {
|
|
2140
|
-
status:
|
|
3574
|
+
status: import_common9.Status.ok,
|
|
2141
3575
|
message: "",
|
|
2142
3576
|
id: resp.id
|
|
2143
3577
|
};
|
|
2144
3578
|
} else {
|
|
2145
3579
|
return {
|
|
2146
|
-
status:
|
|
3580
|
+
status: import_common9.Status.error,
|
|
2147
3581
|
message: "Unexpected error adding note"
|
|
2148
3582
|
};
|
|
2149
3583
|
}
|
|
@@ -2155,7 +3589,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
2155
3589
|
message: ${err.message}`
|
|
2156
3590
|
);
|
|
2157
3591
|
return {
|
|
2158
|
-
status:
|
|
3592
|
+
status: import_common9.Status.error,
|
|
2159
3593
|
message: `Error adding note to course. ${e.reason || err.message}`
|
|
2160
3594
|
};
|
|
2161
3595
|
}
|
|
@@ -2283,7 +3717,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
2283
3717
|
const courseDoc = (await u.getCourseRegistrationsDoc()).courses.find((c) => {
|
|
2284
3718
|
return c.courseID === this.id;
|
|
2285
3719
|
});
|
|
2286
|
-
targetElo = (0,
|
|
3720
|
+
targetElo = (0, import_common9.EloToNumber)(courseDoc.elo);
|
|
2287
3721
|
} catch {
|
|
2288
3722
|
targetElo = 1e3;
|
|
2289
3723
|
}
|
|
@@ -2802,14 +4236,14 @@ var CouchDBSyncStrategy_exports = {};
|
|
|
2802
4236
|
__export(CouchDBSyncStrategy_exports, {
|
|
2803
4237
|
CouchDBSyncStrategy: () => CouchDBSyncStrategy
|
|
2804
4238
|
});
|
|
2805
|
-
var
|
|
4239
|
+
var import_common10, log3, CouchDBSyncStrategy;
|
|
2806
4240
|
var init_CouchDBSyncStrategy = __esm({
|
|
2807
4241
|
"src/impl/couch/CouchDBSyncStrategy.ts"() {
|
|
2808
4242
|
"use strict";
|
|
2809
4243
|
init_factory();
|
|
2810
4244
|
init_types_legacy();
|
|
2811
4245
|
init_logger();
|
|
2812
|
-
|
|
4246
|
+
import_common10 = require("@vue-skuilder/common");
|
|
2813
4247
|
init_common();
|
|
2814
4248
|
init_pouchdb_setup();
|
|
2815
4249
|
init_couch();
|
|
@@ -2880,32 +4314,32 @@ var init_CouchDBSyncStrategy = __esm({
|
|
|
2880
4314
|
}
|
|
2881
4315
|
}
|
|
2882
4316
|
return {
|
|
2883
|
-
status:
|
|
4317
|
+
status: import_common10.Status.ok,
|
|
2884
4318
|
error: void 0
|
|
2885
4319
|
};
|
|
2886
4320
|
} else {
|
|
2887
4321
|
return {
|
|
2888
|
-
status:
|
|
4322
|
+
status: import_common10.Status.error,
|
|
2889
4323
|
error: "Failed to log in after account creation"
|
|
2890
4324
|
};
|
|
2891
4325
|
}
|
|
2892
4326
|
} else {
|
|
2893
4327
|
logger.warn(`Signup not OK: ${JSON.stringify(signupRequest)}`);
|
|
2894
4328
|
return {
|
|
2895
|
-
status:
|
|
4329
|
+
status: import_common10.Status.error,
|
|
2896
4330
|
error: "Account creation failed"
|
|
2897
4331
|
};
|
|
2898
4332
|
}
|
|
2899
4333
|
} catch (e) {
|
|
2900
4334
|
if (e.reason === "Document update conflict.") {
|
|
2901
4335
|
return {
|
|
2902
|
-
status:
|
|
4336
|
+
status: import_common10.Status.error,
|
|
2903
4337
|
error: "This username is taken!"
|
|
2904
4338
|
};
|
|
2905
4339
|
}
|
|
2906
4340
|
logger.error(`Error on signup: ${JSON.stringify(e)}`);
|
|
2907
4341
|
return {
|
|
2908
|
-
status:
|
|
4342
|
+
status: import_common10.Status.error,
|
|
2909
4343
|
error: e.message || "Unknown error during account creation"
|
|
2910
4344
|
};
|
|
2911
4345
|
}
|
|
@@ -3286,13 +4720,13 @@ async function dropUserFromClassroom(user, classID) {
|
|
|
3286
4720
|
async function getUserClassrooms(user) {
|
|
3287
4721
|
return getOrCreateClassroomRegistrationsDoc(user);
|
|
3288
4722
|
}
|
|
3289
|
-
var
|
|
4723
|
+
var import_common12, import_moment6, log4, BaseUser, userCoursesDoc, userClassroomsDoc;
|
|
3290
4724
|
var init_BaseUserDB = __esm({
|
|
3291
4725
|
"src/impl/common/BaseUserDB.ts"() {
|
|
3292
4726
|
"use strict";
|
|
3293
4727
|
init_core();
|
|
3294
4728
|
init_util();
|
|
3295
|
-
|
|
4729
|
+
import_common12 = require("@vue-skuilder/common");
|
|
3296
4730
|
import_moment6 = __toESM(require("moment"), 1);
|
|
3297
4731
|
init_types_legacy();
|
|
3298
4732
|
init_logger();
|
|
@@ -3342,7 +4776,7 @@ Currently logged-in as ${this._username}.`
|
|
|
3342
4776
|
);
|
|
3343
4777
|
}
|
|
3344
4778
|
const result = await this.syncStrategy.createAccount(username, password);
|
|
3345
|
-
if (result.status ===
|
|
4779
|
+
if (result.status === import_common12.Status.ok) {
|
|
3346
4780
|
log4(`Account created successfully, updating username to ${username}`);
|
|
3347
4781
|
this._username = username;
|
|
3348
4782
|
try {
|
|
@@ -3384,7 +4818,7 @@ Currently logged-in as ${this._username}.`
|
|
|
3384
4818
|
async resetUserData() {
|
|
3385
4819
|
if (this.syncStrategy.canAuthenticate()) {
|
|
3386
4820
|
return {
|
|
3387
|
-
status:
|
|
4821
|
+
status: import_common12.Status.error,
|
|
3388
4822
|
error: "Reset user data is only available for local-only mode. Use logout instead for remote sync."
|
|
3389
4823
|
};
|
|
3390
4824
|
}
|
|
@@ -3403,11 +4837,11 @@ Currently logged-in as ${this._username}.`
|
|
|
3403
4837
|
await localDB.bulkDocs(docsToDelete);
|
|
3404
4838
|
}
|
|
3405
4839
|
await this.init();
|
|
3406
|
-
return { status:
|
|
4840
|
+
return { status: import_common12.Status.ok };
|
|
3407
4841
|
} catch (error) {
|
|
3408
4842
|
logger.error("Failed to reset user data:", error);
|
|
3409
4843
|
return {
|
|
3410
|
-
status:
|
|
4844
|
+
status: import_common12.Status.error,
|
|
3411
4845
|
error: error instanceof Error ? error.message : "Unknown error during reset"
|
|
3412
4846
|
};
|
|
3413
4847
|
}
|
|
@@ -4134,6 +5568,19 @@ Currently logged-in as ${this._username}.`
|
|
|
4134
5568
|
};
|
|
4135
5569
|
await this.localDB.put(doc);
|
|
4136
5570
|
}
|
|
5571
|
+
async putUserOutcome(record) {
|
|
5572
|
+
try {
|
|
5573
|
+
await this.localDB.put(record);
|
|
5574
|
+
} catch (err) {
|
|
5575
|
+
if (err.status === 409) {
|
|
5576
|
+
const existing = await this.localDB.get(record._id);
|
|
5577
|
+
record._rev = existing._rev;
|
|
5578
|
+
await this.localDB.put(record);
|
|
5579
|
+
} else {
|
|
5580
|
+
throw err;
|
|
5581
|
+
}
|
|
5582
|
+
}
|
|
5583
|
+
}
|
|
4137
5584
|
async deleteStrategyState(courseId, strategyKey) {
|
|
4138
5585
|
const docId = buildStrategyStateId(courseId, strategyKey);
|
|
4139
5586
|
try {
|
|
@@ -4666,11 +6113,11 @@ var init_StaticDataUnpacker = __esm({
|
|
|
4666
6113
|
});
|
|
4667
6114
|
|
|
4668
6115
|
// src/impl/static/courseDB.ts
|
|
4669
|
-
var
|
|
6116
|
+
var import_common14, StaticCourseDB;
|
|
4670
6117
|
var init_courseDB2 = __esm({
|
|
4671
6118
|
"src/impl/static/courseDB.ts"() {
|
|
4672
6119
|
"use strict";
|
|
4673
|
-
|
|
6120
|
+
import_common14 = require("@vue-skuilder/common");
|
|
4674
6121
|
init_types_legacy();
|
|
4675
6122
|
init_logger();
|
|
4676
6123
|
init_defaults();
|
|
@@ -4927,7 +6374,7 @@ var init_courseDB2 = __esm({
|
|
|
4927
6374
|
}
|
|
4928
6375
|
async addNote(_codeCourse, _shape, _data, _author, _tags, _uploads, _elo) {
|
|
4929
6376
|
return {
|
|
4930
|
-
status:
|
|
6377
|
+
status: import_common14.Status.error,
|
|
4931
6378
|
message: "Cannot add notes in static mode"
|
|
4932
6379
|
};
|
|
4933
6380
|
}
|
|
@@ -5261,6 +6708,7 @@ async function initializeDataLayer(config) {
|
|
|
5261
6708
|
logger.warn("Data layer already initialized. Returning existing instance.");
|
|
5262
6709
|
return dataLayerInstance;
|
|
5263
6710
|
}
|
|
6711
|
+
await initializeNavigatorRegistry();
|
|
5264
6712
|
if (config.options.localStoragePrefix) {
|
|
5265
6713
|
ENV.LOCAL_STORAGE_PREFIX = config.options.localStoragePrefix;
|
|
5266
6714
|
}
|
|
@@ -5315,6 +6763,7 @@ var init_factory = __esm({
|
|
|
5315
6763
|
"use strict";
|
|
5316
6764
|
init_common();
|
|
5317
6765
|
init_logger();
|
|
6766
|
+
init_navigators();
|
|
5318
6767
|
NOT_SET = "NOT_SET";
|
|
5319
6768
|
ENV = {
|
|
5320
6769
|
COUCHDB_SERVER_PROTOCOL: NOT_SET,
|
|
@@ -5326,11 +6775,11 @@ var init_factory = __esm({
|
|
|
5326
6775
|
});
|
|
5327
6776
|
|
|
5328
6777
|
// src/study/TagFilteredContentSource.ts
|
|
5329
|
-
var
|
|
6778
|
+
var import_common18, TagFilteredContentSource;
|
|
5330
6779
|
var init_TagFilteredContentSource = __esm({
|
|
5331
6780
|
"src/study/TagFilteredContentSource.ts"() {
|
|
5332
6781
|
"use strict";
|
|
5333
|
-
|
|
6782
|
+
import_common18 = require("@vue-skuilder/common");
|
|
5334
6783
|
init_courseDB();
|
|
5335
6784
|
init_logger();
|
|
5336
6785
|
TagFilteredContentSource = class {
|
|
@@ -5416,7 +6865,7 @@ var init_TagFilteredContentSource = __esm({
|
|
|
5416
6865
|
* @returns Cards sorted by score descending (all scores = 1.0)
|
|
5417
6866
|
*/
|
|
5418
6867
|
async getWeightedCards(limit) {
|
|
5419
|
-
if (!(0,
|
|
6868
|
+
if (!(0, import_common18.hasActiveFilter)(this.filter)) {
|
|
5420
6869
|
logger.warn("[TagFilteredContentSource] getWeightedCards called with no active filter");
|
|
5421
6870
|
return [];
|
|
5422
6871
|
}
|
|
@@ -5504,19 +6953,19 @@ async function getStudySource(source, user) {
|
|
|
5504
6953
|
if (source.type === "classroom") {
|
|
5505
6954
|
return await StudentClassroomDB.factory(source.id, user);
|
|
5506
6955
|
} else {
|
|
5507
|
-
if ((0,
|
|
6956
|
+
if ((0, import_common19.hasActiveFilter)(source.tagFilter)) {
|
|
5508
6957
|
return new TagFilteredContentSource(source.id, source.tagFilter, user);
|
|
5509
6958
|
}
|
|
5510
6959
|
return getDataLayer().getCourseDB(source.id);
|
|
5511
6960
|
}
|
|
5512
6961
|
}
|
|
5513
|
-
var
|
|
6962
|
+
var import_common19;
|
|
5514
6963
|
var init_contentSource = __esm({
|
|
5515
6964
|
"src/core/interfaces/contentSource.ts"() {
|
|
5516
6965
|
"use strict";
|
|
5517
6966
|
init_factory();
|
|
5518
6967
|
init_classroomDB2();
|
|
5519
|
-
|
|
6968
|
+
import_common19 = require("@vue-skuilder/common");
|
|
5520
6969
|
init_TagFilteredContentSource();
|
|
5521
6970
|
}
|
|
5522
6971
|
});
|
|
@@ -5572,6 +7021,13 @@ var init_strategyState = __esm({
|
|
|
5572
7021
|
}
|
|
5573
7022
|
});
|
|
5574
7023
|
|
|
7024
|
+
// src/core/types/userOutcome.ts
|
|
7025
|
+
var init_userOutcome = __esm({
|
|
7026
|
+
"src/core/types/userOutcome.ts"() {
|
|
7027
|
+
"use strict";
|
|
7028
|
+
}
|
|
7029
|
+
});
|
|
7030
|
+
|
|
5575
7031
|
// src/core/bulkImport/cardProcessor.ts
|
|
5576
7032
|
async function importParsedCards(parsedCards, courseDB, config) {
|
|
5577
7033
|
const results = [];
|
|
@@ -5640,7 +7096,7 @@ elo: ${elo}`;
|
|
|
5640
7096
|
misc: {}
|
|
5641
7097
|
} : void 0
|
|
5642
7098
|
);
|
|
5643
|
-
if (result.status ===
|
|
7099
|
+
if (result.status === import_common20.Status.ok) {
|
|
5644
7100
|
return {
|
|
5645
7101
|
originalText,
|
|
5646
7102
|
status: "success",
|
|
@@ -5684,17 +7140,17 @@ function validateProcessorConfig(config) {
|
|
|
5684
7140
|
}
|
|
5685
7141
|
return { isValid: true };
|
|
5686
7142
|
}
|
|
5687
|
-
var
|
|
7143
|
+
var import_common20;
|
|
5688
7144
|
var init_cardProcessor = __esm({
|
|
5689
7145
|
"src/core/bulkImport/cardProcessor.ts"() {
|
|
5690
7146
|
"use strict";
|
|
5691
|
-
|
|
7147
|
+
import_common20 = require("@vue-skuilder/common");
|
|
5692
7148
|
init_logger();
|
|
5693
7149
|
}
|
|
5694
7150
|
});
|
|
5695
7151
|
|
|
5696
7152
|
// src/core/bulkImport/types.ts
|
|
5697
|
-
var
|
|
7153
|
+
var init_types3 = __esm({
|
|
5698
7154
|
"src/core/bulkImport/types.ts"() {
|
|
5699
7155
|
"use strict";
|
|
5700
7156
|
}
|
|
@@ -5705,7 +7161,7 @@ var init_bulkImport = __esm({
|
|
|
5705
7161
|
"src/core/bulkImport/index.ts"() {
|
|
5706
7162
|
"use strict";
|
|
5707
7163
|
init_cardProcessor();
|
|
5708
|
-
|
|
7164
|
+
init_types3();
|
|
5709
7165
|
}
|
|
5710
7166
|
});
|
|
5711
7167
|
|
|
@@ -5717,10 +7173,12 @@ var init_core = __esm({
|
|
|
5717
7173
|
init_types_legacy();
|
|
5718
7174
|
init_user();
|
|
5719
7175
|
init_strategyState();
|
|
7176
|
+
init_userOutcome();
|
|
5720
7177
|
init_Loggable();
|
|
5721
7178
|
init_util();
|
|
5722
7179
|
init_navigators();
|
|
5723
7180
|
init_bulkImport();
|
|
7181
|
+
init_orchestration();
|
|
5724
7182
|
}
|
|
5725
7183
|
});
|
|
5726
7184
|
|
|
@@ -5745,8 +7203,15 @@ __export(index_exports, {
|
|
|
5745
7203
|
StaticToCouchDBMigrator: () => StaticToCouchDBMigrator,
|
|
5746
7204
|
TagFilteredContentSource: () => TagFilteredContentSource,
|
|
5747
7205
|
_resetDataLayer: () => _resetDataLayer,
|
|
7206
|
+
aggregateOutcomesForGradient: () => aggregateOutcomesForGradient,
|
|
5748
7207
|
areQuestionRecords: () => areQuestionRecords,
|
|
5749
7208
|
buildStrategyStateId: () => buildStrategyStateId,
|
|
7209
|
+
computeDeviation: () => computeDeviation,
|
|
7210
|
+
computeEffectiveWeight: () => computeEffectiveWeight,
|
|
7211
|
+
computeOutcomeSignal: () => computeOutcomeSignal,
|
|
7212
|
+
computeSpread: () => computeSpread,
|
|
7213
|
+
computeStrategyGradient: () => computeStrategyGradient,
|
|
7214
|
+
createOrchestrationContext: () => createOrchestrationContext,
|
|
5750
7215
|
docIsDeleted: () => docIsDeleted,
|
|
5751
7216
|
ensureAppDataDirectory: () => ensureAppDataDirectory,
|
|
5752
7217
|
getAppDataDirectory: () => getAppDataDirectory,
|
|
@@ -5754,10 +7219,15 @@ __export(index_exports, {
|
|
|
5754
7219
|
getCardOrigin: () => getCardOrigin,
|
|
5755
7220
|
getDataLayer: () => getDataLayer,
|
|
5756
7221
|
getDbPath: () => getDbPath,
|
|
7222
|
+
getDefaultLearnableWeight: () => getDefaultLearnableWeight,
|
|
7223
|
+
getRegisteredNavigator: () => getRegisteredNavigator,
|
|
7224
|
+
getRegisteredNavigatorNames: () => getRegisteredNavigatorNames,
|
|
5757
7225
|
getStudySource: () => getStudySource,
|
|
7226
|
+
hasRegisteredNavigator: () => hasRegisteredNavigator,
|
|
5758
7227
|
importParsedCards: () => importParsedCards,
|
|
5759
7228
|
initializeDataDirectory: () => initializeDataDirectory,
|
|
5760
7229
|
initializeDataLayer: () => initializeDataLayer,
|
|
7230
|
+
initializeNavigatorRegistry: () => initializeNavigatorRegistry,
|
|
5761
7231
|
isDataShapeRegistered: () => isDataShapeRegistered,
|
|
5762
7232
|
isDataShapeSchemaAvailable: () => isDataShapeSchemaAvailable,
|
|
5763
7233
|
isFilter: () => isFilter,
|
|
@@ -5769,11 +7239,20 @@ __export(index_exports, {
|
|
|
5769
7239
|
newInterval: () => newInterval,
|
|
5770
7240
|
parseCardHistoryID: () => parseCardHistoryID,
|
|
5771
7241
|
processCustomQuestionsData: () => processCustomQuestionsData,
|
|
7242
|
+
recordUserOutcome: () => recordUserOutcome,
|
|
5772
7243
|
registerBlanksCard: () => registerBlanksCard,
|
|
5773
7244
|
registerCustomQuestionTypes: () => registerCustomQuestionTypes,
|
|
5774
7245
|
registerDataShape: () => registerDataShape,
|
|
7246
|
+
registerNavigator: () => registerNavigator,
|
|
5775
7247
|
registerQuestionType: () => registerQuestionType,
|
|
5776
7248
|
registerSeedData: () => registerSeedData,
|
|
7249
|
+
removeCustomQuestionTypes: () => removeCustomQuestionTypes,
|
|
7250
|
+
removeDataShape: () => removeDataShape,
|
|
7251
|
+
removeQuestionType: () => removeQuestionType,
|
|
7252
|
+
runPeriodUpdate: () => runPeriodUpdate,
|
|
7253
|
+
scoreAccuracyInZone: () => scoreAccuracyInZone,
|
|
7254
|
+
updateLearningState: () => updateLearningState,
|
|
7255
|
+
updateStrategyWeight: () => updateStrategyWeight,
|
|
5777
7256
|
validateMigration: () => validateMigration,
|
|
5778
7257
|
validateProcessorConfig: () => validateProcessorConfig,
|
|
5779
7258
|
validateStaticCourse: () => validateStaticCourse
|
|
@@ -5783,10 +7262,10 @@ init_core();
|
|
|
5783
7262
|
init_courseLookupDB();
|
|
5784
7263
|
|
|
5785
7264
|
// src/courseConfigRegistration.ts
|
|
5786
|
-
var
|
|
7265
|
+
var import_common21 = require("@vue-skuilder/common");
|
|
5787
7266
|
init_logger();
|
|
5788
7267
|
function isDataShapeRegistered(dataShape, courseConfig) {
|
|
5789
|
-
const namespacedName =
|
|
7268
|
+
const namespacedName = import_common21.NameSpacer.getDataShapeString({
|
|
5790
7269
|
dataShape: dataShape.name,
|
|
5791
7270
|
course: dataShape.course
|
|
5792
7271
|
});
|
|
@@ -5794,7 +7273,7 @@ function isDataShapeRegistered(dataShape, courseConfig) {
|
|
|
5794
7273
|
return existingDataShape !== void 0;
|
|
5795
7274
|
}
|
|
5796
7275
|
function isDataShapeSchemaAvailable(dataShape, courseConfig) {
|
|
5797
|
-
const namespacedName =
|
|
7276
|
+
const namespacedName = import_common21.NameSpacer.getDataShapeString({
|
|
5798
7277
|
dataShape: dataShape.name,
|
|
5799
7278
|
course: dataShape.course
|
|
5800
7279
|
});
|
|
@@ -5802,7 +7281,7 @@ function isDataShapeSchemaAvailable(dataShape, courseConfig) {
|
|
|
5802
7281
|
return existingDataShape !== void 0 && existingDataShape.serializedZodSchema !== void 0;
|
|
5803
7282
|
}
|
|
5804
7283
|
function isQuestionTypeRegistered(question, courseConfig) {
|
|
5805
|
-
const namespacedName =
|
|
7284
|
+
const namespacedName = import_common21.NameSpacer.getQuestionString({
|
|
5806
7285
|
course: question.course,
|
|
5807
7286
|
questionType: question.name
|
|
5808
7287
|
});
|
|
@@ -5834,13 +7313,13 @@ function processCustomQuestionsData(customQuestions) {
|
|
|
5834
7313
|
return { dataShapes: processedDataShapes, questions: processedQuestions };
|
|
5835
7314
|
}
|
|
5836
7315
|
function registerDataShape(dataShape, courseConfig) {
|
|
5837
|
-
const namespacedName =
|
|
7316
|
+
const namespacedName = import_common21.NameSpacer.getDataShapeString({
|
|
5838
7317
|
dataShape: dataShape.name,
|
|
5839
7318
|
course: dataShape.course
|
|
5840
7319
|
});
|
|
5841
7320
|
let serializedZodSchema;
|
|
5842
7321
|
try {
|
|
5843
|
-
serializedZodSchema = (0,
|
|
7322
|
+
serializedZodSchema = (0, import_common21.toZodJSON)(dataShape.dataShape);
|
|
5844
7323
|
} catch (error) {
|
|
5845
7324
|
logger.warn(`Failed to generate schema for ${namespacedName}:`, error);
|
|
5846
7325
|
serializedZodSchema = void 0;
|
|
@@ -5885,7 +7364,7 @@ function registerQuestionType(question, courseConfig) {
|
|
|
5885
7364
|
logger.info(`QuestionType '${question.name}' from '${question.course}' already registered`);
|
|
5886
7365
|
return false;
|
|
5887
7366
|
}
|
|
5888
|
-
const namespacedQuestionName =
|
|
7367
|
+
const namespacedQuestionName = import_common21.NameSpacer.getQuestionString({
|
|
5889
7368
|
course: question.course,
|
|
5890
7369
|
questionType: question.name
|
|
5891
7370
|
});
|
|
@@ -5897,7 +7376,7 @@ function registerQuestionType(question, courseConfig) {
|
|
|
5897
7376
|
}
|
|
5898
7377
|
});
|
|
5899
7378
|
const dataShapeList = question.dataShapes.map(
|
|
5900
|
-
(dataShape) =>
|
|
7379
|
+
(dataShape) => import_common21.NameSpacer.getDataShapeString({
|
|
5901
7380
|
course: question.course,
|
|
5902
7381
|
dataShape: dataShape.name
|
|
5903
7382
|
})
|
|
@@ -5908,7 +7387,7 @@ function registerQuestionType(question, courseConfig) {
|
|
|
5908
7387
|
dataShapeList
|
|
5909
7388
|
});
|
|
5910
7389
|
question.dataShapes.forEach((dataShape) => {
|
|
5911
|
-
const namespacedDataShapeName =
|
|
7390
|
+
const namespacedDataShapeName = import_common21.NameSpacer.getDataShapeString({
|
|
5912
7391
|
course: question.course,
|
|
5913
7392
|
dataShape: dataShape.name
|
|
5914
7393
|
});
|
|
@@ -5921,6 +7400,66 @@ function registerQuestionType(question, courseConfig) {
|
|
|
5921
7400
|
logger.info(`Registered QuestionType: ${namespacedQuestionName}`);
|
|
5922
7401
|
return true;
|
|
5923
7402
|
}
|
|
7403
|
+
function removeDataShape(dataShapeName, courseConfig) {
|
|
7404
|
+
const index = courseConfig.dataShapes.findIndex((ds) => ds.name === dataShapeName);
|
|
7405
|
+
if (index === -1) {
|
|
7406
|
+
logger.info(`DataShape '${dataShapeName}' not found in course config`);
|
|
7407
|
+
return false;
|
|
7408
|
+
}
|
|
7409
|
+
courseConfig.dataShapes.splice(index, 1);
|
|
7410
|
+
courseConfig.questionTypes.forEach((qt) => {
|
|
7411
|
+
const dsIndex = qt.dataShapeList.indexOf(dataShapeName);
|
|
7412
|
+
if (dsIndex !== -1) {
|
|
7413
|
+
qt.dataShapeList.splice(dsIndex, 1);
|
|
7414
|
+
}
|
|
7415
|
+
});
|
|
7416
|
+
logger.info(`Removed DataShape: ${dataShapeName}`);
|
|
7417
|
+
return true;
|
|
7418
|
+
}
|
|
7419
|
+
function removeQuestionType(questionTypeName, courseConfig) {
|
|
7420
|
+
const index = courseConfig.questionTypes.findIndex((qt) => qt.name === questionTypeName);
|
|
7421
|
+
if (index === -1) {
|
|
7422
|
+
logger.info(`QuestionType '${questionTypeName}' not found in course config`);
|
|
7423
|
+
return false;
|
|
7424
|
+
}
|
|
7425
|
+
courseConfig.questionTypes.splice(index, 1);
|
|
7426
|
+
courseConfig.dataShapes.forEach((ds) => {
|
|
7427
|
+
const qtIndex = ds.questionTypes.indexOf(questionTypeName);
|
|
7428
|
+
if (qtIndex !== -1) {
|
|
7429
|
+
ds.questionTypes.splice(qtIndex, 1);
|
|
7430
|
+
}
|
|
7431
|
+
});
|
|
7432
|
+
logger.info(`Removed QuestionType: ${questionTypeName}`);
|
|
7433
|
+
return true;
|
|
7434
|
+
}
|
|
7435
|
+
async function removeCustomQuestionTypes(dataShapeNames, questionTypeNames, courseConfig, courseDB) {
|
|
7436
|
+
try {
|
|
7437
|
+
logger.info("Beginning custom question removal");
|
|
7438
|
+
logger.info(`Removing ${dataShapeNames.length} data shapes and ${questionTypeNames.length} question types`);
|
|
7439
|
+
let removedCount = 0;
|
|
7440
|
+
for (const qtName of questionTypeNames) {
|
|
7441
|
+
if (removeQuestionType(qtName, courseConfig)) {
|
|
7442
|
+
removedCount++;
|
|
7443
|
+
}
|
|
7444
|
+
}
|
|
7445
|
+
for (const dsName of dataShapeNames) {
|
|
7446
|
+
if (removeDataShape(dsName, courseConfig)) {
|
|
7447
|
+
removedCount++;
|
|
7448
|
+
}
|
|
7449
|
+
}
|
|
7450
|
+
logger.info("Updating course configuration...");
|
|
7451
|
+
const updateResult = await courseDB.updateCourseConfig(courseConfig);
|
|
7452
|
+
if (!updateResult.ok) {
|
|
7453
|
+
throw new Error(`Failed to update course config: ${JSON.stringify(updateResult)}`);
|
|
7454
|
+
}
|
|
7455
|
+
logger.info(`Custom question removal complete: ${removedCount} items removed`);
|
|
7456
|
+
return { success: true, removedCount };
|
|
7457
|
+
} catch (error) {
|
|
7458
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
7459
|
+
logger.error(`Custom question removal failed: ${errorMessage}`);
|
|
7460
|
+
return { success: false, removedCount: 0, errorMessage };
|
|
7461
|
+
}
|
|
7462
|
+
}
|
|
5924
7463
|
async function registerSeedData(question, courseDB, username) {
|
|
5925
7464
|
if (question.questionClass.seedData && Array.isArray(question.questionClass.seedData)) {
|
|
5926
7465
|
logger.info(`Registering seed data for question: ${question.name}`);
|
|
@@ -6137,7 +7676,7 @@ var SrsService = class {
|
|
|
6137
7676
|
};
|
|
6138
7677
|
|
|
6139
7678
|
// src/study/services/EloService.ts
|
|
6140
|
-
var
|
|
7679
|
+
var import_common22 = require("@vue-skuilder/common");
|
|
6141
7680
|
init_logger();
|
|
6142
7681
|
var EloService = class {
|
|
6143
7682
|
dataLayer;
|
|
@@ -6160,10 +7699,10 @@ var EloService = class {
|
|
|
6160
7699
|
logger.warn(`k value interpretation not currently implemented`);
|
|
6161
7700
|
}
|
|
6162
7701
|
const courseDB = this.dataLayer.getCourseDB(currentCard.card.course_id);
|
|
6163
|
-
const userElo = (0,
|
|
7702
|
+
const userElo = (0, import_common22.toCourseElo)(userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo);
|
|
6164
7703
|
const cardElo = (await courseDB.getCardEloData([currentCard.card.card_id]))[0];
|
|
6165
7704
|
if (cardElo && userElo) {
|
|
6166
|
-
const eloUpdate = (0,
|
|
7705
|
+
const eloUpdate = (0, import_common22.adjustCourseScores)(userElo, cardElo, userScore);
|
|
6167
7706
|
userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo = eloUpdate.userElo;
|
|
6168
7707
|
const results = await Promise.allSettled([
|
|
6169
7708
|
this.user.updateUserElo(course_id, eloUpdate.userElo),
|
|
@@ -6365,7 +7904,7 @@ var ResponseProcessor = class {
|
|
|
6365
7904
|
};
|
|
6366
7905
|
|
|
6367
7906
|
// src/study/services/CardHydrationService.ts
|
|
6368
|
-
var
|
|
7907
|
+
var import_common23 = require("@vue-skuilder/common");
|
|
6369
7908
|
init_logger();
|
|
6370
7909
|
function parseAudioURIs(data) {
|
|
6371
7910
|
if (typeof data !== "string") return [];
|
|
@@ -6500,8 +8039,8 @@ var CardHydrationService = class {
|
|
|
6500
8039
|
try {
|
|
6501
8040
|
const courseDB = this.getCourseDB(item.courseID);
|
|
6502
8041
|
const cardData = await courseDB.getCourseDoc(item.cardID);
|
|
6503
|
-
if (!(0,
|
|
6504
|
-
cardData.elo = (0,
|
|
8042
|
+
if (!(0, import_common23.isCourseElo)(cardData.elo)) {
|
|
8043
|
+
cardData.elo = (0, import_common23.toCourseElo)(cardData.elo);
|
|
6505
8044
|
}
|
|
6506
8045
|
const view = this.getViewComponent(cardData.id_view);
|
|
6507
8046
|
const dataDocs = await Promise.all(
|
|
@@ -6525,7 +8064,7 @@ var CardHydrationService = class {
|
|
|
6525
8064
|
);
|
|
6526
8065
|
await Promise.allSettled(uniqueAudioUrls.map(prefetchAudio));
|
|
6527
8066
|
}
|
|
6528
|
-
const data = dataDocs.map(
|
|
8067
|
+
const data = dataDocs.map(import_common23.displayableDataToViewData).reverse();
|
|
6529
8068
|
this.hydratedCards.set(item.cardID, {
|
|
6530
8069
|
item,
|
|
6531
8070
|
view,
|
|
@@ -6586,6 +8125,7 @@ var ItemQueue = class {
|
|
|
6586
8125
|
|
|
6587
8126
|
// src/study/SessionController.ts
|
|
6588
8127
|
init_couch();
|
|
8128
|
+
init_recording();
|
|
6589
8129
|
|
|
6590
8130
|
// src/util/index.ts
|
|
6591
8131
|
init_Loggable();
|
|
@@ -8433,6 +9973,49 @@ var SessionController = class extends Loggable {
|
|
|
8433
9973
|
this.failedQ.dequeue((queueItem) => queueItem.cardID);
|
|
8434
9974
|
}
|
|
8435
9975
|
}
|
|
9976
|
+
/**
|
|
9977
|
+
* End the session and record learning outcomes.
|
|
9978
|
+
*
|
|
9979
|
+
* This method aggregates all responses from the session and records a
|
|
9980
|
+
* UserOutcomeRecord if evolutionary orchestration is enabled.
|
|
9981
|
+
*/
|
|
9982
|
+
async endSession() {
|
|
9983
|
+
if (!this._sessionRecord || this._sessionRecord.length === 0) {
|
|
9984
|
+
return;
|
|
9985
|
+
}
|
|
9986
|
+
const questionRecords = this._sessionRecord.flatMap((r) => r.records).filter((r) => r.userAnswer !== void 0);
|
|
9987
|
+
if (questionRecords.length === 0) {
|
|
9988
|
+
return;
|
|
9989
|
+
}
|
|
9990
|
+
let orchestrationContext = null;
|
|
9991
|
+
const strategies = [];
|
|
9992
|
+
for (const source of this.sources) {
|
|
9993
|
+
if (source.getOrchestrationContext) {
|
|
9994
|
+
try {
|
|
9995
|
+
orchestrationContext = await source.getOrchestrationContext();
|
|
9996
|
+
if (source.getStrategyIds) {
|
|
9997
|
+
strategies.push(...source.getStrategyIds());
|
|
9998
|
+
}
|
|
9999
|
+
} catch (e) {
|
|
10000
|
+
logger.warn(`[SessionController] Failed to get orchestration context: ${e}`);
|
|
10001
|
+
}
|
|
10002
|
+
if (orchestrationContext) break;
|
|
10003
|
+
}
|
|
10004
|
+
}
|
|
10005
|
+
if (!orchestrationContext) {
|
|
10006
|
+
logger.debug("[SessionController] No orchestration context available, skipping outcome recording");
|
|
10007
|
+
return;
|
|
10008
|
+
}
|
|
10009
|
+
const periodEnd = (/* @__PURE__ */ new Date()).toISOString();
|
|
10010
|
+
const periodStart = new Date(this.startTime).toISOString();
|
|
10011
|
+
await recordUserOutcome(
|
|
10012
|
+
orchestrationContext,
|
|
10013
|
+
periodStart,
|
|
10014
|
+
periodEnd,
|
|
10015
|
+
questionRecords,
|
|
10016
|
+
strategies
|
|
10017
|
+
);
|
|
10018
|
+
}
|
|
8436
10019
|
};
|
|
8437
10020
|
|
|
8438
10021
|
// src/study/index.ts
|
|
@@ -8460,8 +10043,15 @@ init_factory();
|
|
|
8460
10043
|
StaticToCouchDBMigrator,
|
|
8461
10044
|
TagFilteredContentSource,
|
|
8462
10045
|
_resetDataLayer,
|
|
10046
|
+
aggregateOutcomesForGradient,
|
|
8463
10047
|
areQuestionRecords,
|
|
8464
10048
|
buildStrategyStateId,
|
|
10049
|
+
computeDeviation,
|
|
10050
|
+
computeEffectiveWeight,
|
|
10051
|
+
computeOutcomeSignal,
|
|
10052
|
+
computeSpread,
|
|
10053
|
+
computeStrategyGradient,
|
|
10054
|
+
createOrchestrationContext,
|
|
8465
10055
|
docIsDeleted,
|
|
8466
10056
|
ensureAppDataDirectory,
|
|
8467
10057
|
getAppDataDirectory,
|
|
@@ -8469,10 +10059,15 @@ init_factory();
|
|
|
8469
10059
|
getCardOrigin,
|
|
8470
10060
|
getDataLayer,
|
|
8471
10061
|
getDbPath,
|
|
10062
|
+
getDefaultLearnableWeight,
|
|
10063
|
+
getRegisteredNavigator,
|
|
10064
|
+
getRegisteredNavigatorNames,
|
|
8472
10065
|
getStudySource,
|
|
10066
|
+
hasRegisteredNavigator,
|
|
8473
10067
|
importParsedCards,
|
|
8474
10068
|
initializeDataDirectory,
|
|
8475
10069
|
initializeDataLayer,
|
|
10070
|
+
initializeNavigatorRegistry,
|
|
8476
10071
|
isDataShapeRegistered,
|
|
8477
10072
|
isDataShapeSchemaAvailable,
|
|
8478
10073
|
isFilter,
|
|
@@ -8484,11 +10079,20 @@ init_factory();
|
|
|
8484
10079
|
newInterval,
|
|
8485
10080
|
parseCardHistoryID,
|
|
8486
10081
|
processCustomQuestionsData,
|
|
10082
|
+
recordUserOutcome,
|
|
8487
10083
|
registerBlanksCard,
|
|
8488
10084
|
registerCustomQuestionTypes,
|
|
8489
10085
|
registerDataShape,
|
|
10086
|
+
registerNavigator,
|
|
8490
10087
|
registerQuestionType,
|
|
8491
10088
|
registerSeedData,
|
|
10089
|
+
removeCustomQuestionTypes,
|
|
10090
|
+
removeDataShape,
|
|
10091
|
+
removeQuestionType,
|
|
10092
|
+
runPeriodUpdate,
|
|
10093
|
+
scoreAccuracyInZone,
|
|
10094
|
+
updateLearningState,
|
|
10095
|
+
updateStrategyWeight,
|
|
8492
10096
|
validateMigration,
|
|
8493
10097
|
validateProcessorConfig,
|
|
8494
10098
|
validateStaticCourse
|