@vue-skuilder/db 0.1.31-b → 0.1.31
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-ygoFw9oV.d.ts → contentSource-Bdwkvqa8.d.ts} +16 -0
- package/dist/{contentSource-B7nXusjk.d.cts → contentSource-DF1nUbPQ.d.cts} +16 -0
- package/dist/core/index.d.cts +34 -3
- package/dist/core/index.d.ts +34 -3
- package/dist/core/index.js +510 -50
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +510 -50
- package/dist/core/index.mjs.map +1 -1
- package/dist/{dataLayerProvider-BW7HvkMt.d.cts → dataLayerProvider-BKmVoyJR.d.ts} +20 -1
- package/dist/{dataLayerProvider-BfXUVDuG.d.ts → dataLayerProvider-BQdfJuBN.d.cts} +20 -1
- package/dist/impl/couch/index.d.cts +156 -4
- package/dist/impl/couch/index.d.ts +156 -4
- package/dist/impl/couch/index.js +730 -41
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +729 -41
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.d.cts +3 -2
- package/dist/impl/static/index.d.ts +3 -2
- package/dist/impl/static/index.js +467 -31
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +467 -31
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/index.d.cts +64 -3
- package/dist/index.d.ts +64 -3
- package/dist/index.js +948 -72
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +948 -72
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/core/interfaces/contentSource.ts +6 -0
- package/src/core/interfaces/courseDB.ts +6 -0
- package/src/core/interfaces/dataLayerProvider.ts +20 -0
- package/src/core/navigators/Pipeline.ts +414 -9
- package/src/core/navigators/PipelineAssembler.ts +23 -18
- package/src/core/navigators/PipelineDebugger.ts +35 -1
- package/src/core/navigators/filters/hierarchyDefinition.ts +78 -8
- package/src/core/navigators/generators/prescribed.ts +95 -0
- package/src/core/navigators/index.ts +12 -0
- package/src/impl/common/BaseUserDB.ts +4 -1
- package/src/impl/couch/CourseSyncService.ts +356 -0
- package/src/impl/couch/PouchDataLayerProvider.ts +21 -1
- package/src/impl/couch/courseDB.ts +60 -13
- package/src/impl/couch/index.ts +1 -0
- package/src/impl/static/courseDB.ts +5 -0
- package/src/study/ItemQueue.ts +42 -0
- package/src/study/SessionController.ts +195 -22
- package/src/study/SpacedRepetition.ts +3 -1
- package/tests/core/navigators/Pipeline.test.ts +1 -1
- package/tests/core/navigators/PipelineAssembler.test.ts +15 -14
package/dist/index.js
CHANGED
|
@@ -878,8 +878,12 @@ __export(PipelineDebugger_exports, {
|
|
|
878
878
|
buildRunReport: () => buildRunReport,
|
|
879
879
|
captureRun: () => captureRun,
|
|
880
880
|
mountPipelineDebugger: () => mountPipelineDebugger,
|
|
881
|
-
pipelineDebugAPI: () => pipelineDebugAPI
|
|
881
|
+
pipelineDebugAPI: () => pipelineDebugAPI,
|
|
882
|
+
registerPipelineForDebug: () => registerPipelineForDebug
|
|
882
883
|
});
|
|
884
|
+
function registerPipelineForDebug(pipeline) {
|
|
885
|
+
_activePipeline = pipeline;
|
|
886
|
+
}
|
|
883
887
|
function getOrigin(card) {
|
|
884
888
|
const firstEntry = card.provenance[0];
|
|
885
889
|
if (!firstEntry) return "unknown";
|
|
@@ -907,6 +911,7 @@ function buildRunReport(courseId, courseName, generatorName, generators, generat
|
|
|
907
911
|
origin: getOrigin(card),
|
|
908
912
|
finalScore: card.score,
|
|
909
913
|
provenance: card.provenance,
|
|
914
|
+
tags: card.tags,
|
|
910
915
|
selected: selectedIds.has(card.cardId)
|
|
911
916
|
}));
|
|
912
917
|
const reviewsSelected = selectedCards.filter((c) => getOrigin(c) === "review").length;
|
|
@@ -962,12 +967,13 @@ function mountPipelineDebugger() {
|
|
|
962
967
|
win.skuilder = win.skuilder || {};
|
|
963
968
|
win.skuilder.pipeline = pipelineDebugAPI;
|
|
964
969
|
}
|
|
965
|
-
var MAX_RUNS, runHistory, pipelineDebugAPI;
|
|
970
|
+
var _activePipeline, MAX_RUNS, runHistory, pipelineDebugAPI;
|
|
966
971
|
var init_PipelineDebugger = __esm({
|
|
967
972
|
"src/core/navigators/PipelineDebugger.ts"() {
|
|
968
973
|
"use strict";
|
|
969
974
|
init_navigators();
|
|
970
975
|
init_logger();
|
|
976
|
+
_activePipeline = null;
|
|
971
977
|
MAX_RUNS = 10;
|
|
972
978
|
runHistory = [];
|
|
973
979
|
pipelineDebugAPI = {
|
|
@@ -1169,6 +1175,21 @@ var init_PipelineDebugger = __esm({
|
|
|
1169
1175
|
}
|
|
1170
1176
|
console.groupEnd();
|
|
1171
1177
|
},
|
|
1178
|
+
/**
|
|
1179
|
+
* Scan the full card space through the filter chain for the current user.
|
|
1180
|
+
*
|
|
1181
|
+
* Reports how many cards are well-indicated and how many are new.
|
|
1182
|
+
* Use this to understand how the search space grows during onboarding.
|
|
1183
|
+
*
|
|
1184
|
+
* @param threshold - Score threshold for "well indicated" (default 0.10)
|
|
1185
|
+
*/
|
|
1186
|
+
async diagnoseCardSpace(threshold) {
|
|
1187
|
+
if (!_activePipeline) {
|
|
1188
|
+
logger.info("[Pipeline Debug] No active pipeline. Run a session first.");
|
|
1189
|
+
return null;
|
|
1190
|
+
}
|
|
1191
|
+
return _activePipeline.diagnoseCardSpace({ threshold });
|
|
1192
|
+
},
|
|
1172
1193
|
/**
|
|
1173
1194
|
* Show help.
|
|
1174
1195
|
*/
|
|
@@ -1181,6 +1202,7 @@ Commands:
|
|
|
1181
1202
|
.showRun(id|index) Show summary of a specific run (by index or ID suffix)
|
|
1182
1203
|
.showCard(cardId) Show provenance trail for a specific card
|
|
1183
1204
|
.explainReviews() Analyze why reviews were/weren't selected
|
|
1205
|
+
.diagnoseCardSpace() Scan full card space through filters (async)
|
|
1184
1206
|
.showRegistry() Show navigator registry (classes + roles)
|
|
1185
1207
|
.showStrategies() Show registry + strategy mapping from last run
|
|
1186
1208
|
.listRuns() List all captured runs in table format
|
|
@@ -1192,7 +1214,7 @@ Commands:
|
|
|
1192
1214
|
Example:
|
|
1193
1215
|
window.skuilder.pipeline.showLastRun()
|
|
1194
1216
|
window.skuilder.pipeline.showRun(1)
|
|
1195
|
-
window.skuilder.pipeline.
|
|
1217
|
+
await window.skuilder.pipeline.diagnoseCardSpace()
|
|
1196
1218
|
`);
|
|
1197
1219
|
}
|
|
1198
1220
|
};
|
|
@@ -1487,6 +1509,69 @@ var init_generators = __esm({
|
|
|
1487
1509
|
}
|
|
1488
1510
|
});
|
|
1489
1511
|
|
|
1512
|
+
// src/core/navigators/generators/prescribed.ts
|
|
1513
|
+
var prescribed_exports = {};
|
|
1514
|
+
__export(prescribed_exports, {
|
|
1515
|
+
default: () => PrescribedCardsGenerator
|
|
1516
|
+
});
|
|
1517
|
+
var PrescribedCardsGenerator;
|
|
1518
|
+
var init_prescribed = __esm({
|
|
1519
|
+
"src/core/navigators/generators/prescribed.ts"() {
|
|
1520
|
+
"use strict";
|
|
1521
|
+
init_navigators();
|
|
1522
|
+
init_logger();
|
|
1523
|
+
PrescribedCardsGenerator = class extends ContentNavigator {
|
|
1524
|
+
name;
|
|
1525
|
+
config;
|
|
1526
|
+
constructor(user, course, strategyData) {
|
|
1527
|
+
super(user, course, strategyData);
|
|
1528
|
+
this.name = strategyData.name || "Prescribed Cards";
|
|
1529
|
+
try {
|
|
1530
|
+
const parsed = JSON.parse(strategyData.serializedData);
|
|
1531
|
+
this.config = { cardIds: parsed.cardIds || [] };
|
|
1532
|
+
} catch {
|
|
1533
|
+
this.config = { cardIds: [] };
|
|
1534
|
+
}
|
|
1535
|
+
logger.debug(
|
|
1536
|
+
`[Prescribed] Initialized with ${this.config.cardIds.length} prescribed cards`
|
|
1537
|
+
);
|
|
1538
|
+
}
|
|
1539
|
+
async getWeightedCards(limit, _context) {
|
|
1540
|
+
if (this.config.cardIds.length === 0) {
|
|
1541
|
+
return [];
|
|
1542
|
+
}
|
|
1543
|
+
const courseId = this.course.getCourseID();
|
|
1544
|
+
const activeCards = await this.user.getActiveCards();
|
|
1545
|
+
const activeIds = new Set(activeCards.map((ac) => ac.cardID));
|
|
1546
|
+
const eligibleIds = this.config.cardIds.filter((id) => !activeIds.has(id));
|
|
1547
|
+
if (eligibleIds.length === 0) {
|
|
1548
|
+
logger.debug("[Prescribed] All prescribed cards already active, returning empty");
|
|
1549
|
+
return [];
|
|
1550
|
+
}
|
|
1551
|
+
const cards = eligibleIds.slice(0, limit).map((cardId) => ({
|
|
1552
|
+
cardId,
|
|
1553
|
+
courseId,
|
|
1554
|
+
score: 1,
|
|
1555
|
+
provenance: [
|
|
1556
|
+
{
|
|
1557
|
+
strategy: "prescribed",
|
|
1558
|
+
strategyName: this.strategyName || this.name,
|
|
1559
|
+
strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
|
|
1560
|
+
action: "generated",
|
|
1561
|
+
score: 1,
|
|
1562
|
+
reason: `Prescribed card (${eligibleIds.length} eligible of ${this.config.cardIds.length} configured)`
|
|
1563
|
+
}
|
|
1564
|
+
]
|
|
1565
|
+
}));
|
|
1566
|
+
logger.info(
|
|
1567
|
+
`[Prescribed] Emitting ${cards.length} cards (${eligibleIds.length} eligible, ${activeIds.size} already active)`
|
|
1568
|
+
);
|
|
1569
|
+
return cards;
|
|
1570
|
+
}
|
|
1571
|
+
};
|
|
1572
|
+
}
|
|
1573
|
+
});
|
|
1574
|
+
|
|
1490
1575
|
// src/core/navigators/generators/srs.ts
|
|
1491
1576
|
var srs_exports = {};
|
|
1492
1577
|
__export(srs_exports, {
|
|
@@ -1681,6 +1766,7 @@ var init_ = __esm({
|
|
|
1681
1766
|
"./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
|
|
1682
1767
|
"./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
|
|
1683
1768
|
"./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
|
|
1769
|
+
"./generators/prescribed.ts": () => Promise.resolve().then(() => (init_prescribed(), prescribed_exports)),
|
|
1684
1770
|
"./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
|
|
1685
1771
|
"./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports))
|
|
1686
1772
|
});
|
|
@@ -1881,6 +1967,8 @@ var init_hierarchyDefinition = __esm({
|
|
|
1881
1967
|
if (userTagElo.count < minCount) return false;
|
|
1882
1968
|
if (prereq.masteryThreshold?.minElo !== void 0) {
|
|
1883
1969
|
return userTagElo.score >= prereq.masteryThreshold.minElo;
|
|
1970
|
+
} else if (prereq.masteryThreshold?.minCount !== void 0) {
|
|
1971
|
+
return true;
|
|
1884
1972
|
} else {
|
|
1885
1973
|
return userTagElo.score >= userGlobalElo;
|
|
1886
1974
|
}
|
|
@@ -1956,14 +2044,38 @@ var init_hierarchyDefinition = __esm({
|
|
|
1956
2044
|
};
|
|
1957
2045
|
}
|
|
1958
2046
|
}
|
|
2047
|
+
/**
|
|
2048
|
+
* Build a map of prereq tag → max configured boost for all *closed* gates.
|
|
2049
|
+
*
|
|
2050
|
+
* When a gate is closed (prereqs unmet), cards carrying that gate's prereq
|
|
2051
|
+
* tags get boosted — steering the pipeline toward content that helps unlock
|
|
2052
|
+
* the gated material. Once the gate opens, the boost disappears.
|
|
2053
|
+
*/
|
|
2054
|
+
getPreReqBoosts(unlockedTags, masteredTags) {
|
|
2055
|
+
const boosts = /* @__PURE__ */ new Map();
|
|
2056
|
+
for (const [tagId, prereqs] of Object.entries(this.config.prerequisites)) {
|
|
2057
|
+
if (unlockedTags.has(tagId)) continue;
|
|
2058
|
+
for (const prereq of prereqs) {
|
|
2059
|
+
if (!prereq.preReqBoost || prereq.preReqBoost <= 1) continue;
|
|
2060
|
+
if (masteredTags.has(prereq.tag)) continue;
|
|
2061
|
+
const existing = boosts.get(prereq.tag) ?? 1;
|
|
2062
|
+
boosts.set(prereq.tag, Math.max(existing, prereq.preReqBoost));
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
return boosts;
|
|
2066
|
+
}
|
|
1959
2067
|
/**
|
|
1960
2068
|
* CardFilter.transform implementation.
|
|
1961
2069
|
*
|
|
1962
|
-
*
|
|
2070
|
+
* Two effects:
|
|
2071
|
+
* 1. Cards with locked tags receive score * 0.05 (gating penalty)
|
|
2072
|
+
* 2. Cards carrying prereq tags of closed gates receive a configured
|
|
2073
|
+
* boost (preReqBoost), steering toward content that unlocks gates
|
|
1963
2074
|
*/
|
|
1964
2075
|
async transform(cards, context) {
|
|
1965
2076
|
const masteredTags = await this.getMasteredTags(context);
|
|
1966
2077
|
const unlockedTags = this.getUnlockedTags(masteredTags);
|
|
2078
|
+
const preReqBoosts = this.getPreReqBoosts(unlockedTags, masteredTags);
|
|
1967
2079
|
const gated = [];
|
|
1968
2080
|
for (const card of cards) {
|
|
1969
2081
|
const { isUnlocked, reason } = await this.checkCardUnlock(
|
|
@@ -1972,9 +2084,27 @@ var init_hierarchyDefinition = __esm({
|
|
|
1972
2084
|
unlockedTags,
|
|
1973
2085
|
masteredTags
|
|
1974
2086
|
);
|
|
1975
|
-
const LOCKED_PENALTY = 0.
|
|
1976
|
-
|
|
1977
|
-
|
|
2087
|
+
const LOCKED_PENALTY = 0.02;
|
|
2088
|
+
let finalScore = isUnlocked ? card.score : card.score * LOCKED_PENALTY;
|
|
2089
|
+
let action = isUnlocked ? "passed" : "penalized";
|
|
2090
|
+
let finalReason = reason;
|
|
2091
|
+
if (isUnlocked && preReqBoosts.size > 0) {
|
|
2092
|
+
const cardTags = card.tags ?? [];
|
|
2093
|
+
let maxBoost = 1;
|
|
2094
|
+
const boostedPrereqs = [];
|
|
2095
|
+
for (const tag of cardTags) {
|
|
2096
|
+
const boost = preReqBoosts.get(tag);
|
|
2097
|
+
if (boost && boost > maxBoost) {
|
|
2098
|
+
maxBoost = boost;
|
|
2099
|
+
boostedPrereqs.push(tag);
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
if (maxBoost > 1) {
|
|
2103
|
+
finalScore *= maxBoost;
|
|
2104
|
+
action = "boosted";
|
|
2105
|
+
finalReason = `${reason} | preReqBoost \xD7${maxBoost.toFixed(2)} for ${boostedPrereqs.join(", ")}`;
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
1978
2108
|
gated.push({
|
|
1979
2109
|
...card,
|
|
1980
2110
|
score: finalScore,
|
|
@@ -1986,7 +2116,7 @@ var init_hierarchyDefinition = __esm({
|
|
|
1986
2116
|
strategyId: this.strategyId || "NAVIGATION_STRATEGY-hierarchy",
|
|
1987
2117
|
action,
|
|
1988
2118
|
score: finalScore,
|
|
1989
|
-
reason
|
|
2119
|
+
reason: finalReason
|
|
1990
2120
|
}
|
|
1991
2121
|
]
|
|
1992
2122
|
});
|
|
@@ -2920,6 +3050,18 @@ var Pipeline_exports = {};
|
|
|
2920
3050
|
__export(Pipeline_exports, {
|
|
2921
3051
|
Pipeline: () => Pipeline
|
|
2922
3052
|
});
|
|
3053
|
+
function globToRegex(pattern) {
|
|
3054
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
3055
|
+
const withWildcards = escaped.replace(/\*/g, ".*");
|
|
3056
|
+
return new RegExp(`^${withWildcards}$`);
|
|
3057
|
+
}
|
|
3058
|
+
function globMatch(value, pattern) {
|
|
3059
|
+
if (!pattern.includes("*")) return value === pattern;
|
|
3060
|
+
return globToRegex(pattern).test(value);
|
|
3061
|
+
}
|
|
3062
|
+
function cardMatchesTagPattern(card, pattern) {
|
|
3063
|
+
return (card.tags ?? []).some((tag) => globMatch(tag, pattern));
|
|
3064
|
+
}
|
|
2923
3065
|
function logPipelineConfig(generator, filters) {
|
|
2924
3066
|
const filterList = filters.length > 0 ? "\n - " + filters.map((f) => f.name).join("\n - ") : " none";
|
|
2925
3067
|
logger.info(
|
|
@@ -2954,6 +3096,21 @@ function logExecutionSummary(generatorName, generatedCount, filterCount, finalCo
|
|
|
2954
3096
|
\u{1F4A1} Inspect: window.skuilder.pipeline`
|
|
2955
3097
|
);
|
|
2956
3098
|
}
|
|
3099
|
+
function logResultCards(cards) {
|
|
3100
|
+
if (!VERBOSE_RESULTS || cards.length === 0) return;
|
|
3101
|
+
logger.info(`[Pipeline] Results (${cards.length} cards):`);
|
|
3102
|
+
for (let i = 0; i < cards.length; i++) {
|
|
3103
|
+
const c = cards[i];
|
|
3104
|
+
const tags = c.tags?.slice(0, 3).join(", ") || "";
|
|
3105
|
+
const filters = c.provenance.filter((p) => p.strategy === "hierarchyDefinition" || p.strategy === "priorityDefinition" || p.strategy === "interferenceFilter" || p.strategy === "letterGating" || p.strategy === "ephemeralHint").map((p) => {
|
|
3106
|
+
const arrow = p.action === "boosted" ? "\u2191" : p.action === "penalized" ? "\u2193" : "=";
|
|
3107
|
+
return `${p.strategyName}${arrow}${p.score.toFixed(2)}`;
|
|
3108
|
+
}).join(" | ");
|
|
3109
|
+
logger.info(
|
|
3110
|
+
`[Pipeline] ${String(i + 1).padStart(2)}. ${c.score.toFixed(4)} ${c.cardId} [${tags}]${filters ? ` {${filters}}` : ""}`
|
|
3111
|
+
);
|
|
3112
|
+
}
|
|
3113
|
+
}
|
|
2957
3114
|
function logCardProvenance(cards, maxCards = 3) {
|
|
2958
3115
|
const cardsToLog = cards.slice(0, maxCards);
|
|
2959
3116
|
logger.debug(`[Pipeline] Provenance for top ${cardsToLog.length} cards:`);
|
|
@@ -2968,7 +3125,7 @@ function logCardProvenance(cards, maxCards = 3) {
|
|
|
2968
3125
|
}
|
|
2969
3126
|
}
|
|
2970
3127
|
}
|
|
2971
|
-
var import_common8, Pipeline;
|
|
3128
|
+
var import_common8, VERBOSE_RESULTS, Pipeline;
|
|
2972
3129
|
var init_Pipeline = __esm({
|
|
2973
3130
|
"src/core/navigators/Pipeline.ts"() {
|
|
2974
3131
|
"use strict";
|
|
@@ -2977,9 +3134,31 @@ var init_Pipeline = __esm({
|
|
|
2977
3134
|
init_logger();
|
|
2978
3135
|
init_orchestration();
|
|
2979
3136
|
init_PipelineDebugger();
|
|
3137
|
+
VERBOSE_RESULTS = true;
|
|
2980
3138
|
Pipeline = class extends ContentNavigator {
|
|
2981
3139
|
generator;
|
|
2982
3140
|
filters;
|
|
3141
|
+
/**
|
|
3142
|
+
* Cached orchestration context. Course config and salt don't change within
|
|
3143
|
+
* a page load, so we build the orchestration context once and reuse it on
|
|
3144
|
+
* subsequent getWeightedCards() calls (e.g. mid-session replans).
|
|
3145
|
+
*
|
|
3146
|
+
* This eliminates a remote getCourseConfig() round trip per pipeline run.
|
|
3147
|
+
*/
|
|
3148
|
+
_cachedOrchestration = null;
|
|
3149
|
+
/**
|
|
3150
|
+
* Persistent tag cache. Maps cardId → tag names.
|
|
3151
|
+
*
|
|
3152
|
+
* Tags are static within a session (they're set at card generation time),
|
|
3153
|
+
* so we cache them across pipeline runs. On replans, many of the same cards
|
|
3154
|
+
* reappear — cache hits avoid redundant remote getAppliedTagsBatch() queries.
|
|
3155
|
+
*/
|
|
3156
|
+
_tagCache = /* @__PURE__ */ new Map();
|
|
3157
|
+
/**
|
|
3158
|
+
* One-shot replan hints. Applied after the filter chain on the next
|
|
3159
|
+
* getWeightedCards() call, then cleared.
|
|
3160
|
+
*/
|
|
3161
|
+
_ephemeralHints = null;
|
|
2983
3162
|
/**
|
|
2984
3163
|
* Create a new pipeline.
|
|
2985
3164
|
*
|
|
@@ -3000,6 +3179,17 @@ var init_Pipeline = __esm({
|
|
|
3000
3179
|
logger.error(`[pipeline] Failed to lookup courseCfg: ${e}`);
|
|
3001
3180
|
});
|
|
3002
3181
|
logPipelineConfig(generator, filters);
|
|
3182
|
+
registerPipelineForDebug(this);
|
|
3183
|
+
}
|
|
3184
|
+
/**
|
|
3185
|
+
* Set one-shot hints for the next pipeline run.
|
|
3186
|
+
* Consumed after one getWeightedCards() call, then cleared.
|
|
3187
|
+
*
|
|
3188
|
+
* Overrides ContentNavigator.setEphemeralHints() no-op.
|
|
3189
|
+
*/
|
|
3190
|
+
setEphemeralHints(hints) {
|
|
3191
|
+
this._ephemeralHints = hints;
|
|
3192
|
+
logger.info(`[Pipeline] Ephemeral hints set: ${JSON.stringify(hints)}`);
|
|
3003
3193
|
}
|
|
3004
3194
|
/**
|
|
3005
3195
|
* Get weighted cards by running generator and applying filters.
|
|
@@ -3016,13 +3206,15 @@ var init_Pipeline = __esm({
|
|
|
3016
3206
|
* @returns Cards sorted by score descending
|
|
3017
3207
|
*/
|
|
3018
3208
|
async getWeightedCards(limit) {
|
|
3209
|
+
const t0 = performance.now();
|
|
3019
3210
|
const context = await this.buildContext();
|
|
3020
|
-
const
|
|
3021
|
-
const fetchLimit =
|
|
3211
|
+
const tContext = performance.now();
|
|
3212
|
+
const fetchLimit = 500;
|
|
3022
3213
|
logger.debug(
|
|
3023
3214
|
`[Pipeline] Fetching ${fetchLimit} candidates from generator '${this.generator.name}'`
|
|
3024
3215
|
);
|
|
3025
3216
|
let cards = await this.generator.getWeightedCards(fetchLimit, context);
|
|
3217
|
+
const tGenerate = performance.now();
|
|
3026
3218
|
const generatedCount = cards.length;
|
|
3027
3219
|
let generatorSummaries;
|
|
3028
3220
|
if (this.generator.generators) {
|
|
@@ -3051,6 +3243,7 @@ var init_Pipeline = __esm({
|
|
|
3051
3243
|
}
|
|
3052
3244
|
logger.debug(`[Pipeline] Generator returned ${generatedCount} candidates`);
|
|
3053
3245
|
cards = await this.hydrateTags(cards);
|
|
3246
|
+
const tHydrate = performance.now();
|
|
3054
3247
|
const allCardsBeforeFiltering = [...cards];
|
|
3055
3248
|
const filterImpacts = [];
|
|
3056
3249
|
for (const filter of this.filters) {
|
|
@@ -3069,8 +3262,17 @@ var init_Pipeline = __esm({
|
|
|
3069
3262
|
logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeScores.size} \u2192 ${cards.length} cards (\u2191${boosted} \u2193${penalized} =${passed})`);
|
|
3070
3263
|
}
|
|
3071
3264
|
cards = cards.filter((c) => c.score > 0);
|
|
3265
|
+
const hints = this._ephemeralHints;
|
|
3266
|
+
if (hints) {
|
|
3267
|
+
this._ephemeralHints = null;
|
|
3268
|
+
cards = this.applyHints(cards, hints, allCardsBeforeFiltering);
|
|
3269
|
+
}
|
|
3072
3270
|
cards.sort((a, b) => b.score - a.score);
|
|
3271
|
+
const tFilter = performance.now();
|
|
3073
3272
|
const result = cards.slice(0, limit);
|
|
3273
|
+
logger.info(
|
|
3274
|
+
`[Pipeline:timing] total=${(tFilter - t0).toFixed(0)}ms (context=${(tContext - t0).toFixed(0)} generate=${(tGenerate - tContext).toFixed(0)} hydrate=${(tHydrate - tGenerate).toFixed(0)} filter=${(tFilter - tHydrate).toFixed(0)})`
|
|
3275
|
+
);
|
|
3074
3276
|
const topScores = result.slice(0, 3).map((c) => c.score);
|
|
3075
3277
|
logExecutionSummary(
|
|
3076
3278
|
this.generator.name,
|
|
@@ -3080,6 +3282,7 @@ var init_Pipeline = __esm({
|
|
|
3080
3282
|
topScores,
|
|
3081
3283
|
filterImpacts
|
|
3082
3284
|
);
|
|
3285
|
+
logResultCards(result);
|
|
3083
3286
|
logCardProvenance(result, 3);
|
|
3084
3287
|
try {
|
|
3085
3288
|
const courseName = await this.course?.getCourseConfig().then((c) => c.name).catch(() => void 0);
|
|
@@ -3106,6 +3309,10 @@ var init_Pipeline = __esm({
|
|
|
3106
3309
|
* to the WeightedCard objects. Filters can then use card.tags instead of
|
|
3107
3310
|
* making individual getAppliedTags() calls.
|
|
3108
3311
|
*
|
|
3312
|
+
* Uses a persistent tag cache across pipeline runs — tags are static within
|
|
3313
|
+
* a session, so cards seen in a prior run (e.g. before a replan) don't
|
|
3314
|
+
* require a second DB query.
|
|
3315
|
+
*
|
|
3109
3316
|
* @param cards - Cards to hydrate
|
|
3110
3317
|
* @returns Cards with tags populated
|
|
3111
3318
|
*/
|
|
@@ -3113,14 +3320,128 @@ var init_Pipeline = __esm({
|
|
|
3113
3320
|
if (cards.length === 0) {
|
|
3114
3321
|
return cards;
|
|
3115
3322
|
}
|
|
3116
|
-
const
|
|
3117
|
-
const
|
|
3323
|
+
const uncachedIds = [];
|
|
3324
|
+
for (const card of cards) {
|
|
3325
|
+
if (!this._tagCache.has(card.cardId)) {
|
|
3326
|
+
uncachedIds.push(card.cardId);
|
|
3327
|
+
}
|
|
3328
|
+
}
|
|
3329
|
+
if (uncachedIds.length > 0) {
|
|
3330
|
+
const freshTags = await this.course.getAppliedTagsBatch(uncachedIds);
|
|
3331
|
+
for (const [cardId, tags] of freshTags) {
|
|
3332
|
+
this._tagCache.set(cardId, tags);
|
|
3333
|
+
}
|
|
3334
|
+
}
|
|
3335
|
+
const tagsByCard = /* @__PURE__ */ new Map();
|
|
3336
|
+
for (const card of cards) {
|
|
3337
|
+
tagsByCard.set(card.cardId, this._tagCache.get(card.cardId) ?? []);
|
|
3338
|
+
}
|
|
3118
3339
|
logTagHydration(cards, tagsByCard);
|
|
3119
3340
|
return cards.map((card) => ({
|
|
3120
3341
|
...card,
|
|
3121
|
-
tags:
|
|
3342
|
+
tags: this._tagCache.get(card.cardId) ?? []
|
|
3122
3343
|
}));
|
|
3123
3344
|
}
|
|
3345
|
+
// ---------------------------------------------------------------------------
|
|
3346
|
+
// Ephemeral hints application
|
|
3347
|
+
// ---------------------------------------------------------------------------
|
|
3348
|
+
/**
|
|
3349
|
+
* Apply one-shot replan hints to the post-filter card set.
|
|
3350
|
+
*
|
|
3351
|
+
* Order of operations:
|
|
3352
|
+
* 1. Exclude (remove unwanted cards)
|
|
3353
|
+
* 2. Boost (multiply scores)
|
|
3354
|
+
* 3. Require (inject must-have cards from the full pre-filter pool)
|
|
3355
|
+
*
|
|
3356
|
+
* @param cards - Post-filter cards (score > 0)
|
|
3357
|
+
* @param hints - The ephemeral hints to apply
|
|
3358
|
+
* @param allCards - Full pre-filter card pool (for require injection)
|
|
3359
|
+
*/
|
|
3360
|
+
applyHints(cards, hints, allCards) {
|
|
3361
|
+
const beforeCount = cards.length;
|
|
3362
|
+
if (hints.excludeCards?.length) {
|
|
3363
|
+
cards = cards.filter(
|
|
3364
|
+
(c) => !hints.excludeCards.some((pat) => globMatch(c.cardId, pat))
|
|
3365
|
+
);
|
|
3366
|
+
}
|
|
3367
|
+
if (hints.excludeTags?.length) {
|
|
3368
|
+
cards = cards.filter(
|
|
3369
|
+
(c) => !hints.excludeTags.some((pat) => cardMatchesTagPattern(c, pat))
|
|
3370
|
+
);
|
|
3371
|
+
}
|
|
3372
|
+
if (hints.boostTags) {
|
|
3373
|
+
for (const [pattern, factor] of Object.entries(hints.boostTags)) {
|
|
3374
|
+
for (const card of cards) {
|
|
3375
|
+
if (cardMatchesTagPattern(card, pattern)) {
|
|
3376
|
+
card.score *= factor;
|
|
3377
|
+
card.provenance.push({
|
|
3378
|
+
strategy: "ephemeralHint",
|
|
3379
|
+
strategyId: "ephemeral-hint",
|
|
3380
|
+
strategyName: "Replan Hint",
|
|
3381
|
+
action: "boosted",
|
|
3382
|
+
score: card.score,
|
|
3383
|
+
reason: `boostTag ${pattern} \xD7${factor}`
|
|
3384
|
+
});
|
|
3385
|
+
}
|
|
3386
|
+
}
|
|
3387
|
+
}
|
|
3388
|
+
}
|
|
3389
|
+
if (hints.boostCards) {
|
|
3390
|
+
for (const [pattern, factor] of Object.entries(hints.boostCards)) {
|
|
3391
|
+
for (const card of cards) {
|
|
3392
|
+
if (globMatch(card.cardId, pattern)) {
|
|
3393
|
+
card.score *= factor;
|
|
3394
|
+
card.provenance.push({
|
|
3395
|
+
strategy: "ephemeralHint",
|
|
3396
|
+
strategyId: "ephemeral-hint",
|
|
3397
|
+
strategyName: "Replan Hint",
|
|
3398
|
+
action: "boosted",
|
|
3399
|
+
score: card.score,
|
|
3400
|
+
reason: `boostCard ${pattern} \xD7${factor}`
|
|
3401
|
+
});
|
|
3402
|
+
}
|
|
3403
|
+
}
|
|
3404
|
+
}
|
|
3405
|
+
}
|
|
3406
|
+
const cardIds = new Set(cards.map((c) => c.cardId));
|
|
3407
|
+
const inject = (card, reason) => {
|
|
3408
|
+
if (!cardIds.has(card.cardId)) {
|
|
3409
|
+
const floorScore = Math.max(card.score, 1);
|
|
3410
|
+
cards.push({
|
|
3411
|
+
...card,
|
|
3412
|
+
score: floorScore,
|
|
3413
|
+
provenance: [
|
|
3414
|
+
...card.provenance,
|
|
3415
|
+
{
|
|
3416
|
+
strategy: "ephemeralHint",
|
|
3417
|
+
strategyId: "ephemeral-hint",
|
|
3418
|
+
strategyName: "Replan Hint",
|
|
3419
|
+
action: "boosted",
|
|
3420
|
+
score: floorScore,
|
|
3421
|
+
reason
|
|
3422
|
+
}
|
|
3423
|
+
]
|
|
3424
|
+
});
|
|
3425
|
+
cardIds.add(card.cardId);
|
|
3426
|
+
}
|
|
3427
|
+
};
|
|
3428
|
+
if (hints.requireCards?.length) {
|
|
3429
|
+
for (const pattern of hints.requireCards) {
|
|
3430
|
+
for (const card of allCards) {
|
|
3431
|
+
if (globMatch(card.cardId, pattern)) inject(card, `requireCard ${pattern}`);
|
|
3432
|
+
}
|
|
3433
|
+
}
|
|
3434
|
+
}
|
|
3435
|
+
if (hints.requireTags?.length) {
|
|
3436
|
+
for (const pattern of hints.requireTags) {
|
|
3437
|
+
for (const card of allCards) {
|
|
3438
|
+
if (cardMatchesTagPattern(card, pattern)) inject(card, `requireTag ${pattern}`);
|
|
3439
|
+
}
|
|
3440
|
+
}
|
|
3441
|
+
}
|
|
3442
|
+
logger.info(`[Pipeline] Hints applied: ${beforeCount} \u2192 ${cards.length} cards`);
|
|
3443
|
+
return cards;
|
|
3444
|
+
}
|
|
3124
3445
|
/**
|
|
3125
3446
|
* Build shared context for generator and filters.
|
|
3126
3447
|
*
|
|
@@ -3138,7 +3459,10 @@ var init_Pipeline = __esm({
|
|
|
3138
3459
|
} catch (e) {
|
|
3139
3460
|
logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`);
|
|
3140
3461
|
}
|
|
3141
|
-
|
|
3462
|
+
if (!this._cachedOrchestration) {
|
|
3463
|
+
this._cachedOrchestration = await createOrchestrationContext(this.user, this.course);
|
|
3464
|
+
}
|
|
3465
|
+
const orchestration = this._cachedOrchestration;
|
|
3142
3466
|
return {
|
|
3143
3467
|
user: this.user,
|
|
3144
3468
|
course: this.course,
|
|
@@ -3182,6 +3506,87 @@ var init_Pipeline = __esm({
|
|
|
3182
3506
|
}
|
|
3183
3507
|
return [...new Set(ids)];
|
|
3184
3508
|
}
|
|
3509
|
+
// ---------------------------------------------------------------------------
|
|
3510
|
+
// Card-space diagnostic
|
|
3511
|
+
// ---------------------------------------------------------------------------
|
|
3512
|
+
/**
|
|
3513
|
+
* Scan every card in the course through the filter chain and report
|
|
3514
|
+
* how many are "well indicated" (score >= threshold) for the current user.
|
|
3515
|
+
*
|
|
3516
|
+
* Also reports how many well-indicated cards the user has NOT yet encountered.
|
|
3517
|
+
*
|
|
3518
|
+
* Exposed via `window.skuilder.pipeline.diagnoseCardSpace()`.
|
|
3519
|
+
*/
|
|
3520
|
+
async diagnoseCardSpace(opts) {
|
|
3521
|
+
const THRESHOLD = opts?.threshold ?? 0.1;
|
|
3522
|
+
const t0 = performance.now();
|
|
3523
|
+
const allCardIds = await this.course.getAllCardIds();
|
|
3524
|
+
let cards = allCardIds.map((cardId) => ({
|
|
3525
|
+
cardId,
|
|
3526
|
+
courseId: this.course.getCourseID(),
|
|
3527
|
+
score: 1,
|
|
3528
|
+
provenance: []
|
|
3529
|
+
}));
|
|
3530
|
+
cards = await this.hydrateTags(cards);
|
|
3531
|
+
const context = await this.buildContext();
|
|
3532
|
+
const filterBreakdown = [];
|
|
3533
|
+
for (const filter of this.filters) {
|
|
3534
|
+
cards = await filter.transform(cards, context);
|
|
3535
|
+
const wi = cards.filter((c) => c.score >= THRESHOLD).length;
|
|
3536
|
+
filterBreakdown.push({ name: filter.name, wellIndicated: wi });
|
|
3537
|
+
}
|
|
3538
|
+
const wellIndicated = cards.filter((c) => c.score >= THRESHOLD);
|
|
3539
|
+
const wellIndicatedIds = new Set(wellIndicated.map((c) => c.cardId));
|
|
3540
|
+
let encounteredIds;
|
|
3541
|
+
try {
|
|
3542
|
+
const courseId = this.course.getCourseID();
|
|
3543
|
+
const seenCards = await this.user.getSeenCards(courseId);
|
|
3544
|
+
encounteredIds = new Set(seenCards);
|
|
3545
|
+
} catch {
|
|
3546
|
+
encounteredIds = /* @__PURE__ */ new Set();
|
|
3547
|
+
}
|
|
3548
|
+
const wellIndicatedNew = wellIndicated.filter((c) => !encounteredIds.has(c.cardId));
|
|
3549
|
+
const byType = /* @__PURE__ */ new Map();
|
|
3550
|
+
for (const card of cards) {
|
|
3551
|
+
const type = card.cardId.split("-")[1] || "unknown";
|
|
3552
|
+
if (!byType.has(type)) {
|
|
3553
|
+
byType.set(type, { total: 0, wellIndicated: 0, new: 0 });
|
|
3554
|
+
}
|
|
3555
|
+
const entry = byType.get(type);
|
|
3556
|
+
entry.total++;
|
|
3557
|
+
if (card.score >= THRESHOLD) {
|
|
3558
|
+
entry.wellIndicated++;
|
|
3559
|
+
if (!encounteredIds.has(card.cardId)) entry.new++;
|
|
3560
|
+
}
|
|
3561
|
+
}
|
|
3562
|
+
const elapsed = performance.now() - t0;
|
|
3563
|
+
const result = {
|
|
3564
|
+
totalCards: allCardIds.length,
|
|
3565
|
+
threshold: THRESHOLD,
|
|
3566
|
+
wellIndicated: wellIndicatedIds.size,
|
|
3567
|
+
encountered: encounteredIds.size,
|
|
3568
|
+
wellIndicatedNew: wellIndicatedNew.length,
|
|
3569
|
+
byType: Object.fromEntries(byType),
|
|
3570
|
+
filterBreakdown,
|
|
3571
|
+
elapsedMs: Math.round(elapsed)
|
|
3572
|
+
};
|
|
3573
|
+
logger.info(`[Pipeline:diagnose] Card space scan (${result.elapsedMs}ms):`);
|
|
3574
|
+
logger.info(`[Pipeline:diagnose] Total cards: ${result.totalCards}`);
|
|
3575
|
+
logger.info(`[Pipeline:diagnose] Well-indicated (score >= ${THRESHOLD}): ${result.wellIndicated}`);
|
|
3576
|
+
logger.info(`[Pipeline:diagnose] Encountered: ${result.encountered}`);
|
|
3577
|
+
logger.info(`[Pipeline:diagnose] Well-indicated & new: ${result.wellIndicatedNew}`);
|
|
3578
|
+
logger.info(`[Pipeline:diagnose] By type:`);
|
|
3579
|
+
for (const [type, counts] of byType) {
|
|
3580
|
+
logger.info(
|
|
3581
|
+
`[Pipeline:diagnose] ${type}: ${counts.wellIndicated}/${counts.total} well-indicated, ${counts.new} new`
|
|
3582
|
+
);
|
|
3583
|
+
}
|
|
3584
|
+
logger.info(`[Pipeline:diagnose] After each filter:`);
|
|
3585
|
+
for (const fb of filterBreakdown) {
|
|
3586
|
+
logger.info(`[Pipeline:diagnose] ${fb.name}: ${fb.wellIndicated} well-indicated`);
|
|
3587
|
+
}
|
|
3588
|
+
return result;
|
|
3589
|
+
}
|
|
3185
3590
|
};
|
|
3186
3591
|
}
|
|
3187
3592
|
});
|
|
@@ -3286,23 +3691,25 @@ var init_PipelineAssembler = __esm({
|
|
|
3286
3691
|
warnings.push(`Unknown strategy type '${s.implementingClass}', skipping: ${s.name}`);
|
|
3287
3692
|
}
|
|
3288
3693
|
}
|
|
3694
|
+
const courseId = course.getCourseID();
|
|
3695
|
+
const hasElo = generatorStrategies.some((s) => s.implementingClass === "elo" /* ELO */);
|
|
3696
|
+
const hasSrs = generatorStrategies.some((s) => s.implementingClass === "srs" /* SRS */);
|
|
3697
|
+
if (!hasElo) {
|
|
3698
|
+
logger.debug("[PipelineAssembler] No ELO generator configured, adding default");
|
|
3699
|
+
generatorStrategies.push(createDefaultEloStrategy(courseId));
|
|
3700
|
+
}
|
|
3701
|
+
if (!hasSrs) {
|
|
3702
|
+
logger.debug("[PipelineAssembler] No SRS generator configured, adding default");
|
|
3703
|
+
generatorStrategies.push(createDefaultSrsStrategy(courseId));
|
|
3704
|
+
}
|
|
3289
3705
|
if (generatorStrategies.length === 0) {
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
} else {
|
|
3298
|
-
warnings.push("No generator strategy found");
|
|
3299
|
-
return {
|
|
3300
|
-
pipeline: null,
|
|
3301
|
-
generatorStrategies: [],
|
|
3302
|
-
filterStrategies: [],
|
|
3303
|
-
warnings
|
|
3304
|
-
};
|
|
3305
|
-
}
|
|
3706
|
+
warnings.push("No generator strategy found");
|
|
3707
|
+
return {
|
|
3708
|
+
pipeline: null,
|
|
3709
|
+
generatorStrategies: [],
|
|
3710
|
+
filterStrategies: [],
|
|
3711
|
+
warnings
|
|
3712
|
+
};
|
|
3306
3713
|
}
|
|
3307
3714
|
let generator;
|
|
3308
3715
|
if (generatorStrategies.length === 1) {
|
|
@@ -3380,6 +3787,7 @@ var init_3 = __esm({
|
|
|
3380
3787
|
"./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
|
|
3381
3788
|
"./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
|
|
3382
3789
|
"./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
|
|
3790
|
+
"./generators/prescribed.ts": () => Promise.resolve().then(() => (init_prescribed(), prescribed_exports)),
|
|
3383
3791
|
"./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
|
|
3384
3792
|
"./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports)),
|
|
3385
3793
|
"./index.ts": () => Promise.resolve().then(() => (init_navigators(), navigators_exports))
|
|
@@ -3428,8 +3836,10 @@ async function initializeNavigatorRegistry() {
|
|
|
3428
3836
|
Promise.resolve().then(() => (init_elo(), elo_exports)),
|
|
3429
3837
|
Promise.resolve().then(() => (init_srs(), srs_exports))
|
|
3430
3838
|
]);
|
|
3839
|
+
const prescribedModule = await Promise.resolve().then(() => (init_prescribed(), prescribed_exports));
|
|
3431
3840
|
registerNavigator("elo", eloModule.default);
|
|
3432
3841
|
registerNavigator("srs", srsModule.default);
|
|
3842
|
+
registerNavigator("prescribed", prescribedModule.default);
|
|
3433
3843
|
const [
|
|
3434
3844
|
hierarchyModule,
|
|
3435
3845
|
interferenceModule,
|
|
@@ -3484,6 +3894,7 @@ var init_navigators = __esm({
|
|
|
3484
3894
|
Navigators = /* @__PURE__ */ ((Navigators2) => {
|
|
3485
3895
|
Navigators2["ELO"] = "elo";
|
|
3486
3896
|
Navigators2["SRS"] = "srs";
|
|
3897
|
+
Navigators2["PRESCRIBED"] = "prescribed";
|
|
3487
3898
|
Navigators2["HIERARCHY"] = "hierarchyDefinition";
|
|
3488
3899
|
Navigators2["INTERFERENCE"] = "interferenceMitigator";
|
|
3489
3900
|
Navigators2["RELATIVE_PRIORITY"] = "relativePriority";
|
|
@@ -3498,6 +3909,7 @@ var init_navigators = __esm({
|
|
|
3498
3909
|
NavigatorRoles = {
|
|
3499
3910
|
["elo" /* ELO */]: "generator" /* GENERATOR */,
|
|
3500
3911
|
["srs" /* SRS */]: "generator" /* GENERATOR */,
|
|
3912
|
+
["prescribed" /* PRESCRIBED */]: "generator" /* GENERATOR */,
|
|
3501
3913
|
["hierarchyDefinition" /* HIERARCHY */]: "filter" /* FILTER */,
|
|
3502
3914
|
["interferenceMitigator" /* INTERFERENCE */]: "filter" /* FILTER */,
|
|
3503
3915
|
["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */,
|
|
@@ -3662,6 +4074,12 @@ var init_navigators = __esm({
|
|
|
3662
4074
|
async getWeightedCards(_limit) {
|
|
3663
4075
|
throw new Error(`${this.constructor.name} must implement getWeightedCards(). `);
|
|
3664
4076
|
}
|
|
4077
|
+
/**
|
|
4078
|
+
* Set ephemeral hints for the next pipeline run.
|
|
4079
|
+
* No-op for non-Pipeline navigators. Pipeline overrides this.
|
|
4080
|
+
*/
|
|
4081
|
+
setEphemeralHints(_hints) {
|
|
4082
|
+
}
|
|
3665
4083
|
};
|
|
3666
4084
|
}
|
|
3667
4085
|
});
|
|
@@ -3806,15 +4224,42 @@ var init_courseDB = __esm({
|
|
|
3806
4224
|
// private log(msg: string): void {
|
|
3807
4225
|
// log(`CourseLog: ${this.id}\n ${msg}`);
|
|
3808
4226
|
// }
|
|
4227
|
+
/**
|
|
4228
|
+
* Primary database handle used for all **read** operations (queries, gets).
|
|
4229
|
+
*
|
|
4230
|
+
* When local sync is active, this points to the local PouchDB replica for
|
|
4231
|
+
* fast, network-free reads. Otherwise it points to the remote CouchDB.
|
|
4232
|
+
*/
|
|
3809
4233
|
db;
|
|
4234
|
+
/**
|
|
4235
|
+
* Remote database handle used for all **write** operations.
|
|
4236
|
+
*
|
|
4237
|
+
* Always points to the remote CouchDB so that writes (ELO updates, tag
|
|
4238
|
+
* mutations, admin operations) aggregate on the server. The local replica
|
|
4239
|
+
* is a read-only snapshot that refreshes on the next page load.
|
|
4240
|
+
*
|
|
4241
|
+
* When local sync is NOT active, this is the same instance as `this.db`.
|
|
4242
|
+
*/
|
|
4243
|
+
remoteDB;
|
|
3810
4244
|
id;
|
|
3811
4245
|
_getCurrentUser;
|
|
3812
4246
|
updateQueue;
|
|
3813
|
-
|
|
4247
|
+
/**
|
|
4248
|
+
* @param id - Course ID
|
|
4249
|
+
* @param userLookup - Async function returning the current user DB
|
|
4250
|
+
* @param localDB - Optional local PouchDB replica for reads. When provided,
|
|
4251
|
+
* `this.db` uses the local replica and `this.remoteDB` stays remote.
|
|
4252
|
+
* The UpdateQueue reads from remote and writes to remote (local `_rev`
|
|
4253
|
+
* values may be stale, so read-modify-write cycles must go through
|
|
4254
|
+
* the remote DB to avoid conflicts).
|
|
4255
|
+
*/
|
|
4256
|
+
constructor(id, userLookup, localDB) {
|
|
3814
4257
|
this.id = id;
|
|
3815
|
-
|
|
4258
|
+
const remote = getCourseDB2(this.id);
|
|
4259
|
+
this.remoteDB = remote;
|
|
4260
|
+
this.db = localDB ?? remote;
|
|
3816
4261
|
this._getCurrentUser = userLookup;
|
|
3817
|
-
this.updateQueue = new UpdateQueue(this.
|
|
4262
|
+
this.updateQueue = new UpdateQueue(this.remoteDB, this.remoteDB);
|
|
3818
4263
|
}
|
|
3819
4264
|
getCourseID() {
|
|
3820
4265
|
return this.id;
|
|
@@ -3902,7 +4347,7 @@ var init_courseDB = __esm({
|
|
|
3902
4347
|
};
|
|
3903
4348
|
}
|
|
3904
4349
|
async removeCard(id) {
|
|
3905
|
-
const doc = await this.
|
|
4350
|
+
const doc = await this.remoteDB.get(id);
|
|
3906
4351
|
if (!doc.docType || !(doc.docType === "CARD" /* CARD */)) {
|
|
3907
4352
|
throw new Error(`failed to remove ${id} from course ${this.id}. id does not point to a card`);
|
|
3908
4353
|
}
|
|
@@ -3923,7 +4368,7 @@ var init_courseDB = __esm({
|
|
|
3923
4368
|
} catch (error) {
|
|
3924
4369
|
logger.error(`Error removing card ${id} from tags: ${error}`);
|
|
3925
4370
|
}
|
|
3926
|
-
return this.
|
|
4371
|
+
return this.remoteDB.remove(doc);
|
|
3927
4372
|
}
|
|
3928
4373
|
async getCardDisplayableDataIDs(id) {
|
|
3929
4374
|
logger.debug(id.join(", "));
|
|
@@ -4025,8 +4470,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
4025
4470
|
if (cardIds.length === 0) {
|
|
4026
4471
|
return /* @__PURE__ */ new Map();
|
|
4027
4472
|
}
|
|
4028
|
-
const
|
|
4029
|
-
const result = await db.query("getTags", {
|
|
4473
|
+
const result = await this.db.query("getTags", {
|
|
4030
4474
|
keys: cardIds,
|
|
4031
4475
|
include_docs: false
|
|
4032
4476
|
});
|
|
@@ -4043,6 +4487,14 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
4043
4487
|
}
|
|
4044
4488
|
return tagsByCard;
|
|
4045
4489
|
}
|
|
4490
|
+
async getAllCardIds() {
|
|
4491
|
+
const result = await this.db.allDocs({
|
|
4492
|
+
startkey: "CARD-",
|
|
4493
|
+
endkey: "CARD-\uFFF0",
|
|
4494
|
+
include_docs: false
|
|
4495
|
+
});
|
|
4496
|
+
return result.rows.map((row) => row.id);
|
|
4497
|
+
}
|
|
4046
4498
|
async addTagToCard(cardId, tagId, updateELO) {
|
|
4047
4499
|
return await addTagToCard(
|
|
4048
4500
|
this.id,
|
|
@@ -4109,10 +4561,13 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
4109
4561
|
}
|
|
4110
4562
|
}
|
|
4111
4563
|
async getCourseDoc(id, options) {
|
|
4112
|
-
return await
|
|
4564
|
+
return await this.db.get(id, options);
|
|
4113
4565
|
}
|
|
4114
4566
|
async getCourseDocs(ids, options = {}) {
|
|
4115
|
-
return await
|
|
4567
|
+
return await this.db.allDocs({
|
|
4568
|
+
...options,
|
|
4569
|
+
keys: ids
|
|
4570
|
+
});
|
|
4116
4571
|
}
|
|
4117
4572
|
////////////////////////////////////
|
|
4118
4573
|
// NavigationStrategyManager implementation
|
|
@@ -4146,7 +4601,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
4146
4601
|
}
|
|
4147
4602
|
async addNavigationStrategy(data) {
|
|
4148
4603
|
logger.debug(`[courseDB] Adding navigation strategy: ${data._id}`);
|
|
4149
|
-
return this.
|
|
4604
|
+
return this.remoteDB.put(data).then(() => {
|
|
4150
4605
|
});
|
|
4151
4606
|
}
|
|
4152
4607
|
updateNavigationStrategy(id, data) {
|
|
@@ -4703,6 +5158,234 @@ var init_adminDB2 = __esm({
|
|
|
4703
5158
|
}
|
|
4704
5159
|
});
|
|
4705
5160
|
|
|
5161
|
+
// src/impl/couch/CourseSyncService.ts
|
|
5162
|
+
var CourseSyncService;
|
|
5163
|
+
var init_CourseSyncService = __esm({
|
|
5164
|
+
"src/impl/couch/CourseSyncService.ts"() {
|
|
5165
|
+
"use strict";
|
|
5166
|
+
init_pouchdb_setup();
|
|
5167
|
+
init_couch();
|
|
5168
|
+
init_logger();
|
|
5169
|
+
CourseSyncService = class _CourseSyncService {
|
|
5170
|
+
static instance = null;
|
|
5171
|
+
entries = /* @__PURE__ */ new Map();
|
|
5172
|
+
constructor() {
|
|
5173
|
+
}
|
|
5174
|
+
static getInstance() {
|
|
5175
|
+
if (!_CourseSyncService.instance) {
|
|
5176
|
+
_CourseSyncService.instance = new _CourseSyncService();
|
|
5177
|
+
}
|
|
5178
|
+
return _CourseSyncService.instance;
|
|
5179
|
+
}
|
|
5180
|
+
/**
|
|
5181
|
+
* Reset the singleton (for testing).
|
|
5182
|
+
*/
|
|
5183
|
+
static resetInstance() {
|
|
5184
|
+
if (_CourseSyncService.instance) {
|
|
5185
|
+
for (const [, entry] of _CourseSyncService.instance.entries) {
|
|
5186
|
+
if (entry.localDB) {
|
|
5187
|
+
entry.localDB.close().catch(() => {
|
|
5188
|
+
});
|
|
5189
|
+
}
|
|
5190
|
+
}
|
|
5191
|
+
_CourseSyncService.instance.entries.clear();
|
|
5192
|
+
}
|
|
5193
|
+
_CourseSyncService.instance = null;
|
|
5194
|
+
}
|
|
5195
|
+
// --------------------------------------------------------------------------
|
|
5196
|
+
// Public API
|
|
5197
|
+
// --------------------------------------------------------------------------
|
|
5198
|
+
/**
|
|
5199
|
+
* Ensure a course's local replica is synced.
|
|
5200
|
+
*
|
|
5201
|
+
* On first call for a course:
|
|
5202
|
+
* 1. Fetches CourseConfig from remote to check localSync.enabled
|
|
5203
|
+
* 2. If enabled, performs one-shot replication remote → local
|
|
5204
|
+
* 3. Pre-warms PouchDB view indices (elo, getTags)
|
|
5205
|
+
*
|
|
5206
|
+
* On subsequent calls: returns immediately if already synced, or awaits
|
|
5207
|
+
* the in-flight sync if one is in progress.
|
|
5208
|
+
*
|
|
5209
|
+
* Safe to call multiple times — concurrent calls coalesce to one sync.
|
|
5210
|
+
*
|
|
5211
|
+
* @param courseId - The course to sync
|
|
5212
|
+
* @param forceEnabled - Skip the CourseConfig check and sync regardless.
|
|
5213
|
+
* Useful when the caller already knows local sync is desired (e.g.,
|
|
5214
|
+
* LettersPractice hardcodes this).
|
|
5215
|
+
*/
|
|
5216
|
+
async ensureSynced(courseId, forceEnabled) {
|
|
5217
|
+
const existing = this.entries.get(courseId);
|
|
5218
|
+
if (existing?.status.state === "ready") {
|
|
5219
|
+
return;
|
|
5220
|
+
}
|
|
5221
|
+
if (existing?.status.state === "disabled") {
|
|
5222
|
+
return;
|
|
5223
|
+
}
|
|
5224
|
+
if (existing?.readyPromise) {
|
|
5225
|
+
return existing.readyPromise;
|
|
5226
|
+
}
|
|
5227
|
+
const entry = {
|
|
5228
|
+
localDB: null,
|
|
5229
|
+
status: { state: "not-started" },
|
|
5230
|
+
readyPromise: null
|
|
5231
|
+
};
|
|
5232
|
+
this.entries.set(courseId, entry);
|
|
5233
|
+
entry.readyPromise = this.performSync(courseId, entry, forceEnabled);
|
|
5234
|
+
return entry.readyPromise;
|
|
5235
|
+
}
|
|
5236
|
+
/**
|
|
5237
|
+
* Get the local PouchDB for a course, or null if not available.
|
|
5238
|
+
*
|
|
5239
|
+
* Returns null when:
|
|
5240
|
+
* - Local sync is not enabled for this course
|
|
5241
|
+
* - Sync has not been triggered yet
|
|
5242
|
+
* - Sync is still in progress
|
|
5243
|
+
* - Sync failed
|
|
5244
|
+
*/
|
|
5245
|
+
getLocalDB(courseId) {
|
|
5246
|
+
const entry = this.entries.get(courseId);
|
|
5247
|
+
if (entry?.status.state === "ready" && entry.localDB) {
|
|
5248
|
+
return entry.localDB;
|
|
5249
|
+
}
|
|
5250
|
+
return null;
|
|
5251
|
+
}
|
|
5252
|
+
/**
|
|
5253
|
+
* Check whether a course has a ready local replica.
|
|
5254
|
+
*/
|
|
5255
|
+
isReady(courseId) {
|
|
5256
|
+
return this.entries.get(courseId)?.status.state === "ready";
|
|
5257
|
+
}
|
|
5258
|
+
/**
|
|
5259
|
+
* Get detailed sync status for a course.
|
|
5260
|
+
*/
|
|
5261
|
+
getStatus(courseId) {
|
|
5262
|
+
return this.entries.get(courseId)?.status ?? { state: "not-started" };
|
|
5263
|
+
}
|
|
5264
|
+
// --------------------------------------------------------------------------
|
|
5265
|
+
// Internal
|
|
5266
|
+
// --------------------------------------------------------------------------
|
|
5267
|
+
async performSync(courseId, entry, forceEnabled) {
|
|
5268
|
+
try {
|
|
5269
|
+
if (!forceEnabled) {
|
|
5270
|
+
entry.status = { state: "checking-config" };
|
|
5271
|
+
const enabled = await this.checkLocalSyncEnabled(courseId);
|
|
5272
|
+
if (!enabled) {
|
|
5273
|
+
entry.status = { state: "disabled" };
|
|
5274
|
+
entry.readyPromise = null;
|
|
5275
|
+
logger.debug(
|
|
5276
|
+
`[CourseSyncService] Local sync disabled for course ${courseId}`
|
|
5277
|
+
);
|
|
5278
|
+
return;
|
|
5279
|
+
}
|
|
5280
|
+
}
|
|
5281
|
+
entry.status = { state: "syncing" };
|
|
5282
|
+
const localDBName = this.localDBName(courseId);
|
|
5283
|
+
const localDB = new pouchdb_setup_default(localDBName);
|
|
5284
|
+
entry.localDB = localDB;
|
|
5285
|
+
const remoteDB = this.getRemoteDB(courseId);
|
|
5286
|
+
const syncStart = Date.now();
|
|
5287
|
+
logger.info(
|
|
5288
|
+
`[CourseSyncService] Starting one-shot replication for course ${courseId}`
|
|
5289
|
+
);
|
|
5290
|
+
const result = await this.replicate(remoteDB, localDB);
|
|
5291
|
+
const syncTimeMs = Date.now() - syncStart;
|
|
5292
|
+
logger.info(
|
|
5293
|
+
`[CourseSyncService] Replication complete for course ${courseId}: ${result.docs_written} docs in ${syncTimeMs}ms`
|
|
5294
|
+
);
|
|
5295
|
+
entry.status = { state: "warming-views" };
|
|
5296
|
+
const warmStart = Date.now();
|
|
5297
|
+
await this.warmViewIndices(localDB);
|
|
5298
|
+
const viewWarmTimeMs = Date.now() - warmStart;
|
|
5299
|
+
logger.info(
|
|
5300
|
+
`[CourseSyncService] View indices warmed for course ${courseId} in ${viewWarmTimeMs}ms`
|
|
5301
|
+
);
|
|
5302
|
+
entry.status = {
|
|
5303
|
+
state: "ready",
|
|
5304
|
+
docsReplicated: result.docs_written,
|
|
5305
|
+
syncTimeMs,
|
|
5306
|
+
viewWarmTimeMs
|
|
5307
|
+
};
|
|
5308
|
+
} catch (e) {
|
|
5309
|
+
const errorMsg = e instanceof Error ? e.message : String(e);
|
|
5310
|
+
logger.error(
|
|
5311
|
+
`[CourseSyncService] Sync failed for course ${courseId}: ${errorMsg}`
|
|
5312
|
+
);
|
|
5313
|
+
entry.status = { state: "error", error: errorMsg };
|
|
5314
|
+
entry.readyPromise = null;
|
|
5315
|
+
if (entry.localDB) {
|
|
5316
|
+
try {
|
|
5317
|
+
await entry.localDB.destroy();
|
|
5318
|
+
} catch {
|
|
5319
|
+
}
|
|
5320
|
+
entry.localDB = null;
|
|
5321
|
+
}
|
|
5322
|
+
}
|
|
5323
|
+
}
|
|
5324
|
+
/**
|
|
5325
|
+
* Check CourseConfig.localSync.enabled on the remote DB.
|
|
5326
|
+
*/
|
|
5327
|
+
async checkLocalSyncEnabled(courseId) {
|
|
5328
|
+
try {
|
|
5329
|
+
const remoteDB = this.getRemoteDB(courseId);
|
|
5330
|
+
const config = await remoteDB.get("CourseConfig");
|
|
5331
|
+
return config.localSync?.enabled === true;
|
|
5332
|
+
} catch (e) {
|
|
5333
|
+
logger.warn(
|
|
5334
|
+
`[CourseSyncService] Could not read CourseConfig for ${courseId}, assuming local sync disabled: ${e}`
|
|
5335
|
+
);
|
|
5336
|
+
return false;
|
|
5337
|
+
}
|
|
5338
|
+
}
|
|
5339
|
+
/**
|
|
5340
|
+
* One-shot replication from remote to local.
|
|
5341
|
+
*/
|
|
5342
|
+
replicate(source, target) {
|
|
5343
|
+
return new Promise((resolve, reject) => {
|
|
5344
|
+
void pouchdb_setup_default.replicate(source, target, {
|
|
5345
|
+
// One-shot, not live. Local is a read-only snapshot.
|
|
5346
|
+
}).on("complete", (info) => {
|
|
5347
|
+
resolve(info);
|
|
5348
|
+
}).on("error", (err) => {
|
|
5349
|
+
reject(err);
|
|
5350
|
+
});
|
|
5351
|
+
});
|
|
5352
|
+
}
|
|
5353
|
+
/**
|
|
5354
|
+
* Pre-warm PouchDB view indices by running a minimal query against each
|
|
5355
|
+
* design doc. This forces PouchDB to build the MapReduce index now
|
|
5356
|
+
* (during a loading phase) rather than on first pipeline query.
|
|
5357
|
+
*/
|
|
5358
|
+
async warmViewIndices(localDB) {
|
|
5359
|
+
const viewsToWarm = ["elo", "getTags"];
|
|
5360
|
+
for (const viewName of viewsToWarm) {
|
|
5361
|
+
try {
|
|
5362
|
+
await localDB.query(viewName, { limit: 1 });
|
|
5363
|
+
logger.debug(
|
|
5364
|
+
`[CourseSyncService] Warmed view index: ${viewName}`
|
|
5365
|
+
);
|
|
5366
|
+
} catch (e) {
|
|
5367
|
+
logger.debug(
|
|
5368
|
+
`[CourseSyncService] Could not warm view ${viewName}: ${e}`
|
|
5369
|
+
);
|
|
5370
|
+
}
|
|
5371
|
+
}
|
|
5372
|
+
}
|
|
5373
|
+
/**
|
|
5374
|
+
* Get a remote PouchDB handle for a course.
|
|
5375
|
+
*/
|
|
5376
|
+
getRemoteDB(courseId) {
|
|
5377
|
+
return getCourseDB2(courseId);
|
|
5378
|
+
}
|
|
5379
|
+
/**
|
|
5380
|
+
* Local DB naming convention.
|
|
5381
|
+
*/
|
|
5382
|
+
localDBName(courseId) {
|
|
5383
|
+
return `coursedb-local-${courseId}`;
|
|
5384
|
+
}
|
|
5385
|
+
};
|
|
5386
|
+
}
|
|
5387
|
+
});
|
|
5388
|
+
|
|
4706
5389
|
// src/impl/couch/auth.ts
|
|
4707
5390
|
async function getCurrentSession() {
|
|
4708
5391
|
try {
|
|
@@ -5001,15 +5684,6 @@ function getCourseDB2(courseID) {
|
|
|
5001
5684
|
createPouchDBConfig()
|
|
5002
5685
|
);
|
|
5003
5686
|
}
|
|
5004
|
-
function getCourseDocs(courseID, docIDs, options = {}) {
|
|
5005
|
-
return getCourseDB2(courseID).allDocs({
|
|
5006
|
-
...options,
|
|
5007
|
-
keys: docIDs
|
|
5008
|
-
});
|
|
5009
|
-
}
|
|
5010
|
-
function getCourseDoc(courseID, docID, options = {}) {
|
|
5011
|
-
return getCourseDB2(courseID).get(docID, options);
|
|
5012
|
-
}
|
|
5013
5687
|
function filterAllDocsByPrefix2(db, prefix, opts) {
|
|
5014
5688
|
const options = {
|
|
5015
5689
|
startkey: prefix,
|
|
@@ -5043,6 +5717,7 @@ var init_couch = __esm({
|
|
|
5043
5717
|
init_classroomDB2();
|
|
5044
5718
|
init_courseAPI();
|
|
5045
5719
|
init_courseDB();
|
|
5720
|
+
init_CourseSyncService();
|
|
5046
5721
|
init_CouchDBSyncStrategy();
|
|
5047
5722
|
isBrowser = typeof window !== "undefined";
|
|
5048
5723
|
if (isBrowser) {
|
|
@@ -5343,6 +6018,9 @@ Currently logged-in as ${this._username}.`
|
|
|
5343
6018
|
const id = row.id;
|
|
5344
6019
|
return id.startsWith(DocTypePrefixes["CARDRECORD" /* CARDRECORD */]) || // Card interaction history
|
|
5345
6020
|
id.startsWith(DocTypePrefixes["SCHEDULED_CARD" /* SCHEDULED_CARD */]) || // Scheduled reviews
|
|
6021
|
+
id.startsWith(DocTypePrefixes["STRATEGY_STATE" /* STRATEGY_STATE */]) || // Strategy state (user prefs, progression)
|
|
6022
|
+
id.startsWith(DocTypePrefixes["USER_OUTCOME" /* USER_OUTCOME */]) || // Evolutionary orchestration outcomes
|
|
6023
|
+
id.startsWith(DocTypePrefixes["STRATEGY_LEARNING_STATE" /* STRATEGY_LEARNING_STATE */]) || // Strategy learning state
|
|
5346
6024
|
id === _BaseUser.DOC_IDS.COURSE_REGISTRATIONS || // Course registrations
|
|
5347
6025
|
id === _BaseUser.DOC_IDS.CLASSROOM_REGISTRATIONS || // Classroom registrations
|
|
5348
6026
|
id === _BaseUser.DOC_IDS.CONFIG;
|
|
@@ -6139,6 +6817,7 @@ var init_PouchDataLayerProvider = __esm({
|
|
|
6139
6817
|
init_adminDB2();
|
|
6140
6818
|
init_classroomDB2();
|
|
6141
6819
|
init_courseDB();
|
|
6820
|
+
init_CourseSyncService();
|
|
6142
6821
|
init_common();
|
|
6143
6822
|
init_CouchDBSyncStrategy();
|
|
6144
6823
|
CouchDataLayerProvider = class {
|
|
@@ -6178,7 +6857,22 @@ var init_PouchDataLayerProvider = __esm({
|
|
|
6178
6857
|
return this.userDB;
|
|
6179
6858
|
}
|
|
6180
6859
|
getCourseDB(courseId) {
|
|
6181
|
-
|
|
6860
|
+
const localDB = CourseSyncService.getInstance().getLocalDB(courseId);
|
|
6861
|
+
return new CourseDB(courseId, async () => this.getUserDB(), localDB ?? void 0);
|
|
6862
|
+
}
|
|
6863
|
+
/**
|
|
6864
|
+
* Trigger local sync for a course. Call during app initialization or
|
|
6865
|
+
* pre-session loading for courses that opt in via CourseConfig.localSync.
|
|
6866
|
+
*
|
|
6867
|
+
* Safe to call multiple times — concurrent calls coalesce. Returns when
|
|
6868
|
+
* sync is complete (or immediately if already synced / disabled).
|
|
6869
|
+
*
|
|
6870
|
+
* @param courseId - The course to sync locally
|
|
6871
|
+
* @param forceEnabled - Skip CourseConfig check and sync regardless.
|
|
6872
|
+
* Use when the caller already knows local sync is desired.
|
|
6873
|
+
*/
|
|
6874
|
+
async ensureCourseSynced(courseId, forceEnabled) {
|
|
6875
|
+
return CourseSyncService.getInstance().ensureSynced(courseId, forceEnabled);
|
|
6182
6876
|
}
|
|
6183
6877
|
getCoursesDB() {
|
|
6184
6878
|
return new CoursesDB(this._courseIDs);
|
|
@@ -6806,6 +7500,10 @@ var init_courseDB2 = __esm({
|
|
|
6806
7500
|
}
|
|
6807
7501
|
return tagsByCard;
|
|
6808
7502
|
}
|
|
7503
|
+
async getAllCardIds() {
|
|
7504
|
+
const tagsIndex = await this.unpacker.getTagsIndex();
|
|
7505
|
+
return Object.keys(tagsIndex.byCard);
|
|
7506
|
+
}
|
|
6809
7507
|
async addTagToCard(_cardId, _tagId) {
|
|
6810
7508
|
throw new Error("Cannot modify tags in static mode");
|
|
6811
7509
|
}
|
|
@@ -8447,8 +9145,10 @@ function newQuestionInterval(user, cardHistory) {
|
|
|
8447
9145
|
const lastInterval = lastSuccessfulInterval(records);
|
|
8448
9146
|
if (lastInterval > cardHistory.bestInterval) {
|
|
8449
9147
|
cardHistory.bestInterval = lastInterval;
|
|
8450
|
-
|
|
9148
|
+
user.update(cardHistory._id, {
|
|
8451
9149
|
bestInterval: lastInterval
|
|
9150
|
+
}).catch((e) => {
|
|
9151
|
+
logger.warn(`[SpacedRepetition] Failed to update bestInterval for ${cardHistory._id}: ${e?.message ?? e}`);
|
|
8452
9152
|
});
|
|
8453
9153
|
}
|
|
8454
9154
|
if (currentAttempt.isCorrect) {
|
|
@@ -9106,6 +9806,46 @@ var ItemQueue = class {
|
|
|
9106
9806
|
return null;
|
|
9107
9807
|
}
|
|
9108
9808
|
}
|
|
9809
|
+
/**
|
|
9810
|
+
* Atomically replace all queue contents with new items.
|
|
9811
|
+
*
|
|
9812
|
+
* Used by mid-session replanning to swap the queue without a window where
|
|
9813
|
+
* it's empty (avoiding dead-air if nextCard() is called concurrently).
|
|
9814
|
+
*
|
|
9815
|
+
* Preserves dequeueCount (cumulative across the session).
|
|
9816
|
+
* Resets seenCardIds to match the new contents — cards from the old queue
|
|
9817
|
+
* that don't appear in the new set can be re-added in future replans.
|
|
9818
|
+
*/
|
|
9819
|
+
replaceAll(items, cardIdExtractor) {
|
|
9820
|
+
this.q = [];
|
|
9821
|
+
this.seenCardIds = [];
|
|
9822
|
+
for (const item of items) {
|
|
9823
|
+
const cardId = cardIdExtractor(item);
|
|
9824
|
+
if (!this.seenCardIds.includes(cardId)) {
|
|
9825
|
+
this.seenCardIds.push(cardId);
|
|
9826
|
+
this.q.push(item);
|
|
9827
|
+
}
|
|
9828
|
+
}
|
|
9829
|
+
}
|
|
9830
|
+
/**
|
|
9831
|
+
* Merge new items into the front of the queue, skipping duplicates.
|
|
9832
|
+
* Used by additive replans to inject high-quality candidates without
|
|
9833
|
+
* discarding the existing queue contents.
|
|
9834
|
+
*/
|
|
9835
|
+
mergeToFront(items, cardIdExtractor) {
|
|
9836
|
+
let added = 0;
|
|
9837
|
+
const toInsert = [];
|
|
9838
|
+
for (const item of items) {
|
|
9839
|
+
const cardId = cardIdExtractor(item);
|
|
9840
|
+
if (!this.seenCardIds.includes(cardId)) {
|
|
9841
|
+
this.seenCardIds.push(cardId);
|
|
9842
|
+
toInsert.push(item);
|
|
9843
|
+
added++;
|
|
9844
|
+
}
|
|
9845
|
+
}
|
|
9846
|
+
this.q.unshift(...toInsert);
|
|
9847
|
+
return added;
|
|
9848
|
+
}
|
|
9109
9849
|
get toString() {
|
|
9110
9850
|
return `${typeof this.q[0]}:
|
|
9111
9851
|
` + this.q.map((i) => ` ${i.courseID}+${i.cardID}: ${i.status}`).join("\n");
|
|
@@ -11189,7 +11929,7 @@ mountSessionDebugger();
|
|
|
11189
11929
|
|
|
11190
11930
|
// src/study/SessionController.ts
|
|
11191
11931
|
init_logger();
|
|
11192
|
-
var SessionController = class extends Loggable {
|
|
11932
|
+
var SessionController = class _SessionController extends Loggable {
|
|
11193
11933
|
_className = "SessionController";
|
|
11194
11934
|
services;
|
|
11195
11935
|
srsService;
|
|
@@ -11210,6 +11950,18 @@ var SessionController = class extends Loggable {
|
|
|
11210
11950
|
newQ = new ItemQueue();
|
|
11211
11951
|
failedQ = new ItemQueue();
|
|
11212
11952
|
// END Session card stores
|
|
11953
|
+
/**
|
|
11954
|
+
* Promise tracking a currently in-progress replan, or null if idle.
|
|
11955
|
+
* Used by nextCard() to await completion before drawing from queues.
|
|
11956
|
+
*/
|
|
11957
|
+
_replanPromise = null;
|
|
11958
|
+
/**
|
|
11959
|
+
* Number of well-indicated new cards remaining before the queue
|
|
11960
|
+
* degrades to poorly-indicated content. Decremented on each newQ
|
|
11961
|
+
* draw; when it hits 0, a replan is triggered automatically
|
|
11962
|
+
* (user state has changed from completing good cards).
|
|
11963
|
+
*/
|
|
11964
|
+
_wellIndicatedRemaining = 0;
|
|
11213
11965
|
startTime;
|
|
11214
11966
|
endTime;
|
|
11215
11967
|
_secondsRemaining;
|
|
@@ -11303,13 +12055,83 @@ var SessionController = class extends Loggable {
|
|
|
11303
12055
|
"[SessionController] All content sources must implement getWeightedCards()."
|
|
11304
12056
|
);
|
|
11305
12057
|
}
|
|
11306
|
-
await this.getWeightedContent();
|
|
12058
|
+
const wellIndicated = await this.getWeightedContent();
|
|
12059
|
+
this._wellIndicatedRemaining = wellIndicated;
|
|
12060
|
+
if (wellIndicated >= 0 && wellIndicated < _SessionController.MIN_WELL_INDICATED) {
|
|
12061
|
+
this.log(
|
|
12062
|
+
`[Init] Only ${wellIndicated}/${_SessionController.MIN_WELL_INDICATED} well-indicated cards in initial load`
|
|
12063
|
+
);
|
|
12064
|
+
}
|
|
11307
12065
|
await this.hydrationService.ensureHydratedCards();
|
|
11308
12066
|
startSessionTracking(this.reviewQ.length, this.newQ.length, this.failedQ.length);
|
|
11309
12067
|
this._intervalHandle = setInterval(() => {
|
|
11310
12068
|
this.tick();
|
|
11311
12069
|
}, 1e3);
|
|
11312
12070
|
}
|
|
12071
|
+
/**
|
|
12072
|
+
* Request a mid-session replan. Re-runs the pipeline with current user state
|
|
12073
|
+
* and atomically replaces the newQ contents. Safe to call at any time during
|
|
12074
|
+
* a session — if called while a replan is already in progress, returns the
|
|
12075
|
+
* existing replan promise (no duplicate work).
|
|
12076
|
+
*
|
|
12077
|
+
* Does NOT affect reviewQ or failedQ.
|
|
12078
|
+
*
|
|
12079
|
+
* If nextCard() is called while a replan is in flight, it will automatically
|
|
12080
|
+
* await the replan before drawing from queues, ensuring the user always sees
|
|
12081
|
+
* cards scored against their latest state.
|
|
12082
|
+
*
|
|
12083
|
+
* Typical trigger: application-level code (e.g. after a GPC intro completion)
|
|
12084
|
+
* calls this to ensure newly-unlocked content appears in the session.
|
|
12085
|
+
*/
|
|
12086
|
+
async requestReplan(hints) {
|
|
12087
|
+
if (this._replanPromise) {
|
|
12088
|
+
this.log("Replan already in progress, awaiting existing replan");
|
|
12089
|
+
return this._replanPromise;
|
|
12090
|
+
}
|
|
12091
|
+
if (hints) {
|
|
12092
|
+
for (const source of this.sources) {
|
|
12093
|
+
this.log(`[Hints] source type=${source.constructor.name}, hasMethod=${typeof source.setEphemeralHints}`);
|
|
12094
|
+
source.setEphemeralHints?.(hints);
|
|
12095
|
+
}
|
|
12096
|
+
}
|
|
12097
|
+
this.log(`Mid-session replan requested${hints ? ` (hints: ${JSON.stringify(hints)})` : ""}`);
|
|
12098
|
+
this._replanPromise = this._executeReplan();
|
|
12099
|
+
try {
|
|
12100
|
+
await this._replanPromise;
|
|
12101
|
+
} finally {
|
|
12102
|
+
this._replanPromise = null;
|
|
12103
|
+
}
|
|
12104
|
+
}
|
|
12105
|
+
/** Minimum well-indicated cards before an additive retry is attempted */
|
|
12106
|
+
static MIN_WELL_INDICATED = 5;
|
|
12107
|
+
/**
|
|
12108
|
+
* Score threshold for considering a card "well-indicated."
|
|
12109
|
+
* Cards below this score are treated as fallback filler — present only
|
|
12110
|
+
* because no strategy hard-removed them, but likely penalized by one
|
|
12111
|
+
* or more filters. Strategy-agnostic: the SessionController doesn't
|
|
12112
|
+
* know or care which strategy assigned the score.
|
|
12113
|
+
*/
|
|
12114
|
+
static WELL_INDICATED_SCORE = 0.1;
|
|
12115
|
+
/**
|
|
12116
|
+
* Internal replan execution. Runs the pipeline, builds a new newQ,
|
|
12117
|
+
* atomically swaps it in, and triggers hydration for the new contents.
|
|
12118
|
+
*
|
|
12119
|
+
* If the initial replan produces fewer than MIN_WELL_INDICATED cards that
|
|
12120
|
+
* pass all hierarchy filters, one additive retry is attempted — merging
|
|
12121
|
+
* any new high-quality candidates into the front of the queue.
|
|
12122
|
+
*/
|
|
12123
|
+
async _executeReplan() {
|
|
12124
|
+
const wellIndicated = await this.getWeightedContent({ replan: true });
|
|
12125
|
+
this._wellIndicatedRemaining = wellIndicated;
|
|
12126
|
+
if (wellIndicated >= 0 && wellIndicated < _SessionController.MIN_WELL_INDICATED) {
|
|
12127
|
+
this.log(
|
|
12128
|
+
`[Replan] Only ${wellIndicated}/${_SessionController.MIN_WELL_INDICATED} well-indicated cards after replan`
|
|
12129
|
+
);
|
|
12130
|
+
}
|
|
12131
|
+
await this.hydrationService.ensureHydratedCards();
|
|
12132
|
+
this.log(`Replan complete: newQ now has ${this.newQ.length} cards`);
|
|
12133
|
+
snapshotQueues(this.reviewQ.length, this.newQ.length, this.failedQ.length);
|
|
12134
|
+
}
|
|
11313
12135
|
addTime(seconds) {
|
|
11314
12136
|
this.endTime = new Date(this.endTime.valueOf() + 1e3 * seconds);
|
|
11315
12137
|
}
|
|
@@ -11365,6 +12187,9 @@ var SessionController = class extends Loggable {
|
|
|
11365
12187
|
hydratedCache: {
|
|
11366
12188
|
count: this.hydrationService.hydratedCount,
|
|
11367
12189
|
cardIds: this.hydrationService.getHydratedCardIds()
|
|
12190
|
+
},
|
|
12191
|
+
replan: {
|
|
12192
|
+
inProgress: this._replanPromise !== null
|
|
11368
12193
|
}
|
|
11369
12194
|
};
|
|
11370
12195
|
}
|
|
@@ -11377,7 +12202,20 @@ var SessionController = class extends Loggable {
|
|
|
11377
12202
|
* 3. Uses SourceMixer to balance content across sources
|
|
11378
12203
|
* 4. Populates review and new card queues with mixed results
|
|
11379
12204
|
*/
|
|
11380
|
-
|
|
12205
|
+
/**
|
|
12206
|
+
* Fetch weighted content from all sources and populate session queues.
|
|
12207
|
+
*
|
|
12208
|
+
* @param options.replan - If true, this is a mid-session replan rather than
|
|
12209
|
+
* initial session setup. Skips review queue population (avoiding duplicates),
|
|
12210
|
+
* atomically replaces newQ contents, and treats empty results as non-fatal.
|
|
12211
|
+
* @param options.additive - If true (replan only), merge new high-quality
|
|
12212
|
+
* candidates into the front of the existing newQ instead of replacing it.
|
|
12213
|
+
* @returns Number of "well-indicated" cards (passed all hierarchy filters)
|
|
12214
|
+
* in the new content. Returns -1 if no content was loaded.
|
|
12215
|
+
*/
|
|
12216
|
+
async getWeightedContent(options) {
|
|
12217
|
+
const replan = options?.replan ?? false;
|
|
12218
|
+
const additive = options?.additive ?? false;
|
|
11381
12219
|
const limit = 20;
|
|
11382
12220
|
const batches = [];
|
|
11383
12221
|
for (let i = 0; i < this.sources.length; i++) {
|
|
@@ -11396,6 +12234,10 @@ var SessionController = class extends Loggable {
|
|
|
11396
12234
|
}
|
|
11397
12235
|
}
|
|
11398
12236
|
if (batches.length === 0) {
|
|
12237
|
+
if (replan) {
|
|
12238
|
+
this.log("Replan: no content from any source, keeping existing newQ");
|
|
12239
|
+
return -1;
|
|
12240
|
+
}
|
|
11399
12241
|
throw new Error(
|
|
11400
12242
|
`Cannot start session: failed to load content from all ${this.sources.length} source(s). Check logs for details.`
|
|
11401
12243
|
);
|
|
@@ -11407,10 +12249,12 @@ var SessionController = class extends Loggable {
|
|
|
11407
12249
|
});
|
|
11408
12250
|
await Promise.all(
|
|
11409
12251
|
sourceIds.map(async (id) => {
|
|
11410
|
-
|
|
11411
|
-
|
|
11412
|
-
|
|
11413
|
-
|
|
12252
|
+
if (!this.courseNameCache.has(id)) {
|
|
12253
|
+
try {
|
|
12254
|
+
const config = await this.dataLayer.getCoursesDB().getCourseConfig(id);
|
|
12255
|
+
this.courseNameCache.set(id, config.name);
|
|
12256
|
+
} catch {
|
|
12257
|
+
}
|
|
11414
12258
|
}
|
|
11415
12259
|
})
|
|
11416
12260
|
);
|
|
@@ -11428,20 +12272,26 @@ var SessionController = class extends Loggable {
|
|
|
11428
12272
|
const reviewWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "review");
|
|
11429
12273
|
const newWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "new");
|
|
11430
12274
|
logger.debug(`[reviews] got ${reviewWeighted.length} reviews from mixer`);
|
|
11431
|
-
let report = "Mixed content session created with:\n";
|
|
11432
|
-
|
|
11433
|
-
const
|
|
11434
|
-
|
|
11435
|
-
|
|
11436
|
-
|
|
11437
|
-
|
|
11438
|
-
|
|
11439
|
-
|
|
11440
|
-
|
|
11441
|
-
|
|
11442
|
-
|
|
12275
|
+
let report = replan ? "Replan content:\n" : "Mixed content session created with:\n";
|
|
12276
|
+
if (!replan) {
|
|
12277
|
+
for (const w of reviewWeighted) {
|
|
12278
|
+
const reviewItem = {
|
|
12279
|
+
cardID: w.cardId,
|
|
12280
|
+
courseID: w.courseId,
|
|
12281
|
+
contentSourceType: "course",
|
|
12282
|
+
contentSourceID: w.courseId,
|
|
12283
|
+
reviewID: w.reviewID,
|
|
12284
|
+
status: "review"
|
|
12285
|
+
};
|
|
12286
|
+
this.reviewQ.add(reviewItem, reviewItem.cardID);
|
|
12287
|
+
report += `Review: ${w.courseId}::${w.cardId} (score: ${w.score.toFixed(2)})
|
|
11443
12288
|
`;
|
|
12289
|
+
}
|
|
11444
12290
|
}
|
|
12291
|
+
const wellIndicated = newWeighted.filter(
|
|
12292
|
+
(w) => w.score >= _SessionController.WELL_INDICATED_SCORE
|
|
12293
|
+
).length;
|
|
12294
|
+
const newItems = [];
|
|
11445
12295
|
for (const w of newWeighted) {
|
|
11446
12296
|
const newItem = {
|
|
11447
12297
|
cardID: w.cardId,
|
|
@@ -11450,11 +12300,23 @@ var SessionController = class extends Loggable {
|
|
|
11450
12300
|
contentSourceID: w.courseId,
|
|
11451
12301
|
status: "new"
|
|
11452
12302
|
};
|
|
11453
|
-
|
|
12303
|
+
newItems.push(newItem);
|
|
11454
12304
|
report += `New: ${w.courseId}::${w.cardId} (score: ${w.score.toFixed(2)})
|
|
11455
12305
|
`;
|
|
11456
12306
|
}
|
|
12307
|
+
if (additive) {
|
|
12308
|
+
const added = this.newQ.mergeToFront(newItems, (item) => item.cardID);
|
|
12309
|
+
report += `Additive merge: ${added} new cards added to front of newQ
|
|
12310
|
+
`;
|
|
12311
|
+
} else if (replan) {
|
|
12312
|
+
this.newQ.replaceAll(newItems, (item) => item.cardID);
|
|
12313
|
+
} else {
|
|
12314
|
+
for (const item of newItems) {
|
|
12315
|
+
this.newQ.add(item, item.cardID);
|
|
12316
|
+
}
|
|
12317
|
+
}
|
|
11457
12318
|
this.log(report);
|
|
12319
|
+
return wellIndicated;
|
|
11458
12320
|
}
|
|
11459
12321
|
/**
|
|
11460
12322
|
* Returns items that should be pre-hydrated.
|
|
@@ -11531,6 +12393,17 @@ var SessionController = class extends Loggable {
|
|
|
11531
12393
|
}
|
|
11532
12394
|
async nextCard(action = "dismiss-success") {
|
|
11533
12395
|
this.dismissCurrentCard(action);
|
|
12396
|
+
if (this._replanPromise) {
|
|
12397
|
+
this.log("nextCard: awaiting in-flight replan before drawing");
|
|
12398
|
+
await this._replanPromise;
|
|
12399
|
+
}
|
|
12400
|
+
const REPLAN_BUFFER = 3;
|
|
12401
|
+
if (this._wellIndicatedRemaining <= REPLAN_BUFFER && this.newQ.length > 0 && !this._replanPromise) {
|
|
12402
|
+
this.log(
|
|
12403
|
+
`[AutoReplan] ${this._wellIndicatedRemaining} well-indicated cards remaining (newQ: ${this.newQ.length}). Triggering background replan.`
|
|
12404
|
+
);
|
|
12405
|
+
void this.requestReplan();
|
|
12406
|
+
}
|
|
11534
12407
|
if (this._secondsRemaining <= 0 && this.failedQ.length === 0) {
|
|
11535
12408
|
this._currentCard = null;
|
|
11536
12409
|
endSessionTracking();
|
|
@@ -11644,6 +12517,9 @@ var SessionController = class extends Loggable {
|
|
|
11644
12517
|
this.reviewQ.dequeue((queueItem) => queueItem.cardID);
|
|
11645
12518
|
} else if (this.newQ.peek(0)?.cardID === item.cardID) {
|
|
11646
12519
|
this.newQ.dequeue((queueItem) => queueItem.cardID);
|
|
12520
|
+
if (this._wellIndicatedRemaining > 0) {
|
|
12521
|
+
this._wellIndicatedRemaining--;
|
|
12522
|
+
}
|
|
11647
12523
|
} else if (this.failedQ.peek(0)?.cardID === item.cardID) {
|
|
11648
12524
|
this.failedQ.dequeue((queueItem) => queueItem.cardID);
|
|
11649
12525
|
}
|