@vue-skuilder/db 0.1.23 → 0.1.25
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-BmnmvH8C.d.ts} +268 -3
- package/dist/{contentSource-DsJadoBU.d.cts → contentSource-DfBbaLA-.d.cts} +268 -3
- package/dist/core/index.d.cts +310 -6
- package/dist/core/index.d.ts +310 -6
- package/dist/core/index.js +2606 -666
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +2564 -639
- package/dist/core/index.mjs.map +1 -1
- package/dist/{dataLayerProvider-CHYrQ5pB.d.cts → dataLayerProvider-BeRXVMs5.d.cts} +1 -1
- package/dist/{dataLayerProvider-MDTxXq2l.d.ts → dataLayerProvider-CG9GfaAY.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 +2336 -656
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +2316 -631
- 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 +2312 -632
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +2315 -630
- 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 +278 -20
- package/dist/index.d.ts +278 -20
- package/dist/index.js +3603 -720
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +3529 -674
- 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 +210 -9
- package/docs/todo-review-urgency-adaptation.md +205 -0
- 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 +50 -0
- package/src/core/navigators/Pipeline.ts +132 -5
- package/src/core/navigators/PipelineAssembler.ts +21 -22
- package/src/core/navigators/PipelineDebugger.ts +426 -0
- 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 +82 -19
- package/src/core/navigators/generators/elo.ts +14 -1
- package/src/core/navigators/generators/srs.ts +146 -18
- package/src/core/navigators/generators/types.ts +4 -0
- package/src/core/navigators/index.ts +203 -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/impl/couch/user-course-relDB.ts +12 -0
- package/src/study/MixerDebugger.ts +555 -0
- package/src/study/SessionController.ts +159 -20
- package/src/study/SessionDebugger.ts +442 -0
- package/src/study/SourceMixer.ts +36 -17
- package/src/study/TODO-session-scheduling.md +133 -0
- package/src/study/index.ts +2 -0
- package/src/study/services/EloService.ts +79 -4
- package/src/study/services/ResponseProcessor.ts +130 -72
- package/src/study/services/SrsService.ts +9 -0
- package/tests/core/navigators/Pipeline.test.ts +2 -0
- package/tests/core/navigators/PipelineAssembler.test.ts +4 -4
- package/docs/todo-evolutionary-orchestration.md +0 -310
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
var __defProp = Object.defineProperty;
|
|
2
2
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
3
|
+
var __glob = (map) => (path2) => {
|
|
4
|
+
var fn = map[path2];
|
|
5
|
+
if (fn) return fn();
|
|
6
|
+
throw new Error("Module not found in bundle: " + path2);
|
|
7
|
+
};
|
|
3
8
|
var __esm = (fn, res) => function __init() {
|
|
4
9
|
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
5
10
|
};
|
|
@@ -96,6 +101,8 @@ var init_types_legacy = __esm({
|
|
|
96
101
|
DocType3["TAG"] = "TAG";
|
|
97
102
|
DocType3["NAVIGATION_STRATEGY"] = "NAVIGATION_STRATEGY";
|
|
98
103
|
DocType3["STRATEGY_STATE"] = "STRATEGY_STATE";
|
|
104
|
+
DocType3["USER_OUTCOME"] = "USER_OUTCOME";
|
|
105
|
+
DocType3["STRATEGY_LEARNING_STATE"] = "STRATEGY_LEARNING_STATE";
|
|
99
106
|
return DocType3;
|
|
100
107
|
})(DocType || {});
|
|
101
108
|
DocTypePrefixes = {
|
|
@@ -110,7 +117,9 @@ var init_types_legacy = __esm({
|
|
|
110
117
|
["VIEW" /* VIEW */]: "VIEW",
|
|
111
118
|
["PEDAGOGY" /* PEDAGOGY */]: "PEDAGOGY",
|
|
112
119
|
["NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */]: "NAVIGATION_STRATEGY",
|
|
113
|
-
["STRATEGY_STATE" /* STRATEGY_STATE */]: "STRATEGY_STATE"
|
|
120
|
+
["STRATEGY_STATE" /* STRATEGY_STATE */]: "STRATEGY_STATE",
|
|
121
|
+
["USER_OUTCOME" /* USER_OUTCOME */]: "USER_OUTCOME",
|
|
122
|
+
["STRATEGY_LEARNING_STATE" /* STRATEGY_LEARNING_STATE */]: "STRATEGY_LEARNING_STATE"
|
|
114
123
|
};
|
|
115
124
|
}
|
|
116
125
|
});
|
|
@@ -481,6 +490,15 @@ var init_user_course_relDB = __esm({
|
|
|
481
490
|
void this.user.updateCourseSettings(this._courseId, updates);
|
|
482
491
|
}
|
|
483
492
|
}
|
|
493
|
+
async getStrategyState(strategyKey) {
|
|
494
|
+
return this.user.getStrategyState(this._courseId, strategyKey);
|
|
495
|
+
}
|
|
496
|
+
async putStrategyState(strategyKey, data) {
|
|
497
|
+
return this.user.putStrategyState(this._courseId, strategyKey, data);
|
|
498
|
+
}
|
|
499
|
+
async deleteStrategyState(strategyKey) {
|
|
500
|
+
return this.user.deleteStrategyState(this._courseId, strategyKey);
|
|
501
|
+
}
|
|
484
502
|
async getReviewstoDate(targetDate) {
|
|
485
503
|
const allReviews = await this.user.getPendingReviews(this._courseId);
|
|
486
504
|
logger.debug(
|
|
@@ -831,359 +849,289 @@ var init_courseLookupDB = __esm({
|
|
|
831
849
|
}
|
|
832
850
|
});
|
|
833
851
|
|
|
834
|
-
// src/core/navigators/
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
852
|
+
// src/core/navigators/PipelineDebugger.ts
|
|
853
|
+
var PipelineDebugger_exports = {};
|
|
854
|
+
__export(PipelineDebugger_exports, {
|
|
855
|
+
buildRunReport: () => buildRunReport,
|
|
856
|
+
captureRun: () => captureRun,
|
|
857
|
+
mountPipelineDebugger: () => mountPipelineDebugger,
|
|
858
|
+
pipelineDebugAPI: () => pipelineDebugAPI
|
|
859
|
+
});
|
|
860
|
+
function getOrigin(card) {
|
|
839
861
|
const firstEntry = card.provenance[0];
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
862
|
+
if (!firstEntry) return "unknown";
|
|
863
|
+
const reason = firstEntry.reason?.toLowerCase() || "";
|
|
864
|
+
if (reason.includes("new card")) return "new";
|
|
865
|
+
if (reason.includes("review")) return "review";
|
|
866
|
+
return "unknown";
|
|
867
|
+
}
|
|
868
|
+
function captureRun(report) {
|
|
869
|
+
const fullReport = {
|
|
870
|
+
...report,
|
|
871
|
+
runId: `run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
872
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
873
|
+
};
|
|
874
|
+
runHistory.unshift(fullReport);
|
|
875
|
+
if (runHistory.length > MAX_RUNS) {
|
|
876
|
+
runHistory.pop();
|
|
846
877
|
}
|
|
847
|
-
return "new";
|
|
848
878
|
}
|
|
849
|
-
function
|
|
850
|
-
|
|
879
|
+
function buildRunReport(courseId, courseName, generatorName, generators, generatedCount, filters, allCards, selectedCards) {
|
|
880
|
+
const selectedIds = new Set(selectedCards.map((c) => c.cardId));
|
|
881
|
+
const cards = allCards.map((card) => ({
|
|
882
|
+
cardId: card.cardId,
|
|
883
|
+
courseId: card.courseId,
|
|
884
|
+
origin: getOrigin(card),
|
|
885
|
+
finalScore: card.score,
|
|
886
|
+
provenance: card.provenance,
|
|
887
|
+
selected: selectedIds.has(card.cardId)
|
|
888
|
+
}));
|
|
889
|
+
const reviewsSelected = selectedCards.filter((c) => getOrigin(c) === "review").length;
|
|
890
|
+
const newSelected = selectedCards.filter((c) => getOrigin(c) === "new").length;
|
|
891
|
+
return {
|
|
892
|
+
courseId,
|
|
893
|
+
courseName,
|
|
894
|
+
generatorName,
|
|
895
|
+
generators,
|
|
896
|
+
generatedCount,
|
|
897
|
+
filters,
|
|
898
|
+
finalCount: selectedCards.length,
|
|
899
|
+
reviewsSelected,
|
|
900
|
+
newSelected,
|
|
901
|
+
cards
|
|
902
|
+
};
|
|
851
903
|
}
|
|
852
|
-
function
|
|
853
|
-
return
|
|
904
|
+
function formatProvenance(provenance) {
|
|
905
|
+
return provenance.map((p) => {
|
|
906
|
+
const actionSymbol = p.action === "generated" ? "\u{1F3B2}" : p.action === "boosted" ? "\u2B06\uFE0F" : p.action === "penalized" ? "\u2B07\uFE0F" : "\u27A1\uFE0F";
|
|
907
|
+
return ` ${actionSymbol} ${p.strategyName}: ${p.score.toFixed(3)} - ${p.reason}`;
|
|
908
|
+
}).join("\n");
|
|
854
909
|
}
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
910
|
+
function printRunSummary(run) {
|
|
911
|
+
console.group(`\u{1F50D} Pipeline Run: ${run.courseId} (${run.courseName || "unnamed"})`);
|
|
912
|
+
logger.info(`Run ID: ${run.runId}`);
|
|
913
|
+
logger.info(`Time: ${run.timestamp.toISOString()}`);
|
|
914
|
+
logger.info(`Generator: ${run.generatorName} \u2192 ${run.generatedCount} candidates`);
|
|
915
|
+
if (run.generators && run.generators.length > 0) {
|
|
916
|
+
console.group("Generator breakdown:");
|
|
917
|
+
for (const g of run.generators) {
|
|
918
|
+
logger.info(
|
|
919
|
+
` ${g.name}: ${g.cardCount} cards (${g.newCount} new, ${g.reviewCount} reviews, top: ${g.topScore.toFixed(2)})`
|
|
920
|
+
);
|
|
921
|
+
}
|
|
922
|
+
console.groupEnd();
|
|
923
|
+
}
|
|
924
|
+
if (run.filters.length > 0) {
|
|
925
|
+
console.group("Filter impact:");
|
|
926
|
+
for (const f of run.filters) {
|
|
927
|
+
logger.info(` ${f.name}: \u2191${f.boosted} \u2193${f.penalized} =${f.passed} \u2715${f.removed}`);
|
|
928
|
+
}
|
|
929
|
+
console.groupEnd();
|
|
930
|
+
}
|
|
931
|
+
logger.info(
|
|
932
|
+
`Result: ${run.finalCount} cards selected (${run.newSelected} new, ${run.reviewsSelected} reviews)`
|
|
933
|
+
);
|
|
934
|
+
console.groupEnd();
|
|
935
|
+
}
|
|
936
|
+
function mountPipelineDebugger() {
|
|
937
|
+
if (typeof window === "undefined") return;
|
|
938
|
+
const win = window;
|
|
939
|
+
win.skuilder = win.skuilder || {};
|
|
940
|
+
win.skuilder.pipeline = pipelineDebugAPI;
|
|
941
|
+
}
|
|
942
|
+
var MAX_RUNS, runHistory, pipelineDebugAPI;
|
|
943
|
+
var init_PipelineDebugger = __esm({
|
|
944
|
+
"src/core/navigators/PipelineDebugger.ts"() {
|
|
858
945
|
"use strict";
|
|
859
946
|
init_logger();
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
Navigators2["HIERARCHY"] = "hierarchyDefinition";
|
|
864
|
-
Navigators2["INTERFERENCE"] = "interferenceMitigator";
|
|
865
|
-
Navigators2["RELATIVE_PRIORITY"] = "relativePriority";
|
|
866
|
-
Navigators2["USER_TAG_PREFERENCE"] = "userTagPreference";
|
|
867
|
-
return Navigators2;
|
|
868
|
-
})(Navigators || {});
|
|
869
|
-
NavigatorRole = /* @__PURE__ */ ((NavigatorRole2) => {
|
|
870
|
-
NavigatorRole2["GENERATOR"] = "generator";
|
|
871
|
-
NavigatorRole2["FILTER"] = "filter";
|
|
872
|
-
return NavigatorRole2;
|
|
873
|
-
})(NavigatorRole || {});
|
|
874
|
-
NavigatorRoles = {
|
|
875
|
-
["elo" /* ELO */]: "generator" /* GENERATOR */,
|
|
876
|
-
["srs" /* SRS */]: "generator" /* GENERATOR */,
|
|
877
|
-
["hierarchyDefinition" /* HIERARCHY */]: "filter" /* FILTER */,
|
|
878
|
-
["interferenceMitigator" /* INTERFERENCE */]: "filter" /* FILTER */,
|
|
879
|
-
["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */,
|
|
880
|
-
["userTagPreference" /* USER_TAG_PREFERENCE */]: "filter" /* FILTER */
|
|
881
|
-
};
|
|
882
|
-
ContentNavigator = class {
|
|
883
|
-
/** User interface for this navigation session */
|
|
884
|
-
user;
|
|
885
|
-
/** Course interface for this navigation session */
|
|
886
|
-
course;
|
|
887
|
-
/** Human-readable name for this strategy instance (from ContentNavigationStrategyData.name) */
|
|
888
|
-
strategyName;
|
|
889
|
-
/** Unique document ID for this strategy instance (from ContentNavigationStrategyData._id) */
|
|
890
|
-
strategyId;
|
|
947
|
+
MAX_RUNS = 10;
|
|
948
|
+
runHistory = [];
|
|
949
|
+
pipelineDebugAPI = {
|
|
891
950
|
/**
|
|
892
|
-
*
|
|
893
|
-
* Call this from subclass constructors to initialize common fields.
|
|
894
|
-
*
|
|
895
|
-
* Note: CompositeGenerator and Pipeline call super() without args, then set
|
|
896
|
-
* user/course fields directly if needed.
|
|
951
|
+
* Get raw run history for programmatic access.
|
|
897
952
|
*/
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
if (strategyData) {
|
|
902
|
-
this.strategyName = strategyData.name;
|
|
903
|
-
this.strategyId = strategyData._id;
|
|
904
|
-
}
|
|
905
|
-
}
|
|
906
|
-
// ============================================================================
|
|
907
|
-
// STRATEGY STATE HELPERS
|
|
908
|
-
// ============================================================================
|
|
909
|
-
//
|
|
910
|
-
// These methods allow strategies to persist their own state (user preferences,
|
|
911
|
-
// learned patterns, temporal tracking) in the user database.
|
|
912
|
-
//
|
|
913
|
-
// ============================================================================
|
|
953
|
+
get runs() {
|
|
954
|
+
return [...runHistory];
|
|
955
|
+
},
|
|
914
956
|
/**
|
|
915
|
-
*
|
|
916
|
-
*
|
|
917
|
-
* Defaults to the constructor name (e.g., "UserTagPreferenceFilter").
|
|
918
|
-
* Override in subclasses if multiple instances of the same strategy type
|
|
919
|
-
* need separate state storage.
|
|
957
|
+
* Show summary of a specific pipeline run.
|
|
920
958
|
*/
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
959
|
+
showRun(idOrIndex = 0) {
|
|
960
|
+
if (runHistory.length === 0) {
|
|
961
|
+
logger.info("[Pipeline Debug] No runs captured yet.");
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
let run;
|
|
965
|
+
if (typeof idOrIndex === "number") {
|
|
966
|
+
run = runHistory[idOrIndex];
|
|
967
|
+
if (!run) {
|
|
968
|
+
logger.info(
|
|
969
|
+
`[Pipeline Debug] No run found at index ${idOrIndex}. History length: ${runHistory.length}`
|
|
970
|
+
);
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
} else {
|
|
974
|
+
run = runHistory.find((r) => r.runId.endsWith(idOrIndex));
|
|
975
|
+
if (!run) {
|
|
976
|
+
logger.info(`[Pipeline Debug] No run found matching ID '${idOrIndex}'.`);
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
printRunSummary(run);
|
|
981
|
+
},
|
|
924
982
|
/**
|
|
925
|
-
*
|
|
926
|
-
*
|
|
927
|
-
* @returns The strategy's data payload, or null if no state exists
|
|
928
|
-
* @throws Error if user or course is not initialized
|
|
983
|
+
* Show summary of the last pipeline run.
|
|
929
984
|
*/
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
`Cannot get strategy state: navigator not properly initialized. Ensure user and course are provided to constructor.`
|
|
934
|
-
);
|
|
935
|
-
}
|
|
936
|
-
return this.user.getStrategyState(this.course.getCourseID(), this.strategyKey);
|
|
937
|
-
}
|
|
985
|
+
showLastRun() {
|
|
986
|
+
this.showRun(0);
|
|
987
|
+
},
|
|
938
988
|
/**
|
|
939
|
-
*
|
|
940
|
-
*
|
|
941
|
-
* @param data - The strategy's data payload to store
|
|
942
|
-
* @throws Error if user or course is not initialized
|
|
989
|
+
* Show detailed provenance for a specific card.
|
|
943
990
|
*/
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
991
|
+
showCard(cardId) {
|
|
992
|
+
for (const run of runHistory) {
|
|
993
|
+
const card = run.cards.find((c) => c.cardId === cardId);
|
|
994
|
+
if (card) {
|
|
995
|
+
console.group(`\u{1F3B4} Card: ${cardId}`);
|
|
996
|
+
logger.info(`Course: ${card.courseId}`);
|
|
997
|
+
logger.info(`Origin: ${card.origin}`);
|
|
998
|
+
logger.info(`Final score: ${card.finalScore.toFixed(3)}`);
|
|
999
|
+
logger.info(`Selected: ${card.selected ? "Yes \u2705" : "No \u274C"}`);
|
|
1000
|
+
logger.info("Provenance:");
|
|
1001
|
+
logger.info(formatProvenance(card.provenance));
|
|
1002
|
+
console.groupEnd();
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
949
1005
|
}
|
|
950
|
-
|
|
951
|
-
}
|
|
1006
|
+
logger.info(`[Pipeline Debug] Card '${cardId}' not found in recent runs.`);
|
|
1007
|
+
},
|
|
952
1008
|
/**
|
|
953
|
-
*
|
|
954
|
-
*
|
|
955
|
-
* @param user - User interface
|
|
956
|
-
* @param course - Course interface
|
|
957
|
-
* @param strategyData - Strategy configuration document
|
|
958
|
-
* @returns the runtime object used to steer a study session.
|
|
1009
|
+
* Explain why reviews may or may not have been selected.
|
|
959
1010
|
*/
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
1011
|
+
explainReviews() {
|
|
1012
|
+
if (runHistory.length === 0) {
|
|
1013
|
+
logger.info("[Pipeline Debug] No runs captured yet.");
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
console.group("\u{1F4CB} Review Selection Analysis");
|
|
1017
|
+
for (const run of runHistory) {
|
|
1018
|
+
console.group(`Run: ${run.courseId} @ ${run.timestamp.toLocaleTimeString()}`);
|
|
1019
|
+
const allReviews = run.cards.filter((c) => c.origin === "review");
|
|
1020
|
+
const selectedReviews = allReviews.filter((c) => c.selected);
|
|
1021
|
+
if (allReviews.length === 0) {
|
|
1022
|
+
logger.info("\u274C No reviews were generated. Check SRS logs for why.");
|
|
1023
|
+
} else if (selectedReviews.length === 0) {
|
|
1024
|
+
logger.info(`\u26A0\uFE0F ${allReviews.length} reviews generated but none selected.`);
|
|
1025
|
+
logger.info("Possible reasons:");
|
|
1026
|
+
const topNewScore = Math.max(
|
|
1027
|
+
...run.cards.filter((c) => c.origin === "new" && c.selected).map((c) => c.finalScore),
|
|
1028
|
+
0
|
|
1029
|
+
);
|
|
1030
|
+
const topReviewScore = Math.max(...allReviews.map((c) => c.finalScore), 0);
|
|
1031
|
+
if (topReviewScore < topNewScore) {
|
|
1032
|
+
logger.info(
|
|
1033
|
+
` - New cards scored higher (top new: ${topNewScore.toFixed(2)}, top review: ${topReviewScore.toFixed(2)})`
|
|
1034
|
+
);
|
|
1035
|
+
}
|
|
1036
|
+
const topReview = allReviews.sort((a, b) => b.finalScore - a.finalScore)[0];
|
|
1037
|
+
if (topReview) {
|
|
1038
|
+
logger.info(` - Top review score: ${topReview.finalScore.toFixed(3)}`);
|
|
1039
|
+
logger.info(" - Its provenance:");
|
|
1040
|
+
logger.info(formatProvenance(topReview.provenance));
|
|
974
1041
|
}
|
|
1042
|
+
} else {
|
|
1043
|
+
logger.info(`\u2705 ${selectedReviews.length}/${allReviews.length} reviews selected.`);
|
|
1044
|
+
logger.info("Top selected review:");
|
|
1045
|
+
const topSelected = selectedReviews.sort((a, b) => b.finalScore - a.finalScore)[0];
|
|
1046
|
+
logger.info(formatProvenance(topSelected.provenance));
|
|
975
1047
|
}
|
|
1048
|
+
console.groupEnd();
|
|
976
1049
|
}
|
|
977
|
-
|
|
978
|
-
|
|
1050
|
+
console.groupEnd();
|
|
1051
|
+
},
|
|
1052
|
+
/**
|
|
1053
|
+
* Show all runs in compact format.
|
|
1054
|
+
*/
|
|
1055
|
+
listRuns() {
|
|
1056
|
+
if (runHistory.length === 0) {
|
|
1057
|
+
logger.info("[Pipeline Debug] No runs captured yet.");
|
|
1058
|
+
return;
|
|
979
1059
|
}
|
|
980
|
-
|
|
981
|
-
|
|
1060
|
+
console.table(
|
|
1061
|
+
runHistory.map((r) => ({
|
|
1062
|
+
id: r.runId.slice(-8),
|
|
1063
|
+
time: r.timestamp.toLocaleTimeString(),
|
|
1064
|
+
course: r.courseName || r.courseId.slice(0, 8),
|
|
1065
|
+
generated: r.generatedCount,
|
|
1066
|
+
selected: r.finalCount,
|
|
1067
|
+
new: r.newSelected,
|
|
1068
|
+
reviews: r.reviewsSelected
|
|
1069
|
+
}))
|
|
1070
|
+
);
|
|
1071
|
+
},
|
|
982
1072
|
/**
|
|
983
|
-
*
|
|
984
|
-
*
|
|
985
|
-
* **This is the PRIMARY API for navigation strategies.**
|
|
986
|
-
*
|
|
987
|
-
* Returns cards ranked by suitability score (0-1). Higher scores indicate
|
|
988
|
-
* better candidates for presentation. Each card includes a provenance trail
|
|
989
|
-
* documenting how strategies contributed to the final score.
|
|
990
|
-
*
|
|
991
|
-
* ## Implementation Required
|
|
992
|
-
* All navigation strategies MUST override this method. The base class does
|
|
993
|
-
* not provide a default implementation.
|
|
994
|
-
*
|
|
995
|
-
* ## For Generators
|
|
996
|
-
* Override this method to generate candidates and compute scores based on
|
|
997
|
-
* your strategy's logic (e.g., ELO proximity, review urgency). Create the
|
|
998
|
-
* initial provenance entry with action='generated'.
|
|
999
|
-
*
|
|
1000
|
-
* ## For Filters
|
|
1001
|
-
* Filters should implement the CardFilter interface instead and be composed
|
|
1002
|
-
* via Pipeline. Filters do not directly implement getWeightedCards().
|
|
1003
|
-
*
|
|
1004
|
-
* @param limit - Maximum cards to return
|
|
1005
|
-
* @returns Cards sorted by score descending, with provenance trails
|
|
1073
|
+
* Export run history as JSON for bug reports.
|
|
1006
1074
|
*/
|
|
1007
|
-
|
|
1008
|
-
|
|
1075
|
+
export() {
|
|
1076
|
+
const json = JSON.stringify(runHistory, null, 2);
|
|
1077
|
+
logger.info("[Pipeline Debug] Run history exported. Copy the returned string or use:");
|
|
1078
|
+
logger.info(" copy(window.skuilder.pipeline.export())");
|
|
1079
|
+
return json;
|
|
1080
|
+
},
|
|
1081
|
+
/**
|
|
1082
|
+
* Clear run history.
|
|
1083
|
+
*/
|
|
1084
|
+
clear() {
|
|
1085
|
+
runHistory.length = 0;
|
|
1086
|
+
logger.info("[Pipeline Debug] Run history cleared.");
|
|
1087
|
+
},
|
|
1088
|
+
/**
|
|
1089
|
+
* Show help.
|
|
1090
|
+
*/
|
|
1091
|
+
help() {
|
|
1092
|
+
logger.info(`
|
|
1093
|
+
\u{1F527} Pipeline Debug API
|
|
1094
|
+
|
|
1095
|
+
Commands:
|
|
1096
|
+
.showLastRun() Show summary of most recent pipeline run
|
|
1097
|
+
.showRun(id|index) Show summary of a specific run (by index or ID suffix)
|
|
1098
|
+
.showCard(cardId) Show provenance trail for a specific card
|
|
1099
|
+
.explainReviews() Analyze why reviews were/weren't selected
|
|
1100
|
+
.listRuns() List all captured runs in table format
|
|
1101
|
+
.export() Export run history as JSON for bug reports
|
|
1102
|
+
.clear() Clear run history
|
|
1103
|
+
.runs Access raw run history array
|
|
1104
|
+
.help() Show this help message
|
|
1105
|
+
|
|
1106
|
+
Example:
|
|
1107
|
+
window.skuilder.pipeline.showLastRun()
|
|
1108
|
+
window.skuilder.pipeline.showRun(1)
|
|
1109
|
+
window.skuilder.pipeline.showCard('abc123')
|
|
1110
|
+
`);
|
|
1009
1111
|
}
|
|
1010
1112
|
};
|
|
1113
|
+
mountPipelineDebugger();
|
|
1011
1114
|
}
|
|
1012
1115
|
});
|
|
1013
1116
|
|
|
1014
|
-
// src/core/navigators/
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
)
|
|
1023
|
-
}
|
|
1024
|
-
function logTagHydration(cards, tagsByCard) {
|
|
1025
|
-
const totalTags = Array.from(tagsByCard.values()).reduce((sum, tags) => sum + tags.length, 0);
|
|
1026
|
-
const cardsWithTags = Array.from(tagsByCard.values()).filter((tags) => tags.length > 0).length;
|
|
1027
|
-
logger.debug(
|
|
1028
|
-
`[Pipeline] Tag hydration: ${cards.length} cards, ${cardsWithTags} have tags (${totalTags} total tags) - single batch query`
|
|
1029
|
-
);
|
|
1030
|
-
}
|
|
1031
|
-
function logExecutionSummary(generatorName, generatedCount, filterCount, finalCount, topScores) {
|
|
1032
|
-
const scoreDisplay = topScores.length > 0 ? topScores.map((s) => s.toFixed(2)).join(", ") : "none";
|
|
1033
|
-
logger.info(
|
|
1034
|
-
`[Pipeline] Execution: ${generatorName} produced ${generatedCount} \u2192 ${filterCount} filters \u2192 ${finalCount} results (top scores: ${scoreDisplay})`
|
|
1035
|
-
);
|
|
1036
|
-
}
|
|
1037
|
-
function logCardProvenance(cards, maxCards = 3) {
|
|
1038
|
-
const cardsToLog = cards.slice(0, maxCards);
|
|
1039
|
-
logger.debug(`[Pipeline] Provenance for top ${cardsToLog.length} cards:`);
|
|
1040
|
-
for (const card of cardsToLog) {
|
|
1041
|
-
logger.debug(`[Pipeline] ${card.cardId} (final score: ${card.score.toFixed(3)}):`);
|
|
1042
|
-
for (const entry of card.provenance) {
|
|
1043
|
-
const scoreChange = entry.score.toFixed(3);
|
|
1044
|
-
const action = entry.action.padEnd(9);
|
|
1045
|
-
logger.debug(
|
|
1046
|
-
`[Pipeline] ${action} ${scoreChange} - ${entry.strategyName}: ${entry.reason}`
|
|
1047
|
-
);
|
|
1048
|
-
}
|
|
1049
|
-
}
|
|
1050
|
-
}
|
|
1051
|
-
var Pipeline;
|
|
1052
|
-
var init_Pipeline = __esm({
|
|
1053
|
-
"src/core/navigators/Pipeline.ts"() {
|
|
1054
|
-
"use strict";
|
|
1055
|
-
init_navigators();
|
|
1056
|
-
init_logger();
|
|
1057
|
-
Pipeline = class extends ContentNavigator {
|
|
1058
|
-
generator;
|
|
1059
|
-
filters;
|
|
1060
|
-
/**
|
|
1061
|
-
* Create a new pipeline.
|
|
1062
|
-
*
|
|
1063
|
-
* @param generator - The generator (or CompositeGenerator) that produces candidates
|
|
1064
|
-
* @param filters - Filters to apply sequentially (order doesn't matter for multipliers)
|
|
1065
|
-
* @param user - User database interface
|
|
1066
|
-
* @param course - Course database interface
|
|
1067
|
-
*/
|
|
1068
|
-
constructor(generator, filters, user, course) {
|
|
1069
|
-
super();
|
|
1070
|
-
this.generator = generator;
|
|
1071
|
-
this.filters = filters;
|
|
1072
|
-
this.user = user;
|
|
1073
|
-
this.course = course;
|
|
1074
|
-
course.getCourseConfig().then((cfg) => {
|
|
1075
|
-
logger.debug(`[pipeline] Crated pipeline for ${cfg.name}`);
|
|
1076
|
-
}).catch((e) => {
|
|
1077
|
-
logger.error(`[pipeline] Failed to lookup courseCfg: ${e}`);
|
|
1078
|
-
});
|
|
1079
|
-
logPipelineConfig(generator, filters);
|
|
1080
|
-
}
|
|
1081
|
-
/**
|
|
1082
|
-
* Get weighted cards by running generator and applying filters.
|
|
1083
|
-
*
|
|
1084
|
-
* 1. Build shared context (user ELO, etc.)
|
|
1085
|
-
* 2. Get candidates from generator (passing context)
|
|
1086
|
-
* 3. Batch hydrate tags for all candidates
|
|
1087
|
-
* 4. Apply each filter sequentially
|
|
1088
|
-
* 5. Remove zero-score cards
|
|
1089
|
-
* 6. Sort by score descending
|
|
1090
|
-
* 7. Return top N
|
|
1091
|
-
*
|
|
1092
|
-
* @param limit - Maximum number of cards to return
|
|
1093
|
-
* @returns Cards sorted by score descending
|
|
1094
|
-
*/
|
|
1095
|
-
async getWeightedCards(limit) {
|
|
1096
|
-
const context = await this.buildContext();
|
|
1097
|
-
const overFetchMultiplier = 2 + this.filters.length * 0.5;
|
|
1098
|
-
const fetchLimit = Math.ceil(limit * overFetchMultiplier);
|
|
1099
|
-
logger.debug(
|
|
1100
|
-
`[Pipeline] Fetching ${fetchLimit} candidates from generator '${this.generator.name}'`
|
|
1101
|
-
);
|
|
1102
|
-
let cards = await this.generator.getWeightedCards(fetchLimit, context);
|
|
1103
|
-
const generatedCount = cards.length;
|
|
1104
|
-
logger.debug(`[Pipeline] Generator returned ${generatedCount} candidates`);
|
|
1105
|
-
cards = await this.hydrateTags(cards);
|
|
1106
|
-
for (const filter of this.filters) {
|
|
1107
|
-
const beforeCount = cards.length;
|
|
1108
|
-
cards = await filter.transform(cards, context);
|
|
1109
|
-
logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeCount} \u2192 ${cards.length} cards`);
|
|
1110
|
-
}
|
|
1111
|
-
cards = cards.filter((c) => c.score > 0);
|
|
1112
|
-
cards.sort((a, b) => b.score - a.score);
|
|
1113
|
-
const result = cards.slice(0, limit);
|
|
1114
|
-
const topScores = result.slice(0, 3).map((c) => c.score);
|
|
1115
|
-
logExecutionSummary(
|
|
1116
|
-
this.generator.name,
|
|
1117
|
-
generatedCount,
|
|
1118
|
-
this.filters.length,
|
|
1119
|
-
result.length,
|
|
1120
|
-
topScores
|
|
1121
|
-
);
|
|
1122
|
-
logCardProvenance(result, 3);
|
|
1123
|
-
return result;
|
|
1124
|
-
}
|
|
1125
|
-
/**
|
|
1126
|
-
* Batch hydrate tags for all cards.
|
|
1127
|
-
*
|
|
1128
|
-
* Fetches tags for all cards in a single database query and attaches them
|
|
1129
|
-
* to the WeightedCard objects. Filters can then use card.tags instead of
|
|
1130
|
-
* making individual getAppliedTags() calls.
|
|
1131
|
-
*
|
|
1132
|
-
* @param cards - Cards to hydrate
|
|
1133
|
-
* @returns Cards with tags populated
|
|
1134
|
-
*/
|
|
1135
|
-
async hydrateTags(cards) {
|
|
1136
|
-
if (cards.length === 0) {
|
|
1137
|
-
return cards;
|
|
1138
|
-
}
|
|
1139
|
-
const cardIds = cards.map((c) => c.cardId);
|
|
1140
|
-
const tagsByCard = await this.course.getAppliedTagsBatch(cardIds);
|
|
1141
|
-
logTagHydration(cards, tagsByCard);
|
|
1142
|
-
return cards.map((card) => ({
|
|
1143
|
-
...card,
|
|
1144
|
-
tags: tagsByCard.get(card.cardId) ?? []
|
|
1145
|
-
}));
|
|
1146
|
-
}
|
|
1147
|
-
/**
|
|
1148
|
-
* Build shared context for generator and filters.
|
|
1149
|
-
*
|
|
1150
|
-
* Called once per getWeightedCards() invocation.
|
|
1151
|
-
* Contains data that the generator and multiple filters might need.
|
|
1152
|
-
*
|
|
1153
|
-
* The context satisfies both GeneratorContext and FilterContext interfaces.
|
|
1154
|
-
*/
|
|
1155
|
-
async buildContext() {
|
|
1156
|
-
let userElo = 1e3;
|
|
1157
|
-
try {
|
|
1158
|
-
const courseReg = await this.user.getCourseRegDoc(this.course.getCourseID());
|
|
1159
|
-
const courseElo = toCourseElo2(courseReg.elo);
|
|
1160
|
-
userElo = courseElo.global.score;
|
|
1161
|
-
} catch (e) {
|
|
1162
|
-
logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`);
|
|
1163
|
-
}
|
|
1164
|
-
return {
|
|
1165
|
-
user: this.user,
|
|
1166
|
-
course: this.course,
|
|
1167
|
-
userElo
|
|
1168
|
-
};
|
|
1169
|
-
}
|
|
1170
|
-
/**
|
|
1171
|
-
* Get the course ID for this pipeline.
|
|
1172
|
-
*/
|
|
1173
|
-
getCourseID() {
|
|
1174
|
-
return this.course.getCourseID();
|
|
1175
|
-
}
|
|
1176
|
-
};
|
|
1177
|
-
}
|
|
1178
|
-
});
|
|
1179
|
-
|
|
1180
|
-
// src/core/navigators/generators/CompositeGenerator.ts
|
|
1181
|
-
var DEFAULT_AGGREGATION_MODE, FREQUENCY_BOOST_FACTOR, CompositeGenerator;
|
|
1182
|
-
var init_CompositeGenerator = __esm({
|
|
1183
|
-
"src/core/navigators/generators/CompositeGenerator.ts"() {
|
|
1117
|
+
// src/core/navigators/generators/CompositeGenerator.ts
|
|
1118
|
+
var CompositeGenerator_exports = {};
|
|
1119
|
+
__export(CompositeGenerator_exports, {
|
|
1120
|
+
AggregationMode: () => AggregationMode,
|
|
1121
|
+
default: () => CompositeGenerator
|
|
1122
|
+
});
|
|
1123
|
+
var AggregationMode, DEFAULT_AGGREGATION_MODE, FREQUENCY_BOOST_FACTOR, CompositeGenerator;
|
|
1124
|
+
var init_CompositeGenerator = __esm({
|
|
1125
|
+
"src/core/navigators/generators/CompositeGenerator.ts"() {
|
|
1184
1126
|
"use strict";
|
|
1185
1127
|
init_navigators();
|
|
1186
1128
|
init_logger();
|
|
1129
|
+
AggregationMode = /* @__PURE__ */ ((AggregationMode2) => {
|
|
1130
|
+
AggregationMode2["MAX"] = "max";
|
|
1131
|
+
AggregationMode2["AVERAGE"] = "average";
|
|
1132
|
+
AggregationMode2["FREQUENCY_BOOST"] = "frequencyBoost";
|
|
1133
|
+
return AggregationMode2;
|
|
1134
|
+
})(AggregationMode || {});
|
|
1187
1135
|
DEFAULT_AGGREGATION_MODE = "frequencyBoost" /* FREQUENCY_BOOST */;
|
|
1188
1136
|
FREQUENCY_BOOST_FACTOR = 0.1;
|
|
1189
1137
|
CompositeGenerator = class _CompositeGenerator extends ContentNavigator {
|
|
@@ -1234,22 +1182,55 @@ var init_CompositeGenerator = __esm({
|
|
|
1234
1182
|
const results = await Promise.all(
|
|
1235
1183
|
this.generators.map((g) => g.getWeightedCards(limit, context))
|
|
1236
1184
|
);
|
|
1185
|
+
const generatorSummaries = [];
|
|
1186
|
+
results.forEach((cards, index) => {
|
|
1187
|
+
const gen = this.generators[index];
|
|
1188
|
+
const genName = gen.name || `Generator ${index}`;
|
|
1189
|
+
const newCards = cards.filter((c) => c.provenance[0]?.reason?.includes("new card"));
|
|
1190
|
+
const reviewCards = cards.filter((c) => c.provenance[0]?.reason?.includes("review"));
|
|
1191
|
+
if (cards.length > 0) {
|
|
1192
|
+
const topScore = Math.max(...cards.map((c) => c.score)).toFixed(2);
|
|
1193
|
+
const parts = [];
|
|
1194
|
+
if (newCards.length > 0) parts.push(`${newCards.length} new`);
|
|
1195
|
+
if (reviewCards.length > 0) parts.push(`${reviewCards.length} reviews`);
|
|
1196
|
+
const breakdown = parts.length > 0 ? parts.join(", ") : `${cards.length} cards`;
|
|
1197
|
+
generatorSummaries.push(`${genName}: ${breakdown} (top: ${topScore})`);
|
|
1198
|
+
} else {
|
|
1199
|
+
generatorSummaries.push(`${genName}: 0 cards`);
|
|
1200
|
+
}
|
|
1201
|
+
});
|
|
1202
|
+
logger.info(`[Composite] Generator breakdown: ${generatorSummaries.join(" | ")}`);
|
|
1237
1203
|
const byCardId = /* @__PURE__ */ new Map();
|
|
1238
|
-
|
|
1204
|
+
results.forEach((cards, index) => {
|
|
1205
|
+
const gen = this.generators[index];
|
|
1206
|
+
let weight = gen.learnable?.weight ?? 1;
|
|
1207
|
+
let deviation;
|
|
1208
|
+
if (gen.learnable && !gen.staticWeight && context.orchestration) {
|
|
1209
|
+
const strategyId = gen.strategyId;
|
|
1210
|
+
if (strategyId) {
|
|
1211
|
+
weight = context.orchestration.getEffectiveWeight(strategyId, gen.learnable);
|
|
1212
|
+
deviation = context.orchestration.getDeviation(strategyId);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1239
1215
|
for (const card of cards) {
|
|
1216
|
+
if (card.provenance.length > 0) {
|
|
1217
|
+
card.provenance[0].effectiveWeight = weight;
|
|
1218
|
+
card.provenance[0].deviation = deviation;
|
|
1219
|
+
}
|
|
1240
1220
|
const existing = byCardId.get(card.cardId) || [];
|
|
1241
|
-
existing.push(card);
|
|
1221
|
+
existing.push({ card, weight });
|
|
1242
1222
|
byCardId.set(card.cardId, existing);
|
|
1243
1223
|
}
|
|
1244
|
-
}
|
|
1224
|
+
});
|
|
1245
1225
|
const merged = [];
|
|
1246
|
-
for (const [,
|
|
1247
|
-
const
|
|
1226
|
+
for (const [, items] of byCardId) {
|
|
1227
|
+
const cards = items.map((i) => i.card);
|
|
1228
|
+
const aggregatedScore = this.aggregateScores(items);
|
|
1248
1229
|
const finalScore = Math.min(1, aggregatedScore);
|
|
1249
1230
|
const mergedProvenance = cards.flatMap((c) => c.provenance);
|
|
1250
1231
|
const initialScore = cards[0].score;
|
|
1251
1232
|
const action = finalScore > initialScore ? "boosted" : finalScore < initialScore ? "penalized" : "passed";
|
|
1252
|
-
const reason = this.buildAggregationReason(
|
|
1233
|
+
const reason = this.buildAggregationReason(items, finalScore);
|
|
1253
1234
|
merged.push({
|
|
1254
1235
|
...cards[0],
|
|
1255
1236
|
score: finalScore,
|
|
@@ -1271,22 +1252,26 @@ var init_CompositeGenerator = __esm({
|
|
|
1271
1252
|
/**
|
|
1272
1253
|
* Build human-readable reason for score aggregation.
|
|
1273
1254
|
*/
|
|
1274
|
-
buildAggregationReason(
|
|
1255
|
+
buildAggregationReason(items, finalScore) {
|
|
1256
|
+
const cards = items.map((i) => i.card);
|
|
1275
1257
|
const count = cards.length;
|
|
1276
1258
|
const scores = cards.map((c) => c.score.toFixed(2)).join(", ");
|
|
1277
1259
|
if (count === 1) {
|
|
1278
|
-
|
|
1260
|
+
const weightMsg = Math.abs(items[0].weight - 1) > 1e-3 ? ` (w=${items[0].weight.toFixed(2)})` : "";
|
|
1261
|
+
return `Single generator, score ${finalScore.toFixed(2)}${weightMsg}`;
|
|
1279
1262
|
}
|
|
1280
1263
|
const strategies = cards.map((c) => c.provenance[0]?.strategy || "unknown").join(", ");
|
|
1281
1264
|
switch (this.aggregationMode) {
|
|
1282
1265
|
case "max" /* MAX */:
|
|
1283
1266
|
return `Max of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
|
|
1284
1267
|
case "average" /* AVERAGE */:
|
|
1285
|
-
return `
|
|
1268
|
+
return `Weighted Avg of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
|
|
1286
1269
|
case "frequencyBoost" /* FREQUENCY_BOOST */: {
|
|
1287
|
-
const
|
|
1270
|
+
const totalWeight = items.reduce((sum, i) => sum + i.weight, 0);
|
|
1271
|
+
const weightedSum = items.reduce((sum, i) => sum + i.card.score * i.weight, 0);
|
|
1272
|
+
const avg = totalWeight > 0 ? weightedSum / totalWeight : 0;
|
|
1288
1273
|
const boost = 1 + FREQUENCY_BOOST_FACTOR * (count - 1);
|
|
1289
|
-
return `Frequency boost from ${count} generators (${strategies}): avg ${avg.toFixed(2)} \xD7 ${boost.toFixed(2)} \u2192 ${finalScore.toFixed(2)}`;
|
|
1274
|
+
return `Frequency boost from ${count} generators (${strategies}): w-avg ${avg.toFixed(2)} \xD7 ${boost.toFixed(2)} \u2192 ${finalScore.toFixed(2)}`;
|
|
1290
1275
|
}
|
|
1291
1276
|
default:
|
|
1292
1277
|
return `Aggregated from ${count} generators: ${finalScore.toFixed(2)}`;
|
|
@@ -1295,16 +1280,22 @@ var init_CompositeGenerator = __esm({
|
|
|
1295
1280
|
/**
|
|
1296
1281
|
* Aggregate scores from multiple generators for the same card.
|
|
1297
1282
|
*/
|
|
1298
|
-
aggregateScores(
|
|
1299
|
-
const scores =
|
|
1283
|
+
aggregateScores(items) {
|
|
1284
|
+
const scores = items.map((i) => i.card.score);
|
|
1300
1285
|
switch (this.aggregationMode) {
|
|
1301
1286
|
case "max" /* MAX */:
|
|
1302
1287
|
return Math.max(...scores);
|
|
1303
|
-
case "average" /* AVERAGE */:
|
|
1304
|
-
|
|
1288
|
+
case "average" /* AVERAGE */: {
|
|
1289
|
+
const totalWeight = items.reduce((sum, i) => sum + i.weight, 0);
|
|
1290
|
+
if (totalWeight === 0) return 0;
|
|
1291
|
+
const weightedSum = items.reduce((sum, i) => sum + i.card.score * i.weight, 0);
|
|
1292
|
+
return weightedSum / totalWeight;
|
|
1293
|
+
}
|
|
1305
1294
|
case "frequencyBoost" /* FREQUENCY_BOOST */: {
|
|
1306
|
-
const
|
|
1307
|
-
const
|
|
1295
|
+
const totalWeight = items.reduce((sum, i) => sum + i.weight, 0);
|
|
1296
|
+
const weightedSum = items.reduce((sum, i) => sum + i.card.score * i.weight, 0);
|
|
1297
|
+
const avg = totalWeight > 0 ? weightedSum / totalWeight : 0;
|
|
1298
|
+
const frequencyBoost = 1 + FREQUENCY_BOOST_FACTOR * (items.length - 1);
|
|
1308
1299
|
return avg * frequencyBoost;
|
|
1309
1300
|
}
|
|
1310
1301
|
default:
|
|
@@ -1315,134 +1306,18 @@ var init_CompositeGenerator = __esm({
|
|
|
1315
1306
|
}
|
|
1316
1307
|
});
|
|
1317
1308
|
|
|
1318
|
-
// src/core/navigators/PipelineAssembler.ts
|
|
1319
|
-
var PipelineAssembler;
|
|
1320
|
-
var init_PipelineAssembler = __esm({
|
|
1321
|
-
"src/core/navigators/PipelineAssembler.ts"() {
|
|
1322
|
-
"use strict";
|
|
1323
|
-
init_navigators();
|
|
1324
|
-
init_Pipeline();
|
|
1325
|
-
init_types_legacy();
|
|
1326
|
-
init_logger();
|
|
1327
|
-
init_CompositeGenerator();
|
|
1328
|
-
PipelineAssembler = class {
|
|
1329
|
-
/**
|
|
1330
|
-
* Assembles a navigation pipeline from strategy documents.
|
|
1331
|
-
*
|
|
1332
|
-
* 1. Separates into generators and filters by role
|
|
1333
|
-
* 2. Validates at least one generator exists (or creates default ELO)
|
|
1334
|
-
* 3. Instantiates generators - wraps multiple in CompositeGenerator
|
|
1335
|
-
* 4. Instantiates filters
|
|
1336
|
-
* 5. Returns Pipeline(generator, filters)
|
|
1337
|
-
*
|
|
1338
|
-
* @param input - Strategy documents plus user/course interfaces
|
|
1339
|
-
* @returns Assembled pipeline and any warnings
|
|
1340
|
-
*/
|
|
1341
|
-
async assemble(input) {
|
|
1342
|
-
const { strategies, user, course } = input;
|
|
1343
|
-
const warnings = [];
|
|
1344
|
-
if (strategies.length === 0) {
|
|
1345
|
-
return {
|
|
1346
|
-
pipeline: null,
|
|
1347
|
-
generatorStrategies: [],
|
|
1348
|
-
filterStrategies: [],
|
|
1349
|
-
warnings
|
|
1350
|
-
};
|
|
1351
|
-
}
|
|
1352
|
-
const generatorStrategies = [];
|
|
1353
|
-
const filterStrategies = [];
|
|
1354
|
-
for (const s of strategies) {
|
|
1355
|
-
if (isGenerator(s.implementingClass)) {
|
|
1356
|
-
generatorStrategies.push(s);
|
|
1357
|
-
} else if (isFilter(s.implementingClass)) {
|
|
1358
|
-
filterStrategies.push(s);
|
|
1359
|
-
} else {
|
|
1360
|
-
warnings.push(`Unknown strategy type '${s.implementingClass}', skipping: ${s.name}`);
|
|
1361
|
-
}
|
|
1362
|
-
}
|
|
1363
|
-
if (generatorStrategies.length === 0) {
|
|
1364
|
-
if (filterStrategies.length > 0) {
|
|
1365
|
-
logger.debug(
|
|
1366
|
-
"[PipelineAssembler] No generator found, using default ELO with configured filters"
|
|
1367
|
-
);
|
|
1368
|
-
generatorStrategies.push(this.makeDefaultEloStrategy(course.getCourseID()));
|
|
1369
|
-
} else {
|
|
1370
|
-
warnings.push("No generator strategy found");
|
|
1371
|
-
return {
|
|
1372
|
-
pipeline: null,
|
|
1373
|
-
generatorStrategies: [],
|
|
1374
|
-
filterStrategies: [],
|
|
1375
|
-
warnings
|
|
1376
|
-
};
|
|
1377
|
-
}
|
|
1378
|
-
}
|
|
1379
|
-
let generator;
|
|
1380
|
-
if (generatorStrategies.length === 1) {
|
|
1381
|
-
const nav = await ContentNavigator.create(user, course, generatorStrategies[0]);
|
|
1382
|
-
generator = nav;
|
|
1383
|
-
logger.debug(`[PipelineAssembler] Using single generator: ${generatorStrategies[0].name}`);
|
|
1384
|
-
} else {
|
|
1385
|
-
logger.debug(
|
|
1386
|
-
`[PipelineAssembler] Using CompositeGenerator for ${generatorStrategies.length} generators: ${generatorStrategies.map((g) => g.name).join(", ")}`
|
|
1387
|
-
);
|
|
1388
|
-
generator = await CompositeGenerator.fromStrategies(user, course, generatorStrategies);
|
|
1389
|
-
}
|
|
1390
|
-
const filters = [];
|
|
1391
|
-
const sortedFilterStrategies = [...filterStrategies].sort(
|
|
1392
|
-
(a, b) => a.name.localeCompare(b.name)
|
|
1393
|
-
);
|
|
1394
|
-
for (const filterStrategy of sortedFilterStrategies) {
|
|
1395
|
-
try {
|
|
1396
|
-
const nav = await ContentNavigator.create(user, course, filterStrategy);
|
|
1397
|
-
if ("transform" in nav && typeof nav.transform === "function") {
|
|
1398
|
-
filters.push(nav);
|
|
1399
|
-
logger.debug(`[PipelineAssembler] Added filter: ${filterStrategy.name}`);
|
|
1400
|
-
} else {
|
|
1401
|
-
warnings.push(
|
|
1402
|
-
`Filter '${filterStrategy.name}' does not implement CardFilter.transform(), skipping`
|
|
1403
|
-
);
|
|
1404
|
-
}
|
|
1405
|
-
} catch (e) {
|
|
1406
|
-
warnings.push(`Failed to instantiate filter '${filterStrategy.name}': ${e}`);
|
|
1407
|
-
}
|
|
1408
|
-
}
|
|
1409
|
-
const pipeline = new Pipeline(generator, filters, user, course);
|
|
1410
|
-
logger.debug(
|
|
1411
|
-
`[PipelineAssembler] Assembled pipeline with ${generatorStrategies.length} generator(s) and ${filters.length} filter(s)`
|
|
1412
|
-
);
|
|
1413
|
-
return {
|
|
1414
|
-
pipeline,
|
|
1415
|
-
generatorStrategies,
|
|
1416
|
-
filterStrategies: sortedFilterStrategies,
|
|
1417
|
-
warnings
|
|
1418
|
-
};
|
|
1419
|
-
}
|
|
1420
|
-
/**
|
|
1421
|
-
* Creates a default ELO generator strategy.
|
|
1422
|
-
* Used when filters are configured but no generator is specified.
|
|
1423
|
-
*/
|
|
1424
|
-
makeDefaultEloStrategy(courseId) {
|
|
1425
|
-
return {
|
|
1426
|
-
_id: "NAVIGATION_STRATEGY-ELO-default",
|
|
1427
|
-
course: courseId,
|
|
1428
|
-
docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
|
|
1429
|
-
name: "ELO (default)",
|
|
1430
|
-
description: "Default ELO-based generator",
|
|
1431
|
-
implementingClass: "elo" /* ELO */,
|
|
1432
|
-
serializedData: ""
|
|
1433
|
-
};
|
|
1434
|
-
}
|
|
1435
|
-
};
|
|
1436
|
-
}
|
|
1437
|
-
});
|
|
1438
|
-
|
|
1439
1309
|
// src/core/navigators/generators/elo.ts
|
|
1440
|
-
|
|
1310
|
+
var elo_exports = {};
|
|
1311
|
+
__export(elo_exports, {
|
|
1312
|
+
default: () => ELONavigator
|
|
1313
|
+
});
|
|
1314
|
+
import { toCourseElo as toCourseElo2 } from "@vue-skuilder/common";
|
|
1441
1315
|
var ELONavigator;
|
|
1442
1316
|
var init_elo = __esm({
|
|
1443
1317
|
"src/core/navigators/generators/elo.ts"() {
|
|
1444
1318
|
"use strict";
|
|
1445
1319
|
init_navigators();
|
|
1320
|
+
init_logger();
|
|
1446
1321
|
ELONavigator = class extends ContentNavigator {
|
|
1447
1322
|
/** Human-readable name for CardGenerator interface */
|
|
1448
1323
|
name;
|
|
@@ -1471,7 +1346,7 @@ var init_elo = __esm({
|
|
|
1471
1346
|
userGlobalElo = context.userElo;
|
|
1472
1347
|
} else {
|
|
1473
1348
|
const courseReg = await this.user.getCourseRegDoc(this.course.getCourseID());
|
|
1474
|
-
const userElo =
|
|
1349
|
+
const userElo = toCourseElo2(courseReg.elo);
|
|
1475
1350
|
userGlobalElo = userElo.global.score;
|
|
1476
1351
|
}
|
|
1477
1352
|
const activeCards = await this.user.getActiveCards();
|
|
@@ -1502,26 +1377,65 @@ var init_elo = __esm({
|
|
|
1502
1377
|
};
|
|
1503
1378
|
});
|
|
1504
1379
|
scored.sort((a, b) => b.score - a.score);
|
|
1505
|
-
|
|
1380
|
+
const result = scored.slice(0, limit);
|
|
1381
|
+
if (result.length > 0) {
|
|
1382
|
+
const topScores = result.slice(0, 3).map((c) => c.score.toFixed(2)).join(", ");
|
|
1383
|
+
logger.info(
|
|
1384
|
+
`[ELO] Course ${this.course.getCourseID()}: ${result.length} new cards (top scores: ${topScores})`
|
|
1385
|
+
);
|
|
1386
|
+
} else {
|
|
1387
|
+
logger.info(`[ELO] Course ${this.course.getCourseID()}: No new cards available`);
|
|
1388
|
+
}
|
|
1389
|
+
return result;
|
|
1506
1390
|
}
|
|
1507
1391
|
};
|
|
1508
1392
|
}
|
|
1509
1393
|
});
|
|
1510
1394
|
|
|
1395
|
+
// src/core/navigators/generators/index.ts
|
|
1396
|
+
var generators_exports = {};
|
|
1397
|
+
var init_generators = __esm({
|
|
1398
|
+
"src/core/navigators/generators/index.ts"() {
|
|
1399
|
+
"use strict";
|
|
1400
|
+
}
|
|
1401
|
+
});
|
|
1402
|
+
|
|
1511
1403
|
// src/core/navigators/generators/srs.ts
|
|
1404
|
+
var srs_exports = {};
|
|
1405
|
+
__export(srs_exports, {
|
|
1406
|
+
default: () => SRSNavigator
|
|
1407
|
+
});
|
|
1512
1408
|
import moment3 from "moment";
|
|
1513
|
-
var SRSNavigator;
|
|
1409
|
+
var DEFAULT_HEALTHY_BACKLOG, MAX_BACKLOG_PRESSURE, SRSNavigator;
|
|
1514
1410
|
var init_srs = __esm({
|
|
1515
1411
|
"src/core/navigators/generators/srs.ts"() {
|
|
1516
1412
|
"use strict";
|
|
1517
1413
|
init_navigators();
|
|
1518
1414
|
init_logger();
|
|
1415
|
+
DEFAULT_HEALTHY_BACKLOG = 20;
|
|
1416
|
+
MAX_BACKLOG_PRESSURE = 0.5;
|
|
1519
1417
|
SRSNavigator = class extends ContentNavigator {
|
|
1520
1418
|
/** Human-readable name for CardGenerator interface */
|
|
1521
1419
|
name;
|
|
1420
|
+
/** Healthy backlog threshold - when exceeded, backlog pressure kicks in */
|
|
1421
|
+
healthyBacklog;
|
|
1522
1422
|
constructor(user, course, strategyData) {
|
|
1523
1423
|
super(user, course, strategyData);
|
|
1524
1424
|
this.name = strategyData?.name || "SRS";
|
|
1425
|
+
const config = this.parseConfig(strategyData?.serializedData);
|
|
1426
|
+
this.healthyBacklog = config.healthyBacklog ?? DEFAULT_HEALTHY_BACKLOG;
|
|
1427
|
+
}
|
|
1428
|
+
/**
|
|
1429
|
+
* Parse configuration from serialized JSON.
|
|
1430
|
+
*/
|
|
1431
|
+
parseConfig(serializedData) {
|
|
1432
|
+
if (!serializedData) return {};
|
|
1433
|
+
try {
|
|
1434
|
+
return JSON.parse(serializedData);
|
|
1435
|
+
} catch {
|
|
1436
|
+
logger.warn("[SRS] Failed to parse strategy config, using defaults");
|
|
1437
|
+
return {};
|
|
1438
|
+
}
|
|
1525
1439
|
}
|
|
1526
1440
|
/**
|
|
1527
1441
|
* Get review cards scored by urgency.
|
|
@@ -1529,6 +1443,7 @@ var init_srs = __esm({
|
|
|
1529
1443
|
* Score formula combines:
|
|
1530
1444
|
* - Relative overdueness: hoursOverdue / intervalHours
|
|
1531
1445
|
* - Interval recency: exponential decay favoring shorter intervals
|
|
1446
|
+
* - Backlog pressure: boost when due reviews exceed healthy threshold
|
|
1532
1447
|
*
|
|
1533
1448
|
* Cards not yet due are excluded (not scored as 0).
|
|
1534
1449
|
*
|
|
@@ -1542,11 +1457,32 @@ var init_srs = __esm({
|
|
|
1542
1457
|
if (!this.user || !this.course) {
|
|
1543
1458
|
throw new Error("SRSNavigator requires user and course to be set");
|
|
1544
1459
|
}
|
|
1545
|
-
const
|
|
1460
|
+
const courseId = this.course.getCourseID();
|
|
1461
|
+
const reviews = await this.user.getPendingReviews(courseId);
|
|
1546
1462
|
const now = moment3.utc();
|
|
1547
1463
|
const dueReviews = reviews.filter((r) => now.isAfter(moment3.utc(r.reviewTime)));
|
|
1464
|
+
const backlogPressure = this.computeBacklogPressure(dueReviews.length);
|
|
1465
|
+
if (dueReviews.length > 0) {
|
|
1466
|
+
const pressureNote = backlogPressure > 0 ? ` [backlog pressure: +${backlogPressure.toFixed(2)}]` : ` [healthy backlog]`;
|
|
1467
|
+
logger.info(
|
|
1468
|
+
`[SRS] Course ${courseId}: ${dueReviews.length} reviews due now (of ${reviews.length} scheduled)${pressureNote}`
|
|
1469
|
+
);
|
|
1470
|
+
} else if (reviews.length > 0) {
|
|
1471
|
+
const sortedByDue = [...reviews].sort(
|
|
1472
|
+
(a, b) => moment3.utc(a.reviewTime).diff(moment3.utc(b.reviewTime))
|
|
1473
|
+
);
|
|
1474
|
+
const nextDue = sortedByDue[0];
|
|
1475
|
+
const nextDueTime = moment3.utc(nextDue.reviewTime);
|
|
1476
|
+
const untilDue = moment3.duration(nextDueTime.diff(now));
|
|
1477
|
+
const untilDueStr = untilDue.asHours() < 1 ? `${Math.round(untilDue.asMinutes())}m` : untilDue.asHours() < 24 ? `${Math.round(untilDue.asHours())}h` : `${Math.round(untilDue.asDays())}d`;
|
|
1478
|
+
logger.info(
|
|
1479
|
+
`[SRS] Course ${courseId}: 0 reviews due now (${reviews.length} scheduled, next in ${untilDueStr})`
|
|
1480
|
+
);
|
|
1481
|
+
} else {
|
|
1482
|
+
logger.info(`[SRS] Course ${courseId}: No reviews scheduled`);
|
|
1483
|
+
}
|
|
1548
1484
|
const scored = dueReviews.map((review) => {
|
|
1549
|
-
const { score, reason } = this.computeUrgencyScore(review, now);
|
|
1485
|
+
const { score, reason } = this.computeUrgencyScore(review, now, backlogPressure);
|
|
1550
1486
|
return {
|
|
1551
1487
|
cardId: review.cardId,
|
|
1552
1488
|
courseId: review.courseId,
|
|
@@ -1564,143 +1500,2084 @@ var init_srs = __esm({
|
|
|
1564
1500
|
]
|
|
1565
1501
|
};
|
|
1566
1502
|
});
|
|
1567
|
-
logger.debug(`[srsNav] got ${scored.length} weighted cards`);
|
|
1568
1503
|
return scored.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
1569
1504
|
}
|
|
1570
1505
|
/**
|
|
1571
|
-
* Compute
|
|
1506
|
+
* Compute backlog pressure based on number of due reviews.
|
|
1507
|
+
*
|
|
1508
|
+
* Backlog pressure is 0 when at or below healthy threshold,
|
|
1509
|
+
* and increases linearly above it, maxing out at MAX_BACKLOG_PRESSURE.
|
|
1510
|
+
*
|
|
1511
|
+
* Examples (with default healthyBacklog=20):
|
|
1512
|
+
* - 10 due reviews → 0.00 (healthy)
|
|
1513
|
+
* - 20 due reviews → 0.00 (at threshold)
|
|
1514
|
+
* - 40 due reviews → 0.25 (2x threshold)
|
|
1515
|
+
* - 60 due reviews → 0.50 (3x threshold, maxed)
|
|
1516
|
+
*
|
|
1517
|
+
* @param dueCount - Number of reviews currently due
|
|
1518
|
+
* @returns Backlog pressure score to add to urgency (0 to MAX_BACKLOG_PRESSURE)
|
|
1519
|
+
*/
|
|
1520
|
+
computeBacklogPressure(dueCount) {
|
|
1521
|
+
if (dueCount <= this.healthyBacklog) {
|
|
1522
|
+
return 0;
|
|
1523
|
+
}
|
|
1524
|
+
const excess = dueCount - this.healthyBacklog;
|
|
1525
|
+
const pressure = excess / this.healthyBacklog * (MAX_BACKLOG_PRESSURE / 2);
|
|
1526
|
+
return Math.min(MAX_BACKLOG_PRESSURE, pressure);
|
|
1527
|
+
}
|
|
1528
|
+
/**
|
|
1529
|
+
* Compute urgency score for a review card.
|
|
1530
|
+
*
|
|
1531
|
+
* Three factors:
|
|
1532
|
+
* 1. Relative overdueness = hoursOverdue / intervalHours
|
|
1533
|
+
* - 2 days overdue on 3-day interval = 0.67 (urgent)
|
|
1534
|
+
* - 2 days overdue on 180-day interval = 0.01 (not urgent)
|
|
1535
|
+
*
|
|
1536
|
+
* 2. Interval recency factor = 0.3 + 0.7 * exp(-intervalHours / 720)
|
|
1537
|
+
* - 24h interval → ~1.0 (very recent learning)
|
|
1538
|
+
* - 30 days (720h) → ~0.56
|
|
1539
|
+
* - 180 days → ~0.30
|
|
1540
|
+
*
|
|
1541
|
+
* 3. Backlog pressure = global boost when review backlog exceeds healthy threshold
|
|
1542
|
+
* - At healthy backlog: 0
|
|
1543
|
+
* - At 2x healthy: +0.25
|
|
1544
|
+
* - At 3x+ healthy: +0.50 (max)
|
|
1545
|
+
*
|
|
1546
|
+
* Combined: base 0.5 + (urgency factors * 0.45) + backlog pressure
|
|
1547
|
+
* Result range: 0.5 to 1.0 (uncapped to allow high-urgency reviews to compete with new cards)
|
|
1548
|
+
*
|
|
1549
|
+
* @param review - The scheduled card to score
|
|
1550
|
+
* @param now - Current time
|
|
1551
|
+
* @param backlogPressure - Pre-computed backlog pressure (0 to 0.5)
|
|
1552
|
+
*/
|
|
1553
|
+
computeUrgencyScore(review, now, backlogPressure) {
|
|
1554
|
+
const scheduledAt = moment3.utc(review.scheduledAt);
|
|
1555
|
+
const due = moment3.utc(review.reviewTime);
|
|
1556
|
+
const intervalHours = Math.max(1, due.diff(scheduledAt, "hours"));
|
|
1557
|
+
const hoursOverdue = now.diff(due, "hours");
|
|
1558
|
+
const relativeOverdue = hoursOverdue / intervalHours;
|
|
1559
|
+
const recencyFactor = 0.3 + 0.7 * Math.exp(-intervalHours / 720);
|
|
1560
|
+
const overdueContribution = Math.min(1, Math.max(0, relativeOverdue));
|
|
1561
|
+
const urgency = overdueContribution * 0.5 + recencyFactor * 0.5;
|
|
1562
|
+
const baseScore = 0.5 + urgency * 0.45;
|
|
1563
|
+
const score = Math.min(1, baseScore + backlogPressure);
|
|
1564
|
+
const reasonParts = [
|
|
1565
|
+
`${Math.round(hoursOverdue)}h overdue`,
|
|
1566
|
+
`interval: ${Math.round(intervalHours)}h`,
|
|
1567
|
+
`relative: ${relativeOverdue.toFixed(2)}`,
|
|
1568
|
+
`recency: ${recencyFactor.toFixed(2)}`
|
|
1569
|
+
];
|
|
1570
|
+
if (backlogPressure > 0) {
|
|
1571
|
+
reasonParts.push(`backlog: +${backlogPressure.toFixed(2)}`);
|
|
1572
|
+
}
|
|
1573
|
+
reasonParts.push("review");
|
|
1574
|
+
const reason = reasonParts.join(", ");
|
|
1575
|
+
return { score, reason };
|
|
1576
|
+
}
|
|
1577
|
+
};
|
|
1578
|
+
}
|
|
1579
|
+
});
|
|
1580
|
+
|
|
1581
|
+
// src/core/navigators/generators/types.ts
|
|
1582
|
+
var types_exports = {};
|
|
1583
|
+
var init_types = __esm({
|
|
1584
|
+
"src/core/navigators/generators/types.ts"() {
|
|
1585
|
+
"use strict";
|
|
1586
|
+
}
|
|
1587
|
+
});
|
|
1588
|
+
|
|
1589
|
+
// import("./generators/**/*") in src/core/navigators/index.ts
|
|
1590
|
+
var globImport_generators;
|
|
1591
|
+
var init_ = __esm({
|
|
1592
|
+
'import("./generators/**/*") in src/core/navigators/index.ts'() {
|
|
1593
|
+
globImport_generators = __glob({
|
|
1594
|
+
"./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
|
|
1595
|
+
"./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
|
|
1596
|
+
"./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
|
|
1597
|
+
"./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
|
|
1598
|
+
"./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports))
|
|
1599
|
+
});
|
|
1600
|
+
}
|
|
1601
|
+
});
|
|
1602
|
+
|
|
1603
|
+
// src/core/types/contentNavigationStrategy.ts
|
|
1604
|
+
var DEFAULT_LEARNABLE_WEIGHT;
|
|
1605
|
+
var init_contentNavigationStrategy = __esm({
|
|
1606
|
+
"src/core/types/contentNavigationStrategy.ts"() {
|
|
1607
|
+
"use strict";
|
|
1608
|
+
DEFAULT_LEARNABLE_WEIGHT = {
|
|
1609
|
+
weight: 1,
|
|
1610
|
+
confidence: 0.1,
|
|
1611
|
+
// Low confidence initially = wide exploration
|
|
1612
|
+
sampleSize: 0
|
|
1613
|
+
};
|
|
1614
|
+
}
|
|
1615
|
+
});
|
|
1616
|
+
|
|
1617
|
+
// src/core/navigators/filters/WeightedFilter.ts
|
|
1618
|
+
var WeightedFilter_exports = {};
|
|
1619
|
+
__export(WeightedFilter_exports, {
|
|
1620
|
+
WeightedFilter: () => WeightedFilter
|
|
1621
|
+
});
|
|
1622
|
+
var WeightedFilter;
|
|
1623
|
+
var init_WeightedFilter = __esm({
|
|
1624
|
+
"src/core/navigators/filters/WeightedFilter.ts"() {
|
|
1625
|
+
"use strict";
|
|
1626
|
+
init_contentNavigationStrategy();
|
|
1627
|
+
WeightedFilter = class {
|
|
1628
|
+
name;
|
|
1629
|
+
inner;
|
|
1630
|
+
learnable;
|
|
1631
|
+
staticWeight;
|
|
1632
|
+
strategyId;
|
|
1633
|
+
constructor(inner, learnable = DEFAULT_LEARNABLE_WEIGHT, staticWeight = false, strategyId) {
|
|
1634
|
+
this.inner = inner;
|
|
1635
|
+
this.name = inner.name;
|
|
1636
|
+
this.learnable = learnable;
|
|
1637
|
+
this.staticWeight = staticWeight;
|
|
1638
|
+
this.strategyId = strategyId;
|
|
1639
|
+
}
|
|
1640
|
+
/**
|
|
1641
|
+
* Apply the inner filter, then scale its effect by the configured weight.
|
|
1642
|
+
*/
|
|
1643
|
+
async transform(cards, context) {
|
|
1644
|
+
let effectiveWeight = this.learnable.weight;
|
|
1645
|
+
let deviation;
|
|
1646
|
+
if (!this.staticWeight && context.orchestration) {
|
|
1647
|
+
const strategyId = this.strategyId || this.inner.strategyId || this.name;
|
|
1648
|
+
effectiveWeight = context.orchestration.getEffectiveWeight(strategyId, this.learnable);
|
|
1649
|
+
deviation = context.orchestration.getDeviation(strategyId);
|
|
1650
|
+
}
|
|
1651
|
+
if (Math.abs(effectiveWeight - 1) < 1e-3) {
|
|
1652
|
+
return this.inner.transform(cards, context);
|
|
1653
|
+
}
|
|
1654
|
+
const originalScores = /* @__PURE__ */ new Map();
|
|
1655
|
+
for (const card of cards) {
|
|
1656
|
+
originalScores.set(card.cardId, card.score);
|
|
1657
|
+
}
|
|
1658
|
+
const transformedCards = await this.inner.transform(cards, context);
|
|
1659
|
+
return transformedCards.map((card) => {
|
|
1660
|
+
const originalScore = originalScores.get(card.cardId);
|
|
1661
|
+
if (originalScore === void 0 || originalScore === 0 || card.score === 0) {
|
|
1662
|
+
return card;
|
|
1663
|
+
}
|
|
1664
|
+
const rawEffect = card.score / originalScore;
|
|
1665
|
+
if (Math.abs(rawEffect - 1) < 1e-4) {
|
|
1666
|
+
return card;
|
|
1667
|
+
}
|
|
1668
|
+
const weightedEffect = Math.pow(rawEffect, effectiveWeight);
|
|
1669
|
+
const newScore = originalScore * weightedEffect;
|
|
1670
|
+
const lastProvIndex = card.provenance.length - 1;
|
|
1671
|
+
const lastProv = card.provenance[lastProvIndex];
|
|
1672
|
+
if (lastProv) {
|
|
1673
|
+
const updatedProvenance = [...card.provenance];
|
|
1674
|
+
updatedProvenance[lastProvIndex] = {
|
|
1675
|
+
...lastProv,
|
|
1676
|
+
score: newScore,
|
|
1677
|
+
effectiveWeight,
|
|
1678
|
+
deviation
|
|
1679
|
+
// We can optionally append to the reason, but the structured field is key
|
|
1680
|
+
};
|
|
1681
|
+
return {
|
|
1682
|
+
...card,
|
|
1683
|
+
score: newScore,
|
|
1684
|
+
provenance: updatedProvenance
|
|
1685
|
+
};
|
|
1686
|
+
}
|
|
1687
|
+
return {
|
|
1688
|
+
...card,
|
|
1689
|
+
score: newScore
|
|
1690
|
+
};
|
|
1691
|
+
});
|
|
1692
|
+
}
|
|
1693
|
+
};
|
|
1694
|
+
}
|
|
1695
|
+
});
|
|
1696
|
+
|
|
1697
|
+
// src/core/navigators/filters/eloDistance.ts
|
|
1698
|
+
var eloDistance_exports = {};
|
|
1699
|
+
__export(eloDistance_exports, {
|
|
1700
|
+
DEFAULT_HALF_LIFE: () => DEFAULT_HALF_LIFE,
|
|
1701
|
+
DEFAULT_MAX_MULTIPLIER: () => DEFAULT_MAX_MULTIPLIER,
|
|
1702
|
+
DEFAULT_MIN_MULTIPLIER: () => DEFAULT_MIN_MULTIPLIER,
|
|
1703
|
+
createEloDistanceFilter: () => createEloDistanceFilter
|
|
1704
|
+
});
|
|
1705
|
+
function computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier) {
|
|
1706
|
+
const normalizedDistance = distance / halfLife;
|
|
1707
|
+
const decay = Math.exp(-(normalizedDistance * normalizedDistance));
|
|
1708
|
+
return minMultiplier + (maxMultiplier - minMultiplier) * decay;
|
|
1709
|
+
}
|
|
1710
|
+
function createEloDistanceFilter(config) {
|
|
1711
|
+
const halfLife = config?.halfLife ?? DEFAULT_HALF_LIFE;
|
|
1712
|
+
const minMultiplier = config?.minMultiplier ?? DEFAULT_MIN_MULTIPLIER;
|
|
1713
|
+
const maxMultiplier = config?.maxMultiplier ?? DEFAULT_MAX_MULTIPLIER;
|
|
1714
|
+
return {
|
|
1715
|
+
name: "ELO Distance Filter",
|
|
1716
|
+
async transform(cards, context) {
|
|
1717
|
+
const { course, userElo } = context;
|
|
1718
|
+
const cardIds = cards.map((c) => c.cardId);
|
|
1719
|
+
const cardElos = await course.getCardEloData(cardIds);
|
|
1720
|
+
return cards.map((card, i) => {
|
|
1721
|
+
const cardElo = cardElos[i]?.global?.score ?? 1e3;
|
|
1722
|
+
const distance = Math.abs(cardElo - userElo);
|
|
1723
|
+
const multiplier = computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier);
|
|
1724
|
+
const newScore = card.score * multiplier;
|
|
1725
|
+
const action = multiplier < maxMultiplier - 0.01 ? "penalized" : "passed";
|
|
1726
|
+
return {
|
|
1727
|
+
...card,
|
|
1728
|
+
score: newScore,
|
|
1729
|
+
provenance: [
|
|
1730
|
+
...card.provenance,
|
|
1731
|
+
{
|
|
1732
|
+
strategy: "eloDistance",
|
|
1733
|
+
strategyName: "ELO Distance Filter",
|
|
1734
|
+
strategyId: "ELO_DISTANCE_FILTER",
|
|
1735
|
+
action,
|
|
1736
|
+
score: newScore,
|
|
1737
|
+
reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userElo)}) \u2192 ${multiplier.toFixed(2)}x`
|
|
1738
|
+
}
|
|
1739
|
+
]
|
|
1740
|
+
};
|
|
1741
|
+
});
|
|
1742
|
+
}
|
|
1743
|
+
};
|
|
1744
|
+
}
|
|
1745
|
+
var DEFAULT_HALF_LIFE, DEFAULT_MIN_MULTIPLIER, DEFAULT_MAX_MULTIPLIER;
|
|
1746
|
+
var init_eloDistance = __esm({
|
|
1747
|
+
"src/core/navigators/filters/eloDistance.ts"() {
|
|
1748
|
+
"use strict";
|
|
1749
|
+
DEFAULT_HALF_LIFE = 200;
|
|
1750
|
+
DEFAULT_MIN_MULTIPLIER = 0.3;
|
|
1751
|
+
DEFAULT_MAX_MULTIPLIER = 1;
|
|
1752
|
+
}
|
|
1753
|
+
});
|
|
1754
|
+
|
|
1755
|
+
// src/core/navigators/filters/hierarchyDefinition.ts
|
|
1756
|
+
var hierarchyDefinition_exports = {};
|
|
1757
|
+
__export(hierarchyDefinition_exports, {
|
|
1758
|
+
default: () => HierarchyDefinitionNavigator
|
|
1759
|
+
});
|
|
1760
|
+
import { toCourseElo as toCourseElo3 } from "@vue-skuilder/common";
|
|
1761
|
+
var DEFAULT_MIN_COUNT, HierarchyDefinitionNavigator;
|
|
1762
|
+
var init_hierarchyDefinition = __esm({
|
|
1763
|
+
"src/core/navigators/filters/hierarchyDefinition.ts"() {
|
|
1764
|
+
"use strict";
|
|
1765
|
+
init_navigators();
|
|
1766
|
+
DEFAULT_MIN_COUNT = 3;
|
|
1767
|
+
HierarchyDefinitionNavigator = class extends ContentNavigator {
|
|
1768
|
+
config;
|
|
1769
|
+
/** Human-readable name for CardFilter interface */
|
|
1770
|
+
name;
|
|
1771
|
+
constructor(user, course, strategyData) {
|
|
1772
|
+
super(user, course, strategyData);
|
|
1773
|
+
this.config = this.parseConfig(strategyData.serializedData);
|
|
1774
|
+
this.name = strategyData.name || "Hierarchy Definition";
|
|
1775
|
+
}
|
|
1776
|
+
parseConfig(serializedData) {
|
|
1777
|
+
try {
|
|
1778
|
+
const parsed = JSON.parse(serializedData);
|
|
1779
|
+
return {
|
|
1780
|
+
prerequisites: parsed.prerequisites || {}
|
|
1781
|
+
};
|
|
1782
|
+
} catch {
|
|
1783
|
+
return {
|
|
1784
|
+
prerequisites: {}
|
|
1785
|
+
};
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
/**
|
|
1789
|
+
* Check if a specific prerequisite is satisfied
|
|
1790
|
+
*/
|
|
1791
|
+
isPrerequisiteMet(prereq, userTagElo, userGlobalElo) {
|
|
1792
|
+
if (!userTagElo) return false;
|
|
1793
|
+
const minCount = prereq.masteryThreshold?.minCount ?? DEFAULT_MIN_COUNT;
|
|
1794
|
+
if (userTagElo.count < minCount) return false;
|
|
1795
|
+
if (prereq.masteryThreshold?.minElo !== void 0) {
|
|
1796
|
+
return userTagElo.score >= prereq.masteryThreshold.minElo;
|
|
1797
|
+
} else {
|
|
1798
|
+
return userTagElo.score >= userGlobalElo;
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
/**
|
|
1802
|
+
* Get the set of tags the user has mastered.
|
|
1803
|
+
* A tag is "mastered" if it appears as a prerequisite somewhere and meets its threshold.
|
|
1804
|
+
*/
|
|
1805
|
+
async getMasteredTags(context) {
|
|
1806
|
+
const mastered = /* @__PURE__ */ new Set();
|
|
1807
|
+
try {
|
|
1808
|
+
const courseReg = await context.user.getCourseRegDoc(context.course.getCourseID());
|
|
1809
|
+
const userElo = toCourseElo3(courseReg.elo);
|
|
1810
|
+
for (const prereqs of Object.values(this.config.prerequisites)) {
|
|
1811
|
+
for (const prereq of prereqs) {
|
|
1812
|
+
const tagElo = userElo.tags[prereq.tag];
|
|
1813
|
+
if (this.isPrerequisiteMet(prereq, tagElo, userElo.global.score)) {
|
|
1814
|
+
mastered.add(prereq.tag);
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
} catch {
|
|
1819
|
+
}
|
|
1820
|
+
return mastered;
|
|
1821
|
+
}
|
|
1822
|
+
/**
|
|
1823
|
+
* Get the set of tags that are unlocked (prerequisites met)
|
|
1824
|
+
*/
|
|
1825
|
+
getUnlockedTags(masteredTags) {
|
|
1826
|
+
const unlocked = /* @__PURE__ */ new Set();
|
|
1827
|
+
for (const [tagId, prereqs] of Object.entries(this.config.prerequisites)) {
|
|
1828
|
+
const allPrereqsMet = prereqs.every((prereq) => masteredTags.has(prereq.tag));
|
|
1829
|
+
if (allPrereqsMet) {
|
|
1830
|
+
unlocked.add(tagId);
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
return unlocked;
|
|
1834
|
+
}
|
|
1835
|
+
/**
|
|
1836
|
+
* Check if a tag has prerequisites defined in config
|
|
1837
|
+
*/
|
|
1838
|
+
hasPrerequisites(tagId) {
|
|
1839
|
+
return tagId in this.config.prerequisites;
|
|
1840
|
+
}
|
|
1841
|
+
/**
|
|
1842
|
+
* Check if a card is unlocked and generate reason.
|
|
1843
|
+
*/
|
|
1844
|
+
async checkCardUnlock(card, _course, unlockedTags, masteredTags) {
|
|
1845
|
+
try {
|
|
1846
|
+
const cardTags = card.tags ?? [];
|
|
1847
|
+
const lockedTags = cardTags.filter(
|
|
1848
|
+
(tag) => this.hasPrerequisites(tag) && !unlockedTags.has(tag)
|
|
1849
|
+
);
|
|
1850
|
+
if (lockedTags.length === 0) {
|
|
1851
|
+
const tagList = cardTags.length > 0 ? cardTags.join(", ") : "none";
|
|
1852
|
+
return {
|
|
1853
|
+
isUnlocked: true,
|
|
1854
|
+
reason: `Prerequisites met, tags: ${tagList}`
|
|
1855
|
+
};
|
|
1856
|
+
}
|
|
1857
|
+
const missingPrereqs = lockedTags.flatMap((tag) => {
|
|
1858
|
+
const prereqs = this.config.prerequisites[tag] || [];
|
|
1859
|
+
return prereqs.filter((p) => !masteredTags.has(p.tag)).map((p) => p.tag);
|
|
1860
|
+
});
|
|
1861
|
+
return {
|
|
1862
|
+
isUnlocked: false,
|
|
1863
|
+
reason: `Blocked: missing prerequisites ${missingPrereqs.join(", ")} for tags ${lockedTags.join(", ")}`
|
|
1864
|
+
};
|
|
1865
|
+
} catch {
|
|
1866
|
+
return {
|
|
1867
|
+
isUnlocked: true,
|
|
1868
|
+
reason: "Prerequisites check skipped (tag lookup failed)"
|
|
1869
|
+
};
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
/**
|
|
1873
|
+
* CardFilter.transform implementation.
|
|
1874
|
+
*
|
|
1875
|
+
* Apply prerequisite gating to cards. Cards with locked tags receive score: 0.
|
|
1876
|
+
*/
|
|
1877
|
+
async transform(cards, context) {
|
|
1878
|
+
const masteredTags = await this.getMasteredTags(context);
|
|
1879
|
+
const unlockedTags = this.getUnlockedTags(masteredTags);
|
|
1880
|
+
const gated = [];
|
|
1881
|
+
for (const card of cards) {
|
|
1882
|
+
const { isUnlocked, reason } = await this.checkCardUnlock(
|
|
1883
|
+
card,
|
|
1884
|
+
context.course,
|
|
1885
|
+
unlockedTags,
|
|
1886
|
+
masteredTags
|
|
1887
|
+
);
|
|
1888
|
+
const finalScore = isUnlocked ? card.score : 0;
|
|
1889
|
+
const action = isUnlocked ? "passed" : "penalized";
|
|
1890
|
+
gated.push({
|
|
1891
|
+
...card,
|
|
1892
|
+
score: finalScore,
|
|
1893
|
+
provenance: [
|
|
1894
|
+
...card.provenance,
|
|
1895
|
+
{
|
|
1896
|
+
strategy: "hierarchyDefinition",
|
|
1897
|
+
strategyName: this.strategyName || this.name,
|
|
1898
|
+
strategyId: this.strategyId || "NAVIGATION_STRATEGY-hierarchy",
|
|
1899
|
+
action,
|
|
1900
|
+
score: finalScore,
|
|
1901
|
+
reason
|
|
1902
|
+
}
|
|
1903
|
+
]
|
|
1904
|
+
});
|
|
1905
|
+
}
|
|
1906
|
+
return gated;
|
|
1907
|
+
}
|
|
1908
|
+
/**
|
|
1909
|
+
* Legacy getWeightedCards - now throws as filters should not be used as generators.
|
|
1910
|
+
*
|
|
1911
|
+
* Use transform() via Pipeline instead.
|
|
1912
|
+
*/
|
|
1913
|
+
async getWeightedCards(_limit) {
|
|
1914
|
+
throw new Error(
|
|
1915
|
+
"HierarchyDefinitionNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
|
|
1916
|
+
);
|
|
1917
|
+
}
|
|
1918
|
+
};
|
|
1919
|
+
}
|
|
1920
|
+
});
|
|
1921
|
+
|
|
1922
|
+
// src/core/navigators/filters/userTagPreference.ts
|
|
1923
|
+
var userTagPreference_exports = {};
|
|
1924
|
+
__export(userTagPreference_exports, {
|
|
1925
|
+
default: () => UserTagPreferenceFilter
|
|
1926
|
+
});
|
|
1927
|
+
var UserTagPreferenceFilter;
|
|
1928
|
+
var init_userTagPreference = __esm({
|
|
1929
|
+
"src/core/navigators/filters/userTagPreference.ts"() {
|
|
1930
|
+
"use strict";
|
|
1931
|
+
init_navigators();
|
|
1932
|
+
UserTagPreferenceFilter = class extends ContentNavigator {
|
|
1933
|
+
_strategyData;
|
|
1934
|
+
/** Human-readable name for CardFilter interface */
|
|
1935
|
+
name;
|
|
1936
|
+
constructor(user, course, strategyData) {
|
|
1937
|
+
super(user, course, strategyData);
|
|
1938
|
+
this._strategyData = strategyData;
|
|
1939
|
+
this.name = strategyData.name || "User Tag Preferences";
|
|
1940
|
+
}
|
|
1941
|
+
/**
|
|
1942
|
+
* Compute multiplier for a card based on its tags and user preferences.
|
|
1943
|
+
* Returns the maximum multiplier among all matching tags, or 1.0 if no matches.
|
|
1944
|
+
*/
|
|
1945
|
+
computeMultiplier(cardTags, boostMap) {
|
|
1946
|
+
const multipliers = cardTags.map((tag) => boostMap[tag]).filter((val) => val !== void 0);
|
|
1947
|
+
if (multipliers.length === 0) {
|
|
1948
|
+
return 1;
|
|
1949
|
+
}
|
|
1950
|
+
return Math.max(...multipliers);
|
|
1951
|
+
}
|
|
1952
|
+
/**
|
|
1953
|
+
* Build human-readable reason for the filter's decision.
|
|
1954
|
+
*/
|
|
1955
|
+
buildReason(cardTags, boostMap, multiplier) {
|
|
1956
|
+
const matchingTags = cardTags.filter((tag) => boostMap[tag] === multiplier);
|
|
1957
|
+
if (multiplier === 0) {
|
|
1958
|
+
return `Excluded by user preference: ${matchingTags.join(", ")} (${multiplier}x)`;
|
|
1959
|
+
}
|
|
1960
|
+
if (multiplier < 1) {
|
|
1961
|
+
return `Penalized by user preference: ${matchingTags.join(", ")} (${multiplier.toFixed(2)}x)`;
|
|
1962
|
+
}
|
|
1963
|
+
if (multiplier > 1) {
|
|
1964
|
+
return `Boosted by user preference: ${matchingTags.join(", ")} (${multiplier.toFixed(2)}x)`;
|
|
1965
|
+
}
|
|
1966
|
+
return "No matching user preferences";
|
|
1967
|
+
}
|
|
1968
|
+
/**
|
|
1969
|
+
* CardFilter.transform implementation.
|
|
1970
|
+
*
|
|
1971
|
+
* Apply user tag preferences:
|
|
1972
|
+
* 1. Read preferences from strategy state
|
|
1973
|
+
* 2. If no preferences, pass through unchanged
|
|
1974
|
+
* 3. For each card:
|
|
1975
|
+
* - Look up tag in boost record
|
|
1976
|
+
* - If tag found: apply multiplier (0 = exclude, 1 = neutral, >1 = boost)
|
|
1977
|
+
* - If multiple tags match: use max multiplier
|
|
1978
|
+
* - Append provenance with clear reason
|
|
1979
|
+
*/
|
|
1980
|
+
async transform(cards, _context) {
|
|
1981
|
+
const prefs = await this.getStrategyState();
|
|
1982
|
+
if (!prefs || Object.keys(prefs.boost).length === 0) {
|
|
1983
|
+
return cards.map((card) => ({
|
|
1984
|
+
...card,
|
|
1985
|
+
provenance: [
|
|
1986
|
+
...card.provenance,
|
|
1987
|
+
{
|
|
1988
|
+
strategy: "userTagPreference",
|
|
1989
|
+
strategyName: this.strategyName || this.name,
|
|
1990
|
+
strategyId: this.strategyId || this._strategyData._id,
|
|
1991
|
+
action: "passed",
|
|
1992
|
+
score: card.score,
|
|
1993
|
+
reason: "No user tag preferences configured"
|
|
1994
|
+
}
|
|
1995
|
+
]
|
|
1996
|
+
}));
|
|
1997
|
+
}
|
|
1998
|
+
const adjusted = await Promise.all(
|
|
1999
|
+
cards.map(async (card) => {
|
|
2000
|
+
const cardTags = card.tags ?? [];
|
|
2001
|
+
const multiplier = this.computeMultiplier(cardTags, prefs.boost);
|
|
2002
|
+
const finalScore = Math.min(1, card.score * multiplier);
|
|
2003
|
+
let action;
|
|
2004
|
+
if (multiplier === 0 || multiplier < 1) {
|
|
2005
|
+
action = "penalized";
|
|
2006
|
+
} else if (multiplier > 1) {
|
|
2007
|
+
action = "boosted";
|
|
2008
|
+
} else {
|
|
2009
|
+
action = "passed";
|
|
2010
|
+
}
|
|
2011
|
+
return {
|
|
2012
|
+
...card,
|
|
2013
|
+
score: finalScore,
|
|
2014
|
+
provenance: [
|
|
2015
|
+
...card.provenance,
|
|
2016
|
+
{
|
|
2017
|
+
strategy: "userTagPreference",
|
|
2018
|
+
strategyName: this.strategyName || this.name,
|
|
2019
|
+
strategyId: this.strategyId || this._strategyData._id,
|
|
2020
|
+
action,
|
|
2021
|
+
score: finalScore,
|
|
2022
|
+
reason: this.buildReason(cardTags, prefs.boost, multiplier)
|
|
2023
|
+
}
|
|
2024
|
+
]
|
|
2025
|
+
};
|
|
2026
|
+
})
|
|
2027
|
+
);
|
|
2028
|
+
return adjusted;
|
|
2029
|
+
}
|
|
2030
|
+
/**
|
|
2031
|
+
* Legacy getWeightedCards - throws as filters should not be used as generators.
|
|
2032
|
+
*/
|
|
2033
|
+
async getWeightedCards(_limit) {
|
|
2034
|
+
throw new Error(
|
|
2035
|
+
"UserTagPreferenceFilter is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
|
|
2036
|
+
);
|
|
2037
|
+
}
|
|
2038
|
+
};
|
|
2039
|
+
}
|
|
2040
|
+
});
|
|
2041
|
+
|
|
2042
|
+
// src/core/navigators/filters/index.ts
|
|
2043
|
+
var filters_exports = {};
|
|
2044
|
+
__export(filters_exports, {
|
|
2045
|
+
UserTagPreferenceFilter: () => UserTagPreferenceFilter,
|
|
2046
|
+
createEloDistanceFilter: () => createEloDistanceFilter
|
|
2047
|
+
});
|
|
2048
|
+
var init_filters = __esm({
|
|
2049
|
+
"src/core/navigators/filters/index.ts"() {
|
|
2050
|
+
"use strict";
|
|
2051
|
+
init_eloDistance();
|
|
2052
|
+
init_userTagPreference();
|
|
2053
|
+
}
|
|
2054
|
+
});
|
|
2055
|
+
|
|
2056
|
+
// src/core/navigators/filters/inferredPreferenceStub.ts
|
|
2057
|
+
var inferredPreferenceStub_exports = {};
|
|
2058
|
+
__export(inferredPreferenceStub_exports, {
|
|
2059
|
+
INFERRED_PREFERENCE_NAVIGATOR_STUB: () => INFERRED_PREFERENCE_NAVIGATOR_STUB
|
|
2060
|
+
});
|
|
2061
|
+
var INFERRED_PREFERENCE_NAVIGATOR_STUB;
|
|
2062
|
+
var init_inferredPreferenceStub = __esm({
|
|
2063
|
+
"src/core/navigators/filters/inferredPreferenceStub.ts"() {
|
|
2064
|
+
"use strict";
|
|
2065
|
+
INFERRED_PREFERENCE_NAVIGATOR_STUB = true;
|
|
2066
|
+
}
|
|
2067
|
+
});
|
|
2068
|
+
|
|
2069
|
+
// src/core/navigators/filters/interferenceMitigator.ts
|
|
2070
|
+
var interferenceMitigator_exports = {};
|
|
2071
|
+
__export(interferenceMitigator_exports, {
|
|
2072
|
+
default: () => InterferenceMitigatorNavigator
|
|
2073
|
+
});
|
|
2074
|
+
import { toCourseElo as toCourseElo4 } from "@vue-skuilder/common";
|
|
2075
|
+
var DEFAULT_MIN_COUNT2, DEFAULT_MIN_ELAPSED_DAYS, DEFAULT_INTERFERENCE_DECAY, InterferenceMitigatorNavigator;
|
|
2076
|
+
var init_interferenceMitigator = __esm({
|
|
2077
|
+
"src/core/navigators/filters/interferenceMitigator.ts"() {
|
|
2078
|
+
"use strict";
|
|
2079
|
+
init_navigators();
|
|
2080
|
+
DEFAULT_MIN_COUNT2 = 10;
|
|
2081
|
+
DEFAULT_MIN_ELAPSED_DAYS = 3;
|
|
2082
|
+
DEFAULT_INTERFERENCE_DECAY = 0.8;
|
|
2083
|
+
InterferenceMitigatorNavigator = class extends ContentNavigator {
|
|
2084
|
+
config;
|
|
2085
|
+
/** Human-readable name for CardFilter interface */
|
|
2086
|
+
name;
|
|
2087
|
+
/** Precomputed map: tag -> set of { partner, decay } it interferes with */
|
|
2088
|
+
interferenceMap;
|
|
2089
|
+
constructor(user, course, strategyData) {
|
|
2090
|
+
super(user, course, strategyData);
|
|
2091
|
+
this.config = this.parseConfig(strategyData.serializedData);
|
|
2092
|
+
this.interferenceMap = this.buildInterferenceMap();
|
|
2093
|
+
this.name = strategyData.name || "Interference Mitigator";
|
|
2094
|
+
}
|
|
2095
|
+
parseConfig(serializedData) {
|
|
2096
|
+
try {
|
|
2097
|
+
const parsed = JSON.parse(serializedData);
|
|
2098
|
+
let sets = parsed.interferenceSets || [];
|
|
2099
|
+
if (sets.length > 0 && Array.isArray(sets[0])) {
|
|
2100
|
+
sets = sets.map((tags) => ({ tags }));
|
|
2101
|
+
}
|
|
2102
|
+
return {
|
|
2103
|
+
interferenceSets: sets,
|
|
2104
|
+
maturityThreshold: {
|
|
2105
|
+
minCount: parsed.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT2,
|
|
2106
|
+
minElo: parsed.maturityThreshold?.minElo,
|
|
2107
|
+
minElapsedDays: parsed.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS
|
|
2108
|
+
},
|
|
2109
|
+
defaultDecay: parsed.defaultDecay ?? DEFAULT_INTERFERENCE_DECAY
|
|
2110
|
+
};
|
|
2111
|
+
} catch {
|
|
2112
|
+
return {
|
|
2113
|
+
interferenceSets: [],
|
|
2114
|
+
maturityThreshold: {
|
|
2115
|
+
minCount: DEFAULT_MIN_COUNT2,
|
|
2116
|
+
minElapsedDays: DEFAULT_MIN_ELAPSED_DAYS
|
|
2117
|
+
},
|
|
2118
|
+
defaultDecay: DEFAULT_INTERFERENCE_DECAY
|
|
2119
|
+
};
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
/**
|
|
2123
|
+
* Build a map from each tag to its interference partners with decay coefficients.
|
|
2124
|
+
* If tags A, B, C are in an interference group with decay 0.8, then:
|
|
2125
|
+
* - A interferes with B (decay 0.8) and C (decay 0.8)
|
|
2126
|
+
* - B interferes with A (decay 0.8) and C (decay 0.8)
|
|
2127
|
+
* - etc.
|
|
2128
|
+
*/
|
|
2129
|
+
buildInterferenceMap() {
|
|
2130
|
+
const map = /* @__PURE__ */ new Map();
|
|
2131
|
+
for (const group of this.config.interferenceSets) {
|
|
2132
|
+
const decay = group.decay ?? this.config.defaultDecay ?? DEFAULT_INTERFERENCE_DECAY;
|
|
2133
|
+
for (const tag of group.tags) {
|
|
2134
|
+
if (!map.has(tag)) {
|
|
2135
|
+
map.set(tag, []);
|
|
2136
|
+
}
|
|
2137
|
+
const partners = map.get(tag);
|
|
2138
|
+
for (const other of group.tags) {
|
|
2139
|
+
if (other !== tag) {
|
|
2140
|
+
const existing = partners.find((p) => p.partner === other);
|
|
2141
|
+
if (existing) {
|
|
2142
|
+
existing.decay = Math.max(existing.decay, decay);
|
|
2143
|
+
} else {
|
|
2144
|
+
partners.push({ partner: other, decay });
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
return map;
|
|
2151
|
+
}
|
|
2152
|
+
/**
|
|
2153
|
+
* Get the set of tags that are currently immature for this user.
|
|
2154
|
+
* A tag is immature if the user has interacted with it but hasn't
|
|
2155
|
+
* reached the maturity threshold.
|
|
2156
|
+
*/
|
|
2157
|
+
async getImmatureTags(context) {
|
|
2158
|
+
const immature = /* @__PURE__ */ new Set();
|
|
2159
|
+
try {
|
|
2160
|
+
const courseReg = await context.user.getCourseRegDoc(context.course.getCourseID());
|
|
2161
|
+
const userElo = toCourseElo4(courseReg.elo);
|
|
2162
|
+
const minCount = this.config.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT2;
|
|
2163
|
+
const minElo = this.config.maturityThreshold?.minElo;
|
|
2164
|
+
const minElapsedDays = this.config.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS;
|
|
2165
|
+
const minCountForElapsed = minElapsedDays * 2;
|
|
2166
|
+
for (const [tagId, tagElo] of Object.entries(userElo.tags)) {
|
|
2167
|
+
if (tagElo.count === 0) continue;
|
|
2168
|
+
const belowCount = tagElo.count < minCount;
|
|
2169
|
+
const belowElo = minElo !== void 0 && tagElo.score < minElo;
|
|
2170
|
+
const belowElapsed = tagElo.count < minCountForElapsed;
|
|
2171
|
+
if (belowCount || belowElo || belowElapsed) {
|
|
2172
|
+
immature.add(tagId);
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
} catch {
|
|
2176
|
+
}
|
|
2177
|
+
return immature;
|
|
2178
|
+
}
|
|
2179
|
+
/**
|
|
2180
|
+
* Get all tags that interfere with any immature tag, along with their decay coefficients.
|
|
2181
|
+
* These are the tags we want to avoid introducing.
|
|
2182
|
+
*/
|
|
2183
|
+
getTagsToAvoid(immatureTags) {
|
|
2184
|
+
const avoid = /* @__PURE__ */ new Map();
|
|
2185
|
+
for (const immatureTag of immatureTags) {
|
|
2186
|
+
const partners = this.interferenceMap.get(immatureTag);
|
|
2187
|
+
if (partners) {
|
|
2188
|
+
for (const { partner, decay } of partners) {
|
|
2189
|
+
if (!immatureTags.has(partner)) {
|
|
2190
|
+
const existing = avoid.get(partner) ?? 0;
|
|
2191
|
+
avoid.set(partner, Math.max(existing, decay));
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
return avoid;
|
|
2197
|
+
}
|
|
2198
|
+
/**
|
|
2199
|
+
* Compute interference score reduction for a card.
|
|
2200
|
+
* Returns: { multiplier, interfering tags, reason }
|
|
2201
|
+
*/
|
|
2202
|
+
computeInterferenceEffect(cardTags, tagsToAvoid, immatureTags) {
|
|
2203
|
+
if (tagsToAvoid.size === 0) {
|
|
2204
|
+
return {
|
|
2205
|
+
multiplier: 1,
|
|
2206
|
+
interferingTags: [],
|
|
2207
|
+
reason: "No interference detected"
|
|
2208
|
+
};
|
|
2209
|
+
}
|
|
2210
|
+
let multiplier = 1;
|
|
2211
|
+
const interferingTags = [];
|
|
2212
|
+
for (const tag of cardTags) {
|
|
2213
|
+
const decay = tagsToAvoid.get(tag);
|
|
2214
|
+
if (decay !== void 0) {
|
|
2215
|
+
interferingTags.push(tag);
|
|
2216
|
+
multiplier *= 1 - decay;
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
if (interferingTags.length === 0) {
|
|
2220
|
+
return {
|
|
2221
|
+
multiplier: 1,
|
|
2222
|
+
interferingTags: [],
|
|
2223
|
+
reason: "No interference detected"
|
|
2224
|
+
};
|
|
2225
|
+
}
|
|
2226
|
+
const causingTags = /* @__PURE__ */ new Set();
|
|
2227
|
+
for (const tag of interferingTags) {
|
|
2228
|
+
for (const immatureTag of immatureTags) {
|
|
2229
|
+
const partners = this.interferenceMap.get(immatureTag);
|
|
2230
|
+
if (partners?.some((p) => p.partner === tag)) {
|
|
2231
|
+
causingTags.add(immatureTag);
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
const reason = `Interferes with immature tags ${Array.from(causingTags).join(", ")} (tags: ${interferingTags.join(", ")}, multiplier: ${multiplier.toFixed(2)})`;
|
|
2236
|
+
return { multiplier, interferingTags, reason };
|
|
2237
|
+
}
|
|
2238
|
+
/**
|
|
2239
|
+
* CardFilter.transform implementation.
|
|
2240
|
+
*
|
|
2241
|
+
* Apply interference-aware scoring. Cards with tags that interfere with
|
|
2242
|
+
* immature learnings get reduced scores.
|
|
2243
|
+
*/
|
|
2244
|
+
async transform(cards, context) {
|
|
2245
|
+
const immatureTags = await this.getImmatureTags(context);
|
|
2246
|
+
const tagsToAvoid = this.getTagsToAvoid(immatureTags);
|
|
2247
|
+
const adjusted = [];
|
|
2248
|
+
for (const card of cards) {
|
|
2249
|
+
const cardTags = card.tags ?? [];
|
|
2250
|
+
const { multiplier, reason } = this.computeInterferenceEffect(
|
|
2251
|
+
cardTags,
|
|
2252
|
+
tagsToAvoid,
|
|
2253
|
+
immatureTags
|
|
2254
|
+
);
|
|
2255
|
+
const finalScore = card.score * multiplier;
|
|
2256
|
+
const action = multiplier < 1 ? "penalized" : multiplier > 1 ? "boosted" : "passed";
|
|
2257
|
+
adjusted.push({
|
|
2258
|
+
...card,
|
|
2259
|
+
score: finalScore,
|
|
2260
|
+
provenance: [
|
|
2261
|
+
...card.provenance,
|
|
2262
|
+
{
|
|
2263
|
+
strategy: "interferenceMitigator",
|
|
2264
|
+
strategyName: this.strategyName || this.name,
|
|
2265
|
+
strategyId: this.strategyId || "NAVIGATION_STRATEGY-interference",
|
|
2266
|
+
action,
|
|
2267
|
+
score: finalScore,
|
|
2268
|
+
reason
|
|
2269
|
+
}
|
|
2270
|
+
]
|
|
2271
|
+
});
|
|
2272
|
+
}
|
|
2273
|
+
return adjusted;
|
|
2274
|
+
}
|
|
2275
|
+
/**
|
|
2276
|
+
* Legacy getWeightedCards - now throws as filters should not be used as generators.
|
|
2277
|
+
*
|
|
2278
|
+
* Use transform() via Pipeline instead.
|
|
2279
|
+
*/
|
|
2280
|
+
async getWeightedCards(_limit) {
|
|
2281
|
+
throw new Error(
|
|
2282
|
+
"InterferenceMitigatorNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
|
|
2283
|
+
);
|
|
2284
|
+
}
|
|
2285
|
+
};
|
|
2286
|
+
}
|
|
2287
|
+
});
|
|
2288
|
+
|
|
2289
|
+
// src/core/navigators/filters/relativePriority.ts
|
|
2290
|
+
var relativePriority_exports = {};
|
|
2291
|
+
__export(relativePriority_exports, {
|
|
2292
|
+
default: () => RelativePriorityNavigator
|
|
2293
|
+
});
|
|
2294
|
+
var DEFAULT_PRIORITY, DEFAULT_PRIORITY_INFLUENCE, DEFAULT_COMBINE_MODE, RelativePriorityNavigator;
|
|
2295
|
+
var init_relativePriority = __esm({
|
|
2296
|
+
"src/core/navigators/filters/relativePriority.ts"() {
|
|
2297
|
+
"use strict";
|
|
2298
|
+
init_navigators();
|
|
2299
|
+
DEFAULT_PRIORITY = 0.5;
|
|
2300
|
+
DEFAULT_PRIORITY_INFLUENCE = 0.5;
|
|
2301
|
+
DEFAULT_COMBINE_MODE = "max";
|
|
2302
|
+
RelativePriorityNavigator = class extends ContentNavigator {
|
|
2303
|
+
config;
|
|
2304
|
+
/** Human-readable name for CardFilter interface */
|
|
2305
|
+
name;
|
|
2306
|
+
constructor(user, course, strategyData) {
|
|
2307
|
+
super(user, course, strategyData);
|
|
2308
|
+
this.config = this.parseConfig(strategyData.serializedData);
|
|
2309
|
+
this.name = strategyData.name || "Relative Priority";
|
|
2310
|
+
}
|
|
2311
|
+
parseConfig(serializedData) {
|
|
2312
|
+
try {
|
|
2313
|
+
const parsed = JSON.parse(serializedData);
|
|
2314
|
+
return {
|
|
2315
|
+
tagPriorities: parsed.tagPriorities || {},
|
|
2316
|
+
defaultPriority: parsed.defaultPriority ?? DEFAULT_PRIORITY,
|
|
2317
|
+
combineMode: parsed.combineMode ?? DEFAULT_COMBINE_MODE,
|
|
2318
|
+
priorityInfluence: parsed.priorityInfluence ?? DEFAULT_PRIORITY_INFLUENCE
|
|
2319
|
+
};
|
|
2320
|
+
} catch {
|
|
2321
|
+
return {
|
|
2322
|
+
tagPriorities: {},
|
|
2323
|
+
defaultPriority: DEFAULT_PRIORITY,
|
|
2324
|
+
combineMode: DEFAULT_COMBINE_MODE,
|
|
2325
|
+
priorityInfluence: DEFAULT_PRIORITY_INFLUENCE
|
|
2326
|
+
};
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
2329
|
+
/**
|
|
2330
|
+
* Look up the priority for a tag.
|
|
2331
|
+
*/
|
|
2332
|
+
getTagPriority(tagId) {
|
|
2333
|
+
return this.config.tagPriorities[tagId] ?? this.config.defaultPriority ?? DEFAULT_PRIORITY;
|
|
2334
|
+
}
|
|
2335
|
+
/**
|
|
2336
|
+
* Compute combined priority for a card based on its tags.
|
|
2337
|
+
*/
|
|
2338
|
+
computeCardPriority(cardTags) {
|
|
2339
|
+
if (cardTags.length === 0) {
|
|
2340
|
+
return this.config.defaultPriority ?? DEFAULT_PRIORITY;
|
|
2341
|
+
}
|
|
2342
|
+
const priorities = cardTags.map((tag) => this.getTagPriority(tag));
|
|
2343
|
+
switch (this.config.combineMode) {
|
|
2344
|
+
case "max":
|
|
2345
|
+
return Math.max(...priorities);
|
|
2346
|
+
case "min":
|
|
2347
|
+
return Math.min(...priorities);
|
|
2348
|
+
case "average":
|
|
2349
|
+
return priorities.reduce((sum, p) => sum + p, 0) / priorities.length;
|
|
2350
|
+
default:
|
|
2351
|
+
return Math.max(...priorities);
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
/**
|
|
2355
|
+
* Compute boost factor based on priority.
|
|
2356
|
+
*
|
|
2357
|
+
* The formula: 1 + (priority - 0.5) * priorityInfluence
|
|
2358
|
+
*
|
|
2359
|
+
* This creates a multiplier centered around 1.0:
|
|
2360
|
+
* - Priority 1.0 with influence 0.5 → 1.25 (25% boost)
|
|
2361
|
+
* - Priority 0.5 with any influence → 1.00 (neutral)
|
|
2362
|
+
* - Priority 0.0 with influence 0.5 → 0.75 (25% reduction)
|
|
2363
|
+
*/
|
|
2364
|
+
computeBoostFactor(priority) {
|
|
2365
|
+
const influence = this.config.priorityInfluence ?? DEFAULT_PRIORITY_INFLUENCE;
|
|
2366
|
+
return 1 + (priority - 0.5) * influence;
|
|
2367
|
+
}
|
|
2368
|
+
/**
|
|
2369
|
+
* Build human-readable reason for priority adjustment.
|
|
2370
|
+
*/
|
|
2371
|
+
buildPriorityReason(cardTags, priority, boostFactor, finalScore) {
|
|
2372
|
+
if (cardTags.length === 0) {
|
|
2373
|
+
return `No tags, neutral priority (${priority.toFixed(2)})`;
|
|
2374
|
+
}
|
|
2375
|
+
const tagList = cardTags.slice(0, 3).join(", ");
|
|
2376
|
+
const more = cardTags.length > 3 ? ` (+${cardTags.length - 3} more)` : "";
|
|
2377
|
+
if (boostFactor === 1) {
|
|
2378
|
+
return `Neutral priority (${priority.toFixed(2)}) for tags: ${tagList}${more}`;
|
|
2379
|
+
} else if (boostFactor > 1) {
|
|
2380
|
+
return `High-priority tags: ${tagList}${more} (priority ${priority.toFixed(2)} \u2192 boost ${boostFactor.toFixed(2)}x \u2192 ${finalScore.toFixed(2)})`;
|
|
2381
|
+
} else {
|
|
2382
|
+
return `Low-priority tags: ${tagList}${more} (priority ${priority.toFixed(2)} \u2192 reduce ${boostFactor.toFixed(2)}x \u2192 ${finalScore.toFixed(2)})`;
|
|
2383
|
+
}
|
|
2384
|
+
}
|
|
2385
|
+
/**
|
|
2386
|
+
* CardFilter.transform implementation.
|
|
2387
|
+
*
|
|
2388
|
+
* Apply priority-adjusted scoring. Cards with high-priority tags get boosted,
|
|
2389
|
+
* cards with low-priority tags get reduced scores.
|
|
2390
|
+
*/
|
|
2391
|
+
async transform(cards, _context) {
|
|
2392
|
+
const adjusted = await Promise.all(
|
|
2393
|
+
cards.map(async (card) => {
|
|
2394
|
+
const cardTags = card.tags ?? [];
|
|
2395
|
+
const priority = this.computeCardPriority(cardTags);
|
|
2396
|
+
const boostFactor = this.computeBoostFactor(priority);
|
|
2397
|
+
const finalScore = Math.max(0, Math.min(1, card.score * boostFactor));
|
|
2398
|
+
const action = boostFactor > 1 ? "boosted" : boostFactor < 1 ? "penalized" : "passed";
|
|
2399
|
+
const reason = this.buildPriorityReason(cardTags, priority, boostFactor, finalScore);
|
|
2400
|
+
return {
|
|
2401
|
+
...card,
|
|
2402
|
+
score: finalScore,
|
|
2403
|
+
provenance: [
|
|
2404
|
+
...card.provenance,
|
|
2405
|
+
{
|
|
2406
|
+
strategy: "relativePriority",
|
|
2407
|
+
strategyName: this.strategyName || this.name,
|
|
2408
|
+
strategyId: this.strategyId || "NAVIGATION_STRATEGY-priority",
|
|
2409
|
+
action,
|
|
2410
|
+
score: finalScore,
|
|
2411
|
+
reason
|
|
2412
|
+
}
|
|
2413
|
+
]
|
|
2414
|
+
};
|
|
2415
|
+
})
|
|
2416
|
+
);
|
|
2417
|
+
return adjusted;
|
|
2418
|
+
}
|
|
2419
|
+
/**
|
|
2420
|
+
* Legacy getWeightedCards - now throws as filters should not be used as generators.
|
|
2421
|
+
*
|
|
2422
|
+
* Use transform() via Pipeline instead.
|
|
2423
|
+
*/
|
|
2424
|
+
async getWeightedCards(_limit) {
|
|
2425
|
+
throw new Error(
|
|
2426
|
+
"RelativePriorityNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
|
|
2427
|
+
);
|
|
2428
|
+
}
|
|
2429
|
+
};
|
|
2430
|
+
}
|
|
2431
|
+
});
|
|
2432
|
+
|
|
2433
|
+
// src/core/navigators/filters/types.ts
|
|
2434
|
+
var types_exports2 = {};
|
|
2435
|
+
var init_types2 = __esm({
|
|
2436
|
+
"src/core/navigators/filters/types.ts"() {
|
|
2437
|
+
"use strict";
|
|
2438
|
+
}
|
|
2439
|
+
});
|
|
2440
|
+
|
|
2441
|
+
// src/core/navigators/filters/userGoalStub.ts
|
|
2442
|
+
var userGoalStub_exports = {};
|
|
2443
|
+
__export(userGoalStub_exports, {
|
|
2444
|
+
USER_GOAL_NAVIGATOR_STUB: () => USER_GOAL_NAVIGATOR_STUB
|
|
2445
|
+
});
|
|
2446
|
+
var USER_GOAL_NAVIGATOR_STUB;
|
|
2447
|
+
var init_userGoalStub = __esm({
|
|
2448
|
+
"src/core/navigators/filters/userGoalStub.ts"() {
|
|
2449
|
+
"use strict";
|
|
2450
|
+
USER_GOAL_NAVIGATOR_STUB = true;
|
|
2451
|
+
}
|
|
2452
|
+
});
|
|
2453
|
+
|
|
2454
|
+
// import("./filters/**/*") in src/core/navigators/index.ts
|
|
2455
|
+
var globImport_filters;
|
|
2456
|
+
var init_2 = __esm({
|
|
2457
|
+
'import("./filters/**/*") in src/core/navigators/index.ts'() {
|
|
2458
|
+
globImport_filters = __glob({
|
|
2459
|
+
"./filters/WeightedFilter.ts": () => Promise.resolve().then(() => (init_WeightedFilter(), WeightedFilter_exports)),
|
|
2460
|
+
"./filters/eloDistance.ts": () => Promise.resolve().then(() => (init_eloDistance(), eloDistance_exports)),
|
|
2461
|
+
"./filters/hierarchyDefinition.ts": () => Promise.resolve().then(() => (init_hierarchyDefinition(), hierarchyDefinition_exports)),
|
|
2462
|
+
"./filters/index.ts": () => Promise.resolve().then(() => (init_filters(), filters_exports)),
|
|
2463
|
+
"./filters/inferredPreferenceStub.ts": () => Promise.resolve().then(() => (init_inferredPreferenceStub(), inferredPreferenceStub_exports)),
|
|
2464
|
+
"./filters/interferenceMitigator.ts": () => Promise.resolve().then(() => (init_interferenceMitigator(), interferenceMitigator_exports)),
|
|
2465
|
+
"./filters/relativePriority.ts": () => Promise.resolve().then(() => (init_relativePriority(), relativePriority_exports)),
|
|
2466
|
+
"./filters/types.ts": () => Promise.resolve().then(() => (init_types2(), types_exports2)),
|
|
2467
|
+
"./filters/userGoalStub.ts": () => Promise.resolve().then(() => (init_userGoalStub(), userGoalStub_exports)),
|
|
2468
|
+
"./filters/userTagPreference.ts": () => Promise.resolve().then(() => (init_userTagPreference(), userTagPreference_exports))
|
|
2469
|
+
});
|
|
2470
|
+
}
|
|
2471
|
+
});
|
|
2472
|
+
|
|
2473
|
+
// src/core/orchestration/gradient.ts
|
|
2474
|
+
function aggregateOutcomesForGradient(outcomes, strategyId) {
|
|
2475
|
+
const observations = [];
|
|
2476
|
+
for (const outcome of outcomes) {
|
|
2477
|
+
const deviation = outcome.deviations[strategyId];
|
|
2478
|
+
if (deviation === void 0) {
|
|
2479
|
+
continue;
|
|
2480
|
+
}
|
|
2481
|
+
observations.push({
|
|
2482
|
+
deviation,
|
|
2483
|
+
outcomeValue: outcome.outcomeValue,
|
|
2484
|
+
weight: 1
|
|
2485
|
+
});
|
|
2486
|
+
}
|
|
2487
|
+
logger.debug(
|
|
2488
|
+
`[Orchestration] Aggregated ${observations.length} observations for strategy ${strategyId}`
|
|
2489
|
+
);
|
|
2490
|
+
return observations;
|
|
2491
|
+
}
|
|
2492
|
+
function computeStrategyGradient(observations) {
|
|
2493
|
+
const n = observations.length;
|
|
2494
|
+
if (n < 3) {
|
|
2495
|
+
logger.debug(`[Orchestration] Insufficient observations for gradient (${n} < 3)`);
|
|
2496
|
+
return null;
|
|
2497
|
+
}
|
|
2498
|
+
let sumX = 0;
|
|
2499
|
+
let sumY = 0;
|
|
2500
|
+
let sumW = 0;
|
|
2501
|
+
for (const obs of observations) {
|
|
2502
|
+
const w = obs.weight ?? 1;
|
|
2503
|
+
sumX += obs.deviation * w;
|
|
2504
|
+
sumY += obs.outcomeValue * w;
|
|
2505
|
+
sumW += w;
|
|
2506
|
+
}
|
|
2507
|
+
const meanX = sumX / sumW;
|
|
2508
|
+
const meanY = sumY / sumW;
|
|
2509
|
+
let numerator = 0;
|
|
2510
|
+
let denominator = 0;
|
|
2511
|
+
let ssTotal = 0;
|
|
2512
|
+
for (const obs of observations) {
|
|
2513
|
+
const w = obs.weight ?? 1;
|
|
2514
|
+
const dx = obs.deviation - meanX;
|
|
2515
|
+
const dy = obs.outcomeValue - meanY;
|
|
2516
|
+
numerator += w * dx * dy;
|
|
2517
|
+
denominator += w * dx * dx;
|
|
2518
|
+
ssTotal += w * dy * dy;
|
|
2519
|
+
}
|
|
2520
|
+
if (denominator < 1e-10) {
|
|
2521
|
+
logger.debug(`[Orchestration] No variance in deviations, cannot compute gradient`);
|
|
2522
|
+
return {
|
|
2523
|
+
gradient: 0,
|
|
2524
|
+
intercept: meanY,
|
|
2525
|
+
rSquared: 0,
|
|
2526
|
+
sampleSize: n
|
|
2527
|
+
};
|
|
2528
|
+
}
|
|
2529
|
+
const gradient = numerator / denominator;
|
|
2530
|
+
const intercept = meanY - gradient * meanX;
|
|
2531
|
+
let ssResidual = 0;
|
|
2532
|
+
for (const obs of observations) {
|
|
2533
|
+
const w = obs.weight ?? 1;
|
|
2534
|
+
const predicted = gradient * obs.deviation + intercept;
|
|
2535
|
+
const residual = obs.outcomeValue - predicted;
|
|
2536
|
+
ssResidual += w * residual * residual;
|
|
2537
|
+
}
|
|
2538
|
+
const rSquared = ssTotal > 1e-10 ? 1 - ssResidual / ssTotal : 0;
|
|
2539
|
+
logger.debug(
|
|
2540
|
+
`[Orchestration] Computed gradient: ${gradient.toFixed(4)}, intercept: ${intercept.toFixed(4)}, R\xB2: ${rSquared.toFixed(4)}, n=${n}`
|
|
2541
|
+
);
|
|
2542
|
+
return {
|
|
2543
|
+
gradient,
|
|
2544
|
+
intercept,
|
|
2545
|
+
rSquared: Math.max(0, Math.min(1, rSquared)),
|
|
2546
|
+
// Clamp to [0,1]
|
|
2547
|
+
sampleSize: n
|
|
2548
|
+
};
|
|
2549
|
+
}
|
|
2550
|
+
var init_gradient = __esm({
|
|
2551
|
+
"src/core/orchestration/gradient.ts"() {
|
|
2552
|
+
"use strict";
|
|
2553
|
+
init_logger();
|
|
2554
|
+
}
|
|
2555
|
+
});
|
|
2556
|
+
|
|
2557
|
+
// src/core/orchestration/learning.ts
|
|
2558
|
+
function updateStrategyWeight(current, gradient) {
|
|
2559
|
+
if (gradient.sampleSize < MIN_OBSERVATIONS_FOR_UPDATE) {
|
|
2560
|
+
logger.debug(
|
|
2561
|
+
`[Orchestration] Insufficient samples (${gradient.sampleSize} < ${MIN_OBSERVATIONS_FOR_UPDATE}), keeping current weight`
|
|
2562
|
+
);
|
|
2563
|
+
return {
|
|
2564
|
+
...current,
|
|
2565
|
+
sampleSize: current.sampleSize + gradient.sampleSize
|
|
2566
|
+
};
|
|
2567
|
+
}
|
|
2568
|
+
const isReliable = gradient.rSquared >= MIN_R_SQUARED_FOR_GRADIENT;
|
|
2569
|
+
const isFlat = Math.abs(gradient.gradient) < FLAT_GRADIENT_THRESHOLD;
|
|
2570
|
+
let newWeight = current.weight;
|
|
2571
|
+
let newConfidence = current.confidence;
|
|
2572
|
+
if (!isReliable || isFlat) {
|
|
2573
|
+
const confidenceGain = 0.05 * (1 - current.confidence);
|
|
2574
|
+
newConfidence = Math.min(1, current.confidence + confidenceGain);
|
|
2575
|
+
logger.debug(
|
|
2576
|
+
`[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)}`
|
|
2577
|
+
);
|
|
2578
|
+
} else {
|
|
2579
|
+
let delta = gradient.gradient * LEARNING_RATE;
|
|
2580
|
+
delta = Math.max(-MAX_WEIGHT_DELTA, Math.min(MAX_WEIGHT_DELTA, delta));
|
|
2581
|
+
newWeight = current.weight + delta;
|
|
2582
|
+
newWeight = Math.max(0.1, Math.min(3, newWeight));
|
|
2583
|
+
const confidenceGain = 0.02 * (1 - current.confidence);
|
|
2584
|
+
newConfidence = Math.min(1, current.confidence + confidenceGain);
|
|
2585
|
+
logger.debug(
|
|
2586
|
+
`[Orchestration] Adjusting weight: ${current.weight.toFixed(3)} \u2192 ${newWeight.toFixed(3)} (gradient=${gradient.gradient.toFixed(4)}, delta=${delta.toFixed(4)})`
|
|
2587
|
+
);
|
|
2588
|
+
}
|
|
2589
|
+
return {
|
|
2590
|
+
weight: newWeight,
|
|
2591
|
+
confidence: newConfidence,
|
|
2592
|
+
sampleSize: current.sampleSize + gradient.sampleSize
|
|
2593
|
+
};
|
|
2594
|
+
}
|
|
2595
|
+
function updateLearningState(courseId, strategyId, currentWeight, gradient, existing) {
|
|
2596
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2597
|
+
const id = `STRATEGY_LEARNING_STATE::${courseId}::${strategyId}`;
|
|
2598
|
+
const historyEntry = {
|
|
2599
|
+
timestamp: now,
|
|
2600
|
+
weight: currentWeight.weight,
|
|
2601
|
+
confidence: currentWeight.confidence,
|
|
2602
|
+
gradient: gradient.gradient
|
|
2603
|
+
};
|
|
2604
|
+
let history = existing?.history ?? [];
|
|
2605
|
+
history = [...history, historyEntry];
|
|
2606
|
+
if (history.length > MAX_HISTORY_LENGTH) {
|
|
2607
|
+
history = history.slice(history.length - MAX_HISTORY_LENGTH);
|
|
2608
|
+
}
|
|
2609
|
+
const state = {
|
|
2610
|
+
_id: id,
|
|
2611
|
+
_rev: existing?._rev,
|
|
2612
|
+
docType: "STRATEGY_LEARNING_STATE" /* STRATEGY_LEARNING_STATE */,
|
|
2613
|
+
courseId,
|
|
2614
|
+
strategyId,
|
|
2615
|
+
currentWeight,
|
|
2616
|
+
regression: {
|
|
2617
|
+
gradient: gradient.gradient,
|
|
2618
|
+
intercept: gradient.intercept,
|
|
2619
|
+
rSquared: gradient.rSquared,
|
|
2620
|
+
sampleSize: gradient.sampleSize,
|
|
2621
|
+
computedAt: now
|
|
2622
|
+
},
|
|
2623
|
+
history,
|
|
2624
|
+
updatedAt: now
|
|
2625
|
+
};
|
|
2626
|
+
return state;
|
|
2627
|
+
}
|
|
2628
|
+
function runPeriodUpdate(input) {
|
|
2629
|
+
const { courseId, strategyId, currentWeight, gradient, existingState } = input;
|
|
2630
|
+
logger.info(
|
|
2631
|
+
`[Orchestration] Running period update for strategy ${strategyId} (${gradient.sampleSize} observations)`
|
|
2632
|
+
);
|
|
2633
|
+
const newWeight = updateStrategyWeight(currentWeight, gradient);
|
|
2634
|
+
const updated = newWeight.weight !== currentWeight.weight;
|
|
2635
|
+
const learningState = updateLearningState(
|
|
2636
|
+
courseId,
|
|
2637
|
+
strategyId,
|
|
2638
|
+
newWeight,
|
|
2639
|
+
gradient,
|
|
2640
|
+
existingState
|
|
2641
|
+
);
|
|
2642
|
+
logger.info(
|
|
2643
|
+
`[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)}`
|
|
2644
|
+
);
|
|
2645
|
+
return {
|
|
2646
|
+
strategyId,
|
|
2647
|
+
previousWeight: currentWeight,
|
|
2648
|
+
newWeight,
|
|
2649
|
+
gradient,
|
|
2650
|
+
learningState,
|
|
2651
|
+
updated
|
|
2652
|
+
};
|
|
2653
|
+
}
|
|
2654
|
+
function getDefaultLearnableWeight() {
|
|
2655
|
+
return { ...DEFAULT_LEARNABLE_WEIGHT };
|
|
2656
|
+
}
|
|
2657
|
+
var MIN_OBSERVATIONS_FOR_UPDATE, LEARNING_RATE, MAX_WEIGHT_DELTA, MIN_R_SQUARED_FOR_GRADIENT, FLAT_GRADIENT_THRESHOLD, MAX_HISTORY_LENGTH;
|
|
2658
|
+
var init_learning = __esm({
|
|
2659
|
+
"src/core/orchestration/learning.ts"() {
|
|
2660
|
+
"use strict";
|
|
2661
|
+
init_contentNavigationStrategy();
|
|
2662
|
+
init_types_legacy();
|
|
2663
|
+
init_logger();
|
|
2664
|
+
MIN_OBSERVATIONS_FOR_UPDATE = 10;
|
|
2665
|
+
LEARNING_RATE = 0.1;
|
|
2666
|
+
MAX_WEIGHT_DELTA = 0.3;
|
|
2667
|
+
MIN_R_SQUARED_FOR_GRADIENT = 0.05;
|
|
2668
|
+
FLAT_GRADIENT_THRESHOLD = 0.02;
|
|
2669
|
+
MAX_HISTORY_LENGTH = 100;
|
|
2670
|
+
}
|
|
2671
|
+
});
|
|
2672
|
+
|
|
2673
|
+
// src/core/orchestration/signal.ts
|
|
2674
|
+
function computeOutcomeSignal(records, config = {}) {
|
|
2675
|
+
if (!records || records.length === 0) {
|
|
2676
|
+
return null;
|
|
2677
|
+
}
|
|
2678
|
+
const target = config.targetAccuracy ?? 0.85;
|
|
2679
|
+
const tolerance = config.tolerance ?? 0.05;
|
|
2680
|
+
let correct = 0;
|
|
2681
|
+
for (const r of records) {
|
|
2682
|
+
if (r.isCorrect) correct++;
|
|
2683
|
+
}
|
|
2684
|
+
const accuracy = correct / records.length;
|
|
2685
|
+
return scoreAccuracyInZone(accuracy, target, tolerance);
|
|
2686
|
+
}
|
|
2687
|
+
function scoreAccuracyInZone(accuracy, target, tolerance) {
|
|
2688
|
+
const dist = Math.abs(accuracy - target);
|
|
2689
|
+
if (dist <= tolerance) {
|
|
2690
|
+
return 1;
|
|
2691
|
+
}
|
|
2692
|
+
const excess = dist - tolerance;
|
|
2693
|
+
const slope = 2.5;
|
|
2694
|
+
return Math.max(0, 1 - excess * slope);
|
|
2695
|
+
}
|
|
2696
|
+
var init_signal = __esm({
|
|
2697
|
+
"src/core/orchestration/signal.ts"() {
|
|
2698
|
+
"use strict";
|
|
2699
|
+
}
|
|
2700
|
+
});
|
|
2701
|
+
|
|
2702
|
+
// src/core/orchestration/recording.ts
|
|
2703
|
+
async function recordUserOutcome(context, periodStart, periodEnd, records, activeStrategyIds, eloStart = 0, eloEnd = 0, config) {
|
|
2704
|
+
const { user, course, userId } = context;
|
|
2705
|
+
const courseId = course.getCourseID();
|
|
2706
|
+
const outcomeValue = computeOutcomeSignal(records, config);
|
|
2707
|
+
if (outcomeValue === null) {
|
|
2708
|
+
logger.debug(
|
|
2709
|
+
`[Orchestration] No outcome signal computed for ${userId} (insufficient data). Skipping record.`
|
|
2710
|
+
);
|
|
2711
|
+
return;
|
|
2712
|
+
}
|
|
2713
|
+
const deviations = {};
|
|
2714
|
+
for (const strategyId of activeStrategyIds) {
|
|
2715
|
+
deviations[strategyId] = context.getDeviation(strategyId);
|
|
2716
|
+
}
|
|
2717
|
+
const id = `USER_OUTCOME::${courseId}::${userId}::${periodEnd}`;
|
|
2718
|
+
const record = {
|
|
2719
|
+
_id: id,
|
|
2720
|
+
docType: "USER_OUTCOME" /* USER_OUTCOME */,
|
|
2721
|
+
courseId,
|
|
2722
|
+
userId,
|
|
2723
|
+
periodStart,
|
|
2724
|
+
periodEnd,
|
|
2725
|
+
outcomeValue,
|
|
2726
|
+
deviations,
|
|
2727
|
+
metadata: {
|
|
2728
|
+
sessionsCount: 1,
|
|
2729
|
+
// Assumes recording is triggered per-session currently
|
|
2730
|
+
cardsSeen: records.length,
|
|
2731
|
+
eloStart,
|
|
2732
|
+
eloEnd,
|
|
2733
|
+
signalType: "accuracy_in_zone"
|
|
2734
|
+
}
|
|
2735
|
+
};
|
|
2736
|
+
try {
|
|
2737
|
+
await user.putUserOutcome(record);
|
|
2738
|
+
logger.debug(
|
|
2739
|
+
`[Orchestration] Recorded outcome ${outcomeValue.toFixed(3)} for ${userId} (doc: ${id})`
|
|
2740
|
+
);
|
|
2741
|
+
} catch (e) {
|
|
2742
|
+
logger.error(`[Orchestration] Failed to record outcome: ${e}`);
|
|
2743
|
+
}
|
|
2744
|
+
}
|
|
2745
|
+
var init_recording = __esm({
|
|
2746
|
+
"src/core/orchestration/recording.ts"() {
|
|
2747
|
+
"use strict";
|
|
2748
|
+
init_signal();
|
|
2749
|
+
init_types_legacy();
|
|
2750
|
+
init_logger();
|
|
2751
|
+
}
|
|
2752
|
+
});
|
|
2753
|
+
|
|
2754
|
+
// src/core/orchestration/index.ts
|
|
2755
|
+
function fnv1a(str) {
|
|
2756
|
+
let hash = 2166136261;
|
|
2757
|
+
for (let i = 0; i < str.length; i++) {
|
|
2758
|
+
hash ^= str.charCodeAt(i);
|
|
2759
|
+
hash = Math.imul(hash, 16777619);
|
|
2760
|
+
}
|
|
2761
|
+
return hash >>> 0;
|
|
2762
|
+
}
|
|
2763
|
+
function computeDeviation(userId, strategyId, salt) {
|
|
2764
|
+
const input = `${userId}:${strategyId}:${salt}`;
|
|
2765
|
+
const hash = fnv1a(input);
|
|
2766
|
+
const normalized = hash / 4294967296;
|
|
2767
|
+
return normalized * 2 - 1;
|
|
2768
|
+
}
|
|
2769
|
+
function computeSpread(confidence) {
|
|
2770
|
+
const clampedConfidence = Math.max(0, Math.min(1, confidence));
|
|
2771
|
+
return MAX_SPREAD - clampedConfidence * (MAX_SPREAD - MIN_SPREAD);
|
|
2772
|
+
}
|
|
2773
|
+
function computeEffectiveWeight(learnable, userId, strategyId, salt) {
|
|
2774
|
+
const deviation = computeDeviation(userId, strategyId, salt);
|
|
2775
|
+
const spread = computeSpread(learnable.confidence);
|
|
2776
|
+
const adjustment = deviation * spread * learnable.weight;
|
|
2777
|
+
const effective = learnable.weight + adjustment;
|
|
2778
|
+
return Math.max(MIN_WEIGHT, Math.min(MAX_WEIGHT, effective));
|
|
2779
|
+
}
|
|
2780
|
+
async function createOrchestrationContext(user, course) {
|
|
2781
|
+
let courseConfig;
|
|
2782
|
+
try {
|
|
2783
|
+
courseConfig = await course.getCourseConfig();
|
|
2784
|
+
} catch (e) {
|
|
2785
|
+
logger.error(`[Orchestration] Failed to load course config: ${e}`);
|
|
2786
|
+
courseConfig = {
|
|
2787
|
+
name: "Unknown",
|
|
2788
|
+
description: "",
|
|
2789
|
+
public: false,
|
|
2790
|
+
deleted: false,
|
|
2791
|
+
creator: "",
|
|
2792
|
+
admins: [],
|
|
2793
|
+
moderators: [],
|
|
2794
|
+
dataShapes: [],
|
|
2795
|
+
questionTypes: [],
|
|
2796
|
+
orchestration: { salt: "default" }
|
|
2797
|
+
};
|
|
2798
|
+
}
|
|
2799
|
+
const userId = user.getUsername();
|
|
2800
|
+
const salt = courseConfig.orchestration?.salt || "default_salt";
|
|
2801
|
+
return {
|
|
2802
|
+
user,
|
|
2803
|
+
course,
|
|
2804
|
+
userId,
|
|
2805
|
+
courseConfig,
|
|
2806
|
+
getEffectiveWeight(strategyId, learnable) {
|
|
2807
|
+
return computeEffectiveWeight(learnable, userId, strategyId, salt);
|
|
2808
|
+
},
|
|
2809
|
+
getDeviation(strategyId) {
|
|
2810
|
+
return computeDeviation(userId, strategyId, salt);
|
|
2811
|
+
}
|
|
2812
|
+
};
|
|
2813
|
+
}
|
|
2814
|
+
var MIN_SPREAD, MAX_SPREAD, MIN_WEIGHT, MAX_WEIGHT;
|
|
2815
|
+
var init_orchestration = __esm({
|
|
2816
|
+
"src/core/orchestration/index.ts"() {
|
|
2817
|
+
"use strict";
|
|
2818
|
+
init_logger();
|
|
2819
|
+
init_gradient();
|
|
2820
|
+
init_learning();
|
|
2821
|
+
init_signal();
|
|
2822
|
+
init_recording();
|
|
2823
|
+
MIN_SPREAD = 0.1;
|
|
2824
|
+
MAX_SPREAD = 0.5;
|
|
2825
|
+
MIN_WEIGHT = 0.1;
|
|
2826
|
+
MAX_WEIGHT = 3;
|
|
2827
|
+
}
|
|
2828
|
+
});
|
|
2829
|
+
|
|
2830
|
+
// src/core/navigators/Pipeline.ts
|
|
2831
|
+
var Pipeline_exports = {};
|
|
2832
|
+
__export(Pipeline_exports, {
|
|
2833
|
+
Pipeline: () => Pipeline
|
|
2834
|
+
});
|
|
2835
|
+
import { toCourseElo as toCourseElo5 } from "@vue-skuilder/common";
|
|
2836
|
+
function logPipelineConfig(generator, filters) {
|
|
2837
|
+
const filterList = filters.length > 0 ? "\n - " + filters.map((f) => f.name).join("\n - ") : " none";
|
|
2838
|
+
logger.info(
|
|
2839
|
+
`[Pipeline] Configuration:
|
|
2840
|
+
Generator: ${generator.name}
|
|
2841
|
+
Filters:${filterList}`
|
|
2842
|
+
);
|
|
2843
|
+
}
|
|
2844
|
+
function logTagHydration(cards, tagsByCard) {
|
|
2845
|
+
const totalTags = Array.from(tagsByCard.values()).reduce((sum, tags) => sum + tags.length, 0);
|
|
2846
|
+
const cardsWithTags = Array.from(tagsByCard.values()).filter((tags) => tags.length > 0).length;
|
|
2847
|
+
logger.debug(
|
|
2848
|
+
`[Pipeline] Tag hydration: ${cards.length} cards, ${cardsWithTags} have tags (${totalTags} total tags) - single batch query`
|
|
2849
|
+
);
|
|
2850
|
+
}
|
|
2851
|
+
function logExecutionSummary(generatorName, generatedCount, filterCount, finalCount, topScores, filterImpacts) {
|
|
2852
|
+
const scoreDisplay = topScores.length > 0 ? topScores.map((s) => s.toFixed(2)).join(", ") : "none";
|
|
2853
|
+
let filterSummary = "";
|
|
2854
|
+
if (filterImpacts.length > 0) {
|
|
2855
|
+
const impacts = filterImpacts.map((f) => {
|
|
2856
|
+
const parts = [];
|
|
2857
|
+
if (f.boosted > 0) parts.push(`+${f.boosted}`);
|
|
2858
|
+
if (f.penalized > 0) parts.push(`-${f.penalized}`);
|
|
2859
|
+
if (f.passed > 0) parts.push(`=${f.passed}`);
|
|
2860
|
+
return `${f.name}: ${parts.join("/")}`;
|
|
2861
|
+
});
|
|
2862
|
+
filterSummary = `
|
|
2863
|
+
Filter impact: ${impacts.join(", ")}`;
|
|
2864
|
+
}
|
|
2865
|
+
logger.info(
|
|
2866
|
+
`[Pipeline] Execution: ${generatorName} produced ${generatedCount} \u2192 ${filterCount} filters \u2192 ${finalCount} results (top scores: ${scoreDisplay})` + filterSummary + `
|
|
2867
|
+
\u{1F4A1} Inspect: window.skuilder.pipeline`
|
|
2868
|
+
);
|
|
2869
|
+
}
|
|
2870
|
+
function logCardProvenance(cards, maxCards = 3) {
|
|
2871
|
+
const cardsToLog = cards.slice(0, maxCards);
|
|
2872
|
+
logger.debug(`[Pipeline] Provenance for top ${cardsToLog.length} cards:`);
|
|
2873
|
+
for (const card of cardsToLog) {
|
|
2874
|
+
logger.debug(`[Pipeline] ${card.cardId} (final score: ${card.score.toFixed(3)}):`);
|
|
2875
|
+
for (const entry of card.provenance) {
|
|
2876
|
+
const scoreChange = entry.score.toFixed(3);
|
|
2877
|
+
const action = entry.action.padEnd(9);
|
|
2878
|
+
logger.debug(
|
|
2879
|
+
`[Pipeline] ${action} ${scoreChange} - ${entry.strategyName}: ${entry.reason}`
|
|
2880
|
+
);
|
|
2881
|
+
}
|
|
2882
|
+
}
|
|
2883
|
+
}
|
|
2884
|
+
var Pipeline;
|
|
2885
|
+
var init_Pipeline = __esm({
|
|
2886
|
+
"src/core/navigators/Pipeline.ts"() {
|
|
2887
|
+
"use strict";
|
|
2888
|
+
init_navigators();
|
|
2889
|
+
init_logger();
|
|
2890
|
+
init_orchestration();
|
|
2891
|
+
init_PipelineDebugger();
|
|
2892
|
+
Pipeline = class extends ContentNavigator {
|
|
2893
|
+
generator;
|
|
2894
|
+
filters;
|
|
2895
|
+
/**
|
|
2896
|
+
* Create a new pipeline.
|
|
2897
|
+
*
|
|
2898
|
+
* @param generator - The generator (or CompositeGenerator) that produces candidates
|
|
2899
|
+
* @param filters - Filters to apply sequentially (order doesn't matter for multipliers)
|
|
2900
|
+
* @param user - User database interface
|
|
2901
|
+
* @param course - Course database interface
|
|
2902
|
+
*/
|
|
2903
|
+
constructor(generator, filters, user, course) {
|
|
2904
|
+
super();
|
|
2905
|
+
this.generator = generator;
|
|
2906
|
+
this.filters = filters;
|
|
2907
|
+
this.user = user;
|
|
2908
|
+
this.course = course;
|
|
2909
|
+
course.getCourseConfig().then((cfg) => {
|
|
2910
|
+
logger.debug(`[pipeline] Crated pipeline for ${cfg.name}`);
|
|
2911
|
+
}).catch((e) => {
|
|
2912
|
+
logger.error(`[pipeline] Failed to lookup courseCfg: ${e}`);
|
|
2913
|
+
});
|
|
2914
|
+
logPipelineConfig(generator, filters);
|
|
2915
|
+
}
|
|
2916
|
+
/**
|
|
2917
|
+
* Get weighted cards by running generator and applying filters.
|
|
2918
|
+
*
|
|
2919
|
+
* 1. Build shared context (user ELO, etc.)
|
|
2920
|
+
* 2. Get candidates from generator (passing context)
|
|
2921
|
+
* 3. Batch hydrate tags for all candidates
|
|
2922
|
+
* 4. Apply each filter sequentially
|
|
2923
|
+
* 5. Remove zero-score cards
|
|
2924
|
+
* 6. Sort by score descending
|
|
2925
|
+
* 7. Return top N
|
|
2926
|
+
*
|
|
2927
|
+
* @param limit - Maximum number of cards to return
|
|
2928
|
+
* @returns Cards sorted by score descending
|
|
2929
|
+
*/
|
|
2930
|
+
async getWeightedCards(limit) {
|
|
2931
|
+
const context = await this.buildContext();
|
|
2932
|
+
const overFetchMultiplier = 2 + this.filters.length * 0.5;
|
|
2933
|
+
const fetchLimit = Math.ceil(limit * overFetchMultiplier);
|
|
2934
|
+
logger.debug(
|
|
2935
|
+
`[Pipeline] Fetching ${fetchLimit} candidates from generator '${this.generator.name}'`
|
|
2936
|
+
);
|
|
2937
|
+
let cards = await this.generator.getWeightedCards(fetchLimit, context);
|
|
2938
|
+
const generatedCount = cards.length;
|
|
2939
|
+
let generatorSummaries;
|
|
2940
|
+
if (this.generator.generators) {
|
|
2941
|
+
const genMap = /* @__PURE__ */ new Map();
|
|
2942
|
+
for (const card of cards) {
|
|
2943
|
+
const firstProv = card.provenance[0];
|
|
2944
|
+
if (firstProv) {
|
|
2945
|
+
const genName = firstProv.strategyName;
|
|
2946
|
+
if (!genMap.has(genName)) {
|
|
2947
|
+
genMap.set(genName, { cards: [] });
|
|
2948
|
+
}
|
|
2949
|
+
genMap.get(genName).cards.push(card);
|
|
2950
|
+
}
|
|
2951
|
+
}
|
|
2952
|
+
generatorSummaries = Array.from(genMap.entries()).map(([name, data]) => {
|
|
2953
|
+
const newCards = data.cards.filter((c) => c.provenance[0]?.reason?.includes("new card"));
|
|
2954
|
+
const reviewCards = data.cards.filter((c) => c.provenance[0]?.reason?.includes("review"));
|
|
2955
|
+
return {
|
|
2956
|
+
name,
|
|
2957
|
+
cardCount: data.cards.length,
|
|
2958
|
+
newCount: newCards.length,
|
|
2959
|
+
reviewCount: reviewCards.length,
|
|
2960
|
+
topScore: Math.max(...data.cards.map((c) => c.score), 0)
|
|
2961
|
+
};
|
|
2962
|
+
});
|
|
2963
|
+
}
|
|
2964
|
+
logger.debug(`[Pipeline] Generator returned ${generatedCount} candidates`);
|
|
2965
|
+
cards = await this.hydrateTags(cards);
|
|
2966
|
+
const allCardsBeforeFiltering = [...cards];
|
|
2967
|
+
const filterImpacts = [];
|
|
2968
|
+
for (const filter of this.filters) {
|
|
2969
|
+
const beforeCount = cards.length;
|
|
2970
|
+
const beforeScores = new Map(cards.map((c) => [c.cardId, c.score]));
|
|
2971
|
+
cards = await filter.transform(cards, context);
|
|
2972
|
+
let boosted = 0, penalized = 0, passed = 0;
|
|
2973
|
+
const removed = beforeCount - cards.length;
|
|
2974
|
+
for (const card of cards) {
|
|
2975
|
+
const before = beforeScores.get(card.cardId) ?? 0;
|
|
2976
|
+
if (card.score > before) boosted++;
|
|
2977
|
+
else if (card.score < before) penalized++;
|
|
2978
|
+
else passed++;
|
|
2979
|
+
}
|
|
2980
|
+
filterImpacts.push({ name: filter.name, boosted, penalized, passed, removed });
|
|
2981
|
+
logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeScores.size} \u2192 ${cards.length} cards (\u2191${boosted} \u2193${penalized} =${passed})`);
|
|
2982
|
+
}
|
|
2983
|
+
cards = cards.filter((c) => c.score > 0);
|
|
2984
|
+
cards.sort((a, b) => b.score - a.score);
|
|
2985
|
+
const result = cards.slice(0, limit);
|
|
2986
|
+
const topScores = result.slice(0, 3).map((c) => c.score);
|
|
2987
|
+
logExecutionSummary(
|
|
2988
|
+
this.generator.name,
|
|
2989
|
+
generatedCount,
|
|
2990
|
+
this.filters.length,
|
|
2991
|
+
result.length,
|
|
2992
|
+
topScores,
|
|
2993
|
+
filterImpacts
|
|
2994
|
+
);
|
|
2995
|
+
logCardProvenance(result, 3);
|
|
2996
|
+
try {
|
|
2997
|
+
const courseName = await this.course?.getCourseConfig().then((c) => c.name).catch(() => void 0);
|
|
2998
|
+
const report = buildRunReport(
|
|
2999
|
+
this.course?.getCourseID() || "unknown",
|
|
3000
|
+
courseName,
|
|
3001
|
+
this.generator.name,
|
|
3002
|
+
generatorSummaries,
|
|
3003
|
+
generatedCount,
|
|
3004
|
+
filterImpacts,
|
|
3005
|
+
allCardsBeforeFiltering,
|
|
3006
|
+
result
|
|
3007
|
+
);
|
|
3008
|
+
captureRun(report);
|
|
3009
|
+
} catch (e) {
|
|
3010
|
+
logger.debug(`[Pipeline] Failed to capture debug run: ${e}`);
|
|
3011
|
+
}
|
|
3012
|
+
return result;
|
|
3013
|
+
}
|
|
3014
|
+
/**
|
|
3015
|
+
* Batch hydrate tags for all cards.
|
|
3016
|
+
*
|
|
3017
|
+
* Fetches tags for all cards in a single database query and attaches them
|
|
3018
|
+
* to the WeightedCard objects. Filters can then use card.tags instead of
|
|
3019
|
+
* making individual getAppliedTags() calls.
|
|
3020
|
+
*
|
|
3021
|
+
* @param cards - Cards to hydrate
|
|
3022
|
+
* @returns Cards with tags populated
|
|
3023
|
+
*/
|
|
3024
|
+
async hydrateTags(cards) {
|
|
3025
|
+
if (cards.length === 0) {
|
|
3026
|
+
return cards;
|
|
3027
|
+
}
|
|
3028
|
+
const cardIds = cards.map((c) => c.cardId);
|
|
3029
|
+
const tagsByCard = await this.course.getAppliedTagsBatch(cardIds);
|
|
3030
|
+
logTagHydration(cards, tagsByCard);
|
|
3031
|
+
return cards.map((card) => ({
|
|
3032
|
+
...card,
|
|
3033
|
+
tags: tagsByCard.get(card.cardId) ?? []
|
|
3034
|
+
}));
|
|
3035
|
+
}
|
|
3036
|
+
/**
|
|
3037
|
+
* Build shared context for generator and filters.
|
|
3038
|
+
*
|
|
3039
|
+
* Called once per getWeightedCards() invocation.
|
|
3040
|
+
* Contains data that the generator and multiple filters might need.
|
|
3041
|
+
*
|
|
3042
|
+
* The context satisfies both GeneratorContext and FilterContext interfaces.
|
|
3043
|
+
*/
|
|
3044
|
+
async buildContext() {
|
|
3045
|
+
let userElo = 1e3;
|
|
3046
|
+
try {
|
|
3047
|
+
const courseReg = await this.user.getCourseRegDoc(this.course.getCourseID());
|
|
3048
|
+
const courseElo = toCourseElo5(courseReg.elo);
|
|
3049
|
+
userElo = courseElo.global.score;
|
|
3050
|
+
} catch (e) {
|
|
3051
|
+
logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`);
|
|
3052
|
+
}
|
|
3053
|
+
const orchestration = await createOrchestrationContext(this.user, this.course);
|
|
3054
|
+
return {
|
|
3055
|
+
user: this.user,
|
|
3056
|
+
course: this.course,
|
|
3057
|
+
userElo,
|
|
3058
|
+
orchestration
|
|
3059
|
+
};
|
|
3060
|
+
}
|
|
3061
|
+
/**
|
|
3062
|
+
* Get the course ID for this pipeline.
|
|
3063
|
+
*/
|
|
3064
|
+
getCourseID() {
|
|
3065
|
+
return this.course.getCourseID();
|
|
3066
|
+
}
|
|
3067
|
+
/**
|
|
3068
|
+
* Get orchestration context for outcome recording.
|
|
3069
|
+
*/
|
|
3070
|
+
async getOrchestrationContext() {
|
|
3071
|
+
return createOrchestrationContext(this.user, this.course);
|
|
3072
|
+
}
|
|
3073
|
+
/**
|
|
3074
|
+
* Get IDs of all strategies in this pipeline.
|
|
3075
|
+
* Used to record which strategies contributed to an outcome.
|
|
3076
|
+
*/
|
|
3077
|
+
getStrategyIds() {
|
|
3078
|
+
const ids = [];
|
|
3079
|
+
const extractId = (obj) => {
|
|
3080
|
+
if (obj.strategyId) return obj.strategyId;
|
|
3081
|
+
return null;
|
|
3082
|
+
};
|
|
3083
|
+
const genId = extractId(this.generator);
|
|
3084
|
+
if (genId) ids.push(genId);
|
|
3085
|
+
if (this.generator.generators && Array.isArray(this.generator.generators)) {
|
|
3086
|
+
this.generator.generators.forEach((g) => {
|
|
3087
|
+
const subId = extractId(g);
|
|
3088
|
+
if (subId) ids.push(subId);
|
|
3089
|
+
});
|
|
3090
|
+
}
|
|
3091
|
+
for (const filter of this.filters) {
|
|
3092
|
+
const fId = extractId(filter);
|
|
3093
|
+
if (fId) ids.push(fId);
|
|
3094
|
+
}
|
|
3095
|
+
return [...new Set(ids)];
|
|
3096
|
+
}
|
|
3097
|
+
};
|
|
3098
|
+
}
|
|
3099
|
+
});
|
|
3100
|
+
|
|
3101
|
+
// src/core/navigators/defaults.ts
|
|
3102
|
+
var defaults_exports = {};
|
|
3103
|
+
__export(defaults_exports, {
|
|
3104
|
+
createDefaultEloStrategy: () => createDefaultEloStrategy,
|
|
3105
|
+
createDefaultPipeline: () => createDefaultPipeline,
|
|
3106
|
+
createDefaultSrsStrategy: () => createDefaultSrsStrategy
|
|
3107
|
+
});
|
|
3108
|
+
function createDefaultEloStrategy(courseId) {
|
|
3109
|
+
return {
|
|
3110
|
+
_id: "NAVIGATION_STRATEGY-ELO-default",
|
|
3111
|
+
docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
|
|
3112
|
+
name: "ELO (default)",
|
|
3113
|
+
description: "Default ELO-based navigation strategy for new cards",
|
|
3114
|
+
implementingClass: "elo" /* ELO */,
|
|
3115
|
+
course: courseId,
|
|
3116
|
+
serializedData: ""
|
|
3117
|
+
};
|
|
3118
|
+
}
|
|
3119
|
+
function createDefaultSrsStrategy(courseId) {
|
|
3120
|
+
return {
|
|
3121
|
+
_id: "NAVIGATION_STRATEGY-SRS-default",
|
|
3122
|
+
docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
|
|
3123
|
+
name: "SRS (default)",
|
|
3124
|
+
description: "Default SRS-based navigation strategy for reviews",
|
|
3125
|
+
implementingClass: "srs" /* SRS */,
|
|
3126
|
+
course: courseId,
|
|
3127
|
+
serializedData: ""
|
|
3128
|
+
};
|
|
3129
|
+
}
|
|
3130
|
+
function createDefaultPipeline(user, course) {
|
|
3131
|
+
const courseId = course.getCourseID();
|
|
3132
|
+
const eloNavigator = new ELONavigator(user, course, createDefaultEloStrategy(courseId));
|
|
3133
|
+
const srsNavigator = new SRSNavigator(user, course, createDefaultSrsStrategy(courseId));
|
|
3134
|
+
const compositeGenerator = new CompositeGenerator([eloNavigator, srsNavigator]);
|
|
3135
|
+
const eloDistanceFilter = createEloDistanceFilter();
|
|
3136
|
+
return new Pipeline(compositeGenerator, [eloDistanceFilter], user, course);
|
|
3137
|
+
}
|
|
3138
|
+
var init_defaults = __esm({
|
|
3139
|
+
"src/core/navigators/defaults.ts"() {
|
|
3140
|
+
"use strict";
|
|
3141
|
+
init_navigators();
|
|
3142
|
+
init_Pipeline();
|
|
3143
|
+
init_CompositeGenerator();
|
|
3144
|
+
init_elo();
|
|
3145
|
+
init_srs();
|
|
3146
|
+
init_eloDistance();
|
|
3147
|
+
init_types_legacy();
|
|
3148
|
+
}
|
|
3149
|
+
});
|
|
3150
|
+
|
|
3151
|
+
// src/core/navigators/PipelineAssembler.ts
|
|
3152
|
+
var PipelineAssembler_exports = {};
|
|
3153
|
+
__export(PipelineAssembler_exports, {
|
|
3154
|
+
PipelineAssembler: () => PipelineAssembler
|
|
3155
|
+
});
|
|
3156
|
+
var PipelineAssembler;
|
|
3157
|
+
var init_PipelineAssembler = __esm({
|
|
3158
|
+
"src/core/navigators/PipelineAssembler.ts"() {
|
|
3159
|
+
"use strict";
|
|
3160
|
+
init_navigators();
|
|
3161
|
+
init_WeightedFilter();
|
|
3162
|
+
init_Pipeline();
|
|
3163
|
+
init_logger();
|
|
3164
|
+
init_CompositeGenerator();
|
|
3165
|
+
init_defaults();
|
|
3166
|
+
PipelineAssembler = class {
|
|
3167
|
+
/**
|
|
3168
|
+
* Assembles a navigation pipeline from strategy documents.
|
|
3169
|
+
*
|
|
3170
|
+
* 1. Separates into generators and filters by role
|
|
3171
|
+
* 2. Validates at least one generator exists (or creates default ELO)
|
|
3172
|
+
* 3. Instantiates generators - wraps multiple in CompositeGenerator
|
|
3173
|
+
* 4. Instantiates filters
|
|
3174
|
+
* 5. Returns Pipeline(generator, filters)
|
|
3175
|
+
*
|
|
3176
|
+
* @param input - Strategy documents plus user/course interfaces
|
|
3177
|
+
* @returns Assembled pipeline and any warnings
|
|
3178
|
+
*/
|
|
3179
|
+
async assemble(input) {
|
|
3180
|
+
const { strategies, user, course } = input;
|
|
3181
|
+
const warnings = [];
|
|
3182
|
+
if (strategies.length === 0) {
|
|
3183
|
+
return {
|
|
3184
|
+
pipeline: null,
|
|
3185
|
+
generatorStrategies: [],
|
|
3186
|
+
filterStrategies: [],
|
|
3187
|
+
warnings
|
|
3188
|
+
};
|
|
3189
|
+
}
|
|
3190
|
+
const generatorStrategies = [];
|
|
3191
|
+
const filterStrategies = [];
|
|
3192
|
+
for (const s of strategies) {
|
|
3193
|
+
if (isGenerator(s.implementingClass)) {
|
|
3194
|
+
generatorStrategies.push(s);
|
|
3195
|
+
} else if (isFilter(s.implementingClass)) {
|
|
3196
|
+
filterStrategies.push(s);
|
|
3197
|
+
} else {
|
|
3198
|
+
warnings.push(`Unknown strategy type '${s.implementingClass}', skipping: ${s.name}`);
|
|
3199
|
+
}
|
|
3200
|
+
}
|
|
3201
|
+
if (generatorStrategies.length === 0) {
|
|
3202
|
+
if (filterStrategies.length > 0) {
|
|
3203
|
+
logger.debug(
|
|
3204
|
+
"[PipelineAssembler] No generator found, using default ELO and SRS with configured filters"
|
|
3205
|
+
);
|
|
3206
|
+
const courseId = course.getCourseID();
|
|
3207
|
+
generatorStrategies.push(createDefaultEloStrategy(courseId));
|
|
3208
|
+
generatorStrategies.push(createDefaultSrsStrategy(courseId));
|
|
3209
|
+
} else {
|
|
3210
|
+
warnings.push("No generator strategy found");
|
|
3211
|
+
return {
|
|
3212
|
+
pipeline: null,
|
|
3213
|
+
generatorStrategies: [],
|
|
3214
|
+
filterStrategies: [],
|
|
3215
|
+
warnings
|
|
3216
|
+
};
|
|
3217
|
+
}
|
|
3218
|
+
}
|
|
3219
|
+
let generator;
|
|
3220
|
+
if (generatorStrategies.length === 1) {
|
|
3221
|
+
const nav = await ContentNavigator.create(user, course, generatorStrategies[0]);
|
|
3222
|
+
generator = nav;
|
|
3223
|
+
logger.debug(`[PipelineAssembler] Using single generator: ${generatorStrategies[0].name}`);
|
|
3224
|
+
} else {
|
|
3225
|
+
logger.debug(
|
|
3226
|
+
`[PipelineAssembler] Using CompositeGenerator for ${generatorStrategies.length} generators: ${generatorStrategies.map((g) => g.name).join(", ")}`
|
|
3227
|
+
);
|
|
3228
|
+
generator = await CompositeGenerator.fromStrategies(user, course, generatorStrategies);
|
|
3229
|
+
}
|
|
3230
|
+
const filters = [];
|
|
3231
|
+
const sortedFilterStrategies = [...filterStrategies].sort(
|
|
3232
|
+
(a, b) => a.name.localeCompare(b.name)
|
|
3233
|
+
);
|
|
3234
|
+
for (const filterStrategy of sortedFilterStrategies) {
|
|
3235
|
+
try {
|
|
3236
|
+
const nav = await ContentNavigator.create(user, course, filterStrategy);
|
|
3237
|
+
if ("transform" in nav && typeof nav.transform === "function") {
|
|
3238
|
+
let filter = nav;
|
|
3239
|
+
if (filterStrategy.learnable) {
|
|
3240
|
+
filter = new WeightedFilter(
|
|
3241
|
+
filter,
|
|
3242
|
+
filterStrategy.learnable,
|
|
3243
|
+
filterStrategy.staticWeight,
|
|
3244
|
+
filterStrategy._id
|
|
3245
|
+
);
|
|
3246
|
+
}
|
|
3247
|
+
filters.push(filter);
|
|
3248
|
+
logger.debug(`[PipelineAssembler] Added filter: ${filterStrategy.name}`);
|
|
3249
|
+
} else {
|
|
3250
|
+
warnings.push(
|
|
3251
|
+
`Filter '${filterStrategy.name}' does not implement CardFilter.transform(), skipping`
|
|
3252
|
+
);
|
|
3253
|
+
}
|
|
3254
|
+
} catch (e) {
|
|
3255
|
+
warnings.push(`Failed to instantiate filter '${filterStrategy.name}': ${e}`);
|
|
3256
|
+
}
|
|
3257
|
+
}
|
|
3258
|
+
const pipeline = new Pipeline(generator, filters, user, course);
|
|
3259
|
+
logger.debug(
|
|
3260
|
+
`[PipelineAssembler] Assembled pipeline with ${generatorStrategies.length} generator(s) and ${filters.length} filter(s)`
|
|
3261
|
+
);
|
|
3262
|
+
return {
|
|
3263
|
+
pipeline,
|
|
3264
|
+
generatorStrategies,
|
|
3265
|
+
filterStrategies: sortedFilterStrategies,
|
|
3266
|
+
warnings
|
|
3267
|
+
};
|
|
3268
|
+
}
|
|
3269
|
+
};
|
|
3270
|
+
}
|
|
3271
|
+
});
|
|
3272
|
+
|
|
3273
|
+
// import("./**/*") in src/core/navigators/index.ts
|
|
3274
|
+
var globImport;
|
|
3275
|
+
var init_3 = __esm({
|
|
3276
|
+
'import("./**/*") in src/core/navigators/index.ts'() {
|
|
3277
|
+
globImport = __glob({
|
|
3278
|
+
"./Pipeline.ts": () => Promise.resolve().then(() => (init_Pipeline(), Pipeline_exports)),
|
|
3279
|
+
"./PipelineAssembler.ts": () => Promise.resolve().then(() => (init_PipelineAssembler(), PipelineAssembler_exports)),
|
|
3280
|
+
"./PipelineDebugger.ts": () => Promise.resolve().then(() => (init_PipelineDebugger(), PipelineDebugger_exports)),
|
|
3281
|
+
"./defaults.ts": () => Promise.resolve().then(() => (init_defaults(), defaults_exports)),
|
|
3282
|
+
"./filters/WeightedFilter.ts": () => Promise.resolve().then(() => (init_WeightedFilter(), WeightedFilter_exports)),
|
|
3283
|
+
"./filters/eloDistance.ts": () => Promise.resolve().then(() => (init_eloDistance(), eloDistance_exports)),
|
|
3284
|
+
"./filters/hierarchyDefinition.ts": () => Promise.resolve().then(() => (init_hierarchyDefinition(), hierarchyDefinition_exports)),
|
|
3285
|
+
"./filters/index.ts": () => Promise.resolve().then(() => (init_filters(), filters_exports)),
|
|
3286
|
+
"./filters/inferredPreferenceStub.ts": () => Promise.resolve().then(() => (init_inferredPreferenceStub(), inferredPreferenceStub_exports)),
|
|
3287
|
+
"./filters/interferenceMitigator.ts": () => Promise.resolve().then(() => (init_interferenceMitigator(), interferenceMitigator_exports)),
|
|
3288
|
+
"./filters/relativePriority.ts": () => Promise.resolve().then(() => (init_relativePriority(), relativePriority_exports)),
|
|
3289
|
+
"./filters/types.ts": () => Promise.resolve().then(() => (init_types2(), types_exports2)),
|
|
3290
|
+
"./filters/userGoalStub.ts": () => Promise.resolve().then(() => (init_userGoalStub(), userGoalStub_exports)),
|
|
3291
|
+
"./filters/userTagPreference.ts": () => Promise.resolve().then(() => (init_userTagPreference(), userTagPreference_exports)),
|
|
3292
|
+
"./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
|
|
3293
|
+
"./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
|
|
3294
|
+
"./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
|
|
3295
|
+
"./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
|
|
3296
|
+
"./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports)),
|
|
3297
|
+
"./index.ts": () => Promise.resolve().then(() => (init_navigators(), navigators_exports))
|
|
3298
|
+
});
|
|
3299
|
+
}
|
|
3300
|
+
});
|
|
3301
|
+
|
|
3302
|
+
// src/core/navigators/index.ts
|
|
3303
|
+
var navigators_exports = {};
|
|
3304
|
+
__export(navigators_exports, {
|
|
3305
|
+
ContentNavigator: () => ContentNavigator,
|
|
3306
|
+
NavigatorRole: () => NavigatorRole,
|
|
3307
|
+
NavigatorRoles: () => NavigatorRoles,
|
|
3308
|
+
Navigators: () => Navigators,
|
|
3309
|
+
getCardOrigin: () => getCardOrigin,
|
|
3310
|
+
getRegisteredNavigator: () => getRegisteredNavigator,
|
|
3311
|
+
getRegisteredNavigatorNames: () => getRegisteredNavigatorNames,
|
|
3312
|
+
hasRegisteredNavigator: () => hasRegisteredNavigator,
|
|
3313
|
+
initializeNavigatorRegistry: () => initializeNavigatorRegistry,
|
|
3314
|
+
isFilter: () => isFilter,
|
|
3315
|
+
isGenerator: () => isGenerator,
|
|
3316
|
+
mountPipelineDebugger: () => mountPipelineDebugger,
|
|
3317
|
+
pipelineDebugAPI: () => pipelineDebugAPI,
|
|
3318
|
+
registerNavigator: () => registerNavigator
|
|
3319
|
+
});
|
|
3320
|
+
function registerNavigator(implementingClass, constructor) {
|
|
3321
|
+
navigatorRegistry.set(implementingClass, constructor);
|
|
3322
|
+
logger.debug(`[NavigatorRegistry] Registered: ${implementingClass}`);
|
|
3323
|
+
}
|
|
3324
|
+
function getRegisteredNavigator(implementingClass) {
|
|
3325
|
+
return navigatorRegistry.get(implementingClass);
|
|
3326
|
+
}
|
|
3327
|
+
function hasRegisteredNavigator(implementingClass) {
|
|
3328
|
+
return navigatorRegistry.has(implementingClass);
|
|
3329
|
+
}
|
|
3330
|
+
function getRegisteredNavigatorNames() {
|
|
3331
|
+
return Array.from(navigatorRegistry.keys());
|
|
3332
|
+
}
|
|
3333
|
+
async function initializeNavigatorRegistry() {
|
|
3334
|
+
logger.debug("[NavigatorRegistry] Initializing built-in navigators...");
|
|
3335
|
+
const [eloModule, srsModule] = await Promise.all([
|
|
3336
|
+
Promise.resolve().then(() => (init_elo(), elo_exports)),
|
|
3337
|
+
Promise.resolve().then(() => (init_srs(), srs_exports))
|
|
3338
|
+
]);
|
|
3339
|
+
registerNavigator("elo", eloModule.default);
|
|
3340
|
+
registerNavigator("srs", srsModule.default);
|
|
3341
|
+
const [
|
|
3342
|
+
hierarchyModule,
|
|
3343
|
+
interferenceModule,
|
|
3344
|
+
relativePriorityModule,
|
|
3345
|
+
userTagPreferenceModule
|
|
3346
|
+
] = await Promise.all([
|
|
3347
|
+
Promise.resolve().then(() => (init_hierarchyDefinition(), hierarchyDefinition_exports)),
|
|
3348
|
+
Promise.resolve().then(() => (init_interferenceMitigator(), interferenceMitigator_exports)),
|
|
3349
|
+
Promise.resolve().then(() => (init_relativePriority(), relativePriority_exports)),
|
|
3350
|
+
Promise.resolve().then(() => (init_userTagPreference(), userTagPreference_exports))
|
|
3351
|
+
]);
|
|
3352
|
+
registerNavigator("hierarchyDefinition", hierarchyModule.default);
|
|
3353
|
+
registerNavigator("interferenceMitigator", interferenceModule.default);
|
|
3354
|
+
registerNavigator("relativePriority", relativePriorityModule.default);
|
|
3355
|
+
registerNavigator("userTagPreference", userTagPreferenceModule.default);
|
|
3356
|
+
logger.debug(
|
|
3357
|
+
`[NavigatorRegistry] Initialized ${navigatorRegistry.size} navigators: ${getRegisteredNavigatorNames().join(", ")}`
|
|
3358
|
+
);
|
|
3359
|
+
}
|
|
3360
|
+
function getCardOrigin(card) {
|
|
3361
|
+
if (card.provenance.length === 0) {
|
|
3362
|
+
throw new Error("Card has no provenance - cannot determine origin");
|
|
3363
|
+
}
|
|
3364
|
+
const firstEntry = card.provenance[0];
|
|
3365
|
+
const reason = firstEntry.reason.toLowerCase();
|
|
3366
|
+
if (reason.includes("failed")) {
|
|
3367
|
+
return "failed";
|
|
3368
|
+
}
|
|
3369
|
+
if (reason.includes("review")) {
|
|
3370
|
+
return "review";
|
|
3371
|
+
}
|
|
3372
|
+
return "new";
|
|
3373
|
+
}
|
|
3374
|
+
function isGenerator(impl) {
|
|
3375
|
+
return NavigatorRoles[impl] === "generator" /* GENERATOR */;
|
|
3376
|
+
}
|
|
3377
|
+
function isFilter(impl) {
|
|
3378
|
+
return NavigatorRoles[impl] === "filter" /* FILTER */;
|
|
3379
|
+
}
|
|
3380
|
+
var navigatorRegistry, Navigators, NavigatorRole, NavigatorRoles, ContentNavigator;
|
|
3381
|
+
var init_navigators = __esm({
|
|
3382
|
+
"src/core/navigators/index.ts"() {
|
|
3383
|
+
"use strict";
|
|
3384
|
+
init_PipelineDebugger();
|
|
3385
|
+
init_logger();
|
|
3386
|
+
init_();
|
|
3387
|
+
init_2();
|
|
3388
|
+
init_3();
|
|
3389
|
+
navigatorRegistry = /* @__PURE__ */ new Map();
|
|
3390
|
+
Navigators = /* @__PURE__ */ ((Navigators2) => {
|
|
3391
|
+
Navigators2["ELO"] = "elo";
|
|
3392
|
+
Navigators2["SRS"] = "srs";
|
|
3393
|
+
Navigators2["HIERARCHY"] = "hierarchyDefinition";
|
|
3394
|
+
Navigators2["INTERFERENCE"] = "interferenceMitigator";
|
|
3395
|
+
Navigators2["RELATIVE_PRIORITY"] = "relativePriority";
|
|
3396
|
+
Navigators2["USER_TAG_PREFERENCE"] = "userTagPreference";
|
|
3397
|
+
return Navigators2;
|
|
3398
|
+
})(Navigators || {});
|
|
3399
|
+
NavigatorRole = /* @__PURE__ */ ((NavigatorRole2) => {
|
|
3400
|
+
NavigatorRole2["GENERATOR"] = "generator";
|
|
3401
|
+
NavigatorRole2["FILTER"] = "filter";
|
|
3402
|
+
return NavigatorRole2;
|
|
3403
|
+
})(NavigatorRole || {});
|
|
3404
|
+
NavigatorRoles = {
|
|
3405
|
+
["elo" /* ELO */]: "generator" /* GENERATOR */,
|
|
3406
|
+
["srs" /* SRS */]: "generator" /* GENERATOR */,
|
|
3407
|
+
["hierarchyDefinition" /* HIERARCHY */]: "filter" /* FILTER */,
|
|
3408
|
+
["interferenceMitigator" /* INTERFERENCE */]: "filter" /* FILTER */,
|
|
3409
|
+
["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */,
|
|
3410
|
+
["userTagPreference" /* USER_TAG_PREFERENCE */]: "filter" /* FILTER */
|
|
3411
|
+
};
|
|
3412
|
+
ContentNavigator = class {
|
|
3413
|
+
/** User interface for this navigation session */
|
|
3414
|
+
user;
|
|
3415
|
+
/** Course interface for this navigation session */
|
|
3416
|
+
course;
|
|
3417
|
+
/** Human-readable name for this strategy instance (from ContentNavigationStrategyData.name) */
|
|
3418
|
+
strategyName;
|
|
3419
|
+
/** Unique document ID for this strategy instance (from ContentNavigationStrategyData._id) */
|
|
3420
|
+
strategyId;
|
|
3421
|
+
/** Evolutionary weighting configuration */
|
|
3422
|
+
learnable;
|
|
3423
|
+
/** Whether to bypass deviation (manual/static weighting) */
|
|
3424
|
+
staticWeight;
|
|
3425
|
+
/**
|
|
3426
|
+
* Constructor for standard navigators.
|
|
3427
|
+
* Call this from subclass constructors to initialize common fields.
|
|
3428
|
+
*
|
|
3429
|
+
* Note: CompositeGenerator and Pipeline call super() without args, then set
|
|
3430
|
+
* user/course fields directly if needed.
|
|
3431
|
+
*/
|
|
3432
|
+
constructor(user, course, strategyData) {
|
|
3433
|
+
this.user = user;
|
|
3434
|
+
this.course = course;
|
|
3435
|
+
if (strategyData) {
|
|
3436
|
+
this.strategyName = strategyData.name;
|
|
3437
|
+
this.strategyId = strategyData._id;
|
|
3438
|
+
this.learnable = strategyData.learnable;
|
|
3439
|
+
this.staticWeight = strategyData.staticWeight;
|
|
3440
|
+
}
|
|
3441
|
+
}
|
|
3442
|
+
// ============================================================================
|
|
3443
|
+
// STRATEGY STATE HELPERS
|
|
3444
|
+
// ============================================================================
|
|
3445
|
+
//
|
|
3446
|
+
// These methods allow strategies to persist their own state (user preferences,
|
|
3447
|
+
// learned patterns, temporal tracking) in the user database.
|
|
3448
|
+
//
|
|
3449
|
+
// ============================================================================
|
|
3450
|
+
/**
|
|
3451
|
+
* Unique key identifying this strategy for state storage.
|
|
3452
|
+
*
|
|
3453
|
+
* Defaults to the constructor name (e.g., "UserTagPreferenceFilter").
|
|
3454
|
+
* Override in subclasses if multiple instances of the same strategy type
|
|
3455
|
+
* need separate state storage.
|
|
3456
|
+
*/
|
|
3457
|
+
get strategyKey() {
|
|
3458
|
+
return this.constructor.name;
|
|
3459
|
+
}
|
|
3460
|
+
/**
|
|
3461
|
+
* Get this strategy's persisted state for the current course.
|
|
3462
|
+
*
|
|
3463
|
+
* @returns The strategy's data payload, or null if no state exists
|
|
3464
|
+
* @throws Error if user or course is not initialized
|
|
3465
|
+
*/
|
|
3466
|
+
async getStrategyState() {
|
|
3467
|
+
if (!this.user || !this.course) {
|
|
3468
|
+
throw new Error(
|
|
3469
|
+
`Cannot get strategy state: navigator not properly initialized. Ensure user and course are provided to constructor.`
|
|
3470
|
+
);
|
|
3471
|
+
}
|
|
3472
|
+
return this.user.getStrategyState(this.course.getCourseID(), this.strategyKey);
|
|
3473
|
+
}
|
|
3474
|
+
/**
|
|
3475
|
+
* Persist this strategy's state for the current course.
|
|
3476
|
+
*
|
|
3477
|
+
* @param data - The strategy's data payload to store
|
|
3478
|
+
* @throws Error if user or course is not initialized
|
|
3479
|
+
*/
|
|
3480
|
+
async putStrategyState(data) {
|
|
3481
|
+
if (!this.user || !this.course) {
|
|
3482
|
+
throw new Error(
|
|
3483
|
+
`Cannot put strategy state: navigator not properly initialized. Ensure user and course are provided to constructor.`
|
|
3484
|
+
);
|
|
3485
|
+
}
|
|
3486
|
+
return this.user.putStrategyState(this.course.getCourseID(), this.strategyKey, data);
|
|
3487
|
+
}
|
|
3488
|
+
/**
|
|
3489
|
+
* Factory method to create navigator instances.
|
|
3490
|
+
*
|
|
3491
|
+
* First checks the navigator registry for a pre-registered constructor.
|
|
3492
|
+
* If not found, falls back to dynamic import (for custom navigators).
|
|
3493
|
+
*
|
|
3494
|
+
* For reliable operation in test environments, call initializeNavigatorRegistry()
|
|
3495
|
+
* before using this method.
|
|
3496
|
+
*
|
|
3497
|
+
* @param user - User interface
|
|
3498
|
+
* @param course - Course interface
|
|
3499
|
+
* @param strategyData - Strategy configuration document
|
|
3500
|
+
* @returns the runtime object used to steer a study session.
|
|
3501
|
+
*/
|
|
3502
|
+
static async create(user, course, strategyData) {
|
|
3503
|
+
const implementingClass = strategyData.implementingClass;
|
|
3504
|
+
const RegisteredImpl = getRegisteredNavigator(implementingClass);
|
|
3505
|
+
if (RegisteredImpl) {
|
|
3506
|
+
logger.debug(`[ContentNavigator.create] Using registered navigator: ${implementingClass}`);
|
|
3507
|
+
return new RegisteredImpl(user, course, strategyData);
|
|
3508
|
+
}
|
|
3509
|
+
logger.debug(
|
|
3510
|
+
`[ContentNavigator.create] Navigator not in registry, attempting dynamic import: ${implementingClass}`
|
|
3511
|
+
);
|
|
3512
|
+
let NavigatorImpl;
|
|
3513
|
+
const variations = [".ts", ".js", ""];
|
|
3514
|
+
for (const ext of variations) {
|
|
3515
|
+
try {
|
|
3516
|
+
const module = await globImport_generators(`./generators/${implementingClass}${ext}`);
|
|
3517
|
+
NavigatorImpl = module.default;
|
|
3518
|
+
if (NavigatorImpl) break;
|
|
3519
|
+
} catch (e) {
|
|
3520
|
+
logger.debug(`Failed to load generator ${implementingClass}${ext}:`, e);
|
|
3521
|
+
}
|
|
3522
|
+
try {
|
|
3523
|
+
const module = await globImport_filters(`./filters/${implementingClass}${ext}`);
|
|
3524
|
+
NavigatorImpl = module.default;
|
|
3525
|
+
if (NavigatorImpl) break;
|
|
3526
|
+
} catch (e) {
|
|
3527
|
+
logger.debug(`Failed to load filter ${implementingClass}${ext}:`, e);
|
|
3528
|
+
}
|
|
3529
|
+
try {
|
|
3530
|
+
const module = await globImport(`./${implementingClass}${ext}`);
|
|
3531
|
+
NavigatorImpl = module.default;
|
|
3532
|
+
if (NavigatorImpl) break;
|
|
3533
|
+
} catch (e) {
|
|
3534
|
+
logger.debug(`Failed to load legacy ${implementingClass}${ext}:`, e);
|
|
3535
|
+
}
|
|
3536
|
+
if (NavigatorImpl) break;
|
|
3537
|
+
}
|
|
3538
|
+
if (!NavigatorImpl) {
|
|
3539
|
+
throw new Error(`Could not load navigator implementation for: ${implementingClass}`);
|
|
3540
|
+
}
|
|
3541
|
+
return new NavigatorImpl(user, course, strategyData);
|
|
3542
|
+
}
|
|
3543
|
+
/**
|
|
3544
|
+
* Get cards with suitability scores and provenance trails.
|
|
1572
3545
|
*
|
|
1573
|
-
*
|
|
1574
|
-
* 1. Relative overdueness = hoursOverdue / intervalHours
|
|
1575
|
-
* - 2 days overdue on 3-day interval = 0.67 (urgent)
|
|
1576
|
-
* - 2 days overdue on 180-day interval = 0.01 (not urgent)
|
|
3546
|
+
* **This is the PRIMARY API for navigation strategies.**
|
|
1577
3547
|
*
|
|
1578
|
-
*
|
|
1579
|
-
*
|
|
1580
|
-
*
|
|
1581
|
-
*
|
|
3548
|
+
* Returns cards ranked by suitability score (0-1). Higher scores indicate
|
|
3549
|
+
* better candidates for presentation. Each card includes a provenance trail
|
|
3550
|
+
* documenting how strategies contributed to the final score.
|
|
3551
|
+
*
|
|
3552
|
+
* ## Implementation Required
|
|
3553
|
+
* All navigation strategies MUST override this method. The base class does
|
|
3554
|
+
* not provide a default implementation.
|
|
3555
|
+
*
|
|
3556
|
+
* ## For Generators
|
|
3557
|
+
* Override this method to generate candidates and compute scores based on
|
|
3558
|
+
* your strategy's logic (e.g., ELO proximity, review urgency). Create the
|
|
3559
|
+
* initial provenance entry with action='generated'.
|
|
1582
3560
|
*
|
|
1583
|
-
*
|
|
1584
|
-
*
|
|
3561
|
+
* ## For Filters
|
|
3562
|
+
* Filters should implement the CardFilter interface instead and be composed
|
|
3563
|
+
* via Pipeline. Filters do not directly implement getWeightedCards().
|
|
3564
|
+
*
|
|
3565
|
+
* @param limit - Maximum cards to return
|
|
3566
|
+
* @returns Cards sorted by score descending, with provenance trails
|
|
1585
3567
|
*/
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
const due = moment3.utc(review.reviewTime);
|
|
1589
|
-
const intervalHours = Math.max(1, due.diff(scheduledAt, "hours"));
|
|
1590
|
-
const hoursOverdue = now.diff(due, "hours");
|
|
1591
|
-
const relativeOverdue = hoursOverdue / intervalHours;
|
|
1592
|
-
const recencyFactor = 0.3 + 0.7 * Math.exp(-intervalHours / 720);
|
|
1593
|
-
const overdueContribution = Math.min(1, Math.max(0, relativeOverdue));
|
|
1594
|
-
const urgency = overdueContribution * 0.5 + recencyFactor * 0.5;
|
|
1595
|
-
const score = Math.min(0.95, 0.5 + urgency * 0.45);
|
|
1596
|
-
const reason = `${Math.round(hoursOverdue)}h overdue (interval: ${Math.round(intervalHours)}h, relative: ${relativeOverdue.toFixed(2)}), recency: ${recencyFactor.toFixed(2)}, review`;
|
|
1597
|
-
return { score, reason };
|
|
3568
|
+
async getWeightedCards(_limit) {
|
|
3569
|
+
throw new Error(`${this.constructor.name} must implement getWeightedCards(). `);
|
|
1598
3570
|
}
|
|
1599
3571
|
};
|
|
1600
3572
|
}
|
|
1601
3573
|
});
|
|
1602
3574
|
|
|
1603
|
-
// src/core/navigators/filters/eloDistance.ts
|
|
1604
|
-
function computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier) {
|
|
1605
|
-
const normalizedDistance = distance / halfLife;
|
|
1606
|
-
const decay = Math.exp(-(normalizedDistance * normalizedDistance));
|
|
1607
|
-
return minMultiplier + (maxMultiplier - minMultiplier) * decay;
|
|
1608
|
-
}
|
|
1609
|
-
function createEloDistanceFilter(config) {
|
|
1610
|
-
const halfLife = config?.halfLife ?? DEFAULT_HALF_LIFE;
|
|
1611
|
-
const minMultiplier = config?.minMultiplier ?? DEFAULT_MIN_MULTIPLIER;
|
|
1612
|
-
const maxMultiplier = config?.maxMultiplier ?? DEFAULT_MAX_MULTIPLIER;
|
|
1613
|
-
return {
|
|
1614
|
-
name: "ELO Distance Filter",
|
|
1615
|
-
async transform(cards, context) {
|
|
1616
|
-
const { course, userElo } = context;
|
|
1617
|
-
const cardIds = cards.map((c) => c.cardId);
|
|
1618
|
-
const cardElos = await course.getCardEloData(cardIds);
|
|
1619
|
-
return cards.map((card, i) => {
|
|
1620
|
-
const cardElo = cardElos[i]?.global?.score ?? 1e3;
|
|
1621
|
-
const distance = Math.abs(cardElo - userElo);
|
|
1622
|
-
const multiplier = computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier);
|
|
1623
|
-
const newScore = card.score * multiplier;
|
|
1624
|
-
const action = multiplier < maxMultiplier - 0.01 ? "penalized" : "passed";
|
|
1625
|
-
return {
|
|
1626
|
-
...card,
|
|
1627
|
-
score: newScore,
|
|
1628
|
-
provenance: [
|
|
1629
|
-
...card.provenance,
|
|
1630
|
-
{
|
|
1631
|
-
strategy: "eloDistance",
|
|
1632
|
-
strategyName: "ELO Distance Filter",
|
|
1633
|
-
strategyId: "ELO_DISTANCE_FILTER",
|
|
1634
|
-
action,
|
|
1635
|
-
score: newScore,
|
|
1636
|
-
reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userElo)}) \u2192 ${multiplier.toFixed(2)}x`
|
|
1637
|
-
}
|
|
1638
|
-
]
|
|
1639
|
-
};
|
|
1640
|
-
});
|
|
1641
|
-
}
|
|
1642
|
-
};
|
|
1643
|
-
}
|
|
1644
|
-
var DEFAULT_HALF_LIFE, DEFAULT_MIN_MULTIPLIER, DEFAULT_MAX_MULTIPLIER;
|
|
1645
|
-
var init_eloDistance = __esm({
|
|
1646
|
-
"src/core/navigators/filters/eloDistance.ts"() {
|
|
1647
|
-
"use strict";
|
|
1648
|
-
DEFAULT_HALF_LIFE = 200;
|
|
1649
|
-
DEFAULT_MIN_MULTIPLIER = 0.3;
|
|
1650
|
-
DEFAULT_MAX_MULTIPLIER = 1;
|
|
1651
|
-
}
|
|
1652
|
-
});
|
|
1653
|
-
|
|
1654
|
-
// src/core/navigators/defaults.ts
|
|
1655
|
-
function createDefaultEloStrategy(courseId) {
|
|
1656
|
-
return {
|
|
1657
|
-
_id: "NAVIGATION_STRATEGY-ELO-default",
|
|
1658
|
-
docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
|
|
1659
|
-
name: "ELO (default)",
|
|
1660
|
-
description: "Default ELO-based navigation strategy for new cards",
|
|
1661
|
-
implementingClass: "elo" /* ELO */,
|
|
1662
|
-
course: courseId,
|
|
1663
|
-
serializedData: ""
|
|
1664
|
-
};
|
|
1665
|
-
}
|
|
1666
|
-
function createDefaultSrsStrategy(courseId) {
|
|
1667
|
-
return {
|
|
1668
|
-
_id: "NAVIGATION_STRATEGY-SRS-default",
|
|
1669
|
-
docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
|
|
1670
|
-
name: "SRS (default)",
|
|
1671
|
-
description: "Default SRS-based navigation strategy for reviews",
|
|
1672
|
-
implementingClass: "srs" /* SRS */,
|
|
1673
|
-
course: courseId,
|
|
1674
|
-
serializedData: ""
|
|
1675
|
-
};
|
|
1676
|
-
}
|
|
1677
|
-
function createDefaultPipeline(user, course) {
|
|
1678
|
-
const courseId = course.getCourseID();
|
|
1679
|
-
const eloNavigator = new ELONavigator(user, course, createDefaultEloStrategy(courseId));
|
|
1680
|
-
const srsNavigator = new SRSNavigator(user, course, createDefaultSrsStrategy(courseId));
|
|
1681
|
-
const compositeGenerator = new CompositeGenerator([eloNavigator, srsNavigator]);
|
|
1682
|
-
const eloDistanceFilter = createEloDistanceFilter();
|
|
1683
|
-
return new Pipeline(compositeGenerator, [eloDistanceFilter], user, course);
|
|
1684
|
-
}
|
|
1685
|
-
var init_defaults = __esm({
|
|
1686
|
-
"src/core/navigators/defaults.ts"() {
|
|
1687
|
-
"use strict";
|
|
1688
|
-
init_navigators();
|
|
1689
|
-
init_Pipeline();
|
|
1690
|
-
init_CompositeGenerator();
|
|
1691
|
-
init_elo();
|
|
1692
|
-
init_srs();
|
|
1693
|
-
init_eloDistance();
|
|
1694
|
-
init_types_legacy();
|
|
1695
|
-
}
|
|
1696
|
-
});
|
|
1697
|
-
|
|
1698
3575
|
// src/impl/couch/courseDB.ts
|
|
1699
3576
|
import {
|
|
1700
3577
|
EloToNumber,
|
|
1701
3578
|
Status,
|
|
1702
3579
|
blankCourseElo as blankCourseElo2,
|
|
1703
|
-
toCourseElo as
|
|
3580
|
+
toCourseElo as toCourseElo6
|
|
1704
3581
|
} from "@vue-skuilder/common";
|
|
1705
3582
|
function randIntWeightedTowardZero(n) {
|
|
1706
3583
|
return Math.floor(Math.random() * Math.random() * Math.random() * n);
|
|
@@ -1902,7 +3779,7 @@ var init_courseDB = __esm({
|
|
|
1902
3779
|
docs.rows.forEach((r) => {
|
|
1903
3780
|
if (isSuccessRow(r)) {
|
|
1904
3781
|
if (r.doc && r.doc.elo) {
|
|
1905
|
-
ret.push(
|
|
3782
|
+
ret.push(toCourseElo6(r.doc.elo));
|
|
1906
3783
|
} else {
|
|
1907
3784
|
logger.warn("no elo data for card: " + r.id);
|
|
1908
3785
|
ret.push(blankCourseElo2());
|
|
@@ -4115,6 +5992,19 @@ Currently logged-in as ${this._username}.`
|
|
|
4115
5992
|
};
|
|
4116
5993
|
await this.localDB.put(doc);
|
|
4117
5994
|
}
|
|
5995
|
+
async putUserOutcome(record) {
|
|
5996
|
+
try {
|
|
5997
|
+
await this.localDB.put(record);
|
|
5998
|
+
} catch (err) {
|
|
5999
|
+
if (err.status === 409) {
|
|
6000
|
+
const existing = await this.localDB.get(record._id);
|
|
6001
|
+
record._rev = existing._rev;
|
|
6002
|
+
await this.localDB.put(record);
|
|
6003
|
+
} else {
|
|
6004
|
+
throw err;
|
|
6005
|
+
}
|
|
6006
|
+
}
|
|
6007
|
+
}
|
|
4118
6008
|
async deleteStrategyState(courseId, strategyKey) {
|
|
4119
6009
|
const docId = buildStrategyStateId(courseId, strategyKey);
|
|
4120
6010
|
try {
|
|
@@ -5242,6 +7132,7 @@ async function initializeDataLayer(config) {
|
|
|
5242
7132
|
logger.warn("Data layer already initialized. Returning existing instance.");
|
|
5243
7133
|
return dataLayerInstance;
|
|
5244
7134
|
}
|
|
7135
|
+
await initializeNavigatorRegistry();
|
|
5245
7136
|
if (config.options.localStoragePrefix) {
|
|
5246
7137
|
ENV.LOCAL_STORAGE_PREFIX = config.options.localStoragePrefix;
|
|
5247
7138
|
}
|
|
@@ -5296,6 +7187,7 @@ var init_factory = __esm({
|
|
|
5296
7187
|
"use strict";
|
|
5297
7188
|
init_common();
|
|
5298
7189
|
init_logger();
|
|
7190
|
+
init_navigators();
|
|
5299
7191
|
NOT_SET = "NOT_SET";
|
|
5300
7192
|
ENV = {
|
|
5301
7193
|
COUCHDB_SERVER_PROTOCOL: NOT_SET,
|
|
@@ -5552,6 +7444,13 @@ var init_strategyState = __esm({
|
|
|
5552
7444
|
}
|
|
5553
7445
|
});
|
|
5554
7446
|
|
|
7447
|
+
// src/core/types/userOutcome.ts
|
|
7448
|
+
var init_userOutcome = __esm({
|
|
7449
|
+
"src/core/types/userOutcome.ts"() {
|
|
7450
|
+
"use strict";
|
|
7451
|
+
}
|
|
7452
|
+
});
|
|
7453
|
+
|
|
5555
7454
|
// src/core/bulkImport/cardProcessor.ts
|
|
5556
7455
|
import { Status as Status5 } from "@vue-skuilder/common";
|
|
5557
7456
|
async function importParsedCards(parsedCards, courseDB, config) {
|
|
@@ -5673,7 +7572,7 @@ var init_cardProcessor = __esm({
|
|
|
5673
7572
|
});
|
|
5674
7573
|
|
|
5675
7574
|
// src/core/bulkImport/types.ts
|
|
5676
|
-
var
|
|
7575
|
+
var init_types3 = __esm({
|
|
5677
7576
|
"src/core/bulkImport/types.ts"() {
|
|
5678
7577
|
"use strict";
|
|
5679
7578
|
}
|
|
@@ -5684,7 +7583,7 @@ var init_bulkImport = __esm({
|
|
|
5684
7583
|
"src/core/bulkImport/index.ts"() {
|
|
5685
7584
|
"use strict";
|
|
5686
7585
|
init_cardProcessor();
|
|
5687
|
-
|
|
7586
|
+
init_types3();
|
|
5688
7587
|
}
|
|
5689
7588
|
});
|
|
5690
7589
|
|
|
@@ -5696,10 +7595,12 @@ var init_core = __esm({
|
|
|
5696
7595
|
init_types_legacy();
|
|
5697
7596
|
init_user();
|
|
5698
7597
|
init_strategyState();
|
|
7598
|
+
init_userOutcome();
|
|
5699
7599
|
init_Loggable();
|
|
5700
7600
|
init_util();
|
|
5701
7601
|
init_navigators();
|
|
5702
7602
|
init_bulkImport();
|
|
7603
|
+
init_orchestration();
|
|
5703
7604
|
}
|
|
5704
7605
|
});
|
|
5705
7606
|
|
|
@@ -5846,6 +7747,66 @@ function registerQuestionType(question, courseConfig) {
|
|
|
5846
7747
|
logger.info(`Registered QuestionType: ${namespacedQuestionName}`);
|
|
5847
7748
|
return true;
|
|
5848
7749
|
}
|
|
7750
|
+
function removeDataShape(dataShapeName, courseConfig) {
|
|
7751
|
+
const index = courseConfig.dataShapes.findIndex((ds) => ds.name === dataShapeName);
|
|
7752
|
+
if (index === -1) {
|
|
7753
|
+
logger.info(`DataShape '${dataShapeName}' not found in course config`);
|
|
7754
|
+
return false;
|
|
7755
|
+
}
|
|
7756
|
+
courseConfig.dataShapes.splice(index, 1);
|
|
7757
|
+
courseConfig.questionTypes.forEach((qt) => {
|
|
7758
|
+
const dsIndex = qt.dataShapeList.indexOf(dataShapeName);
|
|
7759
|
+
if (dsIndex !== -1) {
|
|
7760
|
+
qt.dataShapeList.splice(dsIndex, 1);
|
|
7761
|
+
}
|
|
7762
|
+
});
|
|
7763
|
+
logger.info(`Removed DataShape: ${dataShapeName}`);
|
|
7764
|
+
return true;
|
|
7765
|
+
}
|
|
7766
|
+
function removeQuestionType(questionTypeName, courseConfig) {
|
|
7767
|
+
const index = courseConfig.questionTypes.findIndex((qt) => qt.name === questionTypeName);
|
|
7768
|
+
if (index === -1) {
|
|
7769
|
+
logger.info(`QuestionType '${questionTypeName}' not found in course config`);
|
|
7770
|
+
return false;
|
|
7771
|
+
}
|
|
7772
|
+
courseConfig.questionTypes.splice(index, 1);
|
|
7773
|
+
courseConfig.dataShapes.forEach((ds) => {
|
|
7774
|
+
const qtIndex = ds.questionTypes.indexOf(questionTypeName);
|
|
7775
|
+
if (qtIndex !== -1) {
|
|
7776
|
+
ds.questionTypes.splice(qtIndex, 1);
|
|
7777
|
+
}
|
|
7778
|
+
});
|
|
7779
|
+
logger.info(`Removed QuestionType: ${questionTypeName}`);
|
|
7780
|
+
return true;
|
|
7781
|
+
}
|
|
7782
|
+
async function removeCustomQuestionTypes(dataShapeNames, questionTypeNames, courseConfig, courseDB) {
|
|
7783
|
+
try {
|
|
7784
|
+
logger.info("Beginning custom question removal");
|
|
7785
|
+
logger.info(`Removing ${dataShapeNames.length} data shapes and ${questionTypeNames.length} question types`);
|
|
7786
|
+
let removedCount = 0;
|
|
7787
|
+
for (const qtName of questionTypeNames) {
|
|
7788
|
+
if (removeQuestionType(qtName, courseConfig)) {
|
|
7789
|
+
removedCount++;
|
|
7790
|
+
}
|
|
7791
|
+
}
|
|
7792
|
+
for (const dsName of dataShapeNames) {
|
|
7793
|
+
if (removeDataShape(dsName, courseConfig)) {
|
|
7794
|
+
removedCount++;
|
|
7795
|
+
}
|
|
7796
|
+
}
|
|
7797
|
+
logger.info("Updating course configuration...");
|
|
7798
|
+
const updateResult = await courseDB.updateCourseConfig(courseConfig);
|
|
7799
|
+
if (!updateResult.ok) {
|
|
7800
|
+
throw new Error(`Failed to update course config: ${JSON.stringify(updateResult)}`);
|
|
7801
|
+
}
|
|
7802
|
+
logger.info(`Custom question removal complete: ${removedCount} items removed`);
|
|
7803
|
+
return { success: true, removedCount };
|
|
7804
|
+
} catch (error) {
|
|
7805
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
7806
|
+
logger.error(`Custom question removal failed: ${errorMessage}`);
|
|
7807
|
+
return { success: false, removedCount: 0, errorMessage };
|
|
7808
|
+
}
|
|
7809
|
+
}
|
|
5849
7810
|
async function registerSeedData(question, courseDB, username) {
|
|
5850
7811
|
if (question.questionClass.seedData && Array.isArray(question.questionClass.seedData)) {
|
|
5851
7812
|
logger.info(`Registering seed data for question: ${question.name}`);
|
|
@@ -6037,6 +7998,14 @@ var SrsService = class {
|
|
|
6037
7998
|
constructor(user) {
|
|
6038
7999
|
this.user = user;
|
|
6039
8000
|
}
|
|
8001
|
+
/**
|
|
8002
|
+
* Remove a scheduled review from the user's database.
|
|
8003
|
+
* Used to clean up orphaned reviews (e.g., card deleted from course DB).
|
|
8004
|
+
*/
|
|
8005
|
+
removeReview(reviewID) {
|
|
8006
|
+
logger.info(`[SrsService] Removing orphaned scheduled review: ${reviewID}`);
|
|
8007
|
+
void this.user.removeScheduledCardReview(reviewID);
|
|
8008
|
+
}
|
|
6040
8009
|
/**
|
|
6041
8010
|
* Calculates the next review time for a card based on its history and
|
|
6042
8011
|
* schedules it in the user's database.
|
|
@@ -6050,45 +8019,102 @@ var SrsService = class {
|
|
|
6050
8019
|
logger.info(`[SrsService] Removing previously scheduled review for: ${item.cardID}`);
|
|
6051
8020
|
void this.user.removeScheduledCardReview(item.reviewID);
|
|
6052
8021
|
}
|
|
6053
|
-
void this.user.scheduleCardReview({
|
|
6054
|
-
user: this.user.getUsername(),
|
|
6055
|
-
course_id: history.courseID,
|
|
6056
|
-
card_id: history.cardID,
|
|
6057
|
-
time: nextReviewTime,
|
|
6058
|
-
scheduledFor: item.contentSourceType,
|
|
6059
|
-
schedulingAgentId: item.contentSourceID
|
|
6060
|
-
});
|
|
6061
|
-
}
|
|
6062
|
-
};
|
|
6063
|
-
|
|
6064
|
-
// src/study/services/EloService.ts
|
|
6065
|
-
init_logger();
|
|
6066
|
-
import {
|
|
6067
|
-
|
|
6068
|
-
|
|
6069
|
-
|
|
6070
|
-
|
|
6071
|
-
|
|
6072
|
-
|
|
8022
|
+
void this.user.scheduleCardReview({
|
|
8023
|
+
user: this.user.getUsername(),
|
|
8024
|
+
course_id: history.courseID,
|
|
8025
|
+
card_id: history.cardID,
|
|
8026
|
+
time: nextReviewTime,
|
|
8027
|
+
scheduledFor: item.contentSourceType,
|
|
8028
|
+
schedulingAgentId: item.contentSourceID
|
|
8029
|
+
});
|
|
8030
|
+
}
|
|
8031
|
+
};
|
|
8032
|
+
|
|
8033
|
+
// src/study/services/EloService.ts
|
|
8034
|
+
init_logger();
|
|
8035
|
+
import {
|
|
8036
|
+
adjustCourseScores,
|
|
8037
|
+
adjustCourseScoresPerTag,
|
|
8038
|
+
toCourseElo as toCourseElo7
|
|
8039
|
+
} from "@vue-skuilder/common";
|
|
8040
|
+
var EloService = class {
|
|
8041
|
+
dataLayer;
|
|
8042
|
+
user;
|
|
8043
|
+
constructor(dataLayer, user) {
|
|
8044
|
+
this.dataLayer = dataLayer;
|
|
8045
|
+
this.user = user;
|
|
8046
|
+
}
|
|
8047
|
+
/**
|
|
8048
|
+
* Updates both user and card ELO ratings based on user performance.
|
|
8049
|
+
* @param userScore Score between 0-1 representing user performance
|
|
8050
|
+
* @param course_id Course identifier
|
|
8051
|
+
* @param card_id Card identifier
|
|
8052
|
+
* @param userCourseRegDoc User's course registration document (will be mutated)
|
|
8053
|
+
* @param currentCard Current card session record
|
|
8054
|
+
* @param k Optional K-factor for ELO calculation
|
|
8055
|
+
*/
|
|
8056
|
+
async updateUserAndCardElo(userScore, course_id, card_id, userCourseRegDoc, currentCard, k) {
|
|
8057
|
+
if (k) {
|
|
8058
|
+
logger.warn(`k value interpretation not currently implemented`);
|
|
8059
|
+
}
|
|
8060
|
+
const courseDB = this.dataLayer.getCourseDB(currentCard.card.course_id);
|
|
8061
|
+
const userElo = toCourseElo7(
|
|
8062
|
+
userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo
|
|
8063
|
+
);
|
|
8064
|
+
const cardElo = (await courseDB.getCardEloData([currentCard.card.card_id]))[0];
|
|
8065
|
+
if (cardElo && userElo) {
|
|
8066
|
+
const eloUpdate = adjustCourseScores(userElo, cardElo, userScore);
|
|
8067
|
+
userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo = eloUpdate.userElo;
|
|
8068
|
+
const results = await Promise.allSettled([
|
|
8069
|
+
this.user.updateUserElo(course_id, eloUpdate.userElo),
|
|
8070
|
+
courseDB.updateCardElo(card_id, eloUpdate.cardElo)
|
|
8071
|
+
]);
|
|
8072
|
+
const userEloStatus = results[0].status === "fulfilled";
|
|
8073
|
+
const cardEloStatus = results[1].status === "fulfilled";
|
|
8074
|
+
if (userEloStatus && cardEloStatus) {
|
|
8075
|
+
const user = results[0].value;
|
|
8076
|
+
const card = results[1].value;
|
|
8077
|
+
if (user.ok && card && card.ok) {
|
|
8078
|
+
logger.info(
|
|
8079
|
+
`[EloService] Updated ELOS:
|
|
8080
|
+
User: ${JSON.stringify(eloUpdate.userElo)})
|
|
8081
|
+
Card: ${JSON.stringify(eloUpdate.cardElo)})
|
|
8082
|
+
`
|
|
8083
|
+
);
|
|
8084
|
+
}
|
|
8085
|
+
} else {
|
|
8086
|
+
logger.warn(
|
|
8087
|
+
`[EloService] Partial ELO update:
|
|
8088
|
+
User ELO update: ${userEloStatus ? "SUCCESS" : "FAILED"}
|
|
8089
|
+
Card ELO update: ${cardEloStatus ? "SUCCESS" : "FAILED"}`
|
|
8090
|
+
);
|
|
8091
|
+
if (!userEloStatus && results[0].status === "rejected") {
|
|
8092
|
+
logger.error("[EloService] User ELO update error:", results[0].reason);
|
|
8093
|
+
}
|
|
8094
|
+
if (!cardEloStatus && results[1].status === "rejected") {
|
|
8095
|
+
logger.error("[EloService] Card ELO update error:", results[1].reason);
|
|
8096
|
+
}
|
|
8097
|
+
}
|
|
8098
|
+
}
|
|
6073
8099
|
}
|
|
6074
8100
|
/**
|
|
6075
|
-
* Updates both user and card ELO ratings
|
|
6076
|
-
*
|
|
8101
|
+
* Updates both user and card ELO ratings with per-tag granularity.
|
|
8102
|
+
* Tags in taggedPerformance but not on card will be created dynamically.
|
|
8103
|
+
*
|
|
8104
|
+
* @param taggedPerformance Performance object with _global and per-tag scores
|
|
6077
8105
|
* @param course_id Course identifier
|
|
6078
|
-
* @param card_id Card identifier
|
|
8106
|
+
* @param card_id Card identifier
|
|
6079
8107
|
* @param userCourseRegDoc User's course registration document (will be mutated)
|
|
6080
8108
|
* @param currentCard Current card session record
|
|
6081
|
-
* @param k Optional K-factor for ELO calculation
|
|
6082
8109
|
*/
|
|
6083
|
-
async
|
|
6084
|
-
if (k) {
|
|
6085
|
-
logger.warn(`k value interpretation not currently implemented`);
|
|
6086
|
-
}
|
|
8110
|
+
async updateUserAndCardEloPerTag(taggedPerformance, course_id, card_id, userCourseRegDoc, currentCard) {
|
|
6087
8111
|
const courseDB = this.dataLayer.getCourseDB(currentCard.card.course_id);
|
|
6088
|
-
const userElo =
|
|
8112
|
+
const userElo = toCourseElo7(
|
|
8113
|
+
userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo
|
|
8114
|
+
);
|
|
6089
8115
|
const cardElo = (await courseDB.getCardEloData([currentCard.card.card_id]))[0];
|
|
6090
8116
|
if (cardElo && userElo) {
|
|
6091
|
-
const eloUpdate =
|
|
8117
|
+
const eloUpdate = adjustCourseScoresPerTag(userElo, cardElo, taggedPerformance);
|
|
6092
8118
|
userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo = eloUpdate.userElo;
|
|
6093
8119
|
const results = await Promise.allSettled([
|
|
6094
8120
|
this.user.updateUserElo(course_id, eloUpdate.userElo),
|
|
@@ -6100,8 +8126,9 @@ var EloService = class {
|
|
|
6100
8126
|
const user = results[0].value;
|
|
6101
8127
|
const card = results[1].value;
|
|
6102
8128
|
if (user.ok && card && card.ok) {
|
|
8129
|
+
const tagCount = Object.keys(taggedPerformance).length - 1;
|
|
6103
8130
|
logger.info(
|
|
6104
|
-
`[EloService] Updated ELOS:
|
|
8131
|
+
`[EloService] Updated ELOS (per-tag, ${tagCount} tags):
|
|
6105
8132
|
User: ${JSON.stringify(eloUpdate.userElo)})
|
|
6106
8133
|
Card: ${JSON.stringify(eloUpdate.cardElo)})
|
|
6107
8134
|
`
|
|
@@ -6109,7 +8136,7 @@ var EloService = class {
|
|
|
6109
8136
|
}
|
|
6110
8137
|
} else {
|
|
6111
8138
|
logger.warn(
|
|
6112
|
-
`[EloService] Partial ELO update:
|
|
8139
|
+
`[EloService] Partial ELO update (per-tag):
|
|
6113
8140
|
User ELO update: ${userEloStatus ? "SUCCESS" : "FAILED"}
|
|
6114
8141
|
Card ELO update: ${cardEloStatus ? "SUCCESS" : "FAILED"}`
|
|
6115
8142
|
);
|
|
@@ -6127,6 +8154,7 @@ var EloService = class {
|
|
|
6127
8154
|
// src/study/services/ResponseProcessor.ts
|
|
6128
8155
|
init_core();
|
|
6129
8156
|
init_logger();
|
|
8157
|
+
import { isTaggedPerformance } from "@vue-skuilder/common";
|
|
6130
8158
|
var ResponseProcessor = class {
|
|
6131
8159
|
srsService;
|
|
6132
8160
|
eloService;
|
|
@@ -6134,6 +8162,33 @@ var ResponseProcessor = class {
|
|
|
6134
8162
|
this.srsService = srsService;
|
|
6135
8163
|
this.eloService = eloService;
|
|
6136
8164
|
}
|
|
8165
|
+
/**
|
|
8166
|
+
* Parses performance data into global score and optional per-tag scores.
|
|
8167
|
+
*
|
|
8168
|
+
* @param performance - Numeric or structured performance from QuestionRecord
|
|
8169
|
+
* @returns Parsed performance with global score and optional tag scores
|
|
8170
|
+
*/
|
|
8171
|
+
parsePerformance(performance2) {
|
|
8172
|
+
if (typeof performance2 === "number") {
|
|
8173
|
+
return {
|
|
8174
|
+
globalScore: performance2,
|
|
8175
|
+
taggedPerformance: null
|
|
8176
|
+
};
|
|
8177
|
+
}
|
|
8178
|
+
if (isTaggedPerformance(performance2)) {
|
|
8179
|
+
return {
|
|
8180
|
+
globalScore: performance2._global,
|
|
8181
|
+
taggedPerformance: performance2
|
|
8182
|
+
};
|
|
8183
|
+
}
|
|
8184
|
+
logger.warn("[ResponseProcessor] Unexpected performance structure, using neutral score", {
|
|
8185
|
+
performance: performance2
|
|
8186
|
+
});
|
|
8187
|
+
return {
|
|
8188
|
+
globalScore: 0.5,
|
|
8189
|
+
taggedPerformance: null
|
|
8190
|
+
};
|
|
8191
|
+
}
|
|
6137
8192
|
/**
|
|
6138
8193
|
* Processes a user's response to a card, handling SRS scheduling and ELO updates.
|
|
6139
8194
|
* @param cardRecord User's response record
|
|
@@ -6194,46 +8249,60 @@ var ResponseProcessor = class {
|
|
|
6194
8249
|
processCorrectResponse(cardRecord, history, studySessionItem, courseRegistrationDoc, currentCard, courseId, cardId) {
|
|
6195
8250
|
if (cardRecord.priorAttemps === 0) {
|
|
6196
8251
|
void this.srsService.scheduleReview(history, studySessionItem);
|
|
6197
|
-
|
|
6198
|
-
|
|
6199
|
-
void this.eloService.
|
|
6200
|
-
|
|
8252
|
+
const { globalScore, taggedPerformance } = this.parsePerformance(cardRecord.performance);
|
|
8253
|
+
if (taggedPerformance) {
|
|
8254
|
+
void this.eloService.updateUserAndCardEloPerTag(
|
|
8255
|
+
taggedPerformance,
|
|
6201
8256
|
courseId,
|
|
6202
8257
|
cardId,
|
|
6203
8258
|
courseRegistrationDoc,
|
|
6204
8259
|
currentCard
|
|
6205
8260
|
);
|
|
8261
|
+
logger.info(
|
|
8262
|
+
`[ResponseProcessor] Processed correct response with per-tag ELO update (${Object.keys(taggedPerformance).length - 1} tags)`
|
|
8263
|
+
);
|
|
6206
8264
|
} else {
|
|
6207
|
-
const
|
|
6208
|
-
|
|
6209
|
-
|
|
6210
|
-
|
|
6211
|
-
|
|
6212
|
-
|
|
6213
|
-
|
|
6214
|
-
|
|
6215
|
-
|
|
8265
|
+
const userScore = 0.5 + globalScore / 2;
|
|
8266
|
+
if (history.records.length === 1) {
|
|
8267
|
+
void this.eloService.updateUserAndCardElo(
|
|
8268
|
+
userScore,
|
|
8269
|
+
courseId,
|
|
8270
|
+
cardId,
|
|
8271
|
+
courseRegistrationDoc,
|
|
8272
|
+
currentCard
|
|
8273
|
+
);
|
|
8274
|
+
} else {
|
|
8275
|
+
const k = Math.ceil(32 / history.records.length);
|
|
8276
|
+
void this.eloService.updateUserAndCardElo(
|
|
8277
|
+
userScore,
|
|
8278
|
+
courseId,
|
|
8279
|
+
cardId,
|
|
8280
|
+
courseRegistrationDoc,
|
|
8281
|
+
currentCard,
|
|
8282
|
+
k
|
|
8283
|
+
);
|
|
8284
|
+
}
|
|
8285
|
+
logger.info(
|
|
8286
|
+
"[ResponseProcessor] Processed correct response with SRS scheduling and ELO update"
|
|
6216
8287
|
);
|
|
6217
8288
|
}
|
|
6218
|
-
logger.info(
|
|
6219
|
-
"[ResponseProcessor] Processed correct response with SRS scheduling and ELO update"
|
|
6220
|
-
);
|
|
6221
8289
|
return {
|
|
6222
8290
|
nextCardAction: "dismiss-success",
|
|
6223
8291
|
shouldLoadNextCard: true,
|
|
6224
8292
|
isCorrect: true,
|
|
6225
|
-
performanceScore:
|
|
8293
|
+
performanceScore: globalScore,
|
|
6226
8294
|
shouldClearFeedbackShadow: true
|
|
6227
8295
|
};
|
|
6228
8296
|
} else {
|
|
6229
8297
|
logger.info(
|
|
6230
8298
|
"[ResponseProcessor] Processed correct response (retry attempt - no scheduling/ELO)"
|
|
6231
8299
|
);
|
|
8300
|
+
const { globalScore } = this.parsePerformance(cardRecord.performance);
|
|
6232
8301
|
return {
|
|
6233
8302
|
nextCardAction: "marked-failed",
|
|
6234
8303
|
shouldLoadNextCard: true,
|
|
6235
8304
|
isCorrect: true,
|
|
6236
|
-
performanceScore:
|
|
8305
|
+
performanceScore: globalScore,
|
|
6237
8306
|
shouldClearFeedbackShadow: true
|
|
6238
8307
|
};
|
|
6239
8308
|
}
|
|
@@ -6242,28 +8311,52 @@ var ResponseProcessor = class {
|
|
|
6242
8311
|
* Handles processing for incorrect responses: ELO updates only.
|
|
6243
8312
|
*/
|
|
6244
8313
|
processIncorrectResponse(cardRecord, history, courseRegistrationDoc, currentCard, courseId, cardId, maxAttemptsPerView, maxSessionViews, sessionViews) {
|
|
8314
|
+
const { taggedPerformance } = this.parsePerformance(cardRecord.performance);
|
|
6245
8315
|
if (history.records.length !== 1 && cardRecord.priorAttemps === 0) {
|
|
6246
|
-
|
|
6247
|
-
|
|
6248
|
-
|
|
6249
|
-
|
|
6250
|
-
|
|
6251
|
-
|
|
6252
|
-
|
|
6253
|
-
|
|
6254
|
-
|
|
6255
|
-
|
|
6256
|
-
|
|
6257
|
-
|
|
6258
|
-
if (currentCard.records.length >= maxAttemptsPerView) {
|
|
6259
|
-
if (sessionViews >= maxSessionViews) {
|
|
8316
|
+
if (taggedPerformance) {
|
|
8317
|
+
void this.eloService.updateUserAndCardEloPerTag(
|
|
8318
|
+
taggedPerformance,
|
|
8319
|
+
courseId,
|
|
8320
|
+
cardId,
|
|
8321
|
+
courseRegistrationDoc,
|
|
8322
|
+
currentCard
|
|
8323
|
+
);
|
|
8324
|
+
logger.info(
|
|
8325
|
+
`[ResponseProcessor] Processed incorrect response with per-tag ELO update (${Object.keys(taggedPerformance).length - 1} tags)`
|
|
8326
|
+
);
|
|
8327
|
+
} else {
|
|
6260
8328
|
void this.eloService.updateUserAndCardElo(
|
|
6261
8329
|
0,
|
|
8330
|
+
// Failed response = 0 score
|
|
6262
8331
|
courseId,
|
|
6263
8332
|
cardId,
|
|
6264
8333
|
courseRegistrationDoc,
|
|
6265
8334
|
currentCard
|
|
6266
8335
|
);
|
|
8336
|
+
logger.info("[ResponseProcessor] Processed incorrect response with ELO update");
|
|
8337
|
+
}
|
|
8338
|
+
} else {
|
|
8339
|
+
logger.info("[ResponseProcessor] Processed incorrect response (no ELO update needed)");
|
|
8340
|
+
}
|
|
8341
|
+
if (currentCard.records.length >= maxAttemptsPerView) {
|
|
8342
|
+
if (sessionViews >= maxSessionViews) {
|
|
8343
|
+
if (taggedPerformance) {
|
|
8344
|
+
void this.eloService.updateUserAndCardEloPerTag(
|
|
8345
|
+
taggedPerformance,
|
|
8346
|
+
courseId,
|
|
8347
|
+
cardId,
|
|
8348
|
+
courseRegistrationDoc,
|
|
8349
|
+
currentCard
|
|
8350
|
+
);
|
|
8351
|
+
} else {
|
|
8352
|
+
void this.eloService.updateUserAndCardElo(
|
|
8353
|
+
0,
|
|
8354
|
+
courseId,
|
|
8355
|
+
cardId,
|
|
8356
|
+
courseRegistrationDoc,
|
|
8357
|
+
currentCard
|
|
8358
|
+
);
|
|
8359
|
+
}
|
|
6267
8360
|
return {
|
|
6268
8361
|
nextCardAction: "dismiss-failed",
|
|
6269
8362
|
shouldLoadNextCard: true,
|
|
@@ -6294,7 +8387,7 @@ init_logger();
|
|
|
6294
8387
|
import {
|
|
6295
8388
|
displayableDataToViewData,
|
|
6296
8389
|
isCourseElo,
|
|
6297
|
-
toCourseElo as
|
|
8390
|
+
toCourseElo as toCourseElo8
|
|
6298
8391
|
} from "@vue-skuilder/common";
|
|
6299
8392
|
function parseAudioURIs(data) {
|
|
6300
8393
|
if (typeof data !== "string") return [];
|
|
@@ -6430,7 +8523,7 @@ var CardHydrationService = class {
|
|
|
6430
8523
|
const courseDB = this.getCourseDB(item.courseID);
|
|
6431
8524
|
const cardData = await courseDB.getCourseDoc(item.cardID);
|
|
6432
8525
|
if (!isCourseElo(cardData.elo)) {
|
|
6433
|
-
cardData.elo =
|
|
8526
|
+
cardData.elo = toCourseElo8(cardData.elo);
|
|
6434
8527
|
}
|
|
6435
8528
|
const view = this.getViewComponent(cardData.id_view);
|
|
6436
8529
|
const dataDocs = await Promise.all(
|
|
@@ -6515,6 +8608,7 @@ var ItemQueue = class {
|
|
|
6515
8608
|
|
|
6516
8609
|
// src/study/SessionController.ts
|
|
6517
8610
|
init_couch();
|
|
8611
|
+
init_recording();
|
|
6518
8612
|
|
|
6519
8613
|
// src/util/index.ts
|
|
6520
8614
|
init_Loggable();
|
|
@@ -7948,15 +10042,644 @@ var QuotaRoundRobinMixer = class {
|
|
|
7948
10042
|
return [];
|
|
7949
10043
|
}
|
|
7950
10044
|
const quotaPerSource = Math.ceil(limit / batches.length);
|
|
7951
|
-
const
|
|
7952
|
-
|
|
7953
|
-
|
|
7954
|
-
|
|
7955
|
-
|
|
10045
|
+
const sourceStacks = batches.map((batch) => {
|
|
10046
|
+
return [...batch.weighted].sort((a, b) => b.score - a.score).slice(0, quotaPerSource);
|
|
10047
|
+
});
|
|
10048
|
+
for (let i = sourceStacks.length - 1; i > 0; i--) {
|
|
10049
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
10050
|
+
[sourceStacks[i], sourceStacks[j]] = [sourceStacks[j], sourceStacks[i]];
|
|
10051
|
+
}
|
|
10052
|
+
const result = [];
|
|
10053
|
+
let exhausted = 0;
|
|
10054
|
+
const cursors = new Array(sourceStacks.length).fill(0);
|
|
10055
|
+
while (result.length < limit && exhausted < sourceStacks.length) {
|
|
10056
|
+
exhausted = 0;
|
|
10057
|
+
for (let s = 0; s < sourceStacks.length; s++) {
|
|
10058
|
+
if (result.length >= limit) break;
|
|
10059
|
+
if (cursors[s] < sourceStacks[s].length) {
|
|
10060
|
+
result.push(sourceStacks[s][cursors[s]]);
|
|
10061
|
+
cursors[s]++;
|
|
10062
|
+
} else {
|
|
10063
|
+
exhausted++;
|
|
10064
|
+
}
|
|
10065
|
+
}
|
|
10066
|
+
}
|
|
10067
|
+
return result;
|
|
10068
|
+
}
|
|
10069
|
+
};
|
|
10070
|
+
|
|
10071
|
+
// src/study/MixerDebugger.ts
|
|
10072
|
+
init_logger();
|
|
10073
|
+
init_navigators();
|
|
10074
|
+
var MAX_RUNS2 = 10;
|
|
10075
|
+
var runHistory2 = [];
|
|
10076
|
+
function buildSourceSummary(batch, sourceId, sourceName) {
|
|
10077
|
+
const scores = batch.weighted.map((c) => c.score);
|
|
10078
|
+
const reviewCount = batch.weighted.filter((c) => getCardOrigin(c) === "review").length;
|
|
10079
|
+
const newCount = batch.weighted.filter((c) => getCardOrigin(c) === "new").length;
|
|
10080
|
+
return {
|
|
10081
|
+
sourceIndex: batch.sourceIndex,
|
|
10082
|
+
sourceId,
|
|
10083
|
+
sourceName,
|
|
10084
|
+
totalCards: batch.weighted.length,
|
|
10085
|
+
reviewCount,
|
|
10086
|
+
newCount,
|
|
10087
|
+
topScore: scores.length > 0 ? Math.max(...scores) : 0,
|
|
10088
|
+
bottomScore: scores.length > 0 ? Math.min(...scores) : 0,
|
|
10089
|
+
scoreRange: scores.length > 0 ? [Math.min(...scores), Math.max(...scores)] : [0, 0],
|
|
10090
|
+
avgScore: scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : 0
|
|
10091
|
+
};
|
|
10092
|
+
}
|
|
10093
|
+
function buildSourceBreakdown(sourceId, sourceName, allCards) {
|
|
10094
|
+
const sourceCards = allCards.filter((c) => c.courseId === sourceId);
|
|
10095
|
+
const selectedCards = sourceCards.filter((c) => c.selected);
|
|
10096
|
+
const reviewsProvided = sourceCards.filter((c) => c.origin === "review").length;
|
|
10097
|
+
const newProvided = sourceCards.filter((c) => c.origin === "new").length;
|
|
10098
|
+
const reviewsSelected = selectedCards.filter((c) => c.origin === "review").length;
|
|
10099
|
+
const newSelected = selectedCards.filter((c) => c.origin === "new").length;
|
|
10100
|
+
return {
|
|
10101
|
+
sourceId,
|
|
10102
|
+
sourceName,
|
|
10103
|
+
reviewsProvided,
|
|
10104
|
+
newProvided,
|
|
10105
|
+
reviewsSelected,
|
|
10106
|
+
newSelected,
|
|
10107
|
+
totalSelected: selectedCards.length,
|
|
10108
|
+
selectionRate: sourceCards.length > 0 ? selectedCards.length / sourceCards.length * 100 : 0
|
|
10109
|
+
};
|
|
10110
|
+
}
|
|
10111
|
+
function captureMixerRun(mixerType, batches, sourceIds, sourceNames, requestedLimit, quotaPerSource, mixedResult) {
|
|
10112
|
+
const sourceSummaries = batches.map(
|
|
10113
|
+
(batch, idx) => buildSourceSummary(batch, sourceIds[idx] || `source-${idx}`, sourceNames[idx])
|
|
10114
|
+
);
|
|
10115
|
+
const selectedIds = new Set(mixedResult.map((c) => c.cardId));
|
|
10116
|
+
const sourceRankings = /* @__PURE__ */ new Map();
|
|
10117
|
+
batches.forEach((batch) => {
|
|
10118
|
+
const sorted = [...batch.weighted].sort((a, b) => b.score - a.score);
|
|
10119
|
+
const rankings = /* @__PURE__ */ new Map();
|
|
10120
|
+
sorted.forEach((card, idx) => {
|
|
10121
|
+
rankings.set(card.cardId, idx + 1);
|
|
10122
|
+
});
|
|
10123
|
+
sourceRankings.set(sourceIds[batch.sourceIndex] || `source-${batch.sourceIndex}`, rankings);
|
|
10124
|
+
});
|
|
10125
|
+
const mixRankings = /* @__PURE__ */ new Map();
|
|
10126
|
+
mixedResult.forEach((card, idx) => {
|
|
10127
|
+
mixRankings.set(card.cardId, idx + 1);
|
|
10128
|
+
});
|
|
10129
|
+
const allCardsMap = /* @__PURE__ */ new Map();
|
|
10130
|
+
batches.forEach((batch) => {
|
|
10131
|
+
batch.weighted.forEach((card) => {
|
|
10132
|
+
allCardsMap.set(card.cardId, card);
|
|
10133
|
+
});
|
|
10134
|
+
});
|
|
10135
|
+
const cards = Array.from(allCardsMap.values()).map((card) => ({
|
|
10136
|
+
cardId: card.cardId,
|
|
10137
|
+
courseId: card.courseId,
|
|
10138
|
+
origin: getCardOrigin(card),
|
|
10139
|
+
score: card.score,
|
|
10140
|
+
sourceIndex: batches.findIndex((b) => b.weighted.some((c) => c.cardId === card.cardId)),
|
|
10141
|
+
selected: selectedIds.has(card.cardId),
|
|
10142
|
+
rankInSource: sourceRankings.get(card.courseId)?.get(card.cardId),
|
|
10143
|
+
rankInMix: mixRankings.get(card.cardId)
|
|
10144
|
+
}));
|
|
10145
|
+
const uniqueSourceIds = Array.from(new Set(sourceIds.filter((id) => id)));
|
|
10146
|
+
const sourceBreakdowns = uniqueSourceIds.map(
|
|
10147
|
+
(sourceId, idx) => buildSourceBreakdown(sourceId, sourceNames[idx], cards)
|
|
10148
|
+
);
|
|
10149
|
+
const report = {
|
|
10150
|
+
runId: `mix-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
10151
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
10152
|
+
mixerType,
|
|
10153
|
+
requestedLimit,
|
|
10154
|
+
quotaPerSource,
|
|
10155
|
+
sourceSummaries,
|
|
10156
|
+
cards,
|
|
10157
|
+
finalCount: mixedResult.length,
|
|
10158
|
+
reviewsSelected: mixedResult.filter((c) => getCardOrigin(c) === "review").length,
|
|
10159
|
+
newSelected: mixedResult.filter((c) => getCardOrigin(c) === "new").length,
|
|
10160
|
+
sourceBreakdowns
|
|
10161
|
+
};
|
|
10162
|
+
runHistory2.unshift(report);
|
|
10163
|
+
if (runHistory2.length > MAX_RUNS2) {
|
|
10164
|
+
runHistory2.pop();
|
|
10165
|
+
}
|
|
10166
|
+
}
|
|
10167
|
+
function printMixerSummary(run) {
|
|
10168
|
+
console.group(`\u{1F3A8} Mixer Run: ${run.mixerType}`);
|
|
10169
|
+
logger.info(`Run ID: ${run.runId}`);
|
|
10170
|
+
logger.info(`Time: ${run.timestamp.toISOString()}`);
|
|
10171
|
+
logger.info(
|
|
10172
|
+
`Config: limit=${run.requestedLimit}${run.quotaPerSource ? `, quota/source=${run.quotaPerSource}` : ""}`
|
|
10173
|
+
);
|
|
10174
|
+
console.group(`\u{1F4E5} Input: ${run.sourceSummaries.length} sources`);
|
|
10175
|
+
for (const src of run.sourceSummaries) {
|
|
10176
|
+
logger.info(
|
|
10177
|
+
` ${src.sourceName || src.sourceId}: ${src.totalCards} cards (${src.reviewCount} reviews, ${src.newCount} new)`
|
|
10178
|
+
);
|
|
10179
|
+
logger.info(` Score range: [${src.scoreRange[0].toFixed(2)}, ${src.scoreRange[1].toFixed(2)}], avg: ${src.avgScore.toFixed(2)}`);
|
|
10180
|
+
}
|
|
10181
|
+
console.groupEnd();
|
|
10182
|
+
console.group(`\u{1F4E4} Output: ${run.finalCount} cards selected (${run.reviewsSelected} reviews, ${run.newSelected} new)`);
|
|
10183
|
+
for (const breakdown of run.sourceBreakdowns) {
|
|
10184
|
+
const name = breakdown.sourceName || breakdown.sourceId;
|
|
10185
|
+
logger.info(
|
|
10186
|
+
` ${name}: ${breakdown.totalSelected} selected (${breakdown.reviewsSelected} reviews, ${breakdown.newSelected} new) - ${breakdown.selectionRate.toFixed(1)}% selection rate`
|
|
10187
|
+
);
|
|
10188
|
+
}
|
|
10189
|
+
console.groupEnd();
|
|
10190
|
+
console.groupEnd();
|
|
10191
|
+
}
|
|
10192
|
+
var mixerDebugAPI = {
|
|
10193
|
+
/**
|
|
10194
|
+
* Get raw run history for programmatic access.
|
|
10195
|
+
*/
|
|
10196
|
+
get runs() {
|
|
10197
|
+
return [...runHistory2];
|
|
10198
|
+
},
|
|
10199
|
+
/**
|
|
10200
|
+
* Show summary of a specific mixer run.
|
|
10201
|
+
*/
|
|
10202
|
+
showRun(idOrIndex = 0) {
|
|
10203
|
+
if (runHistory2.length === 0) {
|
|
10204
|
+
logger.info("[Mixer Debug] No runs captured yet.");
|
|
10205
|
+
return;
|
|
10206
|
+
}
|
|
10207
|
+
let run;
|
|
10208
|
+
if (typeof idOrIndex === "number") {
|
|
10209
|
+
run = runHistory2[idOrIndex];
|
|
10210
|
+
if (!run) {
|
|
10211
|
+
logger.info(`[Mixer Debug] No run found at index ${idOrIndex}. History length: ${runHistory2.length}`);
|
|
10212
|
+
return;
|
|
10213
|
+
}
|
|
10214
|
+
} else {
|
|
10215
|
+
run = runHistory2.find((r) => r.runId.endsWith(idOrIndex));
|
|
10216
|
+
if (!run) {
|
|
10217
|
+
logger.info(`[Mixer Debug] No run found matching ID '${idOrIndex}'.`);
|
|
10218
|
+
return;
|
|
10219
|
+
}
|
|
10220
|
+
}
|
|
10221
|
+
printMixerSummary(run);
|
|
10222
|
+
},
|
|
10223
|
+
/**
|
|
10224
|
+
* Show summary of the last mixer run.
|
|
10225
|
+
*/
|
|
10226
|
+
showLastMix() {
|
|
10227
|
+
this.showRun(0);
|
|
10228
|
+
},
|
|
10229
|
+
/**
|
|
10230
|
+
* Explain source balance in the last run.
|
|
10231
|
+
*/
|
|
10232
|
+
explainSourceBalance() {
|
|
10233
|
+
if (runHistory2.length === 0) {
|
|
10234
|
+
logger.info("[Mixer Debug] No runs captured yet.");
|
|
10235
|
+
return;
|
|
10236
|
+
}
|
|
10237
|
+
const run = runHistory2[0];
|
|
10238
|
+
console.group("\u2696\uFE0F Source Balance Analysis");
|
|
10239
|
+
logger.info(`Mixer: ${run.mixerType}`);
|
|
10240
|
+
logger.info(`Requested limit: ${run.requestedLimit}`);
|
|
10241
|
+
if (run.quotaPerSource) {
|
|
10242
|
+
logger.info(`Quota per source: ${run.quotaPerSource}`);
|
|
10243
|
+
}
|
|
10244
|
+
console.group("Input Distribution:");
|
|
10245
|
+
for (const src of run.sourceSummaries) {
|
|
10246
|
+
const name = src.sourceName || src.sourceId;
|
|
10247
|
+
logger.info(`${name}:`);
|
|
10248
|
+
logger.info(` Provided: ${src.totalCards} cards (${src.reviewCount} reviews, ${src.newCount} new)`);
|
|
10249
|
+
logger.info(` Score range: [${src.scoreRange[0].toFixed(2)}, ${src.scoreRange[1].toFixed(2)}]`);
|
|
10250
|
+
}
|
|
10251
|
+
console.groupEnd();
|
|
10252
|
+
console.group("Selection Results:");
|
|
10253
|
+
for (const breakdown of run.sourceBreakdowns) {
|
|
10254
|
+
const name = breakdown.sourceName || breakdown.sourceId;
|
|
10255
|
+
logger.info(`${name}:`);
|
|
10256
|
+
logger.info(
|
|
10257
|
+
` Selected: ${breakdown.totalSelected}/${breakdown.reviewsProvided + breakdown.newProvided} (${breakdown.selectionRate.toFixed(1)}%)`
|
|
10258
|
+
);
|
|
10259
|
+
logger.info(` Reviews: ${breakdown.reviewsSelected}/${breakdown.reviewsProvided}`);
|
|
10260
|
+
logger.info(` New: ${breakdown.newSelected}/${breakdown.newProvided}`);
|
|
10261
|
+
if (breakdown.reviewsProvided > 0 && breakdown.reviewsSelected === 0) {
|
|
10262
|
+
logger.info(` \u26A0\uFE0F Had reviews but none selected!`);
|
|
10263
|
+
}
|
|
10264
|
+
if (breakdown.totalSelected === 0 && breakdown.reviewsProvided + breakdown.newProvided > 0) {
|
|
10265
|
+
logger.info(` \u26A0\uFE0F Had cards but none selected!`);
|
|
10266
|
+
}
|
|
10267
|
+
}
|
|
10268
|
+
console.groupEnd();
|
|
10269
|
+
const selectionRates = run.sourceBreakdowns.map((b) => b.selectionRate);
|
|
10270
|
+
const avgRate = selectionRates.reduce((a, b) => a + b, 0) / selectionRates.length;
|
|
10271
|
+
const maxDeviation = Math.max(...selectionRates.map((r) => Math.abs(r - avgRate)));
|
|
10272
|
+
if (maxDeviation > 20) {
|
|
10273
|
+
logger.info(`
|
|
10274
|
+
\u26A0\uFE0F Significant imbalance detected (max deviation: ${maxDeviation.toFixed(1)}%)`);
|
|
10275
|
+
logger.info("Possible causes:");
|
|
10276
|
+
logger.info(" - Score range differences between sources");
|
|
10277
|
+
logger.info(" - One source has much better quality cards");
|
|
10278
|
+
logger.info(" - Different card availability (reviews vs new)");
|
|
10279
|
+
}
|
|
10280
|
+
console.groupEnd();
|
|
10281
|
+
},
|
|
10282
|
+
/**
|
|
10283
|
+
* Compare score distributions across sources.
|
|
10284
|
+
*/
|
|
10285
|
+
compareScores() {
|
|
10286
|
+
if (runHistory2.length === 0) {
|
|
10287
|
+
logger.info("[Mixer Debug] No runs captured yet.");
|
|
10288
|
+
return;
|
|
10289
|
+
}
|
|
10290
|
+
const run = runHistory2[0];
|
|
10291
|
+
console.group("\u{1F4CA} Score Distribution Comparison");
|
|
10292
|
+
console.table(
|
|
10293
|
+
run.sourceSummaries.map((src) => ({
|
|
10294
|
+
source: src.sourceName || src.sourceId,
|
|
10295
|
+
cards: src.totalCards,
|
|
10296
|
+
min: src.bottomScore.toFixed(3),
|
|
10297
|
+
max: src.topScore.toFixed(3),
|
|
10298
|
+
avg: src.avgScore.toFixed(3),
|
|
10299
|
+
range: (src.topScore - src.bottomScore).toFixed(3)
|
|
10300
|
+
}))
|
|
10301
|
+
);
|
|
10302
|
+
const ranges = run.sourceSummaries.map((s) => s.topScore - s.bottomScore);
|
|
10303
|
+
const avgScores = run.sourceSummaries.map((s) => s.avgScore);
|
|
10304
|
+
const rangeDiff = Math.max(...ranges) - Math.min(...ranges);
|
|
10305
|
+
const avgDiff = Math.max(...avgScores) - Math.min(...avgScores);
|
|
10306
|
+
if (rangeDiff > 0.3 || avgDiff > 0.2) {
|
|
10307
|
+
logger.info("\n\u26A0\uFE0F Significant score distribution differences detected");
|
|
10308
|
+
logger.info(
|
|
10309
|
+
"This may cause one source to dominate selection if using global sorting (not quota-based)"
|
|
10310
|
+
);
|
|
10311
|
+
}
|
|
10312
|
+
console.groupEnd();
|
|
10313
|
+
},
|
|
10314
|
+
/**
|
|
10315
|
+
* Show detailed information for a specific card.
|
|
10316
|
+
*/
|
|
10317
|
+
showCard(cardId) {
|
|
10318
|
+
for (const run of runHistory2) {
|
|
10319
|
+
const card = run.cards.find((c) => c.cardId === cardId);
|
|
10320
|
+
if (card) {
|
|
10321
|
+
const source = run.sourceSummaries.find((s) => s.sourceIndex === card.sourceIndex);
|
|
10322
|
+
console.group(`\u{1F3B4} Card: ${cardId}`);
|
|
10323
|
+
logger.info(`Course: ${card.courseId}`);
|
|
10324
|
+
logger.info(`Source: ${source?.sourceName || source?.sourceId || "unknown"}`);
|
|
10325
|
+
logger.info(`Origin: ${card.origin}`);
|
|
10326
|
+
logger.info(`Score: ${card.score.toFixed(3)}`);
|
|
10327
|
+
if (card.rankInSource) {
|
|
10328
|
+
logger.info(`Rank in source: #${card.rankInSource}`);
|
|
10329
|
+
}
|
|
10330
|
+
if (card.rankInMix) {
|
|
10331
|
+
logger.info(`Rank in mixed results: #${card.rankInMix}`);
|
|
10332
|
+
}
|
|
10333
|
+
logger.info(`Selected: ${card.selected ? "Yes \u2705" : "No \u274C"}`);
|
|
10334
|
+
if (!card.selected && card.rankInSource) {
|
|
10335
|
+
logger.info("\nWhy not selected:");
|
|
10336
|
+
if (run.quotaPerSource && card.rankInSource > run.quotaPerSource) {
|
|
10337
|
+
logger.info(` - Ranked #${card.rankInSource} in source, but quota was ${run.quotaPerSource}`);
|
|
10338
|
+
}
|
|
10339
|
+
logger.info(" - Check score compared to selected cards using .showRun()");
|
|
10340
|
+
}
|
|
10341
|
+
console.groupEnd();
|
|
10342
|
+
return;
|
|
10343
|
+
}
|
|
10344
|
+
}
|
|
10345
|
+
logger.info(`[Mixer Debug] Card '${cardId}' not found in recent runs.`);
|
|
10346
|
+
},
|
|
10347
|
+
/**
|
|
10348
|
+
* Show all runs in compact format.
|
|
10349
|
+
*/
|
|
10350
|
+
listRuns() {
|
|
10351
|
+
if (runHistory2.length === 0) {
|
|
10352
|
+
logger.info("[Mixer Debug] No runs captured yet.");
|
|
10353
|
+
return;
|
|
10354
|
+
}
|
|
10355
|
+
console.table(
|
|
10356
|
+
runHistory2.map((r) => ({
|
|
10357
|
+
id: r.runId.slice(-8),
|
|
10358
|
+
time: r.timestamp.toLocaleTimeString(),
|
|
10359
|
+
mixer: r.mixerType,
|
|
10360
|
+
sources: r.sourceSummaries.length,
|
|
10361
|
+
selected: r.finalCount,
|
|
10362
|
+
reviews: r.reviewsSelected,
|
|
10363
|
+
new: r.newSelected
|
|
10364
|
+
}))
|
|
10365
|
+
);
|
|
10366
|
+
},
|
|
10367
|
+
/**
|
|
10368
|
+
* Export run history as JSON for bug reports.
|
|
10369
|
+
*/
|
|
10370
|
+
export() {
|
|
10371
|
+
const json = JSON.stringify(runHistory2, null, 2);
|
|
10372
|
+
logger.info("[Mixer Debug] Run history exported. Copy the returned string or use:");
|
|
10373
|
+
logger.info(" copy(window.skuilder.mixer.export())");
|
|
10374
|
+
return json;
|
|
10375
|
+
},
|
|
10376
|
+
/**
|
|
10377
|
+
* Clear run history.
|
|
10378
|
+
*/
|
|
10379
|
+
clear() {
|
|
10380
|
+
runHistory2.length = 0;
|
|
10381
|
+
logger.info("[Mixer Debug] Run history cleared.");
|
|
10382
|
+
},
|
|
10383
|
+
/**
|
|
10384
|
+
* Show help.
|
|
10385
|
+
*/
|
|
10386
|
+
help() {
|
|
10387
|
+
logger.info(`
|
|
10388
|
+
\u{1F3A8} Mixer Debug API
|
|
10389
|
+
|
|
10390
|
+
Commands:
|
|
10391
|
+
.showLastMix() Show summary of most recent mixer run
|
|
10392
|
+
.showRun(id|index) Show summary of a specific run (by index or ID suffix)
|
|
10393
|
+
.explainSourceBalance() Analyze source balance and selection patterns
|
|
10394
|
+
.compareScores() Compare score distributions across sources
|
|
10395
|
+
.showCard(cardId) Show mixer decisions for a specific card
|
|
10396
|
+
.listRuns() List all captured runs in table format
|
|
10397
|
+
.export() Export run history as JSON for bug reports
|
|
10398
|
+
.clear() Clear run history
|
|
10399
|
+
.runs Access raw run history array
|
|
10400
|
+
.help() Show this help message
|
|
10401
|
+
|
|
10402
|
+
Example:
|
|
10403
|
+
window.skuilder.mixer.showLastMix()
|
|
10404
|
+
window.skuilder.mixer.explainSourceBalance()
|
|
10405
|
+
window.skuilder.mixer.compareScores()
|
|
10406
|
+
`);
|
|
10407
|
+
}
|
|
10408
|
+
};
|
|
10409
|
+
function mountMixerDebugger() {
|
|
10410
|
+
if (typeof window === "undefined") return;
|
|
10411
|
+
const win = window;
|
|
10412
|
+
win.skuilder = win.skuilder || {};
|
|
10413
|
+
win.skuilder.mixer = mixerDebugAPI;
|
|
10414
|
+
}
|
|
10415
|
+
mountMixerDebugger();
|
|
10416
|
+
|
|
10417
|
+
// src/study/SessionDebugger.ts
|
|
10418
|
+
init_logger();
|
|
10419
|
+
var activeSession = null;
|
|
10420
|
+
var sessionHistory = [];
|
|
10421
|
+
var MAX_HISTORY = 5;
|
|
10422
|
+
function startSessionTracking(reviewQLength, newQLength, failedQLength) {
|
|
10423
|
+
const sessionId = `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
10424
|
+
activeSession = {
|
|
10425
|
+
sessionId,
|
|
10426
|
+
startTime: /* @__PURE__ */ new Date(),
|
|
10427
|
+
initialQueues: {
|
|
10428
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
10429
|
+
reviewQLength,
|
|
10430
|
+
newQLength,
|
|
10431
|
+
failedQLength
|
|
10432
|
+
},
|
|
10433
|
+
presentations: [],
|
|
10434
|
+
queueSnapshots: []
|
|
10435
|
+
};
|
|
10436
|
+
logger.debug(`[SessionDebugger] Started tracking session: ${sessionId}`);
|
|
10437
|
+
}
|
|
10438
|
+
function recordCardPresentation(cardId, courseId, courseName, origin, queueSource, score) {
|
|
10439
|
+
if (!activeSession) {
|
|
10440
|
+
logger.warn("[SessionDebugger] No active session to record presentation");
|
|
10441
|
+
return;
|
|
10442
|
+
}
|
|
10443
|
+
activeSession.presentations.push({
|
|
10444
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
10445
|
+
sequenceNumber: activeSession.presentations.length + 1,
|
|
10446
|
+
cardId,
|
|
10447
|
+
courseId,
|
|
10448
|
+
courseName,
|
|
10449
|
+
origin,
|
|
10450
|
+
queueSource,
|
|
10451
|
+
score
|
|
10452
|
+
});
|
|
10453
|
+
}
|
|
10454
|
+
function snapshotQueues(reviewQLength, newQLength, failedQLength, reviewQNext3, newQNext3) {
|
|
10455
|
+
if (!activeSession) {
|
|
10456
|
+
return;
|
|
10457
|
+
}
|
|
10458
|
+
activeSession.queueSnapshots.push({
|
|
10459
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
10460
|
+
reviewQLength,
|
|
10461
|
+
newQLength,
|
|
10462
|
+
failedQLength,
|
|
10463
|
+
reviewQNext3,
|
|
10464
|
+
newQNext3
|
|
10465
|
+
});
|
|
10466
|
+
}
|
|
10467
|
+
function endSessionTracking() {
|
|
10468
|
+
if (!activeSession) {
|
|
10469
|
+
return;
|
|
10470
|
+
}
|
|
10471
|
+
activeSession.endTime = /* @__PURE__ */ new Date();
|
|
10472
|
+
sessionHistory.unshift(activeSession);
|
|
10473
|
+
if (sessionHistory.length > MAX_HISTORY) {
|
|
10474
|
+
sessionHistory.pop();
|
|
10475
|
+
}
|
|
10476
|
+
logger.debug(`[SessionDebugger] Ended session: ${activeSession.sessionId}`);
|
|
10477
|
+
activeSession = null;
|
|
10478
|
+
}
|
|
10479
|
+
function showCurrentQueue() {
|
|
10480
|
+
if (!activeSession) {
|
|
10481
|
+
logger.info("[Session Debug] No active session.");
|
|
10482
|
+
return;
|
|
10483
|
+
}
|
|
10484
|
+
const latest = activeSession.queueSnapshots[activeSession.queueSnapshots.length - 1] || activeSession.initialQueues;
|
|
10485
|
+
console.group("\u{1F4CA} Current Queue State");
|
|
10486
|
+
logger.info(`Review Queue: ${latest.reviewQLength} cards`);
|
|
10487
|
+
if (latest.reviewQNext3 && latest.reviewQNext3.length > 0) {
|
|
10488
|
+
logger.info(` Next: ${latest.reviewQNext3.join(", ")}`);
|
|
10489
|
+
}
|
|
10490
|
+
logger.info(`New Queue: ${latest.newQLength} cards`);
|
|
10491
|
+
if (latest.newQNext3 && latest.newQNext3.length > 0) {
|
|
10492
|
+
logger.info(` Next: ${latest.newQNext3.join(", ")}`);
|
|
10493
|
+
}
|
|
10494
|
+
logger.info(`Failed Queue: ${latest.failedQLength} cards`);
|
|
10495
|
+
console.groupEnd();
|
|
10496
|
+
}
|
|
10497
|
+
function showPresentationHistory(sessionIndex = 0) {
|
|
10498
|
+
const session = sessionIndex === 0 && activeSession ? activeSession : sessionHistory[sessionIndex];
|
|
10499
|
+
if (!session) {
|
|
10500
|
+
logger.info(`[Session Debug] No session found at index ${sessionIndex}`);
|
|
10501
|
+
return;
|
|
10502
|
+
}
|
|
10503
|
+
console.group(`\u{1F4DC} Session History: ${session.sessionId}`);
|
|
10504
|
+
logger.info(`Started: ${session.startTime.toLocaleTimeString()}`);
|
|
10505
|
+
if (session.endTime) {
|
|
10506
|
+
logger.info(`Ended: ${session.endTime.toLocaleTimeString()}`);
|
|
10507
|
+
}
|
|
10508
|
+
logger.info(`Cards presented: ${session.presentations.length}`);
|
|
10509
|
+
if (session.presentations.length > 0) {
|
|
10510
|
+
console.table(
|
|
10511
|
+
session.presentations.map((p) => ({
|
|
10512
|
+
"#": p.sequenceNumber,
|
|
10513
|
+
course: p.courseName || p.courseId.slice(0, 8),
|
|
10514
|
+
origin: p.origin,
|
|
10515
|
+
queue: p.queueSource,
|
|
10516
|
+
score: p.score?.toFixed(3) || "-",
|
|
10517
|
+
time: p.timestamp.toLocaleTimeString()
|
|
10518
|
+
}))
|
|
10519
|
+
);
|
|
10520
|
+
}
|
|
10521
|
+
console.groupEnd();
|
|
10522
|
+
}
|
|
10523
|
+
function showInterleaving(sessionIndex = 0) {
|
|
10524
|
+
const session = sessionIndex === 0 && activeSession ? activeSession : sessionHistory[sessionIndex];
|
|
10525
|
+
if (!session) {
|
|
10526
|
+
logger.info(`[Session Debug] No session found at index ${sessionIndex}`);
|
|
10527
|
+
return;
|
|
10528
|
+
}
|
|
10529
|
+
console.group("\u{1F500} Interleaving Analysis");
|
|
10530
|
+
const courseCounts = /* @__PURE__ */ new Map();
|
|
10531
|
+
const courseOrigins = /* @__PURE__ */ new Map();
|
|
10532
|
+
session.presentations.forEach((p) => {
|
|
10533
|
+
const name = p.courseName || p.courseId;
|
|
10534
|
+
courseCounts.set(name, (courseCounts.get(name) || 0) + 1);
|
|
10535
|
+
if (!courseOrigins.has(name)) {
|
|
10536
|
+
courseOrigins.set(name, { review: 0, new: 0, failed: 0 });
|
|
10537
|
+
}
|
|
10538
|
+
const origins = courseOrigins.get(name);
|
|
10539
|
+
origins[p.origin]++;
|
|
10540
|
+
});
|
|
10541
|
+
logger.info("Course distribution:");
|
|
10542
|
+
console.table(
|
|
10543
|
+
Array.from(courseCounts.entries()).map(([course, count]) => {
|
|
10544
|
+
const origins = courseOrigins.get(course);
|
|
10545
|
+
return {
|
|
10546
|
+
course,
|
|
10547
|
+
total: count,
|
|
10548
|
+
reviews: origins.review,
|
|
10549
|
+
new: origins.new,
|
|
10550
|
+
failed: origins.failed,
|
|
10551
|
+
percentage: (count / session.presentations.length * 100).toFixed(1) + "%"
|
|
10552
|
+
};
|
|
10553
|
+
})
|
|
10554
|
+
);
|
|
10555
|
+
if (session.presentations.length > 0) {
|
|
10556
|
+
logger.info("\nPresentation sequence (first 20):");
|
|
10557
|
+
const sequence = session.presentations.slice(0, 20).map((p, idx) => `${idx + 1}. ${p.courseName || p.courseId.slice(0, 8)} (${p.origin})`).join("\n");
|
|
10558
|
+
logger.info(sequence);
|
|
10559
|
+
}
|
|
10560
|
+
let maxCluster = 0;
|
|
10561
|
+
let currentCluster = 1;
|
|
10562
|
+
let currentCourse = session.presentations[0]?.courseId;
|
|
10563
|
+
for (let i = 1; i < session.presentations.length; i++) {
|
|
10564
|
+
if (session.presentations[i].courseId === currentCourse) {
|
|
10565
|
+
currentCluster++;
|
|
10566
|
+
maxCluster = Math.max(maxCluster, currentCluster);
|
|
10567
|
+
} else {
|
|
10568
|
+
currentCourse = session.presentations[i].courseId;
|
|
10569
|
+
currentCluster = 1;
|
|
7956
10570
|
}
|
|
7957
|
-
|
|
10571
|
+
}
|
|
10572
|
+
if (maxCluster > 3) {
|
|
10573
|
+
logger.info(`
|
|
10574
|
+
\u26A0\uFE0F Detected clustering: max ${maxCluster} cards from same course in a row`);
|
|
10575
|
+
logger.info("This suggests cards are sorted by score rather than round-robin by course.");
|
|
10576
|
+
}
|
|
10577
|
+
console.groupEnd();
|
|
10578
|
+
}
|
|
10579
|
+
var sessionDebugAPI = {
|
|
10580
|
+
/**
|
|
10581
|
+
* Get raw session history for programmatic access.
|
|
10582
|
+
*/
|
|
10583
|
+
get sessions() {
|
|
10584
|
+
return [...sessionHistory];
|
|
10585
|
+
},
|
|
10586
|
+
/**
|
|
10587
|
+
* Get active session if any.
|
|
10588
|
+
*/
|
|
10589
|
+
get active() {
|
|
10590
|
+
return activeSession;
|
|
10591
|
+
},
|
|
10592
|
+
/**
|
|
10593
|
+
* Show current queue state.
|
|
10594
|
+
*/
|
|
10595
|
+
showQueue() {
|
|
10596
|
+
showCurrentQueue();
|
|
10597
|
+
},
|
|
10598
|
+
/**
|
|
10599
|
+
* Show presentation history for current or past session.
|
|
10600
|
+
*/
|
|
10601
|
+
showHistory(sessionIndex = 0) {
|
|
10602
|
+
showPresentationHistory(sessionIndex);
|
|
10603
|
+
},
|
|
10604
|
+
/**
|
|
10605
|
+
* Analyze course interleaving pattern.
|
|
10606
|
+
*/
|
|
10607
|
+
showInterleaving(sessionIndex = 0) {
|
|
10608
|
+
showInterleaving(sessionIndex);
|
|
10609
|
+
},
|
|
10610
|
+
/**
|
|
10611
|
+
* List all tracked sessions.
|
|
10612
|
+
*/
|
|
10613
|
+
listSessions() {
|
|
10614
|
+
if (activeSession) {
|
|
10615
|
+
logger.info(`Active session: ${activeSession.sessionId} (${activeSession.presentations.length} cards presented)`);
|
|
10616
|
+
}
|
|
10617
|
+
if (sessionHistory.length === 0) {
|
|
10618
|
+
logger.info("[Session Debug] No completed sessions in history.");
|
|
10619
|
+
return;
|
|
10620
|
+
}
|
|
10621
|
+
console.table(
|
|
10622
|
+
sessionHistory.map((s, idx) => ({
|
|
10623
|
+
index: idx,
|
|
10624
|
+
id: s.sessionId.slice(-8),
|
|
10625
|
+
started: s.startTime.toLocaleTimeString(),
|
|
10626
|
+
ended: s.endTime?.toLocaleTimeString() || "incomplete",
|
|
10627
|
+
cards: s.presentations.length
|
|
10628
|
+
}))
|
|
10629
|
+
);
|
|
10630
|
+
},
|
|
10631
|
+
/**
|
|
10632
|
+
* Export session history as JSON for bug reports.
|
|
10633
|
+
*/
|
|
10634
|
+
export() {
|
|
10635
|
+
const data = {
|
|
10636
|
+
active: activeSession,
|
|
10637
|
+
history: sessionHistory
|
|
10638
|
+
};
|
|
10639
|
+
const json = JSON.stringify(data, null, 2);
|
|
10640
|
+
logger.info("[Session Debug] Session data exported. Copy the returned string or use:");
|
|
10641
|
+
logger.info(" copy(window.skuilder.session.export())");
|
|
10642
|
+
return json;
|
|
10643
|
+
},
|
|
10644
|
+
/**
|
|
10645
|
+
* Clear session history.
|
|
10646
|
+
*/
|
|
10647
|
+
clear() {
|
|
10648
|
+
sessionHistory.length = 0;
|
|
10649
|
+
logger.info("[Session Debug] Session history cleared.");
|
|
10650
|
+
},
|
|
10651
|
+
/**
|
|
10652
|
+
* Show help.
|
|
10653
|
+
*/
|
|
10654
|
+
help() {
|
|
10655
|
+
logger.info(`
|
|
10656
|
+
\u{1F3AF} Session Debug API
|
|
10657
|
+
|
|
10658
|
+
Commands:
|
|
10659
|
+
.showQueue() Show current queue state (active session only)
|
|
10660
|
+
.showHistory(index?) Show presentation history (0=current/last, 1=previous, etc)
|
|
10661
|
+
.showInterleaving(index?) Analyze course interleaving pattern
|
|
10662
|
+
.listSessions() List all tracked sessions
|
|
10663
|
+
.export() Export session data as JSON for bug reports
|
|
10664
|
+
.clear() Clear session history
|
|
10665
|
+
.sessions Access raw session history array
|
|
10666
|
+
.active Access active session (if any)
|
|
10667
|
+
.help() Show this help message
|
|
10668
|
+
|
|
10669
|
+
Example:
|
|
10670
|
+
window.skuilder.session.showHistory()
|
|
10671
|
+
window.skuilder.session.showInterleaving()
|
|
10672
|
+
window.skuilder.session.showQueue()
|
|
10673
|
+
`);
|
|
7958
10674
|
}
|
|
7959
10675
|
};
|
|
10676
|
+
function mountSessionDebugger() {
|
|
10677
|
+
if (typeof window === "undefined") return;
|
|
10678
|
+
const win = window;
|
|
10679
|
+
win.skuilder = win.skuilder || {};
|
|
10680
|
+
win.skuilder.session = sessionDebugAPI;
|
|
10681
|
+
}
|
|
10682
|
+
mountSessionDebugger();
|
|
7960
10683
|
|
|
7961
10684
|
// src/study/SessionController.ts
|
|
7962
10685
|
init_logger();
|
|
@@ -7967,6 +10690,8 @@ var SessionController = class extends Loggable {
|
|
|
7967
10690
|
eloService;
|
|
7968
10691
|
hydrationService;
|
|
7969
10692
|
mixer;
|
|
10693
|
+
dataLayer;
|
|
10694
|
+
courseNameCache = /* @__PURE__ */ new Map();
|
|
7970
10695
|
sources;
|
|
7971
10696
|
// dataLayer and getViewComponent now injected into CardHydrationService
|
|
7972
10697
|
_sessionRecord = [];
|
|
@@ -7986,7 +10711,11 @@ var SessionController = class extends Loggable {
|
|
|
7986
10711
|
return this._secondsRemaining;
|
|
7987
10712
|
}
|
|
7988
10713
|
get report() {
|
|
7989
|
-
|
|
10714
|
+
const reviewCount = this.reviewQ.dequeueCount;
|
|
10715
|
+
const newCount = this.newQ.dequeueCount;
|
|
10716
|
+
const reviewWord = reviewCount === 1 ? "review" : "reviews";
|
|
10717
|
+
const newCardWord = newCount === 1 ? "new card" : "new cards";
|
|
10718
|
+
return `${reviewCount} ${reviewWord}, ${newCount} ${newCardWord}`;
|
|
7990
10719
|
}
|
|
7991
10720
|
get detailedReport() {
|
|
7992
10721
|
return this.newQ.toString + "\n" + this.reviewQ.toString + "\n" + this.failedQ.toString;
|
|
@@ -8002,6 +10731,7 @@ var SessionController = class extends Loggable {
|
|
|
8002
10731
|
*/
|
|
8003
10732
|
constructor(sources, time, dataLayer, getViewComponent, mixer) {
|
|
8004
10733
|
super();
|
|
10734
|
+
this.dataLayer = dataLayer;
|
|
8005
10735
|
this.mixer = mixer || new QuotaRoundRobinMixer();
|
|
8006
10736
|
this.srsService = new SrsService(dataLayer.getUserDB());
|
|
8007
10737
|
this.eloService = new EloService(dataLayer, dataLayer.getUserDB());
|
|
@@ -8069,6 +10799,7 @@ var SessionController = class extends Loggable {
|
|
|
8069
10799
|
}
|
|
8070
10800
|
await this.getWeightedContent();
|
|
8071
10801
|
await this.hydrationService.ensureHydratedCards();
|
|
10802
|
+
startSessionTracking(this.reviewQ.length, this.newQ.length, this.failedQ.length);
|
|
8072
10803
|
this._intervalHandle = setInterval(() => {
|
|
8073
10804
|
this.tick();
|
|
8074
10805
|
}, 1e3);
|
|
@@ -8164,6 +10895,30 @@ var SessionController = class extends Loggable {
|
|
|
8164
10895
|
);
|
|
8165
10896
|
}
|
|
8166
10897
|
const mixedWeighted = this.mixer.mix(batches, limit * this.sources.length);
|
|
10898
|
+
const sourceIds = batches.map((b) => {
|
|
10899
|
+
const firstCard = b.weighted[0];
|
|
10900
|
+
return firstCard?.courseId || `source-${b.sourceIndex}`;
|
|
10901
|
+
});
|
|
10902
|
+
await Promise.all(
|
|
10903
|
+
sourceIds.map(async (id) => {
|
|
10904
|
+
try {
|
|
10905
|
+
const config = await this.dataLayer.getCoursesDB().getCourseConfig(id);
|
|
10906
|
+
this.courseNameCache.set(id, config.name);
|
|
10907
|
+
} catch {
|
|
10908
|
+
}
|
|
10909
|
+
})
|
|
10910
|
+
);
|
|
10911
|
+
const sourceNames = sourceIds.map((id) => this.courseNameCache.get(id));
|
|
10912
|
+
const quotaPerSource = this.mixer instanceof QuotaRoundRobinMixer ? Math.ceil(limit * this.sources.length / batches.length) : void 0;
|
|
10913
|
+
captureMixerRun(
|
|
10914
|
+
this.mixer.constructor.name,
|
|
10915
|
+
batches,
|
|
10916
|
+
sourceIds,
|
|
10917
|
+
sourceNames,
|
|
10918
|
+
limit * this.sources.length,
|
|
10919
|
+
quotaPerSource,
|
|
10920
|
+
mixedWeighted
|
|
10921
|
+
);
|
|
8167
10922
|
const reviewWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "review");
|
|
8168
10923
|
const newWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "new");
|
|
8169
10924
|
logger.debug(`[reviews] got ${reviewWeighted.length} reviews from mixer`);
|
|
@@ -8272,21 +11027,46 @@ var SessionController = class extends Loggable {
|
|
|
8272
11027
|
this.dismissCurrentCard(action);
|
|
8273
11028
|
if (this._secondsRemaining <= 0 && this.failedQ.length === 0) {
|
|
8274
11029
|
this._currentCard = null;
|
|
11030
|
+
endSessionTracking();
|
|
8275
11031
|
return null;
|
|
8276
11032
|
}
|
|
8277
|
-
const
|
|
8278
|
-
|
|
8279
|
-
|
|
8280
|
-
|
|
8281
|
-
|
|
8282
|
-
|
|
8283
|
-
|
|
8284
|
-
|
|
11033
|
+
const MAX_SKIP = 20;
|
|
11034
|
+
for (let attempt = 0; attempt < MAX_SKIP; attempt++) {
|
|
11035
|
+
const nextItem = this._selectNextItemToHydrate();
|
|
11036
|
+
if (!nextItem) {
|
|
11037
|
+
this._currentCard = null;
|
|
11038
|
+
endSessionTracking();
|
|
11039
|
+
return null;
|
|
11040
|
+
}
|
|
11041
|
+
let card = this.hydrationService.getHydratedCard(nextItem.cardID);
|
|
11042
|
+
if (!card) {
|
|
11043
|
+
card = await this.hydrationService.waitForCard(nextItem.cardID);
|
|
11044
|
+
}
|
|
11045
|
+
this.removeItemFromQueue(nextItem);
|
|
11046
|
+
if (card) {
|
|
11047
|
+
await this.hydrationService.ensureHydratedCards();
|
|
11048
|
+
this._currentCard = card;
|
|
11049
|
+
const origin = nextItem.status === "review" || nextItem.status === "failed-review" ? "review" : nextItem.status === "new" || nextItem.status === "failed-new" ? "new" : "failed";
|
|
11050
|
+
const queueSource = nextItem.status.startsWith("failed") ? "failedQ" : nextItem.status === "review" ? "reviewQ" : "newQ";
|
|
11051
|
+
recordCardPresentation(
|
|
11052
|
+
nextItem.cardID,
|
|
11053
|
+
nextItem.courseID,
|
|
11054
|
+
this.courseNameCache.get(nextItem.courseID),
|
|
11055
|
+
origin,
|
|
11056
|
+
queueSource
|
|
11057
|
+
);
|
|
11058
|
+
snapshotQueues(this.reviewQ.length, this.newQ.length, this.failedQ.length);
|
|
11059
|
+
return card;
|
|
11060
|
+
}
|
|
11061
|
+
this.log(`Skipping card ${nextItem.cardID}: hydration failed, trying next`);
|
|
11062
|
+
if (isReview(nextItem)) {
|
|
11063
|
+
this.srsService.removeReview(nextItem.reviewID);
|
|
11064
|
+
}
|
|
8285
11065
|
}
|
|
8286
|
-
this.
|
|
8287
|
-
|
|
8288
|
-
|
|
8289
|
-
return
|
|
11066
|
+
this.log(`Exhausted ${MAX_SKIP} skip attempts finding a hydratable card`);
|
|
11067
|
+
this._currentCard = null;
|
|
11068
|
+
endSessionTracking();
|
|
11069
|
+
return null;
|
|
8290
11070
|
}
|
|
8291
11071
|
/**
|
|
8292
11072
|
* Public API for processing user responses to cards.
|
|
@@ -8362,6 +11142,49 @@ var SessionController = class extends Loggable {
|
|
|
8362
11142
|
this.failedQ.dequeue((queueItem) => queueItem.cardID);
|
|
8363
11143
|
}
|
|
8364
11144
|
}
|
|
11145
|
+
/**
|
|
11146
|
+
* End the session and record learning outcomes.
|
|
11147
|
+
*
|
|
11148
|
+
* This method aggregates all responses from the session and records a
|
|
11149
|
+
* UserOutcomeRecord if evolutionary orchestration is enabled.
|
|
11150
|
+
*/
|
|
11151
|
+
async endSession() {
|
|
11152
|
+
if (!this._sessionRecord || this._sessionRecord.length === 0) {
|
|
11153
|
+
return;
|
|
11154
|
+
}
|
|
11155
|
+
const questionRecords = this._sessionRecord.flatMap((r) => r.records).filter((r) => r.userAnswer !== void 0);
|
|
11156
|
+
if (questionRecords.length === 0) {
|
|
11157
|
+
return;
|
|
11158
|
+
}
|
|
11159
|
+
let orchestrationContext = null;
|
|
11160
|
+
const strategies = [];
|
|
11161
|
+
for (const source of this.sources) {
|
|
11162
|
+
if (source.getOrchestrationContext) {
|
|
11163
|
+
try {
|
|
11164
|
+
orchestrationContext = await source.getOrchestrationContext();
|
|
11165
|
+
if (source.getStrategyIds) {
|
|
11166
|
+
strategies.push(...source.getStrategyIds());
|
|
11167
|
+
}
|
|
11168
|
+
} catch (e) {
|
|
11169
|
+
logger.warn(`[SessionController] Failed to get orchestration context: ${e}`);
|
|
11170
|
+
}
|
|
11171
|
+
if (orchestrationContext) break;
|
|
11172
|
+
}
|
|
11173
|
+
}
|
|
11174
|
+
if (!orchestrationContext) {
|
|
11175
|
+
logger.debug("[SessionController] No orchestration context available, skipping outcome recording");
|
|
11176
|
+
return;
|
|
11177
|
+
}
|
|
11178
|
+
const periodEnd = (/* @__PURE__ */ new Date()).toISOString();
|
|
11179
|
+
const periodStart = new Date(this.startTime).toISOString();
|
|
11180
|
+
await recordUserOutcome(
|
|
11181
|
+
orchestrationContext,
|
|
11182
|
+
periodStart,
|
|
11183
|
+
periodEnd,
|
|
11184
|
+
questionRecords,
|
|
11185
|
+
strategies
|
|
11186
|
+
);
|
|
11187
|
+
}
|
|
8365
11188
|
};
|
|
8366
11189
|
|
|
8367
11190
|
// src/study/index.ts
|
|
@@ -8388,19 +11211,33 @@ export {
|
|
|
8388
11211
|
StaticToCouchDBMigrator,
|
|
8389
11212
|
TagFilteredContentSource,
|
|
8390
11213
|
_resetDataLayer,
|
|
11214
|
+
aggregateOutcomesForGradient,
|
|
8391
11215
|
areQuestionRecords,
|
|
8392
11216
|
buildStrategyStateId,
|
|
11217
|
+
captureMixerRun,
|
|
11218
|
+
computeDeviation,
|
|
11219
|
+
computeEffectiveWeight,
|
|
11220
|
+
computeOutcomeSignal,
|
|
11221
|
+
computeSpread,
|
|
11222
|
+
computeStrategyGradient,
|
|
11223
|
+
createOrchestrationContext,
|
|
8393
11224
|
docIsDeleted,
|
|
11225
|
+
endSessionTracking,
|
|
8394
11226
|
ensureAppDataDirectory,
|
|
8395
11227
|
getAppDataDirectory,
|
|
8396
11228
|
getCardHistoryID,
|
|
8397
11229
|
getCardOrigin,
|
|
8398
11230
|
getDataLayer,
|
|
8399
11231
|
getDbPath,
|
|
11232
|
+
getDefaultLearnableWeight,
|
|
11233
|
+
getRegisteredNavigator,
|
|
11234
|
+
getRegisteredNavigatorNames,
|
|
8400
11235
|
getStudySource,
|
|
11236
|
+
hasRegisteredNavigator,
|
|
8401
11237
|
importParsedCards,
|
|
8402
11238
|
initializeDataDirectory,
|
|
8403
11239
|
initializeDataLayer,
|
|
11240
|
+
initializeNavigatorRegistry,
|
|
8404
11241
|
isDataShapeRegistered,
|
|
8405
11242
|
isDataShapeSchemaAvailable,
|
|
8406
11243
|
isFilter,
|
|
@@ -8409,14 +11246,32 @@ export {
|
|
|
8409
11246
|
isQuestionTypeRegistered,
|
|
8410
11247
|
isReview,
|
|
8411
11248
|
log,
|
|
11249
|
+
mixerDebugAPI,
|
|
11250
|
+
mountMixerDebugger,
|
|
11251
|
+
mountPipelineDebugger,
|
|
11252
|
+
mountSessionDebugger,
|
|
8412
11253
|
newInterval,
|
|
8413
11254
|
parseCardHistoryID,
|
|
11255
|
+
pipelineDebugAPI,
|
|
8414
11256
|
processCustomQuestionsData,
|
|
11257
|
+
recordCardPresentation,
|
|
11258
|
+
recordUserOutcome,
|
|
8415
11259
|
registerBlanksCard,
|
|
8416
11260
|
registerCustomQuestionTypes,
|
|
8417
11261
|
registerDataShape,
|
|
11262
|
+
registerNavigator,
|
|
8418
11263
|
registerQuestionType,
|
|
8419
11264
|
registerSeedData,
|
|
11265
|
+
removeCustomQuestionTypes,
|
|
11266
|
+
removeDataShape,
|
|
11267
|
+
removeQuestionType,
|
|
11268
|
+
runPeriodUpdate,
|
|
11269
|
+
scoreAccuracyInZone,
|
|
11270
|
+
sessionDebugAPI,
|
|
11271
|
+
snapshotQueues,
|
|
11272
|
+
startSessionTracking,
|
|
11273
|
+
updateLearningState,
|
|
11274
|
+
updateStrategyWeight,
|
|
8420
11275
|
validateMigration,
|
|
8421
11276
|
validateProcessorConfig,
|
|
8422
11277
|
validateStaticCourse
|