@vue-skuilder/db 0.1.31-a → 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-BmnmvH8C.d.ts → contentSource-Bdwkvqa8.d.ts} +35 -4
- package/dist/{contentSource-DfBbaLA-.d.cts → contentSource-DF1nUbPQ.d.cts} +35 -4
- package/dist/core/index.d.cts +48 -3
- package/dist/core/index.d.ts +48 -3
- package/dist/core/index.js +587 -56
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +586 -56
- package/dist/core/index.mjs.map +1 -1
- package/dist/{dataLayerProvider-BeRXVMs5.d.cts → dataLayerProvider-BKmVoyJR.d.ts} +20 -1
- package/dist/{dataLayerProvider-CG9GfaAY.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 +805 -47
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +804 -47
- 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 +542 -37
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +542 -37
- 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 +1040 -90
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1030 -81
- package/dist/index.mjs.map +1 -1
- package/docs/navigators-architecture.md +64 -5
- 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 +115 -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 +55 -10
- 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 +7 -2
- 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,11 +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";
|
|
974
|
+
init_navigators();
|
|
969
975
|
init_logger();
|
|
976
|
+
_activePipeline = null;
|
|
970
977
|
MAX_RUNS = 10;
|
|
971
978
|
runHistory = [];
|
|
972
979
|
pipelineDebugAPI = {
|
|
@@ -1108,6 +1115,81 @@ var init_PipelineDebugger = __esm({
|
|
|
1108
1115
|
runHistory.length = 0;
|
|
1109
1116
|
logger.info("[Pipeline Debug] Run history cleared.");
|
|
1110
1117
|
},
|
|
1118
|
+
/**
|
|
1119
|
+
* Show the navigator registry: all registered classes and their roles.
|
|
1120
|
+
*
|
|
1121
|
+
* Useful for verifying that consumer-defined navigators were registered
|
|
1122
|
+
* before pipeline assembly.
|
|
1123
|
+
*/
|
|
1124
|
+
showRegistry() {
|
|
1125
|
+
const names = getRegisteredNavigatorNames();
|
|
1126
|
+
if (names.length === 0) {
|
|
1127
|
+
logger.info("[Pipeline Debug] Navigator registry is empty.");
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
console.group("\u{1F4E6} Navigator Registry");
|
|
1131
|
+
console.table(
|
|
1132
|
+
names.map((name) => {
|
|
1133
|
+
const registryRole = getRegisteredNavigatorRole(name);
|
|
1134
|
+
const builtinRole = NavigatorRoles[name];
|
|
1135
|
+
const effectiveRole = builtinRole || registryRole || "\u26A0\uFE0F NONE";
|
|
1136
|
+
const source = builtinRole ? "built-in" : registryRole ? "consumer" : "unclassified";
|
|
1137
|
+
return {
|
|
1138
|
+
name,
|
|
1139
|
+
role: effectiveRole,
|
|
1140
|
+
source,
|
|
1141
|
+
isGenerator: isGenerator(name),
|
|
1142
|
+
isFilter: isFilter(name)
|
|
1143
|
+
};
|
|
1144
|
+
})
|
|
1145
|
+
);
|
|
1146
|
+
console.groupEnd();
|
|
1147
|
+
},
|
|
1148
|
+
/**
|
|
1149
|
+
* Show strategy documents from the last pipeline run and how they mapped
|
|
1150
|
+
* to the registry.
|
|
1151
|
+
*
|
|
1152
|
+
* If no runs are captured yet, falls back to showing just the registry.
|
|
1153
|
+
*/
|
|
1154
|
+
showStrategies() {
|
|
1155
|
+
this.showRegistry();
|
|
1156
|
+
if (runHistory.length === 0) {
|
|
1157
|
+
logger.info("[Pipeline Debug] No pipeline runs captured yet \u2014 cannot show strategy doc mapping.");
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
const run = runHistory[0];
|
|
1161
|
+
console.group("\u{1F50C} Pipeline Strategy Mapping (last run)");
|
|
1162
|
+
logger.info(`Generator: ${run.generatorName}`);
|
|
1163
|
+
if (run.generators && run.generators.length > 0) {
|
|
1164
|
+
for (const g of run.generators) {
|
|
1165
|
+
logger.info(` \u{1F4E5} ${g.name}: ${g.cardCount} cards (${g.newCount} new, ${g.reviewCount} reviews)`);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
if (run.filters.length > 0) {
|
|
1169
|
+
logger.info("Filters:");
|
|
1170
|
+
for (const f of run.filters) {
|
|
1171
|
+
logger.info(` \u{1F538} ${f.name}: \u2191${f.boosted} \u2193${f.penalized} =${f.passed} \u2715${f.removed}`);
|
|
1172
|
+
}
|
|
1173
|
+
} else {
|
|
1174
|
+
logger.info("Filters: (none)");
|
|
1175
|
+
}
|
|
1176
|
+
console.groupEnd();
|
|
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
|
+
},
|
|
1111
1193
|
/**
|
|
1112
1194
|
* Show help.
|
|
1113
1195
|
*/
|
|
@@ -1120,6 +1202,9 @@ Commands:
|
|
|
1120
1202
|
.showRun(id|index) Show summary of a specific run (by index or ID suffix)
|
|
1121
1203
|
.showCard(cardId) Show provenance trail for a specific card
|
|
1122
1204
|
.explainReviews() Analyze why reviews were/weren't selected
|
|
1205
|
+
.diagnoseCardSpace() Scan full card space through filters (async)
|
|
1206
|
+
.showRegistry() Show navigator registry (classes + roles)
|
|
1207
|
+
.showStrategies() Show registry + strategy mapping from last run
|
|
1123
1208
|
.listRuns() List all captured runs in table format
|
|
1124
1209
|
.export() Export run history as JSON for bug reports
|
|
1125
1210
|
.clear() Clear run history
|
|
@@ -1129,7 +1214,7 @@ Commands:
|
|
|
1129
1214
|
Example:
|
|
1130
1215
|
window.skuilder.pipeline.showLastRun()
|
|
1131
1216
|
window.skuilder.pipeline.showRun(1)
|
|
1132
|
-
window.skuilder.pipeline.
|
|
1217
|
+
await window.skuilder.pipeline.diagnoseCardSpace()
|
|
1133
1218
|
`);
|
|
1134
1219
|
}
|
|
1135
1220
|
};
|
|
@@ -1424,6 +1509,69 @@ var init_generators = __esm({
|
|
|
1424
1509
|
}
|
|
1425
1510
|
});
|
|
1426
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
|
+
|
|
1427
1575
|
// src/core/navigators/generators/srs.ts
|
|
1428
1576
|
var srs_exports = {};
|
|
1429
1577
|
__export(srs_exports, {
|
|
@@ -1618,6 +1766,7 @@ var init_ = __esm({
|
|
|
1618
1766
|
"./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
|
|
1619
1767
|
"./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
|
|
1620
1768
|
"./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
|
|
1769
|
+
"./generators/prescribed.ts": () => Promise.resolve().then(() => (init_prescribed(), prescribed_exports)),
|
|
1621
1770
|
"./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
|
|
1622
1771
|
"./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports))
|
|
1623
1772
|
});
|
|
@@ -1818,6 +1967,8 @@ var init_hierarchyDefinition = __esm({
|
|
|
1818
1967
|
if (userTagElo.count < minCount) return false;
|
|
1819
1968
|
if (prereq.masteryThreshold?.minElo !== void 0) {
|
|
1820
1969
|
return userTagElo.score >= prereq.masteryThreshold.minElo;
|
|
1970
|
+
} else if (prereq.masteryThreshold?.minCount !== void 0) {
|
|
1971
|
+
return true;
|
|
1821
1972
|
} else {
|
|
1822
1973
|
return userTagElo.score >= userGlobalElo;
|
|
1823
1974
|
}
|
|
@@ -1893,14 +2044,38 @@ var init_hierarchyDefinition = __esm({
|
|
|
1893
2044
|
};
|
|
1894
2045
|
}
|
|
1895
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
|
+
}
|
|
1896
2067
|
/**
|
|
1897
2068
|
* CardFilter.transform implementation.
|
|
1898
2069
|
*
|
|
1899
|
-
*
|
|
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
|
|
1900
2074
|
*/
|
|
1901
2075
|
async transform(cards, context) {
|
|
1902
2076
|
const masteredTags = await this.getMasteredTags(context);
|
|
1903
2077
|
const unlockedTags = this.getUnlockedTags(masteredTags);
|
|
2078
|
+
const preReqBoosts = this.getPreReqBoosts(unlockedTags, masteredTags);
|
|
1904
2079
|
const gated = [];
|
|
1905
2080
|
for (const card of cards) {
|
|
1906
2081
|
const { isUnlocked, reason } = await this.checkCardUnlock(
|
|
@@ -1909,9 +2084,27 @@ var init_hierarchyDefinition = __esm({
|
|
|
1909
2084
|
unlockedTags,
|
|
1910
2085
|
masteredTags
|
|
1911
2086
|
);
|
|
1912
|
-
const LOCKED_PENALTY = 0.
|
|
1913
|
-
|
|
1914
|
-
|
|
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
|
+
}
|
|
1915
2108
|
gated.push({
|
|
1916
2109
|
...card,
|
|
1917
2110
|
score: finalScore,
|
|
@@ -1923,7 +2116,7 @@ var init_hierarchyDefinition = __esm({
|
|
|
1923
2116
|
strategyId: this.strategyId || "NAVIGATION_STRATEGY-hierarchy",
|
|
1924
2117
|
action,
|
|
1925
2118
|
score: finalScore,
|
|
1926
|
-
reason
|
|
2119
|
+
reason: finalReason
|
|
1927
2120
|
}
|
|
1928
2121
|
]
|
|
1929
2122
|
});
|
|
@@ -2857,6 +3050,18 @@ var Pipeline_exports = {};
|
|
|
2857
3050
|
__export(Pipeline_exports, {
|
|
2858
3051
|
Pipeline: () => Pipeline
|
|
2859
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
|
+
}
|
|
2860
3065
|
function logPipelineConfig(generator, filters) {
|
|
2861
3066
|
const filterList = filters.length > 0 ? "\n - " + filters.map((f) => f.name).join("\n - ") : " none";
|
|
2862
3067
|
logger.info(
|
|
@@ -2891,6 +3096,21 @@ function logExecutionSummary(generatorName, generatedCount, filterCount, finalCo
|
|
|
2891
3096
|
\u{1F4A1} Inspect: window.skuilder.pipeline`
|
|
2892
3097
|
);
|
|
2893
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
|
+
}
|
|
2894
3114
|
function logCardProvenance(cards, maxCards = 3) {
|
|
2895
3115
|
const cardsToLog = cards.slice(0, maxCards);
|
|
2896
3116
|
logger.debug(`[Pipeline] Provenance for top ${cardsToLog.length} cards:`);
|
|
@@ -2905,7 +3125,7 @@ function logCardProvenance(cards, maxCards = 3) {
|
|
|
2905
3125
|
}
|
|
2906
3126
|
}
|
|
2907
3127
|
}
|
|
2908
|
-
var import_common8, Pipeline;
|
|
3128
|
+
var import_common8, VERBOSE_RESULTS, Pipeline;
|
|
2909
3129
|
var init_Pipeline = __esm({
|
|
2910
3130
|
"src/core/navigators/Pipeline.ts"() {
|
|
2911
3131
|
"use strict";
|
|
@@ -2914,9 +3134,31 @@ var init_Pipeline = __esm({
|
|
|
2914
3134
|
init_logger();
|
|
2915
3135
|
init_orchestration();
|
|
2916
3136
|
init_PipelineDebugger();
|
|
3137
|
+
VERBOSE_RESULTS = true;
|
|
2917
3138
|
Pipeline = class extends ContentNavigator {
|
|
2918
3139
|
generator;
|
|
2919
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;
|
|
2920
3162
|
/**
|
|
2921
3163
|
* Create a new pipeline.
|
|
2922
3164
|
*
|
|
@@ -2937,6 +3179,17 @@ var init_Pipeline = __esm({
|
|
|
2937
3179
|
logger.error(`[pipeline] Failed to lookup courseCfg: ${e}`);
|
|
2938
3180
|
});
|
|
2939
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)}`);
|
|
2940
3193
|
}
|
|
2941
3194
|
/**
|
|
2942
3195
|
* Get weighted cards by running generator and applying filters.
|
|
@@ -2953,13 +3206,15 @@ var init_Pipeline = __esm({
|
|
|
2953
3206
|
* @returns Cards sorted by score descending
|
|
2954
3207
|
*/
|
|
2955
3208
|
async getWeightedCards(limit) {
|
|
3209
|
+
const t0 = performance.now();
|
|
2956
3210
|
const context = await this.buildContext();
|
|
2957
|
-
const
|
|
2958
|
-
const fetchLimit =
|
|
3211
|
+
const tContext = performance.now();
|
|
3212
|
+
const fetchLimit = 500;
|
|
2959
3213
|
logger.debug(
|
|
2960
3214
|
`[Pipeline] Fetching ${fetchLimit} candidates from generator '${this.generator.name}'`
|
|
2961
3215
|
);
|
|
2962
3216
|
let cards = await this.generator.getWeightedCards(fetchLimit, context);
|
|
3217
|
+
const tGenerate = performance.now();
|
|
2963
3218
|
const generatedCount = cards.length;
|
|
2964
3219
|
let generatorSummaries;
|
|
2965
3220
|
if (this.generator.generators) {
|
|
@@ -2988,6 +3243,7 @@ var init_Pipeline = __esm({
|
|
|
2988
3243
|
}
|
|
2989
3244
|
logger.debug(`[Pipeline] Generator returned ${generatedCount} candidates`);
|
|
2990
3245
|
cards = await this.hydrateTags(cards);
|
|
3246
|
+
const tHydrate = performance.now();
|
|
2991
3247
|
const allCardsBeforeFiltering = [...cards];
|
|
2992
3248
|
const filterImpacts = [];
|
|
2993
3249
|
for (const filter of this.filters) {
|
|
@@ -3006,8 +3262,17 @@ var init_Pipeline = __esm({
|
|
|
3006
3262
|
logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeScores.size} \u2192 ${cards.length} cards (\u2191${boosted} \u2193${penalized} =${passed})`);
|
|
3007
3263
|
}
|
|
3008
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
|
+
}
|
|
3009
3270
|
cards.sort((a, b) => b.score - a.score);
|
|
3271
|
+
const tFilter = performance.now();
|
|
3010
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
|
+
);
|
|
3011
3276
|
const topScores = result.slice(0, 3).map((c) => c.score);
|
|
3012
3277
|
logExecutionSummary(
|
|
3013
3278
|
this.generator.name,
|
|
@@ -3017,6 +3282,7 @@ var init_Pipeline = __esm({
|
|
|
3017
3282
|
topScores,
|
|
3018
3283
|
filterImpacts
|
|
3019
3284
|
);
|
|
3285
|
+
logResultCards(result);
|
|
3020
3286
|
logCardProvenance(result, 3);
|
|
3021
3287
|
try {
|
|
3022
3288
|
const courseName = await this.course?.getCourseConfig().then((c) => c.name).catch(() => void 0);
|
|
@@ -3043,6 +3309,10 @@ var init_Pipeline = __esm({
|
|
|
3043
3309
|
* to the WeightedCard objects. Filters can then use card.tags instead of
|
|
3044
3310
|
* making individual getAppliedTags() calls.
|
|
3045
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
|
+
*
|
|
3046
3316
|
* @param cards - Cards to hydrate
|
|
3047
3317
|
* @returns Cards with tags populated
|
|
3048
3318
|
*/
|
|
@@ -3050,14 +3320,128 @@ var init_Pipeline = __esm({
|
|
|
3050
3320
|
if (cards.length === 0) {
|
|
3051
3321
|
return cards;
|
|
3052
3322
|
}
|
|
3053
|
-
const
|
|
3054
|
-
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
|
+
}
|
|
3055
3339
|
logTagHydration(cards, tagsByCard);
|
|
3056
3340
|
return cards.map((card) => ({
|
|
3057
3341
|
...card,
|
|
3058
|
-
tags:
|
|
3342
|
+
tags: this._tagCache.get(card.cardId) ?? []
|
|
3059
3343
|
}));
|
|
3060
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
|
+
}
|
|
3061
3445
|
/**
|
|
3062
3446
|
* Build shared context for generator and filters.
|
|
3063
3447
|
*
|
|
@@ -3075,7 +3459,10 @@ var init_Pipeline = __esm({
|
|
|
3075
3459
|
} catch (e) {
|
|
3076
3460
|
logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`);
|
|
3077
3461
|
}
|
|
3078
|
-
|
|
3462
|
+
if (!this._cachedOrchestration) {
|
|
3463
|
+
this._cachedOrchestration = await createOrchestrationContext(this.user, this.course);
|
|
3464
|
+
}
|
|
3465
|
+
const orchestration = this._cachedOrchestration;
|
|
3079
3466
|
return {
|
|
3080
3467
|
user: this.user,
|
|
3081
3468
|
course: this.course,
|
|
@@ -3119,6 +3506,87 @@ var init_Pipeline = __esm({
|
|
|
3119
3506
|
}
|
|
3120
3507
|
return [...new Set(ids)];
|
|
3121
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
|
+
}
|
|
3122
3590
|
};
|
|
3123
3591
|
}
|
|
3124
3592
|
});
|
|
@@ -3223,23 +3691,25 @@ var init_PipelineAssembler = __esm({
|
|
|
3223
3691
|
warnings.push(`Unknown strategy type '${s.implementingClass}', skipping: ${s.name}`);
|
|
3224
3692
|
}
|
|
3225
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
|
+
}
|
|
3226
3705
|
if (generatorStrategies.length === 0) {
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
} else {
|
|
3235
|
-
warnings.push("No generator strategy found");
|
|
3236
|
-
return {
|
|
3237
|
-
pipeline: null,
|
|
3238
|
-
generatorStrategies: [],
|
|
3239
|
-
filterStrategies: [],
|
|
3240
|
-
warnings
|
|
3241
|
-
};
|
|
3242
|
-
}
|
|
3706
|
+
warnings.push("No generator strategy found");
|
|
3707
|
+
return {
|
|
3708
|
+
pipeline: null,
|
|
3709
|
+
generatorStrategies: [],
|
|
3710
|
+
filterStrategies: [],
|
|
3711
|
+
warnings
|
|
3712
|
+
};
|
|
3243
3713
|
}
|
|
3244
3714
|
let generator;
|
|
3245
3715
|
if (generatorStrategies.length === 1) {
|
|
@@ -3317,6 +3787,7 @@ var init_3 = __esm({
|
|
|
3317
3787
|
"./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
|
|
3318
3788
|
"./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
|
|
3319
3789
|
"./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
|
|
3790
|
+
"./generators/prescribed.ts": () => Promise.resolve().then(() => (init_prescribed(), prescribed_exports)),
|
|
3320
3791
|
"./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
|
|
3321
3792
|
"./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports)),
|
|
3322
3793
|
"./index.ts": () => Promise.resolve().then(() => (init_navigators(), navigators_exports))
|
|
@@ -3334,6 +3805,7 @@ __export(navigators_exports, {
|
|
|
3334
3805
|
getCardOrigin: () => getCardOrigin,
|
|
3335
3806
|
getRegisteredNavigator: () => getRegisteredNavigator,
|
|
3336
3807
|
getRegisteredNavigatorNames: () => getRegisteredNavigatorNames,
|
|
3808
|
+
getRegisteredNavigatorRole: () => getRegisteredNavigatorRole,
|
|
3337
3809
|
hasRegisteredNavigator: () => hasRegisteredNavigator,
|
|
3338
3810
|
initializeNavigatorRegistry: () => initializeNavigatorRegistry,
|
|
3339
3811
|
isFilter: () => isFilter,
|
|
@@ -3342,16 +3814,19 @@ __export(navigators_exports, {
|
|
|
3342
3814
|
pipelineDebugAPI: () => pipelineDebugAPI,
|
|
3343
3815
|
registerNavigator: () => registerNavigator
|
|
3344
3816
|
});
|
|
3345
|
-
function registerNavigator(implementingClass, constructor) {
|
|
3346
|
-
navigatorRegistry.set(implementingClass, constructor);
|
|
3347
|
-
logger.debug(`[NavigatorRegistry] Registered: ${implementingClass}`);
|
|
3817
|
+
function registerNavigator(implementingClass, constructor, role) {
|
|
3818
|
+
navigatorRegistry.set(implementingClass, { constructor, role });
|
|
3819
|
+
logger.debug(`[NavigatorRegistry] Registered: ${implementingClass}${role ? ` (${role})` : ""}`);
|
|
3348
3820
|
}
|
|
3349
3821
|
function getRegisteredNavigator(implementingClass) {
|
|
3350
|
-
return navigatorRegistry.get(implementingClass);
|
|
3822
|
+
return navigatorRegistry.get(implementingClass)?.constructor;
|
|
3351
3823
|
}
|
|
3352
3824
|
function hasRegisteredNavigator(implementingClass) {
|
|
3353
3825
|
return navigatorRegistry.has(implementingClass);
|
|
3354
3826
|
}
|
|
3827
|
+
function getRegisteredNavigatorRole(implementingClass) {
|
|
3828
|
+
return navigatorRegistry.get(implementingClass)?.role;
|
|
3829
|
+
}
|
|
3355
3830
|
function getRegisteredNavigatorNames() {
|
|
3356
3831
|
return Array.from(navigatorRegistry.keys());
|
|
3357
3832
|
}
|
|
@@ -3361,8 +3836,10 @@ async function initializeNavigatorRegistry() {
|
|
|
3361
3836
|
Promise.resolve().then(() => (init_elo(), elo_exports)),
|
|
3362
3837
|
Promise.resolve().then(() => (init_srs(), srs_exports))
|
|
3363
3838
|
]);
|
|
3839
|
+
const prescribedModule = await Promise.resolve().then(() => (init_prescribed(), prescribed_exports));
|
|
3364
3840
|
registerNavigator("elo", eloModule.default);
|
|
3365
3841
|
registerNavigator("srs", srsModule.default);
|
|
3842
|
+
registerNavigator("prescribed", prescribedModule.default);
|
|
3366
3843
|
const [
|
|
3367
3844
|
hierarchyModule,
|
|
3368
3845
|
interferenceModule,
|
|
@@ -3397,10 +3874,12 @@ function getCardOrigin(card) {
|
|
|
3397
3874
|
return "new";
|
|
3398
3875
|
}
|
|
3399
3876
|
function isGenerator(impl) {
|
|
3400
|
-
|
|
3877
|
+
if (NavigatorRoles[impl] === "generator" /* GENERATOR */) return true;
|
|
3878
|
+
return getRegisteredNavigatorRole(impl) === "generator" /* GENERATOR */;
|
|
3401
3879
|
}
|
|
3402
3880
|
function isFilter(impl) {
|
|
3403
|
-
|
|
3881
|
+
if (NavigatorRoles[impl] === "filter" /* FILTER */) return true;
|
|
3882
|
+
return getRegisteredNavigatorRole(impl) === "filter" /* FILTER */;
|
|
3404
3883
|
}
|
|
3405
3884
|
var navigatorRegistry, Navigators, NavigatorRole, NavigatorRoles, ContentNavigator;
|
|
3406
3885
|
var init_navigators = __esm({
|
|
@@ -3415,6 +3894,7 @@ var init_navigators = __esm({
|
|
|
3415
3894
|
Navigators = /* @__PURE__ */ ((Navigators2) => {
|
|
3416
3895
|
Navigators2["ELO"] = "elo";
|
|
3417
3896
|
Navigators2["SRS"] = "srs";
|
|
3897
|
+
Navigators2["PRESCRIBED"] = "prescribed";
|
|
3418
3898
|
Navigators2["HIERARCHY"] = "hierarchyDefinition";
|
|
3419
3899
|
Navigators2["INTERFERENCE"] = "interferenceMitigator";
|
|
3420
3900
|
Navigators2["RELATIVE_PRIORITY"] = "relativePriority";
|
|
@@ -3429,6 +3909,7 @@ var init_navigators = __esm({
|
|
|
3429
3909
|
NavigatorRoles = {
|
|
3430
3910
|
["elo" /* ELO */]: "generator" /* GENERATOR */,
|
|
3431
3911
|
["srs" /* SRS */]: "generator" /* GENERATOR */,
|
|
3912
|
+
["prescribed" /* PRESCRIBED */]: "generator" /* GENERATOR */,
|
|
3432
3913
|
["hierarchyDefinition" /* HIERARCHY */]: "filter" /* FILTER */,
|
|
3433
3914
|
["interferenceMitigator" /* INTERFERENCE */]: "filter" /* FILTER */,
|
|
3434
3915
|
["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */,
|
|
@@ -3593,6 +4074,12 @@ var init_navigators = __esm({
|
|
|
3593
4074
|
async getWeightedCards(_limit) {
|
|
3594
4075
|
throw new Error(`${this.constructor.name} must implement getWeightedCards(). `);
|
|
3595
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
|
+
}
|
|
3596
4083
|
};
|
|
3597
4084
|
}
|
|
3598
4085
|
});
|
|
@@ -3737,15 +4224,42 @@ var init_courseDB = __esm({
|
|
|
3737
4224
|
// private log(msg: string): void {
|
|
3738
4225
|
// log(`CourseLog: ${this.id}\n ${msg}`);
|
|
3739
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
|
+
*/
|
|
3740
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;
|
|
3741
4244
|
id;
|
|
3742
4245
|
_getCurrentUser;
|
|
3743
4246
|
updateQueue;
|
|
3744
|
-
|
|
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) {
|
|
3745
4257
|
this.id = id;
|
|
3746
|
-
|
|
4258
|
+
const remote = getCourseDB2(this.id);
|
|
4259
|
+
this.remoteDB = remote;
|
|
4260
|
+
this.db = localDB ?? remote;
|
|
3747
4261
|
this._getCurrentUser = userLookup;
|
|
3748
|
-
this.updateQueue = new UpdateQueue(this.
|
|
4262
|
+
this.updateQueue = new UpdateQueue(this.remoteDB, this.remoteDB);
|
|
3749
4263
|
}
|
|
3750
4264
|
getCourseID() {
|
|
3751
4265
|
return this.id;
|
|
@@ -3833,7 +4347,7 @@ var init_courseDB = __esm({
|
|
|
3833
4347
|
};
|
|
3834
4348
|
}
|
|
3835
4349
|
async removeCard(id) {
|
|
3836
|
-
const doc = await this.
|
|
4350
|
+
const doc = await this.remoteDB.get(id);
|
|
3837
4351
|
if (!doc.docType || !(doc.docType === "CARD" /* CARD */)) {
|
|
3838
4352
|
throw new Error(`failed to remove ${id} from course ${this.id}. id does not point to a card`);
|
|
3839
4353
|
}
|
|
@@ -3854,7 +4368,7 @@ var init_courseDB = __esm({
|
|
|
3854
4368
|
} catch (error) {
|
|
3855
4369
|
logger.error(`Error removing card ${id} from tags: ${error}`);
|
|
3856
4370
|
}
|
|
3857
|
-
return this.
|
|
4371
|
+
return this.remoteDB.remove(doc);
|
|
3858
4372
|
}
|
|
3859
4373
|
async getCardDisplayableDataIDs(id) {
|
|
3860
4374
|
logger.debug(id.join(", "));
|
|
@@ -3956,8 +4470,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3956
4470
|
if (cardIds.length === 0) {
|
|
3957
4471
|
return /* @__PURE__ */ new Map();
|
|
3958
4472
|
}
|
|
3959
|
-
const
|
|
3960
|
-
const result = await db.query("getTags", {
|
|
4473
|
+
const result = await this.db.query("getTags", {
|
|
3961
4474
|
keys: cardIds,
|
|
3962
4475
|
include_docs: false
|
|
3963
4476
|
});
|
|
@@ -3974,6 +4487,14 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3974
4487
|
}
|
|
3975
4488
|
return tagsByCard;
|
|
3976
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
|
+
}
|
|
3977
4498
|
async addTagToCard(cardId, tagId, updateELO) {
|
|
3978
4499
|
return await addTagToCard(
|
|
3979
4500
|
this.id,
|
|
@@ -4040,10 +4561,13 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
4040
4561
|
}
|
|
4041
4562
|
}
|
|
4042
4563
|
async getCourseDoc(id, options) {
|
|
4043
|
-
return await
|
|
4564
|
+
return await this.db.get(id, options);
|
|
4044
4565
|
}
|
|
4045
4566
|
async getCourseDocs(ids, options = {}) {
|
|
4046
|
-
return await
|
|
4567
|
+
return await this.db.allDocs({
|
|
4568
|
+
...options,
|
|
4569
|
+
keys: ids
|
|
4570
|
+
});
|
|
4047
4571
|
}
|
|
4048
4572
|
////////////////////////////////////
|
|
4049
4573
|
// NavigationStrategyManager implementation
|
|
@@ -4077,7 +4601,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
4077
4601
|
}
|
|
4078
4602
|
async addNavigationStrategy(data) {
|
|
4079
4603
|
logger.debug(`[courseDB] Adding navigation strategy: ${data._id}`);
|
|
4080
|
-
return this.
|
|
4604
|
+
return this.remoteDB.put(data).then(() => {
|
|
4081
4605
|
});
|
|
4082
4606
|
}
|
|
4083
4607
|
updateNavigationStrategy(id, data) {
|
|
@@ -4634,6 +5158,234 @@ var init_adminDB2 = __esm({
|
|
|
4634
5158
|
}
|
|
4635
5159
|
});
|
|
4636
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
|
+
|
|
4637
5389
|
// src/impl/couch/auth.ts
|
|
4638
5390
|
async function getCurrentSession() {
|
|
4639
5391
|
try {
|
|
@@ -4932,15 +5684,6 @@ function getCourseDB2(courseID) {
|
|
|
4932
5684
|
createPouchDBConfig()
|
|
4933
5685
|
);
|
|
4934
5686
|
}
|
|
4935
|
-
function getCourseDocs(courseID, docIDs, options = {}) {
|
|
4936
|
-
return getCourseDB2(courseID).allDocs({
|
|
4937
|
-
...options,
|
|
4938
|
-
keys: docIDs
|
|
4939
|
-
});
|
|
4940
|
-
}
|
|
4941
|
-
function getCourseDoc(courseID, docID, options = {}) {
|
|
4942
|
-
return getCourseDB2(courseID).get(docID, options);
|
|
4943
|
-
}
|
|
4944
5687
|
function filterAllDocsByPrefix2(db, prefix, opts) {
|
|
4945
5688
|
const options = {
|
|
4946
5689
|
startkey: prefix,
|
|
@@ -4974,6 +5717,7 @@ var init_couch = __esm({
|
|
|
4974
5717
|
init_classroomDB2();
|
|
4975
5718
|
init_courseAPI();
|
|
4976
5719
|
init_courseDB();
|
|
5720
|
+
init_CourseSyncService();
|
|
4977
5721
|
init_CouchDBSyncStrategy();
|
|
4978
5722
|
isBrowser = typeof window !== "undefined";
|
|
4979
5723
|
if (isBrowser) {
|
|
@@ -5274,6 +6018,9 @@ Currently logged-in as ${this._username}.`
|
|
|
5274
6018
|
const id = row.id;
|
|
5275
6019
|
return id.startsWith(DocTypePrefixes["CARDRECORD" /* CARDRECORD */]) || // Card interaction history
|
|
5276
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
|
|
5277
6024
|
id === _BaseUser.DOC_IDS.COURSE_REGISTRATIONS || // Course registrations
|
|
5278
6025
|
id === _BaseUser.DOC_IDS.CLASSROOM_REGISTRATIONS || // Classroom registrations
|
|
5279
6026
|
id === _BaseUser.DOC_IDS.CONFIG;
|
|
@@ -6070,6 +6817,7 @@ var init_PouchDataLayerProvider = __esm({
|
|
|
6070
6817
|
init_adminDB2();
|
|
6071
6818
|
init_classroomDB2();
|
|
6072
6819
|
init_courseDB();
|
|
6820
|
+
init_CourseSyncService();
|
|
6073
6821
|
init_common();
|
|
6074
6822
|
init_CouchDBSyncStrategy();
|
|
6075
6823
|
CouchDataLayerProvider = class {
|
|
@@ -6109,7 +6857,22 @@ var init_PouchDataLayerProvider = __esm({
|
|
|
6109
6857
|
return this.userDB;
|
|
6110
6858
|
}
|
|
6111
6859
|
getCourseDB(courseId) {
|
|
6112
|
-
|
|
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);
|
|
6113
6876
|
}
|
|
6114
6877
|
getCoursesDB() {
|
|
6115
6878
|
return new CoursesDB(this._courseIDs);
|
|
@@ -6737,6 +7500,10 @@ var init_courseDB2 = __esm({
|
|
|
6737
7500
|
}
|
|
6738
7501
|
return tagsByCard;
|
|
6739
7502
|
}
|
|
7503
|
+
async getAllCardIds() {
|
|
7504
|
+
const tagsIndex = await this.unpacker.getTagsIndex();
|
|
7505
|
+
return Object.keys(tagsIndex.byCard);
|
|
7506
|
+
}
|
|
6740
7507
|
async addTagToCard(_cardId, _tagId) {
|
|
6741
7508
|
throw new Error("Cannot modify tags in static mode");
|
|
6742
7509
|
}
|
|
@@ -7999,6 +8766,7 @@ __export(index_exports, {
|
|
|
7999
8766
|
getDefaultLearnableWeight: () => getDefaultLearnableWeight,
|
|
8000
8767
|
getRegisteredNavigator: () => getRegisteredNavigator,
|
|
8001
8768
|
getRegisteredNavigatorNames: () => getRegisteredNavigatorNames,
|
|
8769
|
+
getRegisteredNavigatorRole: () => getRegisteredNavigatorRole,
|
|
8002
8770
|
getStudySource: () => getStudySource,
|
|
8003
8771
|
hasRegisteredNavigator: () => hasRegisteredNavigator,
|
|
8004
8772
|
importParsedCards: () => importParsedCards,
|
|
@@ -8361,6 +9129,7 @@ init_couch();
|
|
|
8361
9129
|
// src/study/SpacedRepetition.ts
|
|
8362
9130
|
init_util();
|
|
8363
9131
|
var import_moment7 = __toESM(require("moment"), 1);
|
|
9132
|
+
var import_common22 = require("@vue-skuilder/common");
|
|
8364
9133
|
init_logger();
|
|
8365
9134
|
var duration = import_moment7.default.duration;
|
|
8366
9135
|
function newInterval(user, cardHistory) {
|
|
@@ -8376,12 +9145,16 @@ function newQuestionInterval(user, cardHistory) {
|
|
|
8376
9145
|
const lastInterval = lastSuccessfulInterval(records);
|
|
8377
9146
|
if (lastInterval > cardHistory.bestInterval) {
|
|
8378
9147
|
cardHistory.bestInterval = lastInterval;
|
|
8379
|
-
|
|
9148
|
+
user.update(cardHistory._id, {
|
|
8380
9149
|
bestInterval: lastInterval
|
|
9150
|
+
}).catch((e) => {
|
|
9151
|
+
logger.warn(`[SpacedRepetition] Failed to update bestInterval for ${cardHistory._id}: ${e?.message ?? e}`);
|
|
8381
9152
|
});
|
|
8382
9153
|
}
|
|
8383
9154
|
if (currentAttempt.isCorrect) {
|
|
8384
|
-
const
|
|
9155
|
+
const rawPerf = currentAttempt.performance;
|
|
9156
|
+
const numericPerf = (0, import_common22.isTaggedPerformance)(rawPerf) ? rawPerf._global : rawPerf;
|
|
9157
|
+
const skill = Math.min(1, Math.max(0, numericPerf));
|
|
8385
9158
|
logger.debug(`Demontrated skill: ${skill}`);
|
|
8386
9159
|
const interval = lastInterval * (0.75 + skill);
|
|
8387
9160
|
cardHistory.lapses = getLapses(cardHistory.records);
|
|
@@ -8472,7 +9245,7 @@ var SrsService = class {
|
|
|
8472
9245
|
};
|
|
8473
9246
|
|
|
8474
9247
|
// src/study/services/EloService.ts
|
|
8475
|
-
var
|
|
9248
|
+
var import_common23 = require("@vue-skuilder/common");
|
|
8476
9249
|
init_logger();
|
|
8477
9250
|
var EloService = class {
|
|
8478
9251
|
dataLayer;
|
|
@@ -8495,12 +9268,12 @@ var EloService = class {
|
|
|
8495
9268
|
logger.warn(`k value interpretation not currently implemented`);
|
|
8496
9269
|
}
|
|
8497
9270
|
const courseDB = this.dataLayer.getCourseDB(currentCard.card.course_id);
|
|
8498
|
-
const userElo = (0,
|
|
9271
|
+
const userElo = (0, import_common23.toCourseElo)(
|
|
8499
9272
|
userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo
|
|
8500
9273
|
);
|
|
8501
9274
|
const cardElo = (await courseDB.getCardEloData([currentCard.card.card_id]))[0];
|
|
8502
9275
|
if (cardElo && userElo) {
|
|
8503
|
-
const eloUpdate = (0,
|
|
9276
|
+
const eloUpdate = (0, import_common23.adjustCourseScores)(userElo, cardElo, userScore);
|
|
8504
9277
|
userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo = eloUpdate.userElo;
|
|
8505
9278
|
const results = await Promise.allSettled([
|
|
8506
9279
|
this.user.updateUserElo(course_id, eloUpdate.userElo),
|
|
@@ -8546,12 +9319,12 @@ var EloService = class {
|
|
|
8546
9319
|
*/
|
|
8547
9320
|
async updateUserAndCardEloPerTag(taggedPerformance, course_id, card_id, userCourseRegDoc, currentCard) {
|
|
8548
9321
|
const courseDB = this.dataLayer.getCourseDB(currentCard.card.course_id);
|
|
8549
|
-
const userElo = (0,
|
|
9322
|
+
const userElo = (0, import_common23.toCourseElo)(
|
|
8550
9323
|
userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo
|
|
8551
9324
|
);
|
|
8552
9325
|
const cardElo = (await courseDB.getCardEloData([currentCard.card.card_id]))[0];
|
|
8553
9326
|
if (cardElo && userElo) {
|
|
8554
|
-
const eloUpdate = (0,
|
|
9327
|
+
const eloUpdate = (0, import_common23.adjustCourseScoresPerTag)(userElo, cardElo, taggedPerformance);
|
|
8555
9328
|
userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo = eloUpdate.userElo;
|
|
8556
9329
|
const results = await Promise.allSettled([
|
|
8557
9330
|
this.user.updateUserElo(course_id, eloUpdate.userElo),
|
|
@@ -8591,7 +9364,7 @@ var EloService = class {
|
|
|
8591
9364
|
// src/study/services/ResponseProcessor.ts
|
|
8592
9365
|
init_core();
|
|
8593
9366
|
init_logger();
|
|
8594
|
-
var
|
|
9367
|
+
var import_common24 = require("@vue-skuilder/common");
|
|
8595
9368
|
var ResponseProcessor = class {
|
|
8596
9369
|
srsService;
|
|
8597
9370
|
eloService;
|
|
@@ -8612,7 +9385,7 @@ var ResponseProcessor = class {
|
|
|
8612
9385
|
taggedPerformance: null
|
|
8613
9386
|
};
|
|
8614
9387
|
}
|
|
8615
|
-
if ((0,
|
|
9388
|
+
if ((0, import_common24.isTaggedPerformance)(performance2)) {
|
|
8616
9389
|
return {
|
|
8617
9390
|
globalScore: performance2._global,
|
|
8618
9391
|
taggedPerformance: performance2
|
|
@@ -8820,7 +9593,7 @@ var ResponseProcessor = class {
|
|
|
8820
9593
|
};
|
|
8821
9594
|
|
|
8822
9595
|
// src/study/services/CardHydrationService.ts
|
|
8823
|
-
var
|
|
9596
|
+
var import_common25 = require("@vue-skuilder/common");
|
|
8824
9597
|
init_logger();
|
|
8825
9598
|
function parseAudioURIs(data) {
|
|
8826
9599
|
if (typeof data !== "string") return [];
|
|
@@ -8955,8 +9728,8 @@ var CardHydrationService = class {
|
|
|
8955
9728
|
try {
|
|
8956
9729
|
const courseDB = this.getCourseDB(item.courseID);
|
|
8957
9730
|
const cardData = await courseDB.getCourseDoc(item.cardID);
|
|
8958
|
-
if (!(0,
|
|
8959
|
-
cardData.elo = (0,
|
|
9731
|
+
if (!(0, import_common25.isCourseElo)(cardData.elo)) {
|
|
9732
|
+
cardData.elo = (0, import_common25.toCourseElo)(cardData.elo);
|
|
8960
9733
|
}
|
|
8961
9734
|
const view = this.getViewComponent(cardData.id_view);
|
|
8962
9735
|
const dataDocs = await Promise.all(
|
|
@@ -8980,7 +9753,7 @@ var CardHydrationService = class {
|
|
|
8980
9753
|
);
|
|
8981
9754
|
await Promise.allSettled(uniqueAudioUrls.map(prefetchAudio));
|
|
8982
9755
|
}
|
|
8983
|
-
const data = dataDocs.map(
|
|
9756
|
+
const data = dataDocs.map(import_common25.displayableDataToViewData).reverse();
|
|
8984
9757
|
this.hydratedCards.set(item.cardID, {
|
|
8985
9758
|
item,
|
|
8986
9759
|
view,
|
|
@@ -9033,6 +9806,46 @@ var ItemQueue = class {
|
|
|
9033
9806
|
return null;
|
|
9034
9807
|
}
|
|
9035
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
|
+
}
|
|
9036
9849
|
get toString() {
|
|
9037
9850
|
return `${typeof this.q[0]}:
|
|
9038
9851
|
` + this.q.map((i) => ` ${i.courseID}+${i.cardID}: ${i.status}`).join("\n");
|
|
@@ -11116,7 +11929,7 @@ mountSessionDebugger();
|
|
|
11116
11929
|
|
|
11117
11930
|
// src/study/SessionController.ts
|
|
11118
11931
|
init_logger();
|
|
11119
|
-
var SessionController = class extends Loggable {
|
|
11932
|
+
var SessionController = class _SessionController extends Loggable {
|
|
11120
11933
|
_className = "SessionController";
|
|
11121
11934
|
services;
|
|
11122
11935
|
srsService;
|
|
@@ -11137,6 +11950,18 @@ var SessionController = class extends Loggable {
|
|
|
11137
11950
|
newQ = new ItemQueue();
|
|
11138
11951
|
failedQ = new ItemQueue();
|
|
11139
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;
|
|
11140
11965
|
startTime;
|
|
11141
11966
|
endTime;
|
|
11142
11967
|
_secondsRemaining;
|
|
@@ -11230,13 +12055,83 @@ var SessionController = class extends Loggable {
|
|
|
11230
12055
|
"[SessionController] All content sources must implement getWeightedCards()."
|
|
11231
12056
|
);
|
|
11232
12057
|
}
|
|
11233
|
-
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
|
+
}
|
|
11234
12065
|
await this.hydrationService.ensureHydratedCards();
|
|
11235
12066
|
startSessionTracking(this.reviewQ.length, this.newQ.length, this.failedQ.length);
|
|
11236
12067
|
this._intervalHandle = setInterval(() => {
|
|
11237
12068
|
this.tick();
|
|
11238
12069
|
}, 1e3);
|
|
11239
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
|
+
}
|
|
11240
12135
|
addTime(seconds) {
|
|
11241
12136
|
this.endTime = new Date(this.endTime.valueOf() + 1e3 * seconds);
|
|
11242
12137
|
}
|
|
@@ -11292,6 +12187,9 @@ var SessionController = class extends Loggable {
|
|
|
11292
12187
|
hydratedCache: {
|
|
11293
12188
|
count: this.hydrationService.hydratedCount,
|
|
11294
12189
|
cardIds: this.hydrationService.getHydratedCardIds()
|
|
12190
|
+
},
|
|
12191
|
+
replan: {
|
|
12192
|
+
inProgress: this._replanPromise !== null
|
|
11295
12193
|
}
|
|
11296
12194
|
};
|
|
11297
12195
|
}
|
|
@@ -11304,7 +12202,20 @@ var SessionController = class extends Loggable {
|
|
|
11304
12202
|
* 3. Uses SourceMixer to balance content across sources
|
|
11305
12203
|
* 4. Populates review and new card queues with mixed results
|
|
11306
12204
|
*/
|
|
11307
|
-
|
|
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;
|
|
11308
12219
|
const limit = 20;
|
|
11309
12220
|
const batches = [];
|
|
11310
12221
|
for (let i = 0; i < this.sources.length; i++) {
|
|
@@ -11323,6 +12234,10 @@ var SessionController = class extends Loggable {
|
|
|
11323
12234
|
}
|
|
11324
12235
|
}
|
|
11325
12236
|
if (batches.length === 0) {
|
|
12237
|
+
if (replan) {
|
|
12238
|
+
this.log("Replan: no content from any source, keeping existing newQ");
|
|
12239
|
+
return -1;
|
|
12240
|
+
}
|
|
11326
12241
|
throw new Error(
|
|
11327
12242
|
`Cannot start session: failed to load content from all ${this.sources.length} source(s). Check logs for details.`
|
|
11328
12243
|
);
|
|
@@ -11334,10 +12249,12 @@ var SessionController = class extends Loggable {
|
|
|
11334
12249
|
});
|
|
11335
12250
|
await Promise.all(
|
|
11336
12251
|
sourceIds.map(async (id) => {
|
|
11337
|
-
|
|
11338
|
-
|
|
11339
|
-
|
|
11340
|
-
|
|
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
|
+
}
|
|
11341
12258
|
}
|
|
11342
12259
|
})
|
|
11343
12260
|
);
|
|
@@ -11355,20 +12272,26 @@ var SessionController = class extends Loggable {
|
|
|
11355
12272
|
const reviewWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "review");
|
|
11356
12273
|
const newWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "new");
|
|
11357
12274
|
logger.debug(`[reviews] got ${reviewWeighted.length} reviews from mixer`);
|
|
11358
|
-
let report = "Mixed content session created with:\n";
|
|
11359
|
-
|
|
11360
|
-
const
|
|
11361
|
-
|
|
11362
|
-
|
|
11363
|
-
|
|
11364
|
-
|
|
11365
|
-
|
|
11366
|
-
|
|
11367
|
-
|
|
11368
|
-
|
|
11369
|
-
|
|
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)})
|
|
11370
12288
|
`;
|
|
12289
|
+
}
|
|
11371
12290
|
}
|
|
12291
|
+
const wellIndicated = newWeighted.filter(
|
|
12292
|
+
(w) => w.score >= _SessionController.WELL_INDICATED_SCORE
|
|
12293
|
+
).length;
|
|
12294
|
+
const newItems = [];
|
|
11372
12295
|
for (const w of newWeighted) {
|
|
11373
12296
|
const newItem = {
|
|
11374
12297
|
cardID: w.cardId,
|
|
@@ -11377,11 +12300,23 @@ var SessionController = class extends Loggable {
|
|
|
11377
12300
|
contentSourceID: w.courseId,
|
|
11378
12301
|
status: "new"
|
|
11379
12302
|
};
|
|
11380
|
-
|
|
12303
|
+
newItems.push(newItem);
|
|
11381
12304
|
report += `New: ${w.courseId}::${w.cardId} (score: ${w.score.toFixed(2)})
|
|
11382
12305
|
`;
|
|
11383
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
|
+
}
|
|
11384
12318
|
this.log(report);
|
|
12319
|
+
return wellIndicated;
|
|
11385
12320
|
}
|
|
11386
12321
|
/**
|
|
11387
12322
|
* Returns items that should be pre-hydrated.
|
|
@@ -11458,6 +12393,17 @@ var SessionController = class extends Loggable {
|
|
|
11458
12393
|
}
|
|
11459
12394
|
async nextCard(action = "dismiss-success") {
|
|
11460
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
|
+
}
|
|
11461
12407
|
if (this._secondsRemaining <= 0 && this.failedQ.length === 0) {
|
|
11462
12408
|
this._currentCard = null;
|
|
11463
12409
|
endSessionTracking();
|
|
@@ -11571,6 +12517,9 @@ var SessionController = class extends Loggable {
|
|
|
11571
12517
|
this.reviewQ.dequeue((queueItem) => queueItem.cardID);
|
|
11572
12518
|
} else if (this.newQ.peek(0)?.cardID === item.cardID) {
|
|
11573
12519
|
this.newQ.dequeue((queueItem) => queueItem.cardID);
|
|
12520
|
+
if (this._wellIndicatedRemaining > 0) {
|
|
12521
|
+
this._wellIndicatedRemaining--;
|
|
12522
|
+
}
|
|
11574
12523
|
} else if (this.failedQ.peek(0)?.cardID === item.cardID) {
|
|
11575
12524
|
this.failedQ.dequeue((queueItem) => queueItem.cardID);
|
|
11576
12525
|
}
|
|
@@ -11666,6 +12615,7 @@ init_factory();
|
|
|
11666
12615
|
getDefaultLearnableWeight,
|
|
11667
12616
|
getRegisteredNavigator,
|
|
11668
12617
|
getRegisteredNavigatorNames,
|
|
12618
|
+
getRegisteredNavigatorRole,
|
|
11669
12619
|
getStudySource,
|
|
11670
12620
|
hasRegisteredNavigator,
|
|
11671
12621
|
importParsedCards,
|