@vue-skuilder/db 0.1.24 → 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-BotbOOfX.d.ts → contentSource-BmnmvH8C.d.ts} +41 -0
- package/dist/{contentSource-C90LH-OH.d.cts → contentSource-DfBbaLA-.d.cts} +41 -0
- package/dist/core/index.d.cts +94 -4
- package/dist/core/index.d.ts +94 -4
- package/dist/core/index.js +530 -83
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +528 -83
- package/dist/core/index.mjs.map +1 -1
- package/dist/{dataLayerProvider-DGKp4zFB.d.cts → dataLayerProvider-BeRXVMs5.d.cts} +1 -1
- package/dist/{dataLayerProvider-SBpz9jQf.d.ts → dataLayerProvider-CG9GfaAY.d.ts} +1 -1
- package/dist/impl/couch/index.d.cts +2 -2
- package/dist/impl/couch/index.d.ts +2 -2
- package/dist/impl/couch/index.js +526 -83
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +526 -83
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.d.cts +2 -2
- package/dist/impl/static/index.d.ts +2 -2
- package/dist/impl/static/index.js +526 -83
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +526 -83
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/index.d.cts +247 -14
- package/dist/index.d.ts +247 -14
- package/dist/index.js +1419 -140
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1409 -137
- package/dist/index.mjs.map +1 -1
- package/docs/navigators-architecture.md +22 -4
- package/docs/todo-review-urgency-adaptation.md +205 -0
- package/package.json +3 -3
- package/src/core/interfaces/userDB.ts +44 -0
- package/src/core/navigators/Pipeline.ts +86 -5
- package/src/core/navigators/PipelineAssembler.ts +7 -21
- package/src/core/navigators/PipelineDebugger.ts +426 -0
- package/src/core/navigators/generators/CompositeGenerator.ts +21 -0
- package/src/core/navigators/generators/elo.ts +14 -1
- package/src/core/navigators/generators/srs.ts +146 -18
- package/src/core/navigators/index.ts +9 -0
- package/src/impl/couch/user-course-relDB.ts +12 -0
- package/src/study/MixerDebugger.ts +555 -0
- package/src/study/SessionController.ts +95 -19
- 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/PipelineAssembler.test.ts +4 -4
package/dist/index.mjs
CHANGED
|
@@ -490,6 +490,15 @@ var init_user_course_relDB = __esm({
|
|
|
490
490
|
void this.user.updateCourseSettings(this._courseId, updates);
|
|
491
491
|
}
|
|
492
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
|
+
}
|
|
493
502
|
async getReviewstoDate(targetDate) {
|
|
494
503
|
const allReviews = await this.user.getPendingReviews(this._courseId);
|
|
495
504
|
logger.debug(
|
|
@@ -840,6 +849,271 @@ var init_courseLookupDB = __esm({
|
|
|
840
849
|
}
|
|
841
850
|
});
|
|
842
851
|
|
|
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) {
|
|
861
|
+
const firstEntry = card.provenance[0];
|
|
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();
|
|
877
|
+
}
|
|
878
|
+
}
|
|
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
|
+
};
|
|
903
|
+
}
|
|
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");
|
|
909
|
+
}
|
|
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"() {
|
|
945
|
+
"use strict";
|
|
946
|
+
init_logger();
|
|
947
|
+
MAX_RUNS = 10;
|
|
948
|
+
runHistory = [];
|
|
949
|
+
pipelineDebugAPI = {
|
|
950
|
+
/**
|
|
951
|
+
* Get raw run history for programmatic access.
|
|
952
|
+
*/
|
|
953
|
+
get runs() {
|
|
954
|
+
return [...runHistory];
|
|
955
|
+
},
|
|
956
|
+
/**
|
|
957
|
+
* Show summary of a specific pipeline run.
|
|
958
|
+
*/
|
|
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
|
+
},
|
|
982
|
+
/**
|
|
983
|
+
* Show summary of the last pipeline run.
|
|
984
|
+
*/
|
|
985
|
+
showLastRun() {
|
|
986
|
+
this.showRun(0);
|
|
987
|
+
},
|
|
988
|
+
/**
|
|
989
|
+
* Show detailed provenance for a specific card.
|
|
990
|
+
*/
|
|
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
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
logger.info(`[Pipeline Debug] Card '${cardId}' not found in recent runs.`);
|
|
1007
|
+
},
|
|
1008
|
+
/**
|
|
1009
|
+
* Explain why reviews may or may not have been selected.
|
|
1010
|
+
*/
|
|
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));
|
|
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));
|
|
1047
|
+
}
|
|
1048
|
+
console.groupEnd();
|
|
1049
|
+
}
|
|
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;
|
|
1059
|
+
}
|
|
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
|
+
},
|
|
1072
|
+
/**
|
|
1073
|
+
* Export run history as JSON for bug reports.
|
|
1074
|
+
*/
|
|
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
|
+
`);
|
|
1111
|
+
}
|
|
1112
|
+
};
|
|
1113
|
+
mountPipelineDebugger();
|
|
1114
|
+
}
|
|
1115
|
+
});
|
|
1116
|
+
|
|
843
1117
|
// src/core/navigators/generators/CompositeGenerator.ts
|
|
844
1118
|
var CompositeGenerator_exports = {};
|
|
845
1119
|
__export(CompositeGenerator_exports, {
|
|
@@ -908,6 +1182,24 @@ var init_CompositeGenerator = __esm({
|
|
|
908
1182
|
const results = await Promise.all(
|
|
909
1183
|
this.generators.map((g) => g.getWeightedCards(limit, context))
|
|
910
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(" | ")}`);
|
|
911
1203
|
const byCardId = /* @__PURE__ */ new Map();
|
|
912
1204
|
results.forEach((cards, index) => {
|
|
913
1205
|
const gen = this.generators[index];
|
|
@@ -1025,6 +1317,7 @@ var init_elo = __esm({
|
|
|
1025
1317
|
"src/core/navigators/generators/elo.ts"() {
|
|
1026
1318
|
"use strict";
|
|
1027
1319
|
init_navigators();
|
|
1320
|
+
init_logger();
|
|
1028
1321
|
ELONavigator = class extends ContentNavigator {
|
|
1029
1322
|
/** Human-readable name for CardGenerator interface */
|
|
1030
1323
|
name;
|
|
@@ -1084,7 +1377,16 @@ var init_elo = __esm({
|
|
|
1084
1377
|
};
|
|
1085
1378
|
});
|
|
1086
1379
|
scored.sort((a, b) => b.score - a.score);
|
|
1087
|
-
|
|
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;
|
|
1088
1390
|
}
|
|
1089
1391
|
};
|
|
1090
1392
|
}
|
|
@@ -1104,18 +1406,36 @@ __export(srs_exports, {
|
|
|
1104
1406
|
default: () => SRSNavigator
|
|
1105
1407
|
});
|
|
1106
1408
|
import moment3 from "moment";
|
|
1107
|
-
var SRSNavigator;
|
|
1409
|
+
var DEFAULT_HEALTHY_BACKLOG, MAX_BACKLOG_PRESSURE, SRSNavigator;
|
|
1108
1410
|
var init_srs = __esm({
|
|
1109
1411
|
"src/core/navigators/generators/srs.ts"() {
|
|
1110
1412
|
"use strict";
|
|
1111
1413
|
init_navigators();
|
|
1112
1414
|
init_logger();
|
|
1415
|
+
DEFAULT_HEALTHY_BACKLOG = 20;
|
|
1416
|
+
MAX_BACKLOG_PRESSURE = 0.5;
|
|
1113
1417
|
SRSNavigator = class extends ContentNavigator {
|
|
1114
1418
|
/** Human-readable name for CardGenerator interface */
|
|
1115
1419
|
name;
|
|
1420
|
+
/** Healthy backlog threshold - when exceeded, backlog pressure kicks in */
|
|
1421
|
+
healthyBacklog;
|
|
1116
1422
|
constructor(user, course, strategyData) {
|
|
1117
1423
|
super(user, course, strategyData);
|
|
1118
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
|
+
}
|
|
1119
1439
|
}
|
|
1120
1440
|
/**
|
|
1121
1441
|
* Get review cards scored by urgency.
|
|
@@ -1123,6 +1443,7 @@ var init_srs = __esm({
|
|
|
1123
1443
|
* Score formula combines:
|
|
1124
1444
|
* - Relative overdueness: hoursOverdue / intervalHours
|
|
1125
1445
|
* - Interval recency: exponential decay favoring shorter intervals
|
|
1446
|
+
* - Backlog pressure: boost when due reviews exceed healthy threshold
|
|
1126
1447
|
*
|
|
1127
1448
|
* Cards not yet due are excluded (not scored as 0).
|
|
1128
1449
|
*
|
|
@@ -1136,11 +1457,32 @@ var init_srs = __esm({
|
|
|
1136
1457
|
if (!this.user || !this.course) {
|
|
1137
1458
|
throw new Error("SRSNavigator requires user and course to be set");
|
|
1138
1459
|
}
|
|
1139
|
-
const
|
|
1460
|
+
const courseId = this.course.getCourseID();
|
|
1461
|
+
const reviews = await this.user.getPendingReviews(courseId);
|
|
1140
1462
|
const now = moment3.utc();
|
|
1141
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
|
+
}
|
|
1142
1484
|
const scored = dueReviews.map((review) => {
|
|
1143
|
-
const { score, reason } = this.computeUrgencyScore(review, now);
|
|
1485
|
+
const { score, reason } = this.computeUrgencyScore(review, now, backlogPressure);
|
|
1144
1486
|
return {
|
|
1145
1487
|
cardId: review.cardId,
|
|
1146
1488
|
courseId: review.courseId,
|
|
@@ -1158,13 +1500,35 @@ var init_srs = __esm({
|
|
|
1158
1500
|
]
|
|
1159
1501
|
};
|
|
1160
1502
|
});
|
|
1161
|
-
logger.debug(`[srsNav] got ${scored.length} weighted cards`);
|
|
1162
1503
|
return scored.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
1163
1504
|
}
|
|
1505
|
+
/**
|
|
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
|
+
}
|
|
1164
1528
|
/**
|
|
1165
1529
|
* Compute urgency score for a review card.
|
|
1166
1530
|
*
|
|
1167
|
-
*
|
|
1531
|
+
* Three factors:
|
|
1168
1532
|
* 1. Relative overdueness = hoursOverdue / intervalHours
|
|
1169
1533
|
* - 2 days overdue on 3-day interval = 0.67 (urgent)
|
|
1170
1534
|
* - 2 days overdue on 180-day interval = 0.01 (not urgent)
|
|
@@ -1174,10 +1538,19 @@ var init_srs = __esm({
|
|
|
1174
1538
|
* - 30 days (720h) → ~0.56
|
|
1175
1539
|
* - 180 days → ~0.30
|
|
1176
1540
|
*
|
|
1177
|
-
*
|
|
1178
|
-
*
|
|
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)
|
|
1179
1552
|
*/
|
|
1180
|
-
computeUrgencyScore(review, now) {
|
|
1553
|
+
computeUrgencyScore(review, now, backlogPressure) {
|
|
1181
1554
|
const scheduledAt = moment3.utc(review.scheduledAt);
|
|
1182
1555
|
const due = moment3.utc(review.reviewTime);
|
|
1183
1556
|
const intervalHours = Math.max(1, due.diff(scheduledAt, "hours"));
|
|
@@ -1186,8 +1559,19 @@ var init_srs = __esm({
|
|
|
1186
1559
|
const recencyFactor = 0.3 + 0.7 * Math.exp(-intervalHours / 720);
|
|
1187
1560
|
const overdueContribution = Math.min(1, Math.max(0, relativeOverdue));
|
|
1188
1561
|
const urgency = overdueContribution * 0.5 + recencyFactor * 0.5;
|
|
1189
|
-
const
|
|
1190
|
-
const
|
|
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(", ");
|
|
1191
1575
|
return { score, reason };
|
|
1192
1576
|
}
|
|
1193
1577
|
};
|
|
@@ -2464,10 +2848,23 @@ function logTagHydration(cards, tagsByCard) {
|
|
|
2464
2848
|
`[Pipeline] Tag hydration: ${cards.length} cards, ${cardsWithTags} have tags (${totalTags} total tags) - single batch query`
|
|
2465
2849
|
);
|
|
2466
2850
|
}
|
|
2467
|
-
function logExecutionSummary(generatorName, generatedCount, filterCount, finalCount, topScores) {
|
|
2851
|
+
function logExecutionSummary(generatorName, generatedCount, filterCount, finalCount, topScores, filterImpacts) {
|
|
2468
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
|
+
}
|
|
2469
2865
|
logger.info(
|
|
2470
|
-
`[Pipeline] Execution: ${generatorName} produced ${generatedCount} \u2192 ${filterCount} filters \u2192 ${finalCount} results (top scores: ${scoreDisplay})`
|
|
2866
|
+
`[Pipeline] Execution: ${generatorName} produced ${generatedCount} \u2192 ${filterCount} filters \u2192 ${finalCount} results (top scores: ${scoreDisplay})` + filterSummary + `
|
|
2867
|
+
\u{1F4A1} Inspect: window.skuilder.pipeline`
|
|
2471
2868
|
);
|
|
2472
2869
|
}
|
|
2473
2870
|
function logCardProvenance(cards, maxCards = 3) {
|
|
@@ -2491,6 +2888,7 @@ var init_Pipeline = __esm({
|
|
|
2491
2888
|
init_navigators();
|
|
2492
2889
|
init_logger();
|
|
2493
2890
|
init_orchestration();
|
|
2891
|
+
init_PipelineDebugger();
|
|
2494
2892
|
Pipeline = class extends ContentNavigator {
|
|
2495
2893
|
generator;
|
|
2496
2894
|
filters;
|
|
@@ -2538,12 +2936,49 @@ var init_Pipeline = __esm({
|
|
|
2538
2936
|
);
|
|
2539
2937
|
let cards = await this.generator.getWeightedCards(fetchLimit, context);
|
|
2540
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
|
+
}
|
|
2541
2964
|
logger.debug(`[Pipeline] Generator returned ${generatedCount} candidates`);
|
|
2542
2965
|
cards = await this.hydrateTags(cards);
|
|
2966
|
+
const allCardsBeforeFiltering = [...cards];
|
|
2967
|
+
const filterImpacts = [];
|
|
2543
2968
|
for (const filter of this.filters) {
|
|
2544
2969
|
const beforeCount = cards.length;
|
|
2970
|
+
const beforeScores = new Map(cards.map((c) => [c.cardId, c.score]));
|
|
2545
2971
|
cards = await filter.transform(cards, context);
|
|
2546
|
-
|
|
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})`);
|
|
2547
2982
|
}
|
|
2548
2983
|
cards = cards.filter((c) => c.score > 0);
|
|
2549
2984
|
cards.sort((a, b) => b.score - a.score);
|
|
@@ -2554,9 +2989,26 @@ var init_Pipeline = __esm({
|
|
|
2554
2989
|
generatedCount,
|
|
2555
2990
|
this.filters.length,
|
|
2556
2991
|
result.length,
|
|
2557
|
-
topScores
|
|
2992
|
+
topScores,
|
|
2993
|
+
filterImpacts
|
|
2558
2994
|
);
|
|
2559
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
|
+
}
|
|
2560
3012
|
return result;
|
|
2561
3013
|
}
|
|
2562
3014
|
/**
|
|
@@ -2646,6 +3098,56 @@ var init_Pipeline = __esm({
|
|
|
2646
3098
|
}
|
|
2647
3099
|
});
|
|
2648
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
|
+
|
|
2649
3151
|
// src/core/navigators/PipelineAssembler.ts
|
|
2650
3152
|
var PipelineAssembler_exports = {};
|
|
2651
3153
|
__export(PipelineAssembler_exports, {
|
|
@@ -2658,9 +3160,9 @@ var init_PipelineAssembler = __esm({
|
|
|
2658
3160
|
init_navigators();
|
|
2659
3161
|
init_WeightedFilter();
|
|
2660
3162
|
init_Pipeline();
|
|
2661
|
-
init_types_legacy();
|
|
2662
3163
|
init_logger();
|
|
2663
3164
|
init_CompositeGenerator();
|
|
3165
|
+
init_defaults();
|
|
2664
3166
|
PipelineAssembler = class {
|
|
2665
3167
|
/**
|
|
2666
3168
|
* Assembles a navigation pipeline from strategy documents.
|
|
@@ -2699,9 +3201,11 @@ var init_PipelineAssembler = __esm({
|
|
|
2699
3201
|
if (generatorStrategies.length === 0) {
|
|
2700
3202
|
if (filterStrategies.length > 0) {
|
|
2701
3203
|
logger.debug(
|
|
2702
|
-
"[PipelineAssembler] No generator found, using default ELO with configured filters"
|
|
3204
|
+
"[PipelineAssembler] No generator found, using default ELO and SRS with configured filters"
|
|
2703
3205
|
);
|
|
2704
|
-
|
|
3206
|
+
const courseId = course.getCourseID();
|
|
3207
|
+
generatorStrategies.push(createDefaultEloStrategy(courseId));
|
|
3208
|
+
generatorStrategies.push(createDefaultSrsStrategy(courseId));
|
|
2705
3209
|
} else {
|
|
2706
3210
|
warnings.push("No generator strategy found");
|
|
2707
3211
|
return {
|
|
@@ -2762,75 +3266,10 @@ var init_PipelineAssembler = __esm({
|
|
|
2762
3266
|
warnings
|
|
2763
3267
|
};
|
|
2764
3268
|
}
|
|
2765
|
-
/**
|
|
2766
|
-
* Creates a default ELO generator strategy.
|
|
2767
|
-
* Used when filters are configured but no generator is specified.
|
|
2768
|
-
*/
|
|
2769
|
-
makeDefaultEloStrategy(courseId) {
|
|
2770
|
-
return {
|
|
2771
|
-
_id: "NAVIGATION_STRATEGY-ELO-default",
|
|
2772
|
-
course: courseId,
|
|
2773
|
-
docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
|
|
2774
|
-
name: "ELO (default)",
|
|
2775
|
-
description: "Default ELO-based generator",
|
|
2776
|
-
implementingClass: "elo" /* ELO */,
|
|
2777
|
-
serializedData: ""
|
|
2778
|
-
};
|
|
2779
|
-
}
|
|
2780
3269
|
};
|
|
2781
3270
|
}
|
|
2782
3271
|
});
|
|
2783
3272
|
|
|
2784
|
-
// src/core/navigators/defaults.ts
|
|
2785
|
-
var defaults_exports = {};
|
|
2786
|
-
__export(defaults_exports, {
|
|
2787
|
-
createDefaultEloStrategy: () => createDefaultEloStrategy,
|
|
2788
|
-
createDefaultPipeline: () => createDefaultPipeline,
|
|
2789
|
-
createDefaultSrsStrategy: () => createDefaultSrsStrategy
|
|
2790
|
-
});
|
|
2791
|
-
function createDefaultEloStrategy(courseId) {
|
|
2792
|
-
return {
|
|
2793
|
-
_id: "NAVIGATION_STRATEGY-ELO-default",
|
|
2794
|
-
docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
|
|
2795
|
-
name: "ELO (default)",
|
|
2796
|
-
description: "Default ELO-based navigation strategy for new cards",
|
|
2797
|
-
implementingClass: "elo" /* ELO */,
|
|
2798
|
-
course: courseId,
|
|
2799
|
-
serializedData: ""
|
|
2800
|
-
};
|
|
2801
|
-
}
|
|
2802
|
-
function createDefaultSrsStrategy(courseId) {
|
|
2803
|
-
return {
|
|
2804
|
-
_id: "NAVIGATION_STRATEGY-SRS-default",
|
|
2805
|
-
docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
|
|
2806
|
-
name: "SRS (default)",
|
|
2807
|
-
description: "Default SRS-based navigation strategy for reviews",
|
|
2808
|
-
implementingClass: "srs" /* SRS */,
|
|
2809
|
-
course: courseId,
|
|
2810
|
-
serializedData: ""
|
|
2811
|
-
};
|
|
2812
|
-
}
|
|
2813
|
-
function createDefaultPipeline(user, course) {
|
|
2814
|
-
const courseId = course.getCourseID();
|
|
2815
|
-
const eloNavigator = new ELONavigator(user, course, createDefaultEloStrategy(courseId));
|
|
2816
|
-
const srsNavigator = new SRSNavigator(user, course, createDefaultSrsStrategy(courseId));
|
|
2817
|
-
const compositeGenerator = new CompositeGenerator([eloNavigator, srsNavigator]);
|
|
2818
|
-
const eloDistanceFilter = createEloDistanceFilter();
|
|
2819
|
-
return new Pipeline(compositeGenerator, [eloDistanceFilter], user, course);
|
|
2820
|
-
}
|
|
2821
|
-
var init_defaults = __esm({
|
|
2822
|
-
"src/core/navigators/defaults.ts"() {
|
|
2823
|
-
"use strict";
|
|
2824
|
-
init_navigators();
|
|
2825
|
-
init_Pipeline();
|
|
2826
|
-
init_CompositeGenerator();
|
|
2827
|
-
init_elo();
|
|
2828
|
-
init_srs();
|
|
2829
|
-
init_eloDistance();
|
|
2830
|
-
init_types_legacy();
|
|
2831
|
-
}
|
|
2832
|
-
});
|
|
2833
|
-
|
|
2834
3273
|
// import("./**/*") in src/core/navigators/index.ts
|
|
2835
3274
|
var globImport;
|
|
2836
3275
|
var init_3 = __esm({
|
|
@@ -2838,6 +3277,7 @@ var init_3 = __esm({
|
|
|
2838
3277
|
globImport = __glob({
|
|
2839
3278
|
"./Pipeline.ts": () => Promise.resolve().then(() => (init_Pipeline(), Pipeline_exports)),
|
|
2840
3279
|
"./PipelineAssembler.ts": () => Promise.resolve().then(() => (init_PipelineAssembler(), PipelineAssembler_exports)),
|
|
3280
|
+
"./PipelineDebugger.ts": () => Promise.resolve().then(() => (init_PipelineDebugger(), PipelineDebugger_exports)),
|
|
2841
3281
|
"./defaults.ts": () => Promise.resolve().then(() => (init_defaults(), defaults_exports)),
|
|
2842
3282
|
"./filters/WeightedFilter.ts": () => Promise.resolve().then(() => (init_WeightedFilter(), WeightedFilter_exports)),
|
|
2843
3283
|
"./filters/eloDistance.ts": () => Promise.resolve().then(() => (init_eloDistance(), eloDistance_exports)),
|
|
@@ -2873,6 +3313,8 @@ __export(navigators_exports, {
|
|
|
2873
3313
|
initializeNavigatorRegistry: () => initializeNavigatorRegistry,
|
|
2874
3314
|
isFilter: () => isFilter,
|
|
2875
3315
|
isGenerator: () => isGenerator,
|
|
3316
|
+
mountPipelineDebugger: () => mountPipelineDebugger,
|
|
3317
|
+
pipelineDebugAPI: () => pipelineDebugAPI,
|
|
2876
3318
|
registerNavigator: () => registerNavigator
|
|
2877
3319
|
});
|
|
2878
3320
|
function registerNavigator(implementingClass, constructor) {
|
|
@@ -2939,6 +3381,7 @@ var navigatorRegistry, Navigators, NavigatorRole, NavigatorRoles, ContentNavigat
|
|
|
2939
3381
|
var init_navigators = __esm({
|
|
2940
3382
|
"src/core/navigators/index.ts"() {
|
|
2941
3383
|
"use strict";
|
|
3384
|
+
init_PipelineDebugger();
|
|
2942
3385
|
init_logger();
|
|
2943
3386
|
init_();
|
|
2944
3387
|
init_2();
|
|
@@ -7555,6 +7998,14 @@ var SrsService = class {
|
|
|
7555
7998
|
constructor(user) {
|
|
7556
7999
|
this.user = user;
|
|
7557
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
|
+
}
|
|
7558
8009
|
/**
|
|
7559
8010
|
* Calculates the next review time for a card based on its history and
|
|
7560
8011
|
* schedules it in the user's database.
|
|
@@ -7581,7 +8032,11 @@ var SrsService = class {
|
|
|
7581
8032
|
|
|
7582
8033
|
// src/study/services/EloService.ts
|
|
7583
8034
|
init_logger();
|
|
7584
|
-
import {
|
|
8035
|
+
import {
|
|
8036
|
+
adjustCourseScores,
|
|
8037
|
+
adjustCourseScoresPerTag,
|
|
8038
|
+
toCourseElo as toCourseElo7
|
|
8039
|
+
} from "@vue-skuilder/common";
|
|
7585
8040
|
var EloService = class {
|
|
7586
8041
|
dataLayer;
|
|
7587
8042
|
user;
|
|
@@ -7593,7 +8048,7 @@ var EloService = class {
|
|
|
7593
8048
|
* Updates both user and card ELO ratings based on user performance.
|
|
7594
8049
|
* @param userScore Score between 0-1 representing user performance
|
|
7595
8050
|
* @param course_id Course identifier
|
|
7596
|
-
* @param card_id Card identifier
|
|
8051
|
+
* @param card_id Card identifier
|
|
7597
8052
|
* @param userCourseRegDoc User's course registration document (will be mutated)
|
|
7598
8053
|
* @param currentCard Current card session record
|
|
7599
8054
|
* @param k Optional K-factor for ELO calculation
|
|
@@ -7603,7 +8058,9 @@ var EloService = class {
|
|
|
7603
8058
|
logger.warn(`k value interpretation not currently implemented`);
|
|
7604
8059
|
}
|
|
7605
8060
|
const courseDB = this.dataLayer.getCourseDB(currentCard.card.course_id);
|
|
7606
|
-
const userElo = toCourseElo7(
|
|
8061
|
+
const userElo = toCourseElo7(
|
|
8062
|
+
userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo
|
|
8063
|
+
);
|
|
7607
8064
|
const cardElo = (await courseDB.getCardEloData([currentCard.card.card_id]))[0];
|
|
7608
8065
|
if (cardElo && userElo) {
|
|
7609
8066
|
const eloUpdate = adjustCourseScores(userElo, cardElo, userScore);
|
|
@@ -7640,11 +8097,64 @@ var EloService = class {
|
|
|
7640
8097
|
}
|
|
7641
8098
|
}
|
|
7642
8099
|
}
|
|
8100
|
+
/**
|
|
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
|
|
8105
|
+
* @param course_id Course identifier
|
|
8106
|
+
* @param card_id Card identifier
|
|
8107
|
+
* @param userCourseRegDoc User's course registration document (will be mutated)
|
|
8108
|
+
* @param currentCard Current card session record
|
|
8109
|
+
*/
|
|
8110
|
+
async updateUserAndCardEloPerTag(taggedPerformance, course_id, card_id, userCourseRegDoc, currentCard) {
|
|
8111
|
+
const courseDB = this.dataLayer.getCourseDB(currentCard.card.course_id);
|
|
8112
|
+
const userElo = toCourseElo7(
|
|
8113
|
+
userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo
|
|
8114
|
+
);
|
|
8115
|
+
const cardElo = (await courseDB.getCardEloData([currentCard.card.card_id]))[0];
|
|
8116
|
+
if (cardElo && userElo) {
|
|
8117
|
+
const eloUpdate = adjustCourseScoresPerTag(userElo, cardElo, taggedPerformance);
|
|
8118
|
+
userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo = eloUpdate.userElo;
|
|
8119
|
+
const results = await Promise.allSettled([
|
|
8120
|
+
this.user.updateUserElo(course_id, eloUpdate.userElo),
|
|
8121
|
+
courseDB.updateCardElo(card_id, eloUpdate.cardElo)
|
|
8122
|
+
]);
|
|
8123
|
+
const userEloStatus = results[0].status === "fulfilled";
|
|
8124
|
+
const cardEloStatus = results[1].status === "fulfilled";
|
|
8125
|
+
if (userEloStatus && cardEloStatus) {
|
|
8126
|
+
const user = results[0].value;
|
|
8127
|
+
const card = results[1].value;
|
|
8128
|
+
if (user.ok && card && card.ok) {
|
|
8129
|
+
const tagCount = Object.keys(taggedPerformance).length - 1;
|
|
8130
|
+
logger.info(
|
|
8131
|
+
`[EloService] Updated ELOS (per-tag, ${tagCount} tags):
|
|
8132
|
+
User: ${JSON.stringify(eloUpdate.userElo)})
|
|
8133
|
+
Card: ${JSON.stringify(eloUpdate.cardElo)})
|
|
8134
|
+
`
|
|
8135
|
+
);
|
|
8136
|
+
}
|
|
8137
|
+
} else {
|
|
8138
|
+
logger.warn(
|
|
8139
|
+
`[EloService] Partial ELO update (per-tag):
|
|
8140
|
+
User ELO update: ${userEloStatus ? "SUCCESS" : "FAILED"}
|
|
8141
|
+
Card ELO update: ${cardEloStatus ? "SUCCESS" : "FAILED"}`
|
|
8142
|
+
);
|
|
8143
|
+
if (!userEloStatus && results[0].status === "rejected") {
|
|
8144
|
+
logger.error("[EloService] User ELO update error:", results[0].reason);
|
|
8145
|
+
}
|
|
8146
|
+
if (!cardEloStatus && results[1].status === "rejected") {
|
|
8147
|
+
logger.error("[EloService] Card ELO update error:", results[1].reason);
|
|
8148
|
+
}
|
|
8149
|
+
}
|
|
8150
|
+
}
|
|
8151
|
+
}
|
|
7643
8152
|
};
|
|
7644
8153
|
|
|
7645
8154
|
// src/study/services/ResponseProcessor.ts
|
|
7646
8155
|
init_core();
|
|
7647
8156
|
init_logger();
|
|
8157
|
+
import { isTaggedPerformance } from "@vue-skuilder/common";
|
|
7648
8158
|
var ResponseProcessor = class {
|
|
7649
8159
|
srsService;
|
|
7650
8160
|
eloService;
|
|
@@ -7652,6 +8162,33 @@ var ResponseProcessor = class {
|
|
|
7652
8162
|
this.srsService = srsService;
|
|
7653
8163
|
this.eloService = eloService;
|
|
7654
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
|
+
}
|
|
7655
8192
|
/**
|
|
7656
8193
|
* Processes a user's response to a card, handling SRS scheduling and ELO updates.
|
|
7657
8194
|
* @param cardRecord User's response record
|
|
@@ -7712,46 +8249,60 @@ var ResponseProcessor = class {
|
|
|
7712
8249
|
processCorrectResponse(cardRecord, history, studySessionItem, courseRegistrationDoc, currentCard, courseId, cardId) {
|
|
7713
8250
|
if (cardRecord.priorAttemps === 0) {
|
|
7714
8251
|
void this.srsService.scheduleReview(history, studySessionItem);
|
|
7715
|
-
|
|
7716
|
-
|
|
7717
|
-
void this.eloService.
|
|
7718
|
-
|
|
8252
|
+
const { globalScore, taggedPerformance } = this.parsePerformance(cardRecord.performance);
|
|
8253
|
+
if (taggedPerformance) {
|
|
8254
|
+
void this.eloService.updateUserAndCardEloPerTag(
|
|
8255
|
+
taggedPerformance,
|
|
7719
8256
|
courseId,
|
|
7720
8257
|
cardId,
|
|
7721
8258
|
courseRegistrationDoc,
|
|
7722
8259
|
currentCard
|
|
7723
8260
|
);
|
|
8261
|
+
logger.info(
|
|
8262
|
+
`[ResponseProcessor] Processed correct response with per-tag ELO update (${Object.keys(taggedPerformance).length - 1} tags)`
|
|
8263
|
+
);
|
|
7724
8264
|
} else {
|
|
7725
|
-
const
|
|
7726
|
-
|
|
7727
|
-
|
|
7728
|
-
|
|
7729
|
-
|
|
7730
|
-
|
|
7731
|
-
|
|
7732
|
-
|
|
7733
|
-
|
|
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"
|
|
7734
8287
|
);
|
|
7735
8288
|
}
|
|
7736
|
-
logger.info(
|
|
7737
|
-
"[ResponseProcessor] Processed correct response with SRS scheduling and ELO update"
|
|
7738
|
-
);
|
|
7739
8289
|
return {
|
|
7740
8290
|
nextCardAction: "dismiss-success",
|
|
7741
8291
|
shouldLoadNextCard: true,
|
|
7742
8292
|
isCorrect: true,
|
|
7743
|
-
performanceScore:
|
|
8293
|
+
performanceScore: globalScore,
|
|
7744
8294
|
shouldClearFeedbackShadow: true
|
|
7745
8295
|
};
|
|
7746
8296
|
} else {
|
|
7747
8297
|
logger.info(
|
|
7748
8298
|
"[ResponseProcessor] Processed correct response (retry attempt - no scheduling/ELO)"
|
|
7749
8299
|
);
|
|
8300
|
+
const { globalScore } = this.parsePerformance(cardRecord.performance);
|
|
7750
8301
|
return {
|
|
7751
8302
|
nextCardAction: "marked-failed",
|
|
7752
8303
|
shouldLoadNextCard: true,
|
|
7753
8304
|
isCorrect: true,
|
|
7754
|
-
performanceScore:
|
|
8305
|
+
performanceScore: globalScore,
|
|
7755
8306
|
shouldClearFeedbackShadow: true
|
|
7756
8307
|
};
|
|
7757
8308
|
}
|
|
@@ -7760,28 +8311,52 @@ var ResponseProcessor = class {
|
|
|
7760
8311
|
* Handles processing for incorrect responses: ELO updates only.
|
|
7761
8312
|
*/
|
|
7762
8313
|
processIncorrectResponse(cardRecord, history, courseRegistrationDoc, currentCard, courseId, cardId, maxAttemptsPerView, maxSessionViews, sessionViews) {
|
|
8314
|
+
const { taggedPerformance } = this.parsePerformance(cardRecord.performance);
|
|
7763
8315
|
if (history.records.length !== 1 && cardRecord.priorAttemps === 0) {
|
|
7764
|
-
|
|
7765
|
-
|
|
7766
|
-
|
|
7767
|
-
|
|
7768
|
-
|
|
7769
|
-
|
|
7770
|
-
|
|
7771
|
-
|
|
7772
|
-
|
|
7773
|
-
|
|
7774
|
-
|
|
7775
|
-
|
|
7776
|
-
if (currentCard.records.length >= maxAttemptsPerView) {
|
|
7777
|
-
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 {
|
|
7778
8328
|
void this.eloService.updateUserAndCardElo(
|
|
7779
8329
|
0,
|
|
8330
|
+
// Failed response = 0 score
|
|
7780
8331
|
courseId,
|
|
7781
8332
|
cardId,
|
|
7782
8333
|
courseRegistrationDoc,
|
|
7783
8334
|
currentCard
|
|
7784
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
|
+
}
|
|
7785
8360
|
return {
|
|
7786
8361
|
nextCardAction: "dismiss-failed",
|
|
7787
8362
|
shouldLoadNextCard: true,
|
|
@@ -9467,16 +10042,645 @@ var QuotaRoundRobinMixer = class {
|
|
|
9467
10042
|
return [];
|
|
9468
10043
|
}
|
|
9469
10044
|
const quotaPerSource = Math.ceil(limit / batches.length);
|
|
9470
|
-
const
|
|
9471
|
-
|
|
9472
|
-
|
|
9473
|
-
|
|
9474
|
-
|
|
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
|
+
}
|
|
9475
10066
|
}
|
|
9476
|
-
return
|
|
10067
|
+
return result;
|
|
9477
10068
|
}
|
|
9478
10069
|
};
|
|
9479
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;
|
|
10570
|
+
}
|
|
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
|
+
`);
|
|
10674
|
+
}
|
|
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();
|
|
10683
|
+
|
|
9480
10684
|
// src/study/SessionController.ts
|
|
9481
10685
|
init_logger();
|
|
9482
10686
|
var SessionController = class extends Loggable {
|
|
@@ -9486,6 +10690,8 @@ var SessionController = class extends Loggable {
|
|
|
9486
10690
|
eloService;
|
|
9487
10691
|
hydrationService;
|
|
9488
10692
|
mixer;
|
|
10693
|
+
dataLayer;
|
|
10694
|
+
courseNameCache = /* @__PURE__ */ new Map();
|
|
9489
10695
|
sources;
|
|
9490
10696
|
// dataLayer and getViewComponent now injected into CardHydrationService
|
|
9491
10697
|
_sessionRecord = [];
|
|
@@ -9505,7 +10711,11 @@ var SessionController = class extends Loggable {
|
|
|
9505
10711
|
return this._secondsRemaining;
|
|
9506
10712
|
}
|
|
9507
10713
|
get report() {
|
|
9508
|
-
|
|
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}`;
|
|
9509
10719
|
}
|
|
9510
10720
|
get detailedReport() {
|
|
9511
10721
|
return this.newQ.toString + "\n" + this.reviewQ.toString + "\n" + this.failedQ.toString;
|
|
@@ -9521,6 +10731,7 @@ var SessionController = class extends Loggable {
|
|
|
9521
10731
|
*/
|
|
9522
10732
|
constructor(sources, time, dataLayer, getViewComponent, mixer) {
|
|
9523
10733
|
super();
|
|
10734
|
+
this.dataLayer = dataLayer;
|
|
9524
10735
|
this.mixer = mixer || new QuotaRoundRobinMixer();
|
|
9525
10736
|
this.srsService = new SrsService(dataLayer.getUserDB());
|
|
9526
10737
|
this.eloService = new EloService(dataLayer, dataLayer.getUserDB());
|
|
@@ -9588,6 +10799,7 @@ var SessionController = class extends Loggable {
|
|
|
9588
10799
|
}
|
|
9589
10800
|
await this.getWeightedContent();
|
|
9590
10801
|
await this.hydrationService.ensureHydratedCards();
|
|
10802
|
+
startSessionTracking(this.reviewQ.length, this.newQ.length, this.failedQ.length);
|
|
9591
10803
|
this._intervalHandle = setInterval(() => {
|
|
9592
10804
|
this.tick();
|
|
9593
10805
|
}, 1e3);
|
|
@@ -9683,6 +10895,30 @@ var SessionController = class extends Loggable {
|
|
|
9683
10895
|
);
|
|
9684
10896
|
}
|
|
9685
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
|
+
);
|
|
9686
10922
|
const reviewWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "review");
|
|
9687
10923
|
const newWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "new");
|
|
9688
10924
|
logger.debug(`[reviews] got ${reviewWeighted.length} reviews from mixer`);
|
|
@@ -9791,21 +11027,46 @@ var SessionController = class extends Loggable {
|
|
|
9791
11027
|
this.dismissCurrentCard(action);
|
|
9792
11028
|
if (this._secondsRemaining <= 0 && this.failedQ.length === 0) {
|
|
9793
11029
|
this._currentCard = null;
|
|
11030
|
+
endSessionTracking();
|
|
9794
11031
|
return null;
|
|
9795
11032
|
}
|
|
9796
|
-
const
|
|
9797
|
-
|
|
9798
|
-
|
|
9799
|
-
|
|
9800
|
-
|
|
9801
|
-
|
|
9802
|
-
|
|
9803
|
-
|
|
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
|
+
}
|
|
9804
11065
|
}
|
|
9805
|
-
this.
|
|
9806
|
-
|
|
9807
|
-
|
|
9808
|
-
return
|
|
11066
|
+
this.log(`Exhausted ${MAX_SKIP} skip attempts finding a hydratable card`);
|
|
11067
|
+
this._currentCard = null;
|
|
11068
|
+
endSessionTracking();
|
|
11069
|
+
return null;
|
|
9809
11070
|
}
|
|
9810
11071
|
/**
|
|
9811
11072
|
* Public API for processing user responses to cards.
|
|
@@ -9953,6 +11214,7 @@ export {
|
|
|
9953
11214
|
aggregateOutcomesForGradient,
|
|
9954
11215
|
areQuestionRecords,
|
|
9955
11216
|
buildStrategyStateId,
|
|
11217
|
+
captureMixerRun,
|
|
9956
11218
|
computeDeviation,
|
|
9957
11219
|
computeEffectiveWeight,
|
|
9958
11220
|
computeOutcomeSignal,
|
|
@@ -9960,6 +11222,7 @@ export {
|
|
|
9960
11222
|
computeStrategyGradient,
|
|
9961
11223
|
createOrchestrationContext,
|
|
9962
11224
|
docIsDeleted,
|
|
11225
|
+
endSessionTracking,
|
|
9963
11226
|
ensureAppDataDirectory,
|
|
9964
11227
|
getAppDataDirectory,
|
|
9965
11228
|
getCardHistoryID,
|
|
@@ -9983,9 +11246,15 @@ export {
|
|
|
9983
11246
|
isQuestionTypeRegistered,
|
|
9984
11247
|
isReview,
|
|
9985
11248
|
log,
|
|
11249
|
+
mixerDebugAPI,
|
|
11250
|
+
mountMixerDebugger,
|
|
11251
|
+
mountPipelineDebugger,
|
|
11252
|
+
mountSessionDebugger,
|
|
9986
11253
|
newInterval,
|
|
9987
11254
|
parseCardHistoryID,
|
|
11255
|
+
pipelineDebugAPI,
|
|
9988
11256
|
processCustomQuestionsData,
|
|
11257
|
+
recordCardPresentation,
|
|
9989
11258
|
recordUserOutcome,
|
|
9990
11259
|
registerBlanksCard,
|
|
9991
11260
|
registerCustomQuestionTypes,
|
|
@@ -9998,6 +11267,9 @@ export {
|
|
|
9998
11267
|
removeQuestionType,
|
|
9999
11268
|
runPeriodUpdate,
|
|
10000
11269
|
scoreAccuracyInZone,
|
|
11270
|
+
sessionDebugAPI,
|
|
11271
|
+
snapshotQueues,
|
|
11272
|
+
startSessionTracking,
|
|
10001
11273
|
updateLearningState,
|
|
10002
11274
|
updateStrategyWeight,
|
|
10003
11275
|
validateMigration,
|