@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.mjs
CHANGED
|
@@ -855,8 +855,12 @@ __export(PipelineDebugger_exports, {
|
|
|
855
855
|
buildRunReport: () => buildRunReport,
|
|
856
856
|
captureRun: () => captureRun,
|
|
857
857
|
mountPipelineDebugger: () => mountPipelineDebugger,
|
|
858
|
-
pipelineDebugAPI: () => pipelineDebugAPI
|
|
858
|
+
pipelineDebugAPI: () => pipelineDebugAPI,
|
|
859
|
+
registerPipelineForDebug: () => registerPipelineForDebug
|
|
859
860
|
});
|
|
861
|
+
function registerPipelineForDebug(pipeline) {
|
|
862
|
+
_activePipeline = pipeline;
|
|
863
|
+
}
|
|
860
864
|
function getOrigin(card) {
|
|
861
865
|
const firstEntry = card.provenance[0];
|
|
862
866
|
if (!firstEntry) return "unknown";
|
|
@@ -884,6 +888,7 @@ function buildRunReport(courseId, courseName, generatorName, generators, generat
|
|
|
884
888
|
origin: getOrigin(card),
|
|
885
889
|
finalScore: card.score,
|
|
886
890
|
provenance: card.provenance,
|
|
891
|
+
tags: card.tags,
|
|
887
892
|
selected: selectedIds.has(card.cardId)
|
|
888
893
|
}));
|
|
889
894
|
const reviewsSelected = selectedCards.filter((c) => getOrigin(c) === "review").length;
|
|
@@ -939,12 +944,13 @@ function mountPipelineDebugger() {
|
|
|
939
944
|
win.skuilder = win.skuilder || {};
|
|
940
945
|
win.skuilder.pipeline = pipelineDebugAPI;
|
|
941
946
|
}
|
|
942
|
-
var MAX_RUNS, runHistory, pipelineDebugAPI;
|
|
947
|
+
var _activePipeline, MAX_RUNS, runHistory, pipelineDebugAPI;
|
|
943
948
|
var init_PipelineDebugger = __esm({
|
|
944
949
|
"src/core/navigators/PipelineDebugger.ts"() {
|
|
945
950
|
"use strict";
|
|
946
951
|
init_navigators();
|
|
947
952
|
init_logger();
|
|
953
|
+
_activePipeline = null;
|
|
948
954
|
MAX_RUNS = 10;
|
|
949
955
|
runHistory = [];
|
|
950
956
|
pipelineDebugAPI = {
|
|
@@ -1146,6 +1152,21 @@ var init_PipelineDebugger = __esm({
|
|
|
1146
1152
|
}
|
|
1147
1153
|
console.groupEnd();
|
|
1148
1154
|
},
|
|
1155
|
+
/**
|
|
1156
|
+
* Scan the full card space through the filter chain for the current user.
|
|
1157
|
+
*
|
|
1158
|
+
* Reports how many cards are well-indicated and how many are new.
|
|
1159
|
+
* Use this to understand how the search space grows during onboarding.
|
|
1160
|
+
*
|
|
1161
|
+
* @param threshold - Score threshold for "well indicated" (default 0.10)
|
|
1162
|
+
*/
|
|
1163
|
+
async diagnoseCardSpace(threshold) {
|
|
1164
|
+
if (!_activePipeline) {
|
|
1165
|
+
logger.info("[Pipeline Debug] No active pipeline. Run a session first.");
|
|
1166
|
+
return null;
|
|
1167
|
+
}
|
|
1168
|
+
return _activePipeline.diagnoseCardSpace({ threshold });
|
|
1169
|
+
},
|
|
1149
1170
|
/**
|
|
1150
1171
|
* Show help.
|
|
1151
1172
|
*/
|
|
@@ -1158,6 +1179,7 @@ Commands:
|
|
|
1158
1179
|
.showRun(id|index) Show summary of a specific run (by index or ID suffix)
|
|
1159
1180
|
.showCard(cardId) Show provenance trail for a specific card
|
|
1160
1181
|
.explainReviews() Analyze why reviews were/weren't selected
|
|
1182
|
+
.diagnoseCardSpace() Scan full card space through filters (async)
|
|
1161
1183
|
.showRegistry() Show navigator registry (classes + roles)
|
|
1162
1184
|
.showStrategies() Show registry + strategy mapping from last run
|
|
1163
1185
|
.listRuns() List all captured runs in table format
|
|
@@ -1169,7 +1191,7 @@ Commands:
|
|
|
1169
1191
|
Example:
|
|
1170
1192
|
window.skuilder.pipeline.showLastRun()
|
|
1171
1193
|
window.skuilder.pipeline.showRun(1)
|
|
1172
|
-
window.skuilder.pipeline.
|
|
1194
|
+
await window.skuilder.pipeline.diagnoseCardSpace()
|
|
1173
1195
|
`);
|
|
1174
1196
|
}
|
|
1175
1197
|
};
|
|
@@ -1464,6 +1486,69 @@ var init_generators = __esm({
|
|
|
1464
1486
|
}
|
|
1465
1487
|
});
|
|
1466
1488
|
|
|
1489
|
+
// src/core/navigators/generators/prescribed.ts
|
|
1490
|
+
var prescribed_exports = {};
|
|
1491
|
+
__export(prescribed_exports, {
|
|
1492
|
+
default: () => PrescribedCardsGenerator
|
|
1493
|
+
});
|
|
1494
|
+
var PrescribedCardsGenerator;
|
|
1495
|
+
var init_prescribed = __esm({
|
|
1496
|
+
"src/core/navigators/generators/prescribed.ts"() {
|
|
1497
|
+
"use strict";
|
|
1498
|
+
init_navigators();
|
|
1499
|
+
init_logger();
|
|
1500
|
+
PrescribedCardsGenerator = class extends ContentNavigator {
|
|
1501
|
+
name;
|
|
1502
|
+
config;
|
|
1503
|
+
constructor(user, course, strategyData) {
|
|
1504
|
+
super(user, course, strategyData);
|
|
1505
|
+
this.name = strategyData.name || "Prescribed Cards";
|
|
1506
|
+
try {
|
|
1507
|
+
const parsed = JSON.parse(strategyData.serializedData);
|
|
1508
|
+
this.config = { cardIds: parsed.cardIds || [] };
|
|
1509
|
+
} catch {
|
|
1510
|
+
this.config = { cardIds: [] };
|
|
1511
|
+
}
|
|
1512
|
+
logger.debug(
|
|
1513
|
+
`[Prescribed] Initialized with ${this.config.cardIds.length} prescribed cards`
|
|
1514
|
+
);
|
|
1515
|
+
}
|
|
1516
|
+
async getWeightedCards(limit, _context) {
|
|
1517
|
+
if (this.config.cardIds.length === 0) {
|
|
1518
|
+
return [];
|
|
1519
|
+
}
|
|
1520
|
+
const courseId = this.course.getCourseID();
|
|
1521
|
+
const activeCards = await this.user.getActiveCards();
|
|
1522
|
+
const activeIds = new Set(activeCards.map((ac) => ac.cardID));
|
|
1523
|
+
const eligibleIds = this.config.cardIds.filter((id) => !activeIds.has(id));
|
|
1524
|
+
if (eligibleIds.length === 0) {
|
|
1525
|
+
logger.debug("[Prescribed] All prescribed cards already active, returning empty");
|
|
1526
|
+
return [];
|
|
1527
|
+
}
|
|
1528
|
+
const cards = eligibleIds.slice(0, limit).map((cardId) => ({
|
|
1529
|
+
cardId,
|
|
1530
|
+
courseId,
|
|
1531
|
+
score: 1,
|
|
1532
|
+
provenance: [
|
|
1533
|
+
{
|
|
1534
|
+
strategy: "prescribed",
|
|
1535
|
+
strategyName: this.strategyName || this.name,
|
|
1536
|
+
strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
|
|
1537
|
+
action: "generated",
|
|
1538
|
+
score: 1,
|
|
1539
|
+
reason: `Prescribed card (${eligibleIds.length} eligible of ${this.config.cardIds.length} configured)`
|
|
1540
|
+
}
|
|
1541
|
+
]
|
|
1542
|
+
}));
|
|
1543
|
+
logger.info(
|
|
1544
|
+
`[Prescribed] Emitting ${cards.length} cards (${eligibleIds.length} eligible, ${activeIds.size} already active)`
|
|
1545
|
+
);
|
|
1546
|
+
return cards;
|
|
1547
|
+
}
|
|
1548
|
+
};
|
|
1549
|
+
}
|
|
1550
|
+
});
|
|
1551
|
+
|
|
1467
1552
|
// src/core/navigators/generators/srs.ts
|
|
1468
1553
|
var srs_exports = {};
|
|
1469
1554
|
__export(srs_exports, {
|
|
@@ -1658,6 +1743,7 @@ var init_ = __esm({
|
|
|
1658
1743
|
"./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
|
|
1659
1744
|
"./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
|
|
1660
1745
|
"./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
|
|
1746
|
+
"./generators/prescribed.ts": () => Promise.resolve().then(() => (init_prescribed(), prescribed_exports)),
|
|
1661
1747
|
"./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
|
|
1662
1748
|
"./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports))
|
|
1663
1749
|
});
|
|
@@ -1858,6 +1944,8 @@ var init_hierarchyDefinition = __esm({
|
|
|
1858
1944
|
if (userTagElo.count < minCount) return false;
|
|
1859
1945
|
if (prereq.masteryThreshold?.minElo !== void 0) {
|
|
1860
1946
|
return userTagElo.score >= prereq.masteryThreshold.minElo;
|
|
1947
|
+
} else if (prereq.masteryThreshold?.minCount !== void 0) {
|
|
1948
|
+
return true;
|
|
1861
1949
|
} else {
|
|
1862
1950
|
return userTagElo.score >= userGlobalElo;
|
|
1863
1951
|
}
|
|
@@ -1933,14 +2021,38 @@ var init_hierarchyDefinition = __esm({
|
|
|
1933
2021
|
};
|
|
1934
2022
|
}
|
|
1935
2023
|
}
|
|
2024
|
+
/**
|
|
2025
|
+
* Build a map of prereq tag → max configured boost for all *closed* gates.
|
|
2026
|
+
*
|
|
2027
|
+
* When a gate is closed (prereqs unmet), cards carrying that gate's prereq
|
|
2028
|
+
* tags get boosted — steering the pipeline toward content that helps unlock
|
|
2029
|
+
* the gated material. Once the gate opens, the boost disappears.
|
|
2030
|
+
*/
|
|
2031
|
+
getPreReqBoosts(unlockedTags, masteredTags) {
|
|
2032
|
+
const boosts = /* @__PURE__ */ new Map();
|
|
2033
|
+
for (const [tagId, prereqs] of Object.entries(this.config.prerequisites)) {
|
|
2034
|
+
if (unlockedTags.has(tagId)) continue;
|
|
2035
|
+
for (const prereq of prereqs) {
|
|
2036
|
+
if (!prereq.preReqBoost || prereq.preReqBoost <= 1) continue;
|
|
2037
|
+
if (masteredTags.has(prereq.tag)) continue;
|
|
2038
|
+
const existing = boosts.get(prereq.tag) ?? 1;
|
|
2039
|
+
boosts.set(prereq.tag, Math.max(existing, prereq.preReqBoost));
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
return boosts;
|
|
2043
|
+
}
|
|
1936
2044
|
/**
|
|
1937
2045
|
* CardFilter.transform implementation.
|
|
1938
2046
|
*
|
|
1939
|
-
*
|
|
2047
|
+
* Two effects:
|
|
2048
|
+
* 1. Cards with locked tags receive score * 0.05 (gating penalty)
|
|
2049
|
+
* 2. Cards carrying prereq tags of closed gates receive a configured
|
|
2050
|
+
* boost (preReqBoost), steering toward content that unlocks gates
|
|
1940
2051
|
*/
|
|
1941
2052
|
async transform(cards, context) {
|
|
1942
2053
|
const masteredTags = await this.getMasteredTags(context);
|
|
1943
2054
|
const unlockedTags = this.getUnlockedTags(masteredTags);
|
|
2055
|
+
const preReqBoosts = this.getPreReqBoosts(unlockedTags, masteredTags);
|
|
1944
2056
|
const gated = [];
|
|
1945
2057
|
for (const card of cards) {
|
|
1946
2058
|
const { isUnlocked, reason } = await this.checkCardUnlock(
|
|
@@ -1949,9 +2061,27 @@ var init_hierarchyDefinition = __esm({
|
|
|
1949
2061
|
unlockedTags,
|
|
1950
2062
|
masteredTags
|
|
1951
2063
|
);
|
|
1952
|
-
const LOCKED_PENALTY = 0.
|
|
1953
|
-
|
|
1954
|
-
|
|
2064
|
+
const LOCKED_PENALTY = 0.02;
|
|
2065
|
+
let finalScore = isUnlocked ? card.score : card.score * LOCKED_PENALTY;
|
|
2066
|
+
let action = isUnlocked ? "passed" : "penalized";
|
|
2067
|
+
let finalReason = reason;
|
|
2068
|
+
if (isUnlocked && preReqBoosts.size > 0) {
|
|
2069
|
+
const cardTags = card.tags ?? [];
|
|
2070
|
+
let maxBoost = 1;
|
|
2071
|
+
const boostedPrereqs = [];
|
|
2072
|
+
for (const tag of cardTags) {
|
|
2073
|
+
const boost = preReqBoosts.get(tag);
|
|
2074
|
+
if (boost && boost > maxBoost) {
|
|
2075
|
+
maxBoost = boost;
|
|
2076
|
+
boostedPrereqs.push(tag);
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
if (maxBoost > 1) {
|
|
2080
|
+
finalScore *= maxBoost;
|
|
2081
|
+
action = "boosted";
|
|
2082
|
+
finalReason = `${reason} | preReqBoost \xD7${maxBoost.toFixed(2)} for ${boostedPrereqs.join(", ")}`;
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
1955
2085
|
gated.push({
|
|
1956
2086
|
...card,
|
|
1957
2087
|
score: finalScore,
|
|
@@ -1963,7 +2093,7 @@ var init_hierarchyDefinition = __esm({
|
|
|
1963
2093
|
strategyId: this.strategyId || "NAVIGATION_STRATEGY-hierarchy",
|
|
1964
2094
|
action,
|
|
1965
2095
|
score: finalScore,
|
|
1966
|
-
reason
|
|
2096
|
+
reason: finalReason
|
|
1967
2097
|
}
|
|
1968
2098
|
]
|
|
1969
2099
|
});
|
|
@@ -2898,6 +3028,18 @@ __export(Pipeline_exports, {
|
|
|
2898
3028
|
Pipeline: () => Pipeline
|
|
2899
3029
|
});
|
|
2900
3030
|
import { toCourseElo as toCourseElo5 } from "@vue-skuilder/common";
|
|
3031
|
+
function globToRegex(pattern) {
|
|
3032
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
3033
|
+
const withWildcards = escaped.replace(/\*/g, ".*");
|
|
3034
|
+
return new RegExp(`^${withWildcards}$`);
|
|
3035
|
+
}
|
|
3036
|
+
function globMatch(value, pattern) {
|
|
3037
|
+
if (!pattern.includes("*")) return value === pattern;
|
|
3038
|
+
return globToRegex(pattern).test(value);
|
|
3039
|
+
}
|
|
3040
|
+
function cardMatchesTagPattern(card, pattern) {
|
|
3041
|
+
return (card.tags ?? []).some((tag) => globMatch(tag, pattern));
|
|
3042
|
+
}
|
|
2901
3043
|
function logPipelineConfig(generator, filters) {
|
|
2902
3044
|
const filterList = filters.length > 0 ? "\n - " + filters.map((f) => f.name).join("\n - ") : " none";
|
|
2903
3045
|
logger.info(
|
|
@@ -2932,6 +3074,21 @@ function logExecutionSummary(generatorName, generatedCount, filterCount, finalCo
|
|
|
2932
3074
|
\u{1F4A1} Inspect: window.skuilder.pipeline`
|
|
2933
3075
|
);
|
|
2934
3076
|
}
|
|
3077
|
+
function logResultCards(cards) {
|
|
3078
|
+
if (!VERBOSE_RESULTS || cards.length === 0) return;
|
|
3079
|
+
logger.info(`[Pipeline] Results (${cards.length} cards):`);
|
|
3080
|
+
for (let i = 0; i < cards.length; i++) {
|
|
3081
|
+
const c = cards[i];
|
|
3082
|
+
const tags = c.tags?.slice(0, 3).join(", ") || "";
|
|
3083
|
+
const filters = c.provenance.filter((p) => p.strategy === "hierarchyDefinition" || p.strategy === "priorityDefinition" || p.strategy === "interferenceFilter" || p.strategy === "letterGating" || p.strategy === "ephemeralHint").map((p) => {
|
|
3084
|
+
const arrow = p.action === "boosted" ? "\u2191" : p.action === "penalized" ? "\u2193" : "=";
|
|
3085
|
+
return `${p.strategyName}${arrow}${p.score.toFixed(2)}`;
|
|
3086
|
+
}).join(" | ");
|
|
3087
|
+
logger.info(
|
|
3088
|
+
`[Pipeline] ${String(i + 1).padStart(2)}. ${c.score.toFixed(4)} ${c.cardId} [${tags}]${filters ? ` {${filters}}` : ""}`
|
|
3089
|
+
);
|
|
3090
|
+
}
|
|
3091
|
+
}
|
|
2935
3092
|
function logCardProvenance(cards, maxCards = 3) {
|
|
2936
3093
|
const cardsToLog = cards.slice(0, maxCards);
|
|
2937
3094
|
logger.debug(`[Pipeline] Provenance for top ${cardsToLog.length} cards:`);
|
|
@@ -2946,7 +3103,7 @@ function logCardProvenance(cards, maxCards = 3) {
|
|
|
2946
3103
|
}
|
|
2947
3104
|
}
|
|
2948
3105
|
}
|
|
2949
|
-
var Pipeline;
|
|
3106
|
+
var VERBOSE_RESULTS, Pipeline;
|
|
2950
3107
|
var init_Pipeline = __esm({
|
|
2951
3108
|
"src/core/navigators/Pipeline.ts"() {
|
|
2952
3109
|
"use strict";
|
|
@@ -2954,9 +3111,31 @@ var init_Pipeline = __esm({
|
|
|
2954
3111
|
init_logger();
|
|
2955
3112
|
init_orchestration();
|
|
2956
3113
|
init_PipelineDebugger();
|
|
3114
|
+
VERBOSE_RESULTS = true;
|
|
2957
3115
|
Pipeline = class extends ContentNavigator {
|
|
2958
3116
|
generator;
|
|
2959
3117
|
filters;
|
|
3118
|
+
/**
|
|
3119
|
+
* Cached orchestration context. Course config and salt don't change within
|
|
3120
|
+
* a page load, so we build the orchestration context once and reuse it on
|
|
3121
|
+
* subsequent getWeightedCards() calls (e.g. mid-session replans).
|
|
3122
|
+
*
|
|
3123
|
+
* This eliminates a remote getCourseConfig() round trip per pipeline run.
|
|
3124
|
+
*/
|
|
3125
|
+
_cachedOrchestration = null;
|
|
3126
|
+
/**
|
|
3127
|
+
* Persistent tag cache. Maps cardId → tag names.
|
|
3128
|
+
*
|
|
3129
|
+
* Tags are static within a session (they're set at card generation time),
|
|
3130
|
+
* so we cache them across pipeline runs. On replans, many of the same cards
|
|
3131
|
+
* reappear — cache hits avoid redundant remote getAppliedTagsBatch() queries.
|
|
3132
|
+
*/
|
|
3133
|
+
_tagCache = /* @__PURE__ */ new Map();
|
|
3134
|
+
/**
|
|
3135
|
+
* One-shot replan hints. Applied after the filter chain on the next
|
|
3136
|
+
* getWeightedCards() call, then cleared.
|
|
3137
|
+
*/
|
|
3138
|
+
_ephemeralHints = null;
|
|
2960
3139
|
/**
|
|
2961
3140
|
* Create a new pipeline.
|
|
2962
3141
|
*
|
|
@@ -2977,6 +3156,17 @@ var init_Pipeline = __esm({
|
|
|
2977
3156
|
logger.error(`[pipeline] Failed to lookup courseCfg: ${e}`);
|
|
2978
3157
|
});
|
|
2979
3158
|
logPipelineConfig(generator, filters);
|
|
3159
|
+
registerPipelineForDebug(this);
|
|
3160
|
+
}
|
|
3161
|
+
/**
|
|
3162
|
+
* Set one-shot hints for the next pipeline run.
|
|
3163
|
+
* Consumed after one getWeightedCards() call, then cleared.
|
|
3164
|
+
*
|
|
3165
|
+
* Overrides ContentNavigator.setEphemeralHints() no-op.
|
|
3166
|
+
*/
|
|
3167
|
+
setEphemeralHints(hints) {
|
|
3168
|
+
this._ephemeralHints = hints;
|
|
3169
|
+
logger.info(`[Pipeline] Ephemeral hints set: ${JSON.stringify(hints)}`);
|
|
2980
3170
|
}
|
|
2981
3171
|
/**
|
|
2982
3172
|
* Get weighted cards by running generator and applying filters.
|
|
@@ -2993,13 +3183,15 @@ var init_Pipeline = __esm({
|
|
|
2993
3183
|
* @returns Cards sorted by score descending
|
|
2994
3184
|
*/
|
|
2995
3185
|
async getWeightedCards(limit) {
|
|
3186
|
+
const t0 = performance.now();
|
|
2996
3187
|
const context = await this.buildContext();
|
|
2997
|
-
const
|
|
2998
|
-
const fetchLimit =
|
|
3188
|
+
const tContext = performance.now();
|
|
3189
|
+
const fetchLimit = 500;
|
|
2999
3190
|
logger.debug(
|
|
3000
3191
|
`[Pipeline] Fetching ${fetchLimit} candidates from generator '${this.generator.name}'`
|
|
3001
3192
|
);
|
|
3002
3193
|
let cards = await this.generator.getWeightedCards(fetchLimit, context);
|
|
3194
|
+
const tGenerate = performance.now();
|
|
3003
3195
|
const generatedCount = cards.length;
|
|
3004
3196
|
let generatorSummaries;
|
|
3005
3197
|
if (this.generator.generators) {
|
|
@@ -3028,6 +3220,7 @@ var init_Pipeline = __esm({
|
|
|
3028
3220
|
}
|
|
3029
3221
|
logger.debug(`[Pipeline] Generator returned ${generatedCount} candidates`);
|
|
3030
3222
|
cards = await this.hydrateTags(cards);
|
|
3223
|
+
const tHydrate = performance.now();
|
|
3031
3224
|
const allCardsBeforeFiltering = [...cards];
|
|
3032
3225
|
const filterImpacts = [];
|
|
3033
3226
|
for (const filter of this.filters) {
|
|
@@ -3046,8 +3239,17 @@ var init_Pipeline = __esm({
|
|
|
3046
3239
|
logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeScores.size} \u2192 ${cards.length} cards (\u2191${boosted} \u2193${penalized} =${passed})`);
|
|
3047
3240
|
}
|
|
3048
3241
|
cards = cards.filter((c) => c.score > 0);
|
|
3242
|
+
const hints = this._ephemeralHints;
|
|
3243
|
+
if (hints) {
|
|
3244
|
+
this._ephemeralHints = null;
|
|
3245
|
+
cards = this.applyHints(cards, hints, allCardsBeforeFiltering);
|
|
3246
|
+
}
|
|
3049
3247
|
cards.sort((a, b) => b.score - a.score);
|
|
3248
|
+
const tFilter = performance.now();
|
|
3050
3249
|
const result = cards.slice(0, limit);
|
|
3250
|
+
logger.info(
|
|
3251
|
+
`[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)})`
|
|
3252
|
+
);
|
|
3051
3253
|
const topScores = result.slice(0, 3).map((c) => c.score);
|
|
3052
3254
|
logExecutionSummary(
|
|
3053
3255
|
this.generator.name,
|
|
@@ -3057,6 +3259,7 @@ var init_Pipeline = __esm({
|
|
|
3057
3259
|
topScores,
|
|
3058
3260
|
filterImpacts
|
|
3059
3261
|
);
|
|
3262
|
+
logResultCards(result);
|
|
3060
3263
|
logCardProvenance(result, 3);
|
|
3061
3264
|
try {
|
|
3062
3265
|
const courseName = await this.course?.getCourseConfig().then((c) => c.name).catch(() => void 0);
|
|
@@ -3083,6 +3286,10 @@ var init_Pipeline = __esm({
|
|
|
3083
3286
|
* to the WeightedCard objects. Filters can then use card.tags instead of
|
|
3084
3287
|
* making individual getAppliedTags() calls.
|
|
3085
3288
|
*
|
|
3289
|
+
* Uses a persistent tag cache across pipeline runs — tags are static within
|
|
3290
|
+
* a session, so cards seen in a prior run (e.g. before a replan) don't
|
|
3291
|
+
* require a second DB query.
|
|
3292
|
+
*
|
|
3086
3293
|
* @param cards - Cards to hydrate
|
|
3087
3294
|
* @returns Cards with tags populated
|
|
3088
3295
|
*/
|
|
@@ -3090,14 +3297,128 @@ var init_Pipeline = __esm({
|
|
|
3090
3297
|
if (cards.length === 0) {
|
|
3091
3298
|
return cards;
|
|
3092
3299
|
}
|
|
3093
|
-
const
|
|
3094
|
-
const
|
|
3300
|
+
const uncachedIds = [];
|
|
3301
|
+
for (const card of cards) {
|
|
3302
|
+
if (!this._tagCache.has(card.cardId)) {
|
|
3303
|
+
uncachedIds.push(card.cardId);
|
|
3304
|
+
}
|
|
3305
|
+
}
|
|
3306
|
+
if (uncachedIds.length > 0) {
|
|
3307
|
+
const freshTags = await this.course.getAppliedTagsBatch(uncachedIds);
|
|
3308
|
+
for (const [cardId, tags] of freshTags) {
|
|
3309
|
+
this._tagCache.set(cardId, tags);
|
|
3310
|
+
}
|
|
3311
|
+
}
|
|
3312
|
+
const tagsByCard = /* @__PURE__ */ new Map();
|
|
3313
|
+
for (const card of cards) {
|
|
3314
|
+
tagsByCard.set(card.cardId, this._tagCache.get(card.cardId) ?? []);
|
|
3315
|
+
}
|
|
3095
3316
|
logTagHydration(cards, tagsByCard);
|
|
3096
3317
|
return cards.map((card) => ({
|
|
3097
3318
|
...card,
|
|
3098
|
-
tags:
|
|
3319
|
+
tags: this._tagCache.get(card.cardId) ?? []
|
|
3099
3320
|
}));
|
|
3100
3321
|
}
|
|
3322
|
+
// ---------------------------------------------------------------------------
|
|
3323
|
+
// Ephemeral hints application
|
|
3324
|
+
// ---------------------------------------------------------------------------
|
|
3325
|
+
/**
|
|
3326
|
+
* Apply one-shot replan hints to the post-filter card set.
|
|
3327
|
+
*
|
|
3328
|
+
* Order of operations:
|
|
3329
|
+
* 1. Exclude (remove unwanted cards)
|
|
3330
|
+
* 2. Boost (multiply scores)
|
|
3331
|
+
* 3. Require (inject must-have cards from the full pre-filter pool)
|
|
3332
|
+
*
|
|
3333
|
+
* @param cards - Post-filter cards (score > 0)
|
|
3334
|
+
* @param hints - The ephemeral hints to apply
|
|
3335
|
+
* @param allCards - Full pre-filter card pool (for require injection)
|
|
3336
|
+
*/
|
|
3337
|
+
applyHints(cards, hints, allCards) {
|
|
3338
|
+
const beforeCount = cards.length;
|
|
3339
|
+
if (hints.excludeCards?.length) {
|
|
3340
|
+
cards = cards.filter(
|
|
3341
|
+
(c) => !hints.excludeCards.some((pat) => globMatch(c.cardId, pat))
|
|
3342
|
+
);
|
|
3343
|
+
}
|
|
3344
|
+
if (hints.excludeTags?.length) {
|
|
3345
|
+
cards = cards.filter(
|
|
3346
|
+
(c) => !hints.excludeTags.some((pat) => cardMatchesTagPattern(c, pat))
|
|
3347
|
+
);
|
|
3348
|
+
}
|
|
3349
|
+
if (hints.boostTags) {
|
|
3350
|
+
for (const [pattern, factor] of Object.entries(hints.boostTags)) {
|
|
3351
|
+
for (const card of cards) {
|
|
3352
|
+
if (cardMatchesTagPattern(card, pattern)) {
|
|
3353
|
+
card.score *= factor;
|
|
3354
|
+
card.provenance.push({
|
|
3355
|
+
strategy: "ephemeralHint",
|
|
3356
|
+
strategyId: "ephemeral-hint",
|
|
3357
|
+
strategyName: "Replan Hint",
|
|
3358
|
+
action: "boosted",
|
|
3359
|
+
score: card.score,
|
|
3360
|
+
reason: `boostTag ${pattern} \xD7${factor}`
|
|
3361
|
+
});
|
|
3362
|
+
}
|
|
3363
|
+
}
|
|
3364
|
+
}
|
|
3365
|
+
}
|
|
3366
|
+
if (hints.boostCards) {
|
|
3367
|
+
for (const [pattern, factor] of Object.entries(hints.boostCards)) {
|
|
3368
|
+
for (const card of cards) {
|
|
3369
|
+
if (globMatch(card.cardId, pattern)) {
|
|
3370
|
+
card.score *= factor;
|
|
3371
|
+
card.provenance.push({
|
|
3372
|
+
strategy: "ephemeralHint",
|
|
3373
|
+
strategyId: "ephemeral-hint",
|
|
3374
|
+
strategyName: "Replan Hint",
|
|
3375
|
+
action: "boosted",
|
|
3376
|
+
score: card.score,
|
|
3377
|
+
reason: `boostCard ${pattern} \xD7${factor}`
|
|
3378
|
+
});
|
|
3379
|
+
}
|
|
3380
|
+
}
|
|
3381
|
+
}
|
|
3382
|
+
}
|
|
3383
|
+
const cardIds = new Set(cards.map((c) => c.cardId));
|
|
3384
|
+
const inject = (card, reason) => {
|
|
3385
|
+
if (!cardIds.has(card.cardId)) {
|
|
3386
|
+
const floorScore = Math.max(card.score, 1);
|
|
3387
|
+
cards.push({
|
|
3388
|
+
...card,
|
|
3389
|
+
score: floorScore,
|
|
3390
|
+
provenance: [
|
|
3391
|
+
...card.provenance,
|
|
3392
|
+
{
|
|
3393
|
+
strategy: "ephemeralHint",
|
|
3394
|
+
strategyId: "ephemeral-hint",
|
|
3395
|
+
strategyName: "Replan Hint",
|
|
3396
|
+
action: "boosted",
|
|
3397
|
+
score: floorScore,
|
|
3398
|
+
reason
|
|
3399
|
+
}
|
|
3400
|
+
]
|
|
3401
|
+
});
|
|
3402
|
+
cardIds.add(card.cardId);
|
|
3403
|
+
}
|
|
3404
|
+
};
|
|
3405
|
+
if (hints.requireCards?.length) {
|
|
3406
|
+
for (const pattern of hints.requireCards) {
|
|
3407
|
+
for (const card of allCards) {
|
|
3408
|
+
if (globMatch(card.cardId, pattern)) inject(card, `requireCard ${pattern}`);
|
|
3409
|
+
}
|
|
3410
|
+
}
|
|
3411
|
+
}
|
|
3412
|
+
if (hints.requireTags?.length) {
|
|
3413
|
+
for (const pattern of hints.requireTags) {
|
|
3414
|
+
for (const card of allCards) {
|
|
3415
|
+
if (cardMatchesTagPattern(card, pattern)) inject(card, `requireTag ${pattern}`);
|
|
3416
|
+
}
|
|
3417
|
+
}
|
|
3418
|
+
}
|
|
3419
|
+
logger.info(`[Pipeline] Hints applied: ${beforeCount} \u2192 ${cards.length} cards`);
|
|
3420
|
+
return cards;
|
|
3421
|
+
}
|
|
3101
3422
|
/**
|
|
3102
3423
|
* Build shared context for generator and filters.
|
|
3103
3424
|
*
|
|
@@ -3115,7 +3436,10 @@ var init_Pipeline = __esm({
|
|
|
3115
3436
|
} catch (e) {
|
|
3116
3437
|
logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`);
|
|
3117
3438
|
}
|
|
3118
|
-
|
|
3439
|
+
if (!this._cachedOrchestration) {
|
|
3440
|
+
this._cachedOrchestration = await createOrchestrationContext(this.user, this.course);
|
|
3441
|
+
}
|
|
3442
|
+
const orchestration = this._cachedOrchestration;
|
|
3119
3443
|
return {
|
|
3120
3444
|
user: this.user,
|
|
3121
3445
|
course: this.course,
|
|
@@ -3159,6 +3483,87 @@ var init_Pipeline = __esm({
|
|
|
3159
3483
|
}
|
|
3160
3484
|
return [...new Set(ids)];
|
|
3161
3485
|
}
|
|
3486
|
+
// ---------------------------------------------------------------------------
|
|
3487
|
+
// Card-space diagnostic
|
|
3488
|
+
// ---------------------------------------------------------------------------
|
|
3489
|
+
/**
|
|
3490
|
+
* Scan every card in the course through the filter chain and report
|
|
3491
|
+
* how many are "well indicated" (score >= threshold) for the current user.
|
|
3492
|
+
*
|
|
3493
|
+
* Also reports how many well-indicated cards the user has NOT yet encountered.
|
|
3494
|
+
*
|
|
3495
|
+
* Exposed via `window.skuilder.pipeline.diagnoseCardSpace()`.
|
|
3496
|
+
*/
|
|
3497
|
+
async diagnoseCardSpace(opts) {
|
|
3498
|
+
const THRESHOLD = opts?.threshold ?? 0.1;
|
|
3499
|
+
const t0 = performance.now();
|
|
3500
|
+
const allCardIds = await this.course.getAllCardIds();
|
|
3501
|
+
let cards = allCardIds.map((cardId) => ({
|
|
3502
|
+
cardId,
|
|
3503
|
+
courseId: this.course.getCourseID(),
|
|
3504
|
+
score: 1,
|
|
3505
|
+
provenance: []
|
|
3506
|
+
}));
|
|
3507
|
+
cards = await this.hydrateTags(cards);
|
|
3508
|
+
const context = await this.buildContext();
|
|
3509
|
+
const filterBreakdown = [];
|
|
3510
|
+
for (const filter of this.filters) {
|
|
3511
|
+
cards = await filter.transform(cards, context);
|
|
3512
|
+
const wi = cards.filter((c) => c.score >= THRESHOLD).length;
|
|
3513
|
+
filterBreakdown.push({ name: filter.name, wellIndicated: wi });
|
|
3514
|
+
}
|
|
3515
|
+
const wellIndicated = cards.filter((c) => c.score >= THRESHOLD);
|
|
3516
|
+
const wellIndicatedIds = new Set(wellIndicated.map((c) => c.cardId));
|
|
3517
|
+
let encounteredIds;
|
|
3518
|
+
try {
|
|
3519
|
+
const courseId = this.course.getCourseID();
|
|
3520
|
+
const seenCards = await this.user.getSeenCards(courseId);
|
|
3521
|
+
encounteredIds = new Set(seenCards);
|
|
3522
|
+
} catch {
|
|
3523
|
+
encounteredIds = /* @__PURE__ */ new Set();
|
|
3524
|
+
}
|
|
3525
|
+
const wellIndicatedNew = wellIndicated.filter((c) => !encounteredIds.has(c.cardId));
|
|
3526
|
+
const byType = /* @__PURE__ */ new Map();
|
|
3527
|
+
for (const card of cards) {
|
|
3528
|
+
const type = card.cardId.split("-")[1] || "unknown";
|
|
3529
|
+
if (!byType.has(type)) {
|
|
3530
|
+
byType.set(type, { total: 0, wellIndicated: 0, new: 0 });
|
|
3531
|
+
}
|
|
3532
|
+
const entry = byType.get(type);
|
|
3533
|
+
entry.total++;
|
|
3534
|
+
if (card.score >= THRESHOLD) {
|
|
3535
|
+
entry.wellIndicated++;
|
|
3536
|
+
if (!encounteredIds.has(card.cardId)) entry.new++;
|
|
3537
|
+
}
|
|
3538
|
+
}
|
|
3539
|
+
const elapsed = performance.now() - t0;
|
|
3540
|
+
const result = {
|
|
3541
|
+
totalCards: allCardIds.length,
|
|
3542
|
+
threshold: THRESHOLD,
|
|
3543
|
+
wellIndicated: wellIndicatedIds.size,
|
|
3544
|
+
encountered: encounteredIds.size,
|
|
3545
|
+
wellIndicatedNew: wellIndicatedNew.length,
|
|
3546
|
+
byType: Object.fromEntries(byType),
|
|
3547
|
+
filterBreakdown,
|
|
3548
|
+
elapsedMs: Math.round(elapsed)
|
|
3549
|
+
};
|
|
3550
|
+
logger.info(`[Pipeline:diagnose] Card space scan (${result.elapsedMs}ms):`);
|
|
3551
|
+
logger.info(`[Pipeline:diagnose] Total cards: ${result.totalCards}`);
|
|
3552
|
+
logger.info(`[Pipeline:diagnose] Well-indicated (score >= ${THRESHOLD}): ${result.wellIndicated}`);
|
|
3553
|
+
logger.info(`[Pipeline:diagnose] Encountered: ${result.encountered}`);
|
|
3554
|
+
logger.info(`[Pipeline:diagnose] Well-indicated & new: ${result.wellIndicatedNew}`);
|
|
3555
|
+
logger.info(`[Pipeline:diagnose] By type:`);
|
|
3556
|
+
for (const [type, counts] of byType) {
|
|
3557
|
+
logger.info(
|
|
3558
|
+
`[Pipeline:diagnose] ${type}: ${counts.wellIndicated}/${counts.total} well-indicated, ${counts.new} new`
|
|
3559
|
+
);
|
|
3560
|
+
}
|
|
3561
|
+
logger.info(`[Pipeline:diagnose] After each filter:`);
|
|
3562
|
+
for (const fb of filterBreakdown) {
|
|
3563
|
+
logger.info(`[Pipeline:diagnose] ${fb.name}: ${fb.wellIndicated} well-indicated`);
|
|
3564
|
+
}
|
|
3565
|
+
return result;
|
|
3566
|
+
}
|
|
3162
3567
|
};
|
|
3163
3568
|
}
|
|
3164
3569
|
});
|
|
@@ -3263,23 +3668,25 @@ var init_PipelineAssembler = __esm({
|
|
|
3263
3668
|
warnings.push(`Unknown strategy type '${s.implementingClass}', skipping: ${s.name}`);
|
|
3264
3669
|
}
|
|
3265
3670
|
}
|
|
3671
|
+
const courseId = course.getCourseID();
|
|
3672
|
+
const hasElo = generatorStrategies.some((s) => s.implementingClass === "elo" /* ELO */);
|
|
3673
|
+
const hasSrs = generatorStrategies.some((s) => s.implementingClass === "srs" /* SRS */);
|
|
3674
|
+
if (!hasElo) {
|
|
3675
|
+
logger.debug("[PipelineAssembler] No ELO generator configured, adding default");
|
|
3676
|
+
generatorStrategies.push(createDefaultEloStrategy(courseId));
|
|
3677
|
+
}
|
|
3678
|
+
if (!hasSrs) {
|
|
3679
|
+
logger.debug("[PipelineAssembler] No SRS generator configured, adding default");
|
|
3680
|
+
generatorStrategies.push(createDefaultSrsStrategy(courseId));
|
|
3681
|
+
}
|
|
3266
3682
|
if (generatorStrategies.length === 0) {
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
} else {
|
|
3275
|
-
warnings.push("No generator strategy found");
|
|
3276
|
-
return {
|
|
3277
|
-
pipeline: null,
|
|
3278
|
-
generatorStrategies: [],
|
|
3279
|
-
filterStrategies: [],
|
|
3280
|
-
warnings
|
|
3281
|
-
};
|
|
3282
|
-
}
|
|
3683
|
+
warnings.push("No generator strategy found");
|
|
3684
|
+
return {
|
|
3685
|
+
pipeline: null,
|
|
3686
|
+
generatorStrategies: [],
|
|
3687
|
+
filterStrategies: [],
|
|
3688
|
+
warnings
|
|
3689
|
+
};
|
|
3283
3690
|
}
|
|
3284
3691
|
let generator;
|
|
3285
3692
|
if (generatorStrategies.length === 1) {
|
|
@@ -3357,6 +3764,7 @@ var init_3 = __esm({
|
|
|
3357
3764
|
"./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
|
|
3358
3765
|
"./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
|
|
3359
3766
|
"./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
|
|
3767
|
+
"./generators/prescribed.ts": () => Promise.resolve().then(() => (init_prescribed(), prescribed_exports)),
|
|
3360
3768
|
"./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
|
|
3361
3769
|
"./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports)),
|
|
3362
3770
|
"./index.ts": () => Promise.resolve().then(() => (init_navigators(), navigators_exports))
|
|
@@ -3405,8 +3813,10 @@ async function initializeNavigatorRegistry() {
|
|
|
3405
3813
|
Promise.resolve().then(() => (init_elo(), elo_exports)),
|
|
3406
3814
|
Promise.resolve().then(() => (init_srs(), srs_exports))
|
|
3407
3815
|
]);
|
|
3816
|
+
const prescribedModule = await Promise.resolve().then(() => (init_prescribed(), prescribed_exports));
|
|
3408
3817
|
registerNavigator("elo", eloModule.default);
|
|
3409
3818
|
registerNavigator("srs", srsModule.default);
|
|
3819
|
+
registerNavigator("prescribed", prescribedModule.default);
|
|
3410
3820
|
const [
|
|
3411
3821
|
hierarchyModule,
|
|
3412
3822
|
interferenceModule,
|
|
@@ -3461,6 +3871,7 @@ var init_navigators = __esm({
|
|
|
3461
3871
|
Navigators = /* @__PURE__ */ ((Navigators2) => {
|
|
3462
3872
|
Navigators2["ELO"] = "elo";
|
|
3463
3873
|
Navigators2["SRS"] = "srs";
|
|
3874
|
+
Navigators2["PRESCRIBED"] = "prescribed";
|
|
3464
3875
|
Navigators2["HIERARCHY"] = "hierarchyDefinition";
|
|
3465
3876
|
Navigators2["INTERFERENCE"] = "interferenceMitigator";
|
|
3466
3877
|
Navigators2["RELATIVE_PRIORITY"] = "relativePriority";
|
|
@@ -3475,6 +3886,7 @@ var init_navigators = __esm({
|
|
|
3475
3886
|
NavigatorRoles = {
|
|
3476
3887
|
["elo" /* ELO */]: "generator" /* GENERATOR */,
|
|
3477
3888
|
["srs" /* SRS */]: "generator" /* GENERATOR */,
|
|
3889
|
+
["prescribed" /* PRESCRIBED */]: "generator" /* GENERATOR */,
|
|
3478
3890
|
["hierarchyDefinition" /* HIERARCHY */]: "filter" /* FILTER */,
|
|
3479
3891
|
["interferenceMitigator" /* INTERFERENCE */]: "filter" /* FILTER */,
|
|
3480
3892
|
["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */,
|
|
@@ -3639,6 +4051,12 @@ var init_navigators = __esm({
|
|
|
3639
4051
|
async getWeightedCards(_limit) {
|
|
3640
4052
|
throw new Error(`${this.constructor.name} must implement getWeightedCards(). `);
|
|
3641
4053
|
}
|
|
4054
|
+
/**
|
|
4055
|
+
* Set ephemeral hints for the next pipeline run.
|
|
4056
|
+
* No-op for non-Pipeline navigators. Pipeline overrides this.
|
|
4057
|
+
*/
|
|
4058
|
+
setEphemeralHints(_hints) {
|
|
4059
|
+
}
|
|
3642
4060
|
};
|
|
3643
4061
|
}
|
|
3644
4062
|
});
|
|
@@ -3788,15 +4206,42 @@ var init_courseDB = __esm({
|
|
|
3788
4206
|
// private log(msg: string): void {
|
|
3789
4207
|
// log(`CourseLog: ${this.id}\n ${msg}`);
|
|
3790
4208
|
// }
|
|
4209
|
+
/**
|
|
4210
|
+
* Primary database handle used for all **read** operations (queries, gets).
|
|
4211
|
+
*
|
|
4212
|
+
* When local sync is active, this points to the local PouchDB replica for
|
|
4213
|
+
* fast, network-free reads. Otherwise it points to the remote CouchDB.
|
|
4214
|
+
*/
|
|
3791
4215
|
db;
|
|
4216
|
+
/**
|
|
4217
|
+
* Remote database handle used for all **write** operations.
|
|
4218
|
+
*
|
|
4219
|
+
* Always points to the remote CouchDB so that writes (ELO updates, tag
|
|
4220
|
+
* mutations, admin operations) aggregate on the server. The local replica
|
|
4221
|
+
* is a read-only snapshot that refreshes on the next page load.
|
|
4222
|
+
*
|
|
4223
|
+
* When local sync is NOT active, this is the same instance as `this.db`.
|
|
4224
|
+
*/
|
|
4225
|
+
remoteDB;
|
|
3792
4226
|
id;
|
|
3793
4227
|
_getCurrentUser;
|
|
3794
4228
|
updateQueue;
|
|
3795
|
-
|
|
4229
|
+
/**
|
|
4230
|
+
* @param id - Course ID
|
|
4231
|
+
* @param userLookup - Async function returning the current user DB
|
|
4232
|
+
* @param localDB - Optional local PouchDB replica for reads. When provided,
|
|
4233
|
+
* `this.db` uses the local replica and `this.remoteDB` stays remote.
|
|
4234
|
+
* The UpdateQueue reads from remote and writes to remote (local `_rev`
|
|
4235
|
+
* values may be stale, so read-modify-write cycles must go through
|
|
4236
|
+
* the remote DB to avoid conflicts).
|
|
4237
|
+
*/
|
|
4238
|
+
constructor(id, userLookup, localDB) {
|
|
3796
4239
|
this.id = id;
|
|
3797
|
-
|
|
4240
|
+
const remote = getCourseDB2(this.id);
|
|
4241
|
+
this.remoteDB = remote;
|
|
4242
|
+
this.db = localDB ?? remote;
|
|
3798
4243
|
this._getCurrentUser = userLookup;
|
|
3799
|
-
this.updateQueue = new UpdateQueue(this.
|
|
4244
|
+
this.updateQueue = new UpdateQueue(this.remoteDB, this.remoteDB);
|
|
3800
4245
|
}
|
|
3801
4246
|
getCourseID() {
|
|
3802
4247
|
return this.id;
|
|
@@ -3884,7 +4329,7 @@ var init_courseDB = __esm({
|
|
|
3884
4329
|
};
|
|
3885
4330
|
}
|
|
3886
4331
|
async removeCard(id) {
|
|
3887
|
-
const doc = await this.
|
|
4332
|
+
const doc = await this.remoteDB.get(id);
|
|
3888
4333
|
if (!doc.docType || !(doc.docType === "CARD" /* CARD */)) {
|
|
3889
4334
|
throw new Error(`failed to remove ${id} from course ${this.id}. id does not point to a card`);
|
|
3890
4335
|
}
|
|
@@ -3905,7 +4350,7 @@ var init_courseDB = __esm({
|
|
|
3905
4350
|
} catch (error) {
|
|
3906
4351
|
logger.error(`Error removing card ${id} from tags: ${error}`);
|
|
3907
4352
|
}
|
|
3908
|
-
return this.
|
|
4353
|
+
return this.remoteDB.remove(doc);
|
|
3909
4354
|
}
|
|
3910
4355
|
async getCardDisplayableDataIDs(id) {
|
|
3911
4356
|
logger.debug(id.join(", "));
|
|
@@ -4007,8 +4452,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
4007
4452
|
if (cardIds.length === 0) {
|
|
4008
4453
|
return /* @__PURE__ */ new Map();
|
|
4009
4454
|
}
|
|
4010
|
-
const
|
|
4011
|
-
const result = await db.query("getTags", {
|
|
4455
|
+
const result = await this.db.query("getTags", {
|
|
4012
4456
|
keys: cardIds,
|
|
4013
4457
|
include_docs: false
|
|
4014
4458
|
});
|
|
@@ -4025,6 +4469,14 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
4025
4469
|
}
|
|
4026
4470
|
return tagsByCard;
|
|
4027
4471
|
}
|
|
4472
|
+
async getAllCardIds() {
|
|
4473
|
+
const result = await this.db.allDocs({
|
|
4474
|
+
startkey: "CARD-",
|
|
4475
|
+
endkey: "CARD-\uFFF0",
|
|
4476
|
+
include_docs: false
|
|
4477
|
+
});
|
|
4478
|
+
return result.rows.map((row) => row.id);
|
|
4479
|
+
}
|
|
4028
4480
|
async addTagToCard(cardId, tagId, updateELO) {
|
|
4029
4481
|
return await addTagToCard(
|
|
4030
4482
|
this.id,
|
|
@@ -4091,10 +4543,13 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
4091
4543
|
}
|
|
4092
4544
|
}
|
|
4093
4545
|
async getCourseDoc(id, options) {
|
|
4094
|
-
return await
|
|
4546
|
+
return await this.db.get(id, options);
|
|
4095
4547
|
}
|
|
4096
4548
|
async getCourseDocs(ids, options = {}) {
|
|
4097
|
-
return await
|
|
4549
|
+
return await this.db.allDocs({
|
|
4550
|
+
...options,
|
|
4551
|
+
keys: ids
|
|
4552
|
+
});
|
|
4098
4553
|
}
|
|
4099
4554
|
////////////////////////////////////
|
|
4100
4555
|
// NavigationStrategyManager implementation
|
|
@@ -4128,7 +4583,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
4128
4583
|
}
|
|
4129
4584
|
async addNavigationStrategy(data) {
|
|
4130
4585
|
logger.debug(`[courseDB] Adding navigation strategy: ${data._id}`);
|
|
4131
|
-
return this.
|
|
4586
|
+
return this.remoteDB.put(data).then(() => {
|
|
4132
4587
|
});
|
|
4133
4588
|
}
|
|
4134
4589
|
updateNavigationStrategy(id, data) {
|
|
@@ -4685,6 +5140,234 @@ var init_adminDB2 = __esm({
|
|
|
4685
5140
|
}
|
|
4686
5141
|
});
|
|
4687
5142
|
|
|
5143
|
+
// src/impl/couch/CourseSyncService.ts
|
|
5144
|
+
var CourseSyncService;
|
|
5145
|
+
var init_CourseSyncService = __esm({
|
|
5146
|
+
"src/impl/couch/CourseSyncService.ts"() {
|
|
5147
|
+
"use strict";
|
|
5148
|
+
init_pouchdb_setup();
|
|
5149
|
+
init_couch();
|
|
5150
|
+
init_logger();
|
|
5151
|
+
CourseSyncService = class _CourseSyncService {
|
|
5152
|
+
static instance = null;
|
|
5153
|
+
entries = /* @__PURE__ */ new Map();
|
|
5154
|
+
constructor() {
|
|
5155
|
+
}
|
|
5156
|
+
static getInstance() {
|
|
5157
|
+
if (!_CourseSyncService.instance) {
|
|
5158
|
+
_CourseSyncService.instance = new _CourseSyncService();
|
|
5159
|
+
}
|
|
5160
|
+
return _CourseSyncService.instance;
|
|
5161
|
+
}
|
|
5162
|
+
/**
|
|
5163
|
+
* Reset the singleton (for testing).
|
|
5164
|
+
*/
|
|
5165
|
+
static resetInstance() {
|
|
5166
|
+
if (_CourseSyncService.instance) {
|
|
5167
|
+
for (const [, entry] of _CourseSyncService.instance.entries) {
|
|
5168
|
+
if (entry.localDB) {
|
|
5169
|
+
entry.localDB.close().catch(() => {
|
|
5170
|
+
});
|
|
5171
|
+
}
|
|
5172
|
+
}
|
|
5173
|
+
_CourseSyncService.instance.entries.clear();
|
|
5174
|
+
}
|
|
5175
|
+
_CourseSyncService.instance = null;
|
|
5176
|
+
}
|
|
5177
|
+
// --------------------------------------------------------------------------
|
|
5178
|
+
// Public API
|
|
5179
|
+
// --------------------------------------------------------------------------
|
|
5180
|
+
/**
|
|
5181
|
+
* Ensure a course's local replica is synced.
|
|
5182
|
+
*
|
|
5183
|
+
* On first call for a course:
|
|
5184
|
+
* 1. Fetches CourseConfig from remote to check localSync.enabled
|
|
5185
|
+
* 2. If enabled, performs one-shot replication remote → local
|
|
5186
|
+
* 3. Pre-warms PouchDB view indices (elo, getTags)
|
|
5187
|
+
*
|
|
5188
|
+
* On subsequent calls: returns immediately if already synced, or awaits
|
|
5189
|
+
* the in-flight sync if one is in progress.
|
|
5190
|
+
*
|
|
5191
|
+
* Safe to call multiple times — concurrent calls coalesce to one sync.
|
|
5192
|
+
*
|
|
5193
|
+
* @param courseId - The course to sync
|
|
5194
|
+
* @param forceEnabled - Skip the CourseConfig check and sync regardless.
|
|
5195
|
+
* Useful when the caller already knows local sync is desired (e.g.,
|
|
5196
|
+
* LettersPractice hardcodes this).
|
|
5197
|
+
*/
|
|
5198
|
+
async ensureSynced(courseId, forceEnabled) {
|
|
5199
|
+
const existing = this.entries.get(courseId);
|
|
5200
|
+
if (existing?.status.state === "ready") {
|
|
5201
|
+
return;
|
|
5202
|
+
}
|
|
5203
|
+
if (existing?.status.state === "disabled") {
|
|
5204
|
+
return;
|
|
5205
|
+
}
|
|
5206
|
+
if (existing?.readyPromise) {
|
|
5207
|
+
return existing.readyPromise;
|
|
5208
|
+
}
|
|
5209
|
+
const entry = {
|
|
5210
|
+
localDB: null,
|
|
5211
|
+
status: { state: "not-started" },
|
|
5212
|
+
readyPromise: null
|
|
5213
|
+
};
|
|
5214
|
+
this.entries.set(courseId, entry);
|
|
5215
|
+
entry.readyPromise = this.performSync(courseId, entry, forceEnabled);
|
|
5216
|
+
return entry.readyPromise;
|
|
5217
|
+
}
|
|
5218
|
+
/**
|
|
5219
|
+
* Get the local PouchDB for a course, or null if not available.
|
|
5220
|
+
*
|
|
5221
|
+
* Returns null when:
|
|
5222
|
+
* - Local sync is not enabled for this course
|
|
5223
|
+
* - Sync has not been triggered yet
|
|
5224
|
+
* - Sync is still in progress
|
|
5225
|
+
* - Sync failed
|
|
5226
|
+
*/
|
|
5227
|
+
getLocalDB(courseId) {
|
|
5228
|
+
const entry = this.entries.get(courseId);
|
|
5229
|
+
if (entry?.status.state === "ready" && entry.localDB) {
|
|
5230
|
+
return entry.localDB;
|
|
5231
|
+
}
|
|
5232
|
+
return null;
|
|
5233
|
+
}
|
|
5234
|
+
/**
|
|
5235
|
+
* Check whether a course has a ready local replica.
|
|
5236
|
+
*/
|
|
5237
|
+
isReady(courseId) {
|
|
5238
|
+
return this.entries.get(courseId)?.status.state === "ready";
|
|
5239
|
+
}
|
|
5240
|
+
/**
|
|
5241
|
+
* Get detailed sync status for a course.
|
|
5242
|
+
*/
|
|
5243
|
+
getStatus(courseId) {
|
|
5244
|
+
return this.entries.get(courseId)?.status ?? { state: "not-started" };
|
|
5245
|
+
}
|
|
5246
|
+
// --------------------------------------------------------------------------
|
|
5247
|
+
// Internal
|
|
5248
|
+
// --------------------------------------------------------------------------
|
|
5249
|
+
async performSync(courseId, entry, forceEnabled) {
|
|
5250
|
+
try {
|
|
5251
|
+
if (!forceEnabled) {
|
|
5252
|
+
entry.status = { state: "checking-config" };
|
|
5253
|
+
const enabled = await this.checkLocalSyncEnabled(courseId);
|
|
5254
|
+
if (!enabled) {
|
|
5255
|
+
entry.status = { state: "disabled" };
|
|
5256
|
+
entry.readyPromise = null;
|
|
5257
|
+
logger.debug(
|
|
5258
|
+
`[CourseSyncService] Local sync disabled for course ${courseId}`
|
|
5259
|
+
);
|
|
5260
|
+
return;
|
|
5261
|
+
}
|
|
5262
|
+
}
|
|
5263
|
+
entry.status = { state: "syncing" };
|
|
5264
|
+
const localDBName = this.localDBName(courseId);
|
|
5265
|
+
const localDB = new pouchdb_setup_default(localDBName);
|
|
5266
|
+
entry.localDB = localDB;
|
|
5267
|
+
const remoteDB = this.getRemoteDB(courseId);
|
|
5268
|
+
const syncStart = Date.now();
|
|
5269
|
+
logger.info(
|
|
5270
|
+
`[CourseSyncService] Starting one-shot replication for course ${courseId}`
|
|
5271
|
+
);
|
|
5272
|
+
const result = await this.replicate(remoteDB, localDB);
|
|
5273
|
+
const syncTimeMs = Date.now() - syncStart;
|
|
5274
|
+
logger.info(
|
|
5275
|
+
`[CourseSyncService] Replication complete for course ${courseId}: ${result.docs_written} docs in ${syncTimeMs}ms`
|
|
5276
|
+
);
|
|
5277
|
+
entry.status = { state: "warming-views" };
|
|
5278
|
+
const warmStart = Date.now();
|
|
5279
|
+
await this.warmViewIndices(localDB);
|
|
5280
|
+
const viewWarmTimeMs = Date.now() - warmStart;
|
|
5281
|
+
logger.info(
|
|
5282
|
+
`[CourseSyncService] View indices warmed for course ${courseId} in ${viewWarmTimeMs}ms`
|
|
5283
|
+
);
|
|
5284
|
+
entry.status = {
|
|
5285
|
+
state: "ready",
|
|
5286
|
+
docsReplicated: result.docs_written,
|
|
5287
|
+
syncTimeMs,
|
|
5288
|
+
viewWarmTimeMs
|
|
5289
|
+
};
|
|
5290
|
+
} catch (e) {
|
|
5291
|
+
const errorMsg = e instanceof Error ? e.message : String(e);
|
|
5292
|
+
logger.error(
|
|
5293
|
+
`[CourseSyncService] Sync failed for course ${courseId}: ${errorMsg}`
|
|
5294
|
+
);
|
|
5295
|
+
entry.status = { state: "error", error: errorMsg };
|
|
5296
|
+
entry.readyPromise = null;
|
|
5297
|
+
if (entry.localDB) {
|
|
5298
|
+
try {
|
|
5299
|
+
await entry.localDB.destroy();
|
|
5300
|
+
} catch {
|
|
5301
|
+
}
|
|
5302
|
+
entry.localDB = null;
|
|
5303
|
+
}
|
|
5304
|
+
}
|
|
5305
|
+
}
|
|
5306
|
+
/**
|
|
5307
|
+
* Check CourseConfig.localSync.enabled on the remote DB.
|
|
5308
|
+
*/
|
|
5309
|
+
async checkLocalSyncEnabled(courseId) {
|
|
5310
|
+
try {
|
|
5311
|
+
const remoteDB = this.getRemoteDB(courseId);
|
|
5312
|
+
const config = await remoteDB.get("CourseConfig");
|
|
5313
|
+
return config.localSync?.enabled === true;
|
|
5314
|
+
} catch (e) {
|
|
5315
|
+
logger.warn(
|
|
5316
|
+
`[CourseSyncService] Could not read CourseConfig for ${courseId}, assuming local sync disabled: ${e}`
|
|
5317
|
+
);
|
|
5318
|
+
return false;
|
|
5319
|
+
}
|
|
5320
|
+
}
|
|
5321
|
+
/**
|
|
5322
|
+
* One-shot replication from remote to local.
|
|
5323
|
+
*/
|
|
5324
|
+
replicate(source, target) {
|
|
5325
|
+
return new Promise((resolve, reject) => {
|
|
5326
|
+
void pouchdb_setup_default.replicate(source, target, {
|
|
5327
|
+
// One-shot, not live. Local is a read-only snapshot.
|
|
5328
|
+
}).on("complete", (info) => {
|
|
5329
|
+
resolve(info);
|
|
5330
|
+
}).on("error", (err) => {
|
|
5331
|
+
reject(err);
|
|
5332
|
+
});
|
|
5333
|
+
});
|
|
5334
|
+
}
|
|
5335
|
+
/**
|
|
5336
|
+
* Pre-warm PouchDB view indices by running a minimal query against each
|
|
5337
|
+
* design doc. This forces PouchDB to build the MapReduce index now
|
|
5338
|
+
* (during a loading phase) rather than on first pipeline query.
|
|
5339
|
+
*/
|
|
5340
|
+
async warmViewIndices(localDB) {
|
|
5341
|
+
const viewsToWarm = ["elo", "getTags"];
|
|
5342
|
+
for (const viewName of viewsToWarm) {
|
|
5343
|
+
try {
|
|
5344
|
+
await localDB.query(viewName, { limit: 1 });
|
|
5345
|
+
logger.debug(
|
|
5346
|
+
`[CourseSyncService] Warmed view index: ${viewName}`
|
|
5347
|
+
);
|
|
5348
|
+
} catch (e) {
|
|
5349
|
+
logger.debug(
|
|
5350
|
+
`[CourseSyncService] Could not warm view ${viewName}: ${e}`
|
|
5351
|
+
);
|
|
5352
|
+
}
|
|
5353
|
+
}
|
|
5354
|
+
}
|
|
5355
|
+
/**
|
|
5356
|
+
* Get a remote PouchDB handle for a course.
|
|
5357
|
+
*/
|
|
5358
|
+
getRemoteDB(courseId) {
|
|
5359
|
+
return getCourseDB2(courseId);
|
|
5360
|
+
}
|
|
5361
|
+
/**
|
|
5362
|
+
* Local DB naming convention.
|
|
5363
|
+
*/
|
|
5364
|
+
localDBName(courseId) {
|
|
5365
|
+
return `coursedb-local-${courseId}`;
|
|
5366
|
+
}
|
|
5367
|
+
};
|
|
5368
|
+
}
|
|
5369
|
+
});
|
|
5370
|
+
|
|
4688
5371
|
// src/impl/couch/auth.ts
|
|
4689
5372
|
import fetch2 from "cross-fetch";
|
|
4690
5373
|
async function getCurrentSession() {
|
|
@@ -4985,15 +5668,6 @@ function getCourseDB2(courseID) {
|
|
|
4985
5668
|
createPouchDBConfig()
|
|
4986
5669
|
);
|
|
4987
5670
|
}
|
|
4988
|
-
function getCourseDocs(courseID, docIDs, options = {}) {
|
|
4989
|
-
return getCourseDB2(courseID).allDocs({
|
|
4990
|
-
...options,
|
|
4991
|
-
keys: docIDs
|
|
4992
|
-
});
|
|
4993
|
-
}
|
|
4994
|
-
function getCourseDoc(courseID, docID, options = {}) {
|
|
4995
|
-
return getCourseDB2(courseID).get(docID, options);
|
|
4996
|
-
}
|
|
4997
5671
|
function filterAllDocsByPrefix2(db, prefix, opts) {
|
|
4998
5672
|
const options = {
|
|
4999
5673
|
startkey: prefix,
|
|
@@ -5024,6 +5698,7 @@ var init_couch = __esm({
|
|
|
5024
5698
|
init_classroomDB2();
|
|
5025
5699
|
init_courseAPI();
|
|
5026
5700
|
init_courseDB();
|
|
5701
|
+
init_CourseSyncService();
|
|
5027
5702
|
init_CouchDBSyncStrategy();
|
|
5028
5703
|
isBrowser = typeof window !== "undefined";
|
|
5029
5704
|
if (isBrowser) {
|
|
@@ -5324,6 +5999,9 @@ Currently logged-in as ${this._username}.`
|
|
|
5324
5999
|
const id = row.id;
|
|
5325
6000
|
return id.startsWith(DocTypePrefixes["CARDRECORD" /* CARDRECORD */]) || // Card interaction history
|
|
5326
6001
|
id.startsWith(DocTypePrefixes["SCHEDULED_CARD" /* SCHEDULED_CARD */]) || // Scheduled reviews
|
|
6002
|
+
id.startsWith(DocTypePrefixes["STRATEGY_STATE" /* STRATEGY_STATE */]) || // Strategy state (user prefs, progression)
|
|
6003
|
+
id.startsWith(DocTypePrefixes["USER_OUTCOME" /* USER_OUTCOME */]) || // Evolutionary orchestration outcomes
|
|
6004
|
+
id.startsWith(DocTypePrefixes["STRATEGY_LEARNING_STATE" /* STRATEGY_LEARNING_STATE */]) || // Strategy learning state
|
|
5327
6005
|
id === _BaseUser.DOC_IDS.COURSE_REGISTRATIONS || // Course registrations
|
|
5328
6006
|
id === _BaseUser.DOC_IDS.CLASSROOM_REGISTRATIONS || // Classroom registrations
|
|
5329
6007
|
id === _BaseUser.DOC_IDS.CONFIG;
|
|
@@ -6120,6 +6798,7 @@ var init_PouchDataLayerProvider = __esm({
|
|
|
6120
6798
|
init_adminDB2();
|
|
6121
6799
|
init_classroomDB2();
|
|
6122
6800
|
init_courseDB();
|
|
6801
|
+
init_CourseSyncService();
|
|
6123
6802
|
init_common();
|
|
6124
6803
|
init_CouchDBSyncStrategy();
|
|
6125
6804
|
CouchDataLayerProvider = class {
|
|
@@ -6159,7 +6838,22 @@ var init_PouchDataLayerProvider = __esm({
|
|
|
6159
6838
|
return this.userDB;
|
|
6160
6839
|
}
|
|
6161
6840
|
getCourseDB(courseId) {
|
|
6162
|
-
|
|
6841
|
+
const localDB = CourseSyncService.getInstance().getLocalDB(courseId);
|
|
6842
|
+
return new CourseDB(courseId, async () => this.getUserDB(), localDB ?? void 0);
|
|
6843
|
+
}
|
|
6844
|
+
/**
|
|
6845
|
+
* Trigger local sync for a course. Call during app initialization or
|
|
6846
|
+
* pre-session loading for courses that opt in via CourseConfig.localSync.
|
|
6847
|
+
*
|
|
6848
|
+
* Safe to call multiple times — concurrent calls coalesce. Returns when
|
|
6849
|
+
* sync is complete (or immediately if already synced / disabled).
|
|
6850
|
+
*
|
|
6851
|
+
* @param courseId - The course to sync locally
|
|
6852
|
+
* @param forceEnabled - Skip CourseConfig check and sync regardless.
|
|
6853
|
+
* Use when the caller already knows local sync is desired.
|
|
6854
|
+
*/
|
|
6855
|
+
async ensureCourseSynced(courseId, forceEnabled) {
|
|
6856
|
+
return CourseSyncService.getInstance().ensureSynced(courseId, forceEnabled);
|
|
6163
6857
|
}
|
|
6164
6858
|
getCoursesDB() {
|
|
6165
6859
|
return new CoursesDB(this._courseIDs);
|
|
@@ -6787,6 +7481,10 @@ var init_courseDB2 = __esm({
|
|
|
6787
7481
|
}
|
|
6788
7482
|
return tagsByCard;
|
|
6789
7483
|
}
|
|
7484
|
+
async getAllCardIds() {
|
|
7485
|
+
const tagsIndex = await this.unpacker.getTagsIndex();
|
|
7486
|
+
return Object.keys(tagsIndex.byCard);
|
|
7487
|
+
}
|
|
6790
7488
|
async addTagToCard(_cardId, _tagId) {
|
|
6791
7489
|
throw new Error("Cannot modify tags in static mode");
|
|
6792
7490
|
}
|
|
@@ -8337,8 +9035,10 @@ function newQuestionInterval(user, cardHistory) {
|
|
|
8337
9035
|
const lastInterval = lastSuccessfulInterval(records);
|
|
8338
9036
|
if (lastInterval > cardHistory.bestInterval) {
|
|
8339
9037
|
cardHistory.bestInterval = lastInterval;
|
|
8340
|
-
|
|
9038
|
+
user.update(cardHistory._id, {
|
|
8341
9039
|
bestInterval: lastInterval
|
|
9040
|
+
}).catch((e) => {
|
|
9041
|
+
logger.warn(`[SpacedRepetition] Failed to update bestInterval for ${cardHistory._id}: ${e?.message ?? e}`);
|
|
8342
9042
|
});
|
|
8343
9043
|
}
|
|
8344
9044
|
if (currentAttempt.isCorrect) {
|
|
@@ -9004,6 +9704,46 @@ var ItemQueue = class {
|
|
|
9004
9704
|
return null;
|
|
9005
9705
|
}
|
|
9006
9706
|
}
|
|
9707
|
+
/**
|
|
9708
|
+
* Atomically replace all queue contents with new items.
|
|
9709
|
+
*
|
|
9710
|
+
* Used by mid-session replanning to swap the queue without a window where
|
|
9711
|
+
* it's empty (avoiding dead-air if nextCard() is called concurrently).
|
|
9712
|
+
*
|
|
9713
|
+
* Preserves dequeueCount (cumulative across the session).
|
|
9714
|
+
* Resets seenCardIds to match the new contents — cards from the old queue
|
|
9715
|
+
* that don't appear in the new set can be re-added in future replans.
|
|
9716
|
+
*/
|
|
9717
|
+
replaceAll(items, cardIdExtractor) {
|
|
9718
|
+
this.q = [];
|
|
9719
|
+
this.seenCardIds = [];
|
|
9720
|
+
for (const item of items) {
|
|
9721
|
+
const cardId = cardIdExtractor(item);
|
|
9722
|
+
if (!this.seenCardIds.includes(cardId)) {
|
|
9723
|
+
this.seenCardIds.push(cardId);
|
|
9724
|
+
this.q.push(item);
|
|
9725
|
+
}
|
|
9726
|
+
}
|
|
9727
|
+
}
|
|
9728
|
+
/**
|
|
9729
|
+
* Merge new items into the front of the queue, skipping duplicates.
|
|
9730
|
+
* Used by additive replans to inject high-quality candidates without
|
|
9731
|
+
* discarding the existing queue contents.
|
|
9732
|
+
*/
|
|
9733
|
+
mergeToFront(items, cardIdExtractor) {
|
|
9734
|
+
let added = 0;
|
|
9735
|
+
const toInsert = [];
|
|
9736
|
+
for (const item of items) {
|
|
9737
|
+
const cardId = cardIdExtractor(item);
|
|
9738
|
+
if (!this.seenCardIds.includes(cardId)) {
|
|
9739
|
+
this.seenCardIds.push(cardId);
|
|
9740
|
+
toInsert.push(item);
|
|
9741
|
+
added++;
|
|
9742
|
+
}
|
|
9743
|
+
}
|
|
9744
|
+
this.q.unshift(...toInsert);
|
|
9745
|
+
return added;
|
|
9746
|
+
}
|
|
9007
9747
|
get toString() {
|
|
9008
9748
|
return `${typeof this.q[0]}:
|
|
9009
9749
|
` + this.q.map((i) => ` ${i.courseID}+${i.cardID}: ${i.status}`).join("\n");
|
|
@@ -11087,7 +11827,7 @@ mountSessionDebugger();
|
|
|
11087
11827
|
|
|
11088
11828
|
// src/study/SessionController.ts
|
|
11089
11829
|
init_logger();
|
|
11090
|
-
var SessionController = class extends Loggable {
|
|
11830
|
+
var SessionController = class _SessionController extends Loggable {
|
|
11091
11831
|
_className = "SessionController";
|
|
11092
11832
|
services;
|
|
11093
11833
|
srsService;
|
|
@@ -11108,6 +11848,18 @@ var SessionController = class extends Loggable {
|
|
|
11108
11848
|
newQ = new ItemQueue();
|
|
11109
11849
|
failedQ = new ItemQueue();
|
|
11110
11850
|
// END Session card stores
|
|
11851
|
+
/**
|
|
11852
|
+
* Promise tracking a currently in-progress replan, or null if idle.
|
|
11853
|
+
* Used by nextCard() to await completion before drawing from queues.
|
|
11854
|
+
*/
|
|
11855
|
+
_replanPromise = null;
|
|
11856
|
+
/**
|
|
11857
|
+
* Number of well-indicated new cards remaining before the queue
|
|
11858
|
+
* degrades to poorly-indicated content. Decremented on each newQ
|
|
11859
|
+
* draw; when it hits 0, a replan is triggered automatically
|
|
11860
|
+
* (user state has changed from completing good cards).
|
|
11861
|
+
*/
|
|
11862
|
+
_wellIndicatedRemaining = 0;
|
|
11111
11863
|
startTime;
|
|
11112
11864
|
endTime;
|
|
11113
11865
|
_secondsRemaining;
|
|
@@ -11201,13 +11953,83 @@ var SessionController = class extends Loggable {
|
|
|
11201
11953
|
"[SessionController] All content sources must implement getWeightedCards()."
|
|
11202
11954
|
);
|
|
11203
11955
|
}
|
|
11204
|
-
await this.getWeightedContent();
|
|
11956
|
+
const wellIndicated = await this.getWeightedContent();
|
|
11957
|
+
this._wellIndicatedRemaining = wellIndicated;
|
|
11958
|
+
if (wellIndicated >= 0 && wellIndicated < _SessionController.MIN_WELL_INDICATED) {
|
|
11959
|
+
this.log(
|
|
11960
|
+
`[Init] Only ${wellIndicated}/${_SessionController.MIN_WELL_INDICATED} well-indicated cards in initial load`
|
|
11961
|
+
);
|
|
11962
|
+
}
|
|
11205
11963
|
await this.hydrationService.ensureHydratedCards();
|
|
11206
11964
|
startSessionTracking(this.reviewQ.length, this.newQ.length, this.failedQ.length);
|
|
11207
11965
|
this._intervalHandle = setInterval(() => {
|
|
11208
11966
|
this.tick();
|
|
11209
11967
|
}, 1e3);
|
|
11210
11968
|
}
|
|
11969
|
+
/**
|
|
11970
|
+
* Request a mid-session replan. Re-runs the pipeline with current user state
|
|
11971
|
+
* and atomically replaces the newQ contents. Safe to call at any time during
|
|
11972
|
+
* a session — if called while a replan is already in progress, returns the
|
|
11973
|
+
* existing replan promise (no duplicate work).
|
|
11974
|
+
*
|
|
11975
|
+
* Does NOT affect reviewQ or failedQ.
|
|
11976
|
+
*
|
|
11977
|
+
* If nextCard() is called while a replan is in flight, it will automatically
|
|
11978
|
+
* await the replan before drawing from queues, ensuring the user always sees
|
|
11979
|
+
* cards scored against their latest state.
|
|
11980
|
+
*
|
|
11981
|
+
* Typical trigger: application-level code (e.g. after a GPC intro completion)
|
|
11982
|
+
* calls this to ensure newly-unlocked content appears in the session.
|
|
11983
|
+
*/
|
|
11984
|
+
async requestReplan(hints) {
|
|
11985
|
+
if (this._replanPromise) {
|
|
11986
|
+
this.log("Replan already in progress, awaiting existing replan");
|
|
11987
|
+
return this._replanPromise;
|
|
11988
|
+
}
|
|
11989
|
+
if (hints) {
|
|
11990
|
+
for (const source of this.sources) {
|
|
11991
|
+
this.log(`[Hints] source type=${source.constructor.name}, hasMethod=${typeof source.setEphemeralHints}`);
|
|
11992
|
+
source.setEphemeralHints?.(hints);
|
|
11993
|
+
}
|
|
11994
|
+
}
|
|
11995
|
+
this.log(`Mid-session replan requested${hints ? ` (hints: ${JSON.stringify(hints)})` : ""}`);
|
|
11996
|
+
this._replanPromise = this._executeReplan();
|
|
11997
|
+
try {
|
|
11998
|
+
await this._replanPromise;
|
|
11999
|
+
} finally {
|
|
12000
|
+
this._replanPromise = null;
|
|
12001
|
+
}
|
|
12002
|
+
}
|
|
12003
|
+
/** Minimum well-indicated cards before an additive retry is attempted */
|
|
12004
|
+
static MIN_WELL_INDICATED = 5;
|
|
12005
|
+
/**
|
|
12006
|
+
* Score threshold for considering a card "well-indicated."
|
|
12007
|
+
* Cards below this score are treated as fallback filler — present only
|
|
12008
|
+
* because no strategy hard-removed them, but likely penalized by one
|
|
12009
|
+
* or more filters. Strategy-agnostic: the SessionController doesn't
|
|
12010
|
+
* know or care which strategy assigned the score.
|
|
12011
|
+
*/
|
|
12012
|
+
static WELL_INDICATED_SCORE = 0.1;
|
|
12013
|
+
/**
|
|
12014
|
+
* Internal replan execution. Runs the pipeline, builds a new newQ,
|
|
12015
|
+
* atomically swaps it in, and triggers hydration for the new contents.
|
|
12016
|
+
*
|
|
12017
|
+
* If the initial replan produces fewer than MIN_WELL_INDICATED cards that
|
|
12018
|
+
* pass all hierarchy filters, one additive retry is attempted — merging
|
|
12019
|
+
* any new high-quality candidates into the front of the queue.
|
|
12020
|
+
*/
|
|
12021
|
+
async _executeReplan() {
|
|
12022
|
+
const wellIndicated = await this.getWeightedContent({ replan: true });
|
|
12023
|
+
this._wellIndicatedRemaining = wellIndicated;
|
|
12024
|
+
if (wellIndicated >= 0 && wellIndicated < _SessionController.MIN_WELL_INDICATED) {
|
|
12025
|
+
this.log(
|
|
12026
|
+
`[Replan] Only ${wellIndicated}/${_SessionController.MIN_WELL_INDICATED} well-indicated cards after replan`
|
|
12027
|
+
);
|
|
12028
|
+
}
|
|
12029
|
+
await this.hydrationService.ensureHydratedCards();
|
|
12030
|
+
this.log(`Replan complete: newQ now has ${this.newQ.length} cards`);
|
|
12031
|
+
snapshotQueues(this.reviewQ.length, this.newQ.length, this.failedQ.length);
|
|
12032
|
+
}
|
|
11211
12033
|
addTime(seconds) {
|
|
11212
12034
|
this.endTime = new Date(this.endTime.valueOf() + 1e3 * seconds);
|
|
11213
12035
|
}
|
|
@@ -11263,6 +12085,9 @@ var SessionController = class extends Loggable {
|
|
|
11263
12085
|
hydratedCache: {
|
|
11264
12086
|
count: this.hydrationService.hydratedCount,
|
|
11265
12087
|
cardIds: this.hydrationService.getHydratedCardIds()
|
|
12088
|
+
},
|
|
12089
|
+
replan: {
|
|
12090
|
+
inProgress: this._replanPromise !== null
|
|
11266
12091
|
}
|
|
11267
12092
|
};
|
|
11268
12093
|
}
|
|
@@ -11275,7 +12100,20 @@ var SessionController = class extends Loggable {
|
|
|
11275
12100
|
* 3. Uses SourceMixer to balance content across sources
|
|
11276
12101
|
* 4. Populates review and new card queues with mixed results
|
|
11277
12102
|
*/
|
|
11278
|
-
|
|
12103
|
+
/**
|
|
12104
|
+
* Fetch weighted content from all sources and populate session queues.
|
|
12105
|
+
*
|
|
12106
|
+
* @param options.replan - If true, this is a mid-session replan rather than
|
|
12107
|
+
* initial session setup. Skips review queue population (avoiding duplicates),
|
|
12108
|
+
* atomically replaces newQ contents, and treats empty results as non-fatal.
|
|
12109
|
+
* @param options.additive - If true (replan only), merge new high-quality
|
|
12110
|
+
* candidates into the front of the existing newQ instead of replacing it.
|
|
12111
|
+
* @returns Number of "well-indicated" cards (passed all hierarchy filters)
|
|
12112
|
+
* in the new content. Returns -1 if no content was loaded.
|
|
12113
|
+
*/
|
|
12114
|
+
async getWeightedContent(options) {
|
|
12115
|
+
const replan = options?.replan ?? false;
|
|
12116
|
+
const additive = options?.additive ?? false;
|
|
11279
12117
|
const limit = 20;
|
|
11280
12118
|
const batches = [];
|
|
11281
12119
|
for (let i = 0; i < this.sources.length; i++) {
|
|
@@ -11294,6 +12132,10 @@ var SessionController = class extends Loggable {
|
|
|
11294
12132
|
}
|
|
11295
12133
|
}
|
|
11296
12134
|
if (batches.length === 0) {
|
|
12135
|
+
if (replan) {
|
|
12136
|
+
this.log("Replan: no content from any source, keeping existing newQ");
|
|
12137
|
+
return -1;
|
|
12138
|
+
}
|
|
11297
12139
|
throw new Error(
|
|
11298
12140
|
`Cannot start session: failed to load content from all ${this.sources.length} source(s). Check logs for details.`
|
|
11299
12141
|
);
|
|
@@ -11305,10 +12147,12 @@ var SessionController = class extends Loggable {
|
|
|
11305
12147
|
});
|
|
11306
12148
|
await Promise.all(
|
|
11307
12149
|
sourceIds.map(async (id) => {
|
|
11308
|
-
|
|
11309
|
-
|
|
11310
|
-
|
|
11311
|
-
|
|
12150
|
+
if (!this.courseNameCache.has(id)) {
|
|
12151
|
+
try {
|
|
12152
|
+
const config = await this.dataLayer.getCoursesDB().getCourseConfig(id);
|
|
12153
|
+
this.courseNameCache.set(id, config.name);
|
|
12154
|
+
} catch {
|
|
12155
|
+
}
|
|
11312
12156
|
}
|
|
11313
12157
|
})
|
|
11314
12158
|
);
|
|
@@ -11326,20 +12170,26 @@ var SessionController = class extends Loggable {
|
|
|
11326
12170
|
const reviewWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "review");
|
|
11327
12171
|
const newWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "new");
|
|
11328
12172
|
logger.debug(`[reviews] got ${reviewWeighted.length} reviews from mixer`);
|
|
11329
|
-
let report = "Mixed content session created with:\n";
|
|
11330
|
-
|
|
11331
|
-
const
|
|
11332
|
-
|
|
11333
|
-
|
|
11334
|
-
|
|
11335
|
-
|
|
11336
|
-
|
|
11337
|
-
|
|
11338
|
-
|
|
11339
|
-
|
|
11340
|
-
|
|
12173
|
+
let report = replan ? "Replan content:\n" : "Mixed content session created with:\n";
|
|
12174
|
+
if (!replan) {
|
|
12175
|
+
for (const w of reviewWeighted) {
|
|
12176
|
+
const reviewItem = {
|
|
12177
|
+
cardID: w.cardId,
|
|
12178
|
+
courseID: w.courseId,
|
|
12179
|
+
contentSourceType: "course",
|
|
12180
|
+
contentSourceID: w.courseId,
|
|
12181
|
+
reviewID: w.reviewID,
|
|
12182
|
+
status: "review"
|
|
12183
|
+
};
|
|
12184
|
+
this.reviewQ.add(reviewItem, reviewItem.cardID);
|
|
12185
|
+
report += `Review: ${w.courseId}::${w.cardId} (score: ${w.score.toFixed(2)})
|
|
11341
12186
|
`;
|
|
12187
|
+
}
|
|
11342
12188
|
}
|
|
12189
|
+
const wellIndicated = newWeighted.filter(
|
|
12190
|
+
(w) => w.score >= _SessionController.WELL_INDICATED_SCORE
|
|
12191
|
+
).length;
|
|
12192
|
+
const newItems = [];
|
|
11343
12193
|
for (const w of newWeighted) {
|
|
11344
12194
|
const newItem = {
|
|
11345
12195
|
cardID: w.cardId,
|
|
@@ -11348,11 +12198,23 @@ var SessionController = class extends Loggable {
|
|
|
11348
12198
|
contentSourceID: w.courseId,
|
|
11349
12199
|
status: "new"
|
|
11350
12200
|
};
|
|
11351
|
-
|
|
12201
|
+
newItems.push(newItem);
|
|
11352
12202
|
report += `New: ${w.courseId}::${w.cardId} (score: ${w.score.toFixed(2)})
|
|
11353
12203
|
`;
|
|
11354
12204
|
}
|
|
12205
|
+
if (additive) {
|
|
12206
|
+
const added = this.newQ.mergeToFront(newItems, (item) => item.cardID);
|
|
12207
|
+
report += `Additive merge: ${added} new cards added to front of newQ
|
|
12208
|
+
`;
|
|
12209
|
+
} else if (replan) {
|
|
12210
|
+
this.newQ.replaceAll(newItems, (item) => item.cardID);
|
|
12211
|
+
} else {
|
|
12212
|
+
for (const item of newItems) {
|
|
12213
|
+
this.newQ.add(item, item.cardID);
|
|
12214
|
+
}
|
|
12215
|
+
}
|
|
11355
12216
|
this.log(report);
|
|
12217
|
+
return wellIndicated;
|
|
11356
12218
|
}
|
|
11357
12219
|
/**
|
|
11358
12220
|
* Returns items that should be pre-hydrated.
|
|
@@ -11429,6 +12291,17 @@ var SessionController = class extends Loggable {
|
|
|
11429
12291
|
}
|
|
11430
12292
|
async nextCard(action = "dismiss-success") {
|
|
11431
12293
|
this.dismissCurrentCard(action);
|
|
12294
|
+
if (this._replanPromise) {
|
|
12295
|
+
this.log("nextCard: awaiting in-flight replan before drawing");
|
|
12296
|
+
await this._replanPromise;
|
|
12297
|
+
}
|
|
12298
|
+
const REPLAN_BUFFER = 3;
|
|
12299
|
+
if (this._wellIndicatedRemaining <= REPLAN_BUFFER && this.newQ.length > 0 && !this._replanPromise) {
|
|
12300
|
+
this.log(
|
|
12301
|
+
`[AutoReplan] ${this._wellIndicatedRemaining} well-indicated cards remaining (newQ: ${this.newQ.length}). Triggering background replan.`
|
|
12302
|
+
);
|
|
12303
|
+
void this.requestReplan();
|
|
12304
|
+
}
|
|
11432
12305
|
if (this._secondsRemaining <= 0 && this.failedQ.length === 0) {
|
|
11433
12306
|
this._currentCard = null;
|
|
11434
12307
|
endSessionTracking();
|
|
@@ -11542,6 +12415,9 @@ var SessionController = class extends Loggable {
|
|
|
11542
12415
|
this.reviewQ.dequeue((queueItem) => queueItem.cardID);
|
|
11543
12416
|
} else if (this.newQ.peek(0)?.cardID === item.cardID) {
|
|
11544
12417
|
this.newQ.dequeue((queueItem) => queueItem.cardID);
|
|
12418
|
+
if (this._wellIndicatedRemaining > 0) {
|
|
12419
|
+
this._wellIndicatedRemaining--;
|
|
12420
|
+
}
|
|
11545
12421
|
} else if (this.failedQ.peek(0)?.cardID === item.cardID) {
|
|
11546
12422
|
this.failedQ.dequeue((queueItem) => queueItem.cardID);
|
|
11547
12423
|
}
|