@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.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,11 +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";
|
|
951
|
+
init_navigators();
|
|
946
952
|
init_logger();
|
|
953
|
+
_activePipeline = null;
|
|
947
954
|
MAX_RUNS = 10;
|
|
948
955
|
runHistory = [];
|
|
949
956
|
pipelineDebugAPI = {
|
|
@@ -1085,6 +1092,81 @@ var init_PipelineDebugger = __esm({
|
|
|
1085
1092
|
runHistory.length = 0;
|
|
1086
1093
|
logger.info("[Pipeline Debug] Run history cleared.");
|
|
1087
1094
|
},
|
|
1095
|
+
/**
|
|
1096
|
+
* Show the navigator registry: all registered classes and their roles.
|
|
1097
|
+
*
|
|
1098
|
+
* Useful for verifying that consumer-defined navigators were registered
|
|
1099
|
+
* before pipeline assembly.
|
|
1100
|
+
*/
|
|
1101
|
+
showRegistry() {
|
|
1102
|
+
const names = getRegisteredNavigatorNames();
|
|
1103
|
+
if (names.length === 0) {
|
|
1104
|
+
logger.info("[Pipeline Debug] Navigator registry is empty.");
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
console.group("\u{1F4E6} Navigator Registry");
|
|
1108
|
+
console.table(
|
|
1109
|
+
names.map((name) => {
|
|
1110
|
+
const registryRole = getRegisteredNavigatorRole(name);
|
|
1111
|
+
const builtinRole = NavigatorRoles[name];
|
|
1112
|
+
const effectiveRole = builtinRole || registryRole || "\u26A0\uFE0F NONE";
|
|
1113
|
+
const source = builtinRole ? "built-in" : registryRole ? "consumer" : "unclassified";
|
|
1114
|
+
return {
|
|
1115
|
+
name,
|
|
1116
|
+
role: effectiveRole,
|
|
1117
|
+
source,
|
|
1118
|
+
isGenerator: isGenerator(name),
|
|
1119
|
+
isFilter: isFilter(name)
|
|
1120
|
+
};
|
|
1121
|
+
})
|
|
1122
|
+
);
|
|
1123
|
+
console.groupEnd();
|
|
1124
|
+
},
|
|
1125
|
+
/**
|
|
1126
|
+
* Show strategy documents from the last pipeline run and how they mapped
|
|
1127
|
+
* to the registry.
|
|
1128
|
+
*
|
|
1129
|
+
* If no runs are captured yet, falls back to showing just the registry.
|
|
1130
|
+
*/
|
|
1131
|
+
showStrategies() {
|
|
1132
|
+
this.showRegistry();
|
|
1133
|
+
if (runHistory.length === 0) {
|
|
1134
|
+
logger.info("[Pipeline Debug] No pipeline runs captured yet \u2014 cannot show strategy doc mapping.");
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
const run = runHistory[0];
|
|
1138
|
+
console.group("\u{1F50C} Pipeline Strategy Mapping (last run)");
|
|
1139
|
+
logger.info(`Generator: ${run.generatorName}`);
|
|
1140
|
+
if (run.generators && run.generators.length > 0) {
|
|
1141
|
+
for (const g of run.generators) {
|
|
1142
|
+
logger.info(` \u{1F4E5} ${g.name}: ${g.cardCount} cards (${g.newCount} new, ${g.reviewCount} reviews)`);
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
if (run.filters.length > 0) {
|
|
1146
|
+
logger.info("Filters:");
|
|
1147
|
+
for (const f of run.filters) {
|
|
1148
|
+
logger.info(` \u{1F538} ${f.name}: \u2191${f.boosted} \u2193${f.penalized} =${f.passed} \u2715${f.removed}`);
|
|
1149
|
+
}
|
|
1150
|
+
} else {
|
|
1151
|
+
logger.info("Filters: (none)");
|
|
1152
|
+
}
|
|
1153
|
+
console.groupEnd();
|
|
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
|
+
},
|
|
1088
1170
|
/**
|
|
1089
1171
|
* Show help.
|
|
1090
1172
|
*/
|
|
@@ -1097,6 +1179,9 @@ Commands:
|
|
|
1097
1179
|
.showRun(id|index) Show summary of a specific run (by index or ID suffix)
|
|
1098
1180
|
.showCard(cardId) Show provenance trail for a specific card
|
|
1099
1181
|
.explainReviews() Analyze why reviews were/weren't selected
|
|
1182
|
+
.diagnoseCardSpace() Scan full card space through filters (async)
|
|
1183
|
+
.showRegistry() Show navigator registry (classes + roles)
|
|
1184
|
+
.showStrategies() Show registry + strategy mapping from last run
|
|
1100
1185
|
.listRuns() List all captured runs in table format
|
|
1101
1186
|
.export() Export run history as JSON for bug reports
|
|
1102
1187
|
.clear() Clear run history
|
|
@@ -1106,7 +1191,7 @@ Commands:
|
|
|
1106
1191
|
Example:
|
|
1107
1192
|
window.skuilder.pipeline.showLastRun()
|
|
1108
1193
|
window.skuilder.pipeline.showRun(1)
|
|
1109
|
-
window.skuilder.pipeline.
|
|
1194
|
+
await window.skuilder.pipeline.diagnoseCardSpace()
|
|
1110
1195
|
`);
|
|
1111
1196
|
}
|
|
1112
1197
|
};
|
|
@@ -1401,6 +1486,69 @@ var init_generators = __esm({
|
|
|
1401
1486
|
}
|
|
1402
1487
|
});
|
|
1403
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
|
+
|
|
1404
1552
|
// src/core/navigators/generators/srs.ts
|
|
1405
1553
|
var srs_exports = {};
|
|
1406
1554
|
__export(srs_exports, {
|
|
@@ -1595,6 +1743,7 @@ var init_ = __esm({
|
|
|
1595
1743
|
"./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
|
|
1596
1744
|
"./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
|
|
1597
1745
|
"./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
|
|
1746
|
+
"./generators/prescribed.ts": () => Promise.resolve().then(() => (init_prescribed(), prescribed_exports)),
|
|
1598
1747
|
"./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
|
|
1599
1748
|
"./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports))
|
|
1600
1749
|
});
|
|
@@ -1795,6 +1944,8 @@ var init_hierarchyDefinition = __esm({
|
|
|
1795
1944
|
if (userTagElo.count < minCount) return false;
|
|
1796
1945
|
if (prereq.masteryThreshold?.minElo !== void 0) {
|
|
1797
1946
|
return userTagElo.score >= prereq.masteryThreshold.minElo;
|
|
1947
|
+
} else if (prereq.masteryThreshold?.minCount !== void 0) {
|
|
1948
|
+
return true;
|
|
1798
1949
|
} else {
|
|
1799
1950
|
return userTagElo.score >= userGlobalElo;
|
|
1800
1951
|
}
|
|
@@ -1870,14 +2021,38 @@ var init_hierarchyDefinition = __esm({
|
|
|
1870
2021
|
};
|
|
1871
2022
|
}
|
|
1872
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
|
+
}
|
|
1873
2044
|
/**
|
|
1874
2045
|
* CardFilter.transform implementation.
|
|
1875
2046
|
*
|
|
1876
|
-
*
|
|
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
|
|
1877
2051
|
*/
|
|
1878
2052
|
async transform(cards, context) {
|
|
1879
2053
|
const masteredTags = await this.getMasteredTags(context);
|
|
1880
2054
|
const unlockedTags = this.getUnlockedTags(masteredTags);
|
|
2055
|
+
const preReqBoosts = this.getPreReqBoosts(unlockedTags, masteredTags);
|
|
1881
2056
|
const gated = [];
|
|
1882
2057
|
for (const card of cards) {
|
|
1883
2058
|
const { isUnlocked, reason } = await this.checkCardUnlock(
|
|
@@ -1886,9 +2061,27 @@ var init_hierarchyDefinition = __esm({
|
|
|
1886
2061
|
unlockedTags,
|
|
1887
2062
|
masteredTags
|
|
1888
2063
|
);
|
|
1889
|
-
const LOCKED_PENALTY = 0.
|
|
1890
|
-
|
|
1891
|
-
|
|
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
|
+
}
|
|
1892
2085
|
gated.push({
|
|
1893
2086
|
...card,
|
|
1894
2087
|
score: finalScore,
|
|
@@ -1900,7 +2093,7 @@ var init_hierarchyDefinition = __esm({
|
|
|
1900
2093
|
strategyId: this.strategyId || "NAVIGATION_STRATEGY-hierarchy",
|
|
1901
2094
|
action,
|
|
1902
2095
|
score: finalScore,
|
|
1903
|
-
reason
|
|
2096
|
+
reason: finalReason
|
|
1904
2097
|
}
|
|
1905
2098
|
]
|
|
1906
2099
|
});
|
|
@@ -2835,6 +3028,18 @@ __export(Pipeline_exports, {
|
|
|
2835
3028
|
Pipeline: () => Pipeline
|
|
2836
3029
|
});
|
|
2837
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
|
+
}
|
|
2838
3043
|
function logPipelineConfig(generator, filters) {
|
|
2839
3044
|
const filterList = filters.length > 0 ? "\n - " + filters.map((f) => f.name).join("\n - ") : " none";
|
|
2840
3045
|
logger.info(
|
|
@@ -2869,6 +3074,21 @@ function logExecutionSummary(generatorName, generatedCount, filterCount, finalCo
|
|
|
2869
3074
|
\u{1F4A1} Inspect: window.skuilder.pipeline`
|
|
2870
3075
|
);
|
|
2871
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
|
+
}
|
|
2872
3092
|
function logCardProvenance(cards, maxCards = 3) {
|
|
2873
3093
|
const cardsToLog = cards.slice(0, maxCards);
|
|
2874
3094
|
logger.debug(`[Pipeline] Provenance for top ${cardsToLog.length} cards:`);
|
|
@@ -2883,7 +3103,7 @@ function logCardProvenance(cards, maxCards = 3) {
|
|
|
2883
3103
|
}
|
|
2884
3104
|
}
|
|
2885
3105
|
}
|
|
2886
|
-
var Pipeline;
|
|
3106
|
+
var VERBOSE_RESULTS, Pipeline;
|
|
2887
3107
|
var init_Pipeline = __esm({
|
|
2888
3108
|
"src/core/navigators/Pipeline.ts"() {
|
|
2889
3109
|
"use strict";
|
|
@@ -2891,9 +3111,31 @@ var init_Pipeline = __esm({
|
|
|
2891
3111
|
init_logger();
|
|
2892
3112
|
init_orchestration();
|
|
2893
3113
|
init_PipelineDebugger();
|
|
3114
|
+
VERBOSE_RESULTS = true;
|
|
2894
3115
|
Pipeline = class extends ContentNavigator {
|
|
2895
3116
|
generator;
|
|
2896
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;
|
|
2897
3139
|
/**
|
|
2898
3140
|
* Create a new pipeline.
|
|
2899
3141
|
*
|
|
@@ -2914,6 +3156,17 @@ var init_Pipeline = __esm({
|
|
|
2914
3156
|
logger.error(`[pipeline] Failed to lookup courseCfg: ${e}`);
|
|
2915
3157
|
});
|
|
2916
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)}`);
|
|
2917
3170
|
}
|
|
2918
3171
|
/**
|
|
2919
3172
|
* Get weighted cards by running generator and applying filters.
|
|
@@ -2930,13 +3183,15 @@ var init_Pipeline = __esm({
|
|
|
2930
3183
|
* @returns Cards sorted by score descending
|
|
2931
3184
|
*/
|
|
2932
3185
|
async getWeightedCards(limit) {
|
|
3186
|
+
const t0 = performance.now();
|
|
2933
3187
|
const context = await this.buildContext();
|
|
2934
|
-
const
|
|
2935
|
-
const fetchLimit =
|
|
3188
|
+
const tContext = performance.now();
|
|
3189
|
+
const fetchLimit = 500;
|
|
2936
3190
|
logger.debug(
|
|
2937
3191
|
`[Pipeline] Fetching ${fetchLimit} candidates from generator '${this.generator.name}'`
|
|
2938
3192
|
);
|
|
2939
3193
|
let cards = await this.generator.getWeightedCards(fetchLimit, context);
|
|
3194
|
+
const tGenerate = performance.now();
|
|
2940
3195
|
const generatedCount = cards.length;
|
|
2941
3196
|
let generatorSummaries;
|
|
2942
3197
|
if (this.generator.generators) {
|
|
@@ -2965,6 +3220,7 @@ var init_Pipeline = __esm({
|
|
|
2965
3220
|
}
|
|
2966
3221
|
logger.debug(`[Pipeline] Generator returned ${generatedCount} candidates`);
|
|
2967
3222
|
cards = await this.hydrateTags(cards);
|
|
3223
|
+
const tHydrate = performance.now();
|
|
2968
3224
|
const allCardsBeforeFiltering = [...cards];
|
|
2969
3225
|
const filterImpacts = [];
|
|
2970
3226
|
for (const filter of this.filters) {
|
|
@@ -2983,8 +3239,17 @@ var init_Pipeline = __esm({
|
|
|
2983
3239
|
logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeScores.size} \u2192 ${cards.length} cards (\u2191${boosted} \u2193${penalized} =${passed})`);
|
|
2984
3240
|
}
|
|
2985
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
|
+
}
|
|
2986
3247
|
cards.sort((a, b) => b.score - a.score);
|
|
3248
|
+
const tFilter = performance.now();
|
|
2987
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
|
+
);
|
|
2988
3253
|
const topScores = result.slice(0, 3).map((c) => c.score);
|
|
2989
3254
|
logExecutionSummary(
|
|
2990
3255
|
this.generator.name,
|
|
@@ -2994,6 +3259,7 @@ var init_Pipeline = __esm({
|
|
|
2994
3259
|
topScores,
|
|
2995
3260
|
filterImpacts
|
|
2996
3261
|
);
|
|
3262
|
+
logResultCards(result);
|
|
2997
3263
|
logCardProvenance(result, 3);
|
|
2998
3264
|
try {
|
|
2999
3265
|
const courseName = await this.course?.getCourseConfig().then((c) => c.name).catch(() => void 0);
|
|
@@ -3020,6 +3286,10 @@ var init_Pipeline = __esm({
|
|
|
3020
3286
|
* to the WeightedCard objects. Filters can then use card.tags instead of
|
|
3021
3287
|
* making individual getAppliedTags() calls.
|
|
3022
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
|
+
*
|
|
3023
3293
|
* @param cards - Cards to hydrate
|
|
3024
3294
|
* @returns Cards with tags populated
|
|
3025
3295
|
*/
|
|
@@ -3027,14 +3297,128 @@ var init_Pipeline = __esm({
|
|
|
3027
3297
|
if (cards.length === 0) {
|
|
3028
3298
|
return cards;
|
|
3029
3299
|
}
|
|
3030
|
-
const
|
|
3031
|
-
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
|
+
}
|
|
3032
3316
|
logTagHydration(cards, tagsByCard);
|
|
3033
3317
|
return cards.map((card) => ({
|
|
3034
3318
|
...card,
|
|
3035
|
-
tags:
|
|
3319
|
+
tags: this._tagCache.get(card.cardId) ?? []
|
|
3036
3320
|
}));
|
|
3037
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
|
+
}
|
|
3038
3422
|
/**
|
|
3039
3423
|
* Build shared context for generator and filters.
|
|
3040
3424
|
*
|
|
@@ -3052,7 +3436,10 @@ var init_Pipeline = __esm({
|
|
|
3052
3436
|
} catch (e) {
|
|
3053
3437
|
logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`);
|
|
3054
3438
|
}
|
|
3055
|
-
|
|
3439
|
+
if (!this._cachedOrchestration) {
|
|
3440
|
+
this._cachedOrchestration = await createOrchestrationContext(this.user, this.course);
|
|
3441
|
+
}
|
|
3442
|
+
const orchestration = this._cachedOrchestration;
|
|
3056
3443
|
return {
|
|
3057
3444
|
user: this.user,
|
|
3058
3445
|
course: this.course,
|
|
@@ -3096,6 +3483,87 @@ var init_Pipeline = __esm({
|
|
|
3096
3483
|
}
|
|
3097
3484
|
return [...new Set(ids)];
|
|
3098
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
|
+
}
|
|
3099
3567
|
};
|
|
3100
3568
|
}
|
|
3101
3569
|
});
|
|
@@ -3200,23 +3668,25 @@ var init_PipelineAssembler = __esm({
|
|
|
3200
3668
|
warnings.push(`Unknown strategy type '${s.implementingClass}', skipping: ${s.name}`);
|
|
3201
3669
|
}
|
|
3202
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
|
+
}
|
|
3203
3682
|
if (generatorStrategies.length === 0) {
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
} else {
|
|
3212
|
-
warnings.push("No generator strategy found");
|
|
3213
|
-
return {
|
|
3214
|
-
pipeline: null,
|
|
3215
|
-
generatorStrategies: [],
|
|
3216
|
-
filterStrategies: [],
|
|
3217
|
-
warnings
|
|
3218
|
-
};
|
|
3219
|
-
}
|
|
3683
|
+
warnings.push("No generator strategy found");
|
|
3684
|
+
return {
|
|
3685
|
+
pipeline: null,
|
|
3686
|
+
generatorStrategies: [],
|
|
3687
|
+
filterStrategies: [],
|
|
3688
|
+
warnings
|
|
3689
|
+
};
|
|
3220
3690
|
}
|
|
3221
3691
|
let generator;
|
|
3222
3692
|
if (generatorStrategies.length === 1) {
|
|
@@ -3294,6 +3764,7 @@ var init_3 = __esm({
|
|
|
3294
3764
|
"./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
|
|
3295
3765
|
"./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
|
|
3296
3766
|
"./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
|
|
3767
|
+
"./generators/prescribed.ts": () => Promise.resolve().then(() => (init_prescribed(), prescribed_exports)),
|
|
3297
3768
|
"./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
|
|
3298
3769
|
"./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports)),
|
|
3299
3770
|
"./index.ts": () => Promise.resolve().then(() => (init_navigators(), navigators_exports))
|
|
@@ -3311,6 +3782,7 @@ __export(navigators_exports, {
|
|
|
3311
3782
|
getCardOrigin: () => getCardOrigin,
|
|
3312
3783
|
getRegisteredNavigator: () => getRegisteredNavigator,
|
|
3313
3784
|
getRegisteredNavigatorNames: () => getRegisteredNavigatorNames,
|
|
3785
|
+
getRegisteredNavigatorRole: () => getRegisteredNavigatorRole,
|
|
3314
3786
|
hasRegisteredNavigator: () => hasRegisteredNavigator,
|
|
3315
3787
|
initializeNavigatorRegistry: () => initializeNavigatorRegistry,
|
|
3316
3788
|
isFilter: () => isFilter,
|
|
@@ -3319,16 +3791,19 @@ __export(navigators_exports, {
|
|
|
3319
3791
|
pipelineDebugAPI: () => pipelineDebugAPI,
|
|
3320
3792
|
registerNavigator: () => registerNavigator
|
|
3321
3793
|
});
|
|
3322
|
-
function registerNavigator(implementingClass, constructor) {
|
|
3323
|
-
navigatorRegistry.set(implementingClass, constructor);
|
|
3324
|
-
logger.debug(`[NavigatorRegistry] Registered: ${implementingClass}`);
|
|
3794
|
+
function registerNavigator(implementingClass, constructor, role) {
|
|
3795
|
+
navigatorRegistry.set(implementingClass, { constructor, role });
|
|
3796
|
+
logger.debug(`[NavigatorRegistry] Registered: ${implementingClass}${role ? ` (${role})` : ""}`);
|
|
3325
3797
|
}
|
|
3326
3798
|
function getRegisteredNavigator(implementingClass) {
|
|
3327
|
-
return navigatorRegistry.get(implementingClass);
|
|
3799
|
+
return navigatorRegistry.get(implementingClass)?.constructor;
|
|
3328
3800
|
}
|
|
3329
3801
|
function hasRegisteredNavigator(implementingClass) {
|
|
3330
3802
|
return navigatorRegistry.has(implementingClass);
|
|
3331
3803
|
}
|
|
3804
|
+
function getRegisteredNavigatorRole(implementingClass) {
|
|
3805
|
+
return navigatorRegistry.get(implementingClass)?.role;
|
|
3806
|
+
}
|
|
3332
3807
|
function getRegisteredNavigatorNames() {
|
|
3333
3808
|
return Array.from(navigatorRegistry.keys());
|
|
3334
3809
|
}
|
|
@@ -3338,8 +3813,10 @@ async function initializeNavigatorRegistry() {
|
|
|
3338
3813
|
Promise.resolve().then(() => (init_elo(), elo_exports)),
|
|
3339
3814
|
Promise.resolve().then(() => (init_srs(), srs_exports))
|
|
3340
3815
|
]);
|
|
3816
|
+
const prescribedModule = await Promise.resolve().then(() => (init_prescribed(), prescribed_exports));
|
|
3341
3817
|
registerNavigator("elo", eloModule.default);
|
|
3342
3818
|
registerNavigator("srs", srsModule.default);
|
|
3819
|
+
registerNavigator("prescribed", prescribedModule.default);
|
|
3343
3820
|
const [
|
|
3344
3821
|
hierarchyModule,
|
|
3345
3822
|
interferenceModule,
|
|
@@ -3374,10 +3851,12 @@ function getCardOrigin(card) {
|
|
|
3374
3851
|
return "new";
|
|
3375
3852
|
}
|
|
3376
3853
|
function isGenerator(impl) {
|
|
3377
|
-
|
|
3854
|
+
if (NavigatorRoles[impl] === "generator" /* GENERATOR */) return true;
|
|
3855
|
+
return getRegisteredNavigatorRole(impl) === "generator" /* GENERATOR */;
|
|
3378
3856
|
}
|
|
3379
3857
|
function isFilter(impl) {
|
|
3380
|
-
|
|
3858
|
+
if (NavigatorRoles[impl] === "filter" /* FILTER */) return true;
|
|
3859
|
+
return getRegisteredNavigatorRole(impl) === "filter" /* FILTER */;
|
|
3381
3860
|
}
|
|
3382
3861
|
var navigatorRegistry, Navigators, NavigatorRole, NavigatorRoles, ContentNavigator;
|
|
3383
3862
|
var init_navigators = __esm({
|
|
@@ -3392,6 +3871,7 @@ var init_navigators = __esm({
|
|
|
3392
3871
|
Navigators = /* @__PURE__ */ ((Navigators2) => {
|
|
3393
3872
|
Navigators2["ELO"] = "elo";
|
|
3394
3873
|
Navigators2["SRS"] = "srs";
|
|
3874
|
+
Navigators2["PRESCRIBED"] = "prescribed";
|
|
3395
3875
|
Navigators2["HIERARCHY"] = "hierarchyDefinition";
|
|
3396
3876
|
Navigators2["INTERFERENCE"] = "interferenceMitigator";
|
|
3397
3877
|
Navigators2["RELATIVE_PRIORITY"] = "relativePriority";
|
|
@@ -3406,6 +3886,7 @@ var init_navigators = __esm({
|
|
|
3406
3886
|
NavigatorRoles = {
|
|
3407
3887
|
["elo" /* ELO */]: "generator" /* GENERATOR */,
|
|
3408
3888
|
["srs" /* SRS */]: "generator" /* GENERATOR */,
|
|
3889
|
+
["prescribed" /* PRESCRIBED */]: "generator" /* GENERATOR */,
|
|
3409
3890
|
["hierarchyDefinition" /* HIERARCHY */]: "filter" /* FILTER */,
|
|
3410
3891
|
["interferenceMitigator" /* INTERFERENCE */]: "filter" /* FILTER */,
|
|
3411
3892
|
["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */,
|
|
@@ -3570,6 +4051,12 @@ var init_navigators = __esm({
|
|
|
3570
4051
|
async getWeightedCards(_limit) {
|
|
3571
4052
|
throw new Error(`${this.constructor.name} must implement getWeightedCards(). `);
|
|
3572
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
|
+
}
|
|
3573
4060
|
};
|
|
3574
4061
|
}
|
|
3575
4062
|
});
|
|
@@ -3719,15 +4206,42 @@ var init_courseDB = __esm({
|
|
|
3719
4206
|
// private log(msg: string): void {
|
|
3720
4207
|
// log(`CourseLog: ${this.id}\n ${msg}`);
|
|
3721
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
|
+
*/
|
|
3722
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;
|
|
3723
4226
|
id;
|
|
3724
4227
|
_getCurrentUser;
|
|
3725
4228
|
updateQueue;
|
|
3726
|
-
|
|
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) {
|
|
3727
4239
|
this.id = id;
|
|
3728
|
-
|
|
4240
|
+
const remote = getCourseDB2(this.id);
|
|
4241
|
+
this.remoteDB = remote;
|
|
4242
|
+
this.db = localDB ?? remote;
|
|
3729
4243
|
this._getCurrentUser = userLookup;
|
|
3730
|
-
this.updateQueue = new UpdateQueue(this.
|
|
4244
|
+
this.updateQueue = new UpdateQueue(this.remoteDB, this.remoteDB);
|
|
3731
4245
|
}
|
|
3732
4246
|
getCourseID() {
|
|
3733
4247
|
return this.id;
|
|
@@ -3815,7 +4329,7 @@ var init_courseDB = __esm({
|
|
|
3815
4329
|
};
|
|
3816
4330
|
}
|
|
3817
4331
|
async removeCard(id) {
|
|
3818
|
-
const doc = await this.
|
|
4332
|
+
const doc = await this.remoteDB.get(id);
|
|
3819
4333
|
if (!doc.docType || !(doc.docType === "CARD" /* CARD */)) {
|
|
3820
4334
|
throw new Error(`failed to remove ${id} from course ${this.id}. id does not point to a card`);
|
|
3821
4335
|
}
|
|
@@ -3836,7 +4350,7 @@ var init_courseDB = __esm({
|
|
|
3836
4350
|
} catch (error) {
|
|
3837
4351
|
logger.error(`Error removing card ${id} from tags: ${error}`);
|
|
3838
4352
|
}
|
|
3839
|
-
return this.
|
|
4353
|
+
return this.remoteDB.remove(doc);
|
|
3840
4354
|
}
|
|
3841
4355
|
async getCardDisplayableDataIDs(id) {
|
|
3842
4356
|
logger.debug(id.join(", "));
|
|
@@ -3938,8 +4452,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3938
4452
|
if (cardIds.length === 0) {
|
|
3939
4453
|
return /* @__PURE__ */ new Map();
|
|
3940
4454
|
}
|
|
3941
|
-
const
|
|
3942
|
-
const result = await db.query("getTags", {
|
|
4455
|
+
const result = await this.db.query("getTags", {
|
|
3943
4456
|
keys: cardIds,
|
|
3944
4457
|
include_docs: false
|
|
3945
4458
|
});
|
|
@@ -3956,6 +4469,14 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3956
4469
|
}
|
|
3957
4470
|
return tagsByCard;
|
|
3958
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
|
+
}
|
|
3959
4480
|
async addTagToCard(cardId, tagId, updateELO) {
|
|
3960
4481
|
return await addTagToCard(
|
|
3961
4482
|
this.id,
|
|
@@ -4022,10 +4543,13 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
4022
4543
|
}
|
|
4023
4544
|
}
|
|
4024
4545
|
async getCourseDoc(id, options) {
|
|
4025
|
-
return await
|
|
4546
|
+
return await this.db.get(id, options);
|
|
4026
4547
|
}
|
|
4027
4548
|
async getCourseDocs(ids, options = {}) {
|
|
4028
|
-
return await
|
|
4549
|
+
return await this.db.allDocs({
|
|
4550
|
+
...options,
|
|
4551
|
+
keys: ids
|
|
4552
|
+
});
|
|
4029
4553
|
}
|
|
4030
4554
|
////////////////////////////////////
|
|
4031
4555
|
// NavigationStrategyManager implementation
|
|
@@ -4059,7 +4583,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
4059
4583
|
}
|
|
4060
4584
|
async addNavigationStrategy(data) {
|
|
4061
4585
|
logger.debug(`[courseDB] Adding navigation strategy: ${data._id}`);
|
|
4062
|
-
return this.
|
|
4586
|
+
return this.remoteDB.put(data).then(() => {
|
|
4063
4587
|
});
|
|
4064
4588
|
}
|
|
4065
4589
|
updateNavigationStrategy(id, data) {
|
|
@@ -4616,6 +5140,234 @@ var init_adminDB2 = __esm({
|
|
|
4616
5140
|
}
|
|
4617
5141
|
});
|
|
4618
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
|
+
|
|
4619
5371
|
// src/impl/couch/auth.ts
|
|
4620
5372
|
import fetch2 from "cross-fetch";
|
|
4621
5373
|
async function getCurrentSession() {
|
|
@@ -4916,15 +5668,6 @@ function getCourseDB2(courseID) {
|
|
|
4916
5668
|
createPouchDBConfig()
|
|
4917
5669
|
);
|
|
4918
5670
|
}
|
|
4919
|
-
function getCourseDocs(courseID, docIDs, options = {}) {
|
|
4920
|
-
return getCourseDB2(courseID).allDocs({
|
|
4921
|
-
...options,
|
|
4922
|
-
keys: docIDs
|
|
4923
|
-
});
|
|
4924
|
-
}
|
|
4925
|
-
function getCourseDoc(courseID, docID, options = {}) {
|
|
4926
|
-
return getCourseDB2(courseID).get(docID, options);
|
|
4927
|
-
}
|
|
4928
5671
|
function filterAllDocsByPrefix2(db, prefix, opts) {
|
|
4929
5672
|
const options = {
|
|
4930
5673
|
startkey: prefix,
|
|
@@ -4955,6 +5698,7 @@ var init_couch = __esm({
|
|
|
4955
5698
|
init_classroomDB2();
|
|
4956
5699
|
init_courseAPI();
|
|
4957
5700
|
init_courseDB();
|
|
5701
|
+
init_CourseSyncService();
|
|
4958
5702
|
init_CouchDBSyncStrategy();
|
|
4959
5703
|
isBrowser = typeof window !== "undefined";
|
|
4960
5704
|
if (isBrowser) {
|
|
@@ -5255,6 +5999,9 @@ Currently logged-in as ${this._username}.`
|
|
|
5255
5999
|
const id = row.id;
|
|
5256
6000
|
return id.startsWith(DocTypePrefixes["CARDRECORD" /* CARDRECORD */]) || // Card interaction history
|
|
5257
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
|
|
5258
6005
|
id === _BaseUser.DOC_IDS.COURSE_REGISTRATIONS || // Course registrations
|
|
5259
6006
|
id === _BaseUser.DOC_IDS.CLASSROOM_REGISTRATIONS || // Classroom registrations
|
|
5260
6007
|
id === _BaseUser.DOC_IDS.CONFIG;
|
|
@@ -6051,6 +6798,7 @@ var init_PouchDataLayerProvider = __esm({
|
|
|
6051
6798
|
init_adminDB2();
|
|
6052
6799
|
init_classroomDB2();
|
|
6053
6800
|
init_courseDB();
|
|
6801
|
+
init_CourseSyncService();
|
|
6054
6802
|
init_common();
|
|
6055
6803
|
init_CouchDBSyncStrategy();
|
|
6056
6804
|
CouchDataLayerProvider = class {
|
|
@@ -6090,7 +6838,22 @@ var init_PouchDataLayerProvider = __esm({
|
|
|
6090
6838
|
return this.userDB;
|
|
6091
6839
|
}
|
|
6092
6840
|
getCourseDB(courseId) {
|
|
6093
|
-
|
|
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);
|
|
6094
6857
|
}
|
|
6095
6858
|
getCoursesDB() {
|
|
6096
6859
|
return new CoursesDB(this._courseIDs);
|
|
@@ -6718,6 +7481,10 @@ var init_courseDB2 = __esm({
|
|
|
6718
7481
|
}
|
|
6719
7482
|
return tagsByCard;
|
|
6720
7483
|
}
|
|
7484
|
+
async getAllCardIds() {
|
|
7485
|
+
const tagsIndex = await this.unpacker.getTagsIndex();
|
|
7486
|
+
return Object.keys(tagsIndex.byCard);
|
|
7487
|
+
}
|
|
6721
7488
|
async addTagToCard(_cardId, _tagId) {
|
|
6722
7489
|
throw new Error("Cannot modify tags in static mode");
|
|
6723
7490
|
}
|
|
@@ -8253,6 +9020,7 @@ import moment8 from "moment";
|
|
|
8253
9020
|
init_util();
|
|
8254
9021
|
init_logger();
|
|
8255
9022
|
import moment7 from "moment";
|
|
9023
|
+
import { isTaggedPerformance } from "@vue-skuilder/common";
|
|
8256
9024
|
var duration = moment7.duration;
|
|
8257
9025
|
function newInterval(user, cardHistory) {
|
|
8258
9026
|
if (areQuestionRecords(cardHistory)) {
|
|
@@ -8267,12 +9035,16 @@ function newQuestionInterval(user, cardHistory) {
|
|
|
8267
9035
|
const lastInterval = lastSuccessfulInterval(records);
|
|
8268
9036
|
if (lastInterval > cardHistory.bestInterval) {
|
|
8269
9037
|
cardHistory.bestInterval = lastInterval;
|
|
8270
|
-
|
|
9038
|
+
user.update(cardHistory._id, {
|
|
8271
9039
|
bestInterval: lastInterval
|
|
9040
|
+
}).catch((e) => {
|
|
9041
|
+
logger.warn(`[SpacedRepetition] Failed to update bestInterval for ${cardHistory._id}: ${e?.message ?? e}`);
|
|
8272
9042
|
});
|
|
8273
9043
|
}
|
|
8274
9044
|
if (currentAttempt.isCorrect) {
|
|
8275
|
-
const
|
|
9045
|
+
const rawPerf = currentAttempt.performance;
|
|
9046
|
+
const numericPerf = isTaggedPerformance(rawPerf) ? rawPerf._global : rawPerf;
|
|
9047
|
+
const skill = Math.min(1, Math.max(0, numericPerf));
|
|
8276
9048
|
logger.debug(`Demontrated skill: ${skill}`);
|
|
8277
9049
|
const interval = lastInterval * (0.75 + skill);
|
|
8278
9050
|
cardHistory.lapses = getLapses(cardHistory.records);
|
|
@@ -8486,7 +9258,7 @@ var EloService = class {
|
|
|
8486
9258
|
// src/study/services/ResponseProcessor.ts
|
|
8487
9259
|
init_core();
|
|
8488
9260
|
init_logger();
|
|
8489
|
-
import { isTaggedPerformance } from "@vue-skuilder/common";
|
|
9261
|
+
import { isTaggedPerformance as isTaggedPerformance2 } from "@vue-skuilder/common";
|
|
8490
9262
|
var ResponseProcessor = class {
|
|
8491
9263
|
srsService;
|
|
8492
9264
|
eloService;
|
|
@@ -8507,7 +9279,7 @@ var ResponseProcessor = class {
|
|
|
8507
9279
|
taggedPerformance: null
|
|
8508
9280
|
};
|
|
8509
9281
|
}
|
|
8510
|
-
if (
|
|
9282
|
+
if (isTaggedPerformance2(performance2)) {
|
|
8511
9283
|
return {
|
|
8512
9284
|
globalScore: performance2._global,
|
|
8513
9285
|
taggedPerformance: performance2
|
|
@@ -8932,6 +9704,46 @@ var ItemQueue = class {
|
|
|
8932
9704
|
return null;
|
|
8933
9705
|
}
|
|
8934
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
|
+
}
|
|
8935
9747
|
get toString() {
|
|
8936
9748
|
return `${typeof this.q[0]}:
|
|
8937
9749
|
` + this.q.map((i) => ` ${i.courseID}+${i.cardID}: ${i.status}`).join("\n");
|
|
@@ -11015,7 +11827,7 @@ mountSessionDebugger();
|
|
|
11015
11827
|
|
|
11016
11828
|
// src/study/SessionController.ts
|
|
11017
11829
|
init_logger();
|
|
11018
|
-
var SessionController = class extends Loggable {
|
|
11830
|
+
var SessionController = class _SessionController extends Loggable {
|
|
11019
11831
|
_className = "SessionController";
|
|
11020
11832
|
services;
|
|
11021
11833
|
srsService;
|
|
@@ -11036,6 +11848,18 @@ var SessionController = class extends Loggable {
|
|
|
11036
11848
|
newQ = new ItemQueue();
|
|
11037
11849
|
failedQ = new ItemQueue();
|
|
11038
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;
|
|
11039
11863
|
startTime;
|
|
11040
11864
|
endTime;
|
|
11041
11865
|
_secondsRemaining;
|
|
@@ -11129,13 +11953,83 @@ var SessionController = class extends Loggable {
|
|
|
11129
11953
|
"[SessionController] All content sources must implement getWeightedCards()."
|
|
11130
11954
|
);
|
|
11131
11955
|
}
|
|
11132
|
-
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
|
+
}
|
|
11133
11963
|
await this.hydrationService.ensureHydratedCards();
|
|
11134
11964
|
startSessionTracking(this.reviewQ.length, this.newQ.length, this.failedQ.length);
|
|
11135
11965
|
this._intervalHandle = setInterval(() => {
|
|
11136
11966
|
this.tick();
|
|
11137
11967
|
}, 1e3);
|
|
11138
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
|
+
}
|
|
11139
12033
|
addTime(seconds) {
|
|
11140
12034
|
this.endTime = new Date(this.endTime.valueOf() + 1e3 * seconds);
|
|
11141
12035
|
}
|
|
@@ -11191,6 +12085,9 @@ var SessionController = class extends Loggable {
|
|
|
11191
12085
|
hydratedCache: {
|
|
11192
12086
|
count: this.hydrationService.hydratedCount,
|
|
11193
12087
|
cardIds: this.hydrationService.getHydratedCardIds()
|
|
12088
|
+
},
|
|
12089
|
+
replan: {
|
|
12090
|
+
inProgress: this._replanPromise !== null
|
|
11194
12091
|
}
|
|
11195
12092
|
};
|
|
11196
12093
|
}
|
|
@@ -11203,7 +12100,20 @@ var SessionController = class extends Loggable {
|
|
|
11203
12100
|
* 3. Uses SourceMixer to balance content across sources
|
|
11204
12101
|
* 4. Populates review and new card queues with mixed results
|
|
11205
12102
|
*/
|
|
11206
|
-
|
|
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;
|
|
11207
12117
|
const limit = 20;
|
|
11208
12118
|
const batches = [];
|
|
11209
12119
|
for (let i = 0; i < this.sources.length; i++) {
|
|
@@ -11222,6 +12132,10 @@ var SessionController = class extends Loggable {
|
|
|
11222
12132
|
}
|
|
11223
12133
|
}
|
|
11224
12134
|
if (batches.length === 0) {
|
|
12135
|
+
if (replan) {
|
|
12136
|
+
this.log("Replan: no content from any source, keeping existing newQ");
|
|
12137
|
+
return -1;
|
|
12138
|
+
}
|
|
11225
12139
|
throw new Error(
|
|
11226
12140
|
`Cannot start session: failed to load content from all ${this.sources.length} source(s). Check logs for details.`
|
|
11227
12141
|
);
|
|
@@ -11233,10 +12147,12 @@ var SessionController = class extends Loggable {
|
|
|
11233
12147
|
});
|
|
11234
12148
|
await Promise.all(
|
|
11235
12149
|
sourceIds.map(async (id) => {
|
|
11236
|
-
|
|
11237
|
-
|
|
11238
|
-
|
|
11239
|
-
|
|
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
|
+
}
|
|
11240
12156
|
}
|
|
11241
12157
|
})
|
|
11242
12158
|
);
|
|
@@ -11254,20 +12170,26 @@ var SessionController = class extends Loggable {
|
|
|
11254
12170
|
const reviewWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "review");
|
|
11255
12171
|
const newWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "new");
|
|
11256
12172
|
logger.debug(`[reviews] got ${reviewWeighted.length} reviews from mixer`);
|
|
11257
|
-
let report = "Mixed content session created with:\n";
|
|
11258
|
-
|
|
11259
|
-
const
|
|
11260
|
-
|
|
11261
|
-
|
|
11262
|
-
|
|
11263
|
-
|
|
11264
|
-
|
|
11265
|
-
|
|
11266
|
-
|
|
11267
|
-
|
|
11268
|
-
|
|
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)})
|
|
11269
12186
|
`;
|
|
12187
|
+
}
|
|
11270
12188
|
}
|
|
12189
|
+
const wellIndicated = newWeighted.filter(
|
|
12190
|
+
(w) => w.score >= _SessionController.WELL_INDICATED_SCORE
|
|
12191
|
+
).length;
|
|
12192
|
+
const newItems = [];
|
|
11271
12193
|
for (const w of newWeighted) {
|
|
11272
12194
|
const newItem = {
|
|
11273
12195
|
cardID: w.cardId,
|
|
@@ -11276,11 +12198,23 @@ var SessionController = class extends Loggable {
|
|
|
11276
12198
|
contentSourceID: w.courseId,
|
|
11277
12199
|
status: "new"
|
|
11278
12200
|
};
|
|
11279
|
-
|
|
12201
|
+
newItems.push(newItem);
|
|
11280
12202
|
report += `New: ${w.courseId}::${w.cardId} (score: ${w.score.toFixed(2)})
|
|
11281
12203
|
`;
|
|
11282
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
|
+
}
|
|
11283
12216
|
this.log(report);
|
|
12217
|
+
return wellIndicated;
|
|
11284
12218
|
}
|
|
11285
12219
|
/**
|
|
11286
12220
|
* Returns items that should be pre-hydrated.
|
|
@@ -11357,6 +12291,17 @@ var SessionController = class extends Loggable {
|
|
|
11357
12291
|
}
|
|
11358
12292
|
async nextCard(action = "dismiss-success") {
|
|
11359
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
|
+
}
|
|
11360
12305
|
if (this._secondsRemaining <= 0 && this.failedQ.length === 0) {
|
|
11361
12306
|
this._currentCard = null;
|
|
11362
12307
|
endSessionTracking();
|
|
@@ -11470,6 +12415,9 @@ var SessionController = class extends Loggable {
|
|
|
11470
12415
|
this.reviewQ.dequeue((queueItem) => queueItem.cardID);
|
|
11471
12416
|
} else if (this.newQ.peek(0)?.cardID === item.cardID) {
|
|
11472
12417
|
this.newQ.dequeue((queueItem) => queueItem.cardID);
|
|
12418
|
+
if (this._wellIndicatedRemaining > 0) {
|
|
12419
|
+
this._wellIndicatedRemaining--;
|
|
12420
|
+
}
|
|
11473
12421
|
} else if (this.failedQ.peek(0)?.cardID === item.cardID) {
|
|
11474
12422
|
this.failedQ.dequeue((queueItem) => queueItem.cardID);
|
|
11475
12423
|
}
|
|
@@ -11564,6 +12512,7 @@ export {
|
|
|
11564
12512
|
getDefaultLearnableWeight,
|
|
11565
12513
|
getRegisteredNavigator,
|
|
11566
12514
|
getRegisteredNavigatorNames,
|
|
12515
|
+
getRegisteredNavigatorRole,
|
|
11567
12516
|
getStudySource,
|
|
11568
12517
|
hasRegisteredNavigator,
|
|
11569
12518
|
importParsedCards,
|