@vue-skuilder/db 0.1.31-b → 0.1.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{contentSource-ygoFw9oV.d.ts → contentSource-Bdwkvqa8.d.ts} +16 -0
- package/dist/{contentSource-B7nXusjk.d.cts → contentSource-DF1nUbPQ.d.cts} +16 -0
- package/dist/core/index.d.cts +34 -3
- package/dist/core/index.d.ts +34 -3
- package/dist/core/index.js +510 -50
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +510 -50
- package/dist/core/index.mjs.map +1 -1
- package/dist/{dataLayerProvider-BW7HvkMt.d.cts → dataLayerProvider-BKmVoyJR.d.ts} +20 -1
- package/dist/{dataLayerProvider-BfXUVDuG.d.ts → dataLayerProvider-BQdfJuBN.d.cts} +20 -1
- package/dist/impl/couch/index.d.cts +156 -4
- package/dist/impl/couch/index.d.ts +156 -4
- package/dist/impl/couch/index.js +730 -41
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +729 -41
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.d.cts +3 -2
- package/dist/impl/static/index.d.ts +3 -2
- package/dist/impl/static/index.js +467 -31
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +467 -31
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/index.d.cts +64 -3
- package/dist/index.d.ts +64 -3
- package/dist/index.js +948 -72
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +948 -72
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/core/interfaces/contentSource.ts +6 -0
- package/src/core/interfaces/courseDB.ts +6 -0
- package/src/core/interfaces/dataLayerProvider.ts +20 -0
- package/src/core/navigators/Pipeline.ts +414 -9
- package/src/core/navigators/PipelineAssembler.ts +23 -18
- package/src/core/navigators/PipelineDebugger.ts +35 -1
- package/src/core/navigators/filters/hierarchyDefinition.ts +78 -8
- package/src/core/navigators/generators/prescribed.ts +95 -0
- package/src/core/navigators/index.ts +12 -0
- package/src/impl/common/BaseUserDB.ts +4 -1
- package/src/impl/couch/CourseSyncService.ts +356 -0
- package/src/impl/couch/PouchDataLayerProvider.ts +21 -1
- package/src/impl/couch/courseDB.ts +60 -13
- package/src/impl/couch/index.ts +1 -0
- package/src/impl/static/courseDB.ts +5 -0
- package/src/study/ItemQueue.ts +42 -0
- package/src/study/SessionController.ts +195 -22
- package/src/study/SpacedRepetition.ts +3 -1
- package/tests/core/navigators/Pipeline.test.ts +1 -1
- package/tests/core/navigators/PipelineAssembler.test.ts +15 -14
package/dist/impl/couch/index.js
CHANGED
|
@@ -649,8 +649,12 @@ __export(PipelineDebugger_exports, {
|
|
|
649
649
|
buildRunReport: () => buildRunReport,
|
|
650
650
|
captureRun: () => captureRun,
|
|
651
651
|
mountPipelineDebugger: () => mountPipelineDebugger,
|
|
652
|
-
pipelineDebugAPI: () => pipelineDebugAPI
|
|
652
|
+
pipelineDebugAPI: () => pipelineDebugAPI,
|
|
653
|
+
registerPipelineForDebug: () => registerPipelineForDebug
|
|
653
654
|
});
|
|
655
|
+
function registerPipelineForDebug(pipeline) {
|
|
656
|
+
_activePipeline = pipeline;
|
|
657
|
+
}
|
|
654
658
|
function getOrigin(card) {
|
|
655
659
|
const firstEntry = card.provenance[0];
|
|
656
660
|
if (!firstEntry) return "unknown";
|
|
@@ -678,6 +682,7 @@ function buildRunReport(courseId, courseName, generatorName, generators, generat
|
|
|
678
682
|
origin: getOrigin(card),
|
|
679
683
|
finalScore: card.score,
|
|
680
684
|
provenance: card.provenance,
|
|
685
|
+
tags: card.tags,
|
|
681
686
|
selected: selectedIds.has(card.cardId)
|
|
682
687
|
}));
|
|
683
688
|
const reviewsSelected = selectedCards.filter((c) => getOrigin(c) === "review").length;
|
|
@@ -733,12 +738,13 @@ function mountPipelineDebugger() {
|
|
|
733
738
|
win.skuilder = win.skuilder || {};
|
|
734
739
|
win.skuilder.pipeline = pipelineDebugAPI;
|
|
735
740
|
}
|
|
736
|
-
var MAX_RUNS, runHistory, pipelineDebugAPI;
|
|
741
|
+
var _activePipeline, MAX_RUNS, runHistory, pipelineDebugAPI;
|
|
737
742
|
var init_PipelineDebugger = __esm({
|
|
738
743
|
"src/core/navigators/PipelineDebugger.ts"() {
|
|
739
744
|
"use strict";
|
|
740
745
|
init_navigators();
|
|
741
746
|
init_logger();
|
|
747
|
+
_activePipeline = null;
|
|
742
748
|
MAX_RUNS = 10;
|
|
743
749
|
runHistory = [];
|
|
744
750
|
pipelineDebugAPI = {
|
|
@@ -940,6 +946,21 @@ var init_PipelineDebugger = __esm({
|
|
|
940
946
|
}
|
|
941
947
|
console.groupEnd();
|
|
942
948
|
},
|
|
949
|
+
/**
|
|
950
|
+
* Scan the full card space through the filter chain for the current user.
|
|
951
|
+
*
|
|
952
|
+
* Reports how many cards are well-indicated and how many are new.
|
|
953
|
+
* Use this to understand how the search space grows during onboarding.
|
|
954
|
+
*
|
|
955
|
+
* @param threshold - Score threshold for "well indicated" (default 0.10)
|
|
956
|
+
*/
|
|
957
|
+
async diagnoseCardSpace(threshold) {
|
|
958
|
+
if (!_activePipeline) {
|
|
959
|
+
logger.info("[Pipeline Debug] No active pipeline. Run a session first.");
|
|
960
|
+
return null;
|
|
961
|
+
}
|
|
962
|
+
return _activePipeline.diagnoseCardSpace({ threshold });
|
|
963
|
+
},
|
|
943
964
|
/**
|
|
944
965
|
* Show help.
|
|
945
966
|
*/
|
|
@@ -952,6 +973,7 @@ Commands:
|
|
|
952
973
|
.showRun(id|index) Show summary of a specific run (by index or ID suffix)
|
|
953
974
|
.showCard(cardId) Show provenance trail for a specific card
|
|
954
975
|
.explainReviews() Analyze why reviews were/weren't selected
|
|
976
|
+
.diagnoseCardSpace() Scan full card space through filters (async)
|
|
955
977
|
.showRegistry() Show navigator registry (classes + roles)
|
|
956
978
|
.showStrategies() Show registry + strategy mapping from last run
|
|
957
979
|
.listRuns() List all captured runs in table format
|
|
@@ -963,7 +985,7 @@ Commands:
|
|
|
963
985
|
Example:
|
|
964
986
|
window.skuilder.pipeline.showLastRun()
|
|
965
987
|
window.skuilder.pipeline.showRun(1)
|
|
966
|
-
window.skuilder.pipeline.
|
|
988
|
+
await window.skuilder.pipeline.diagnoseCardSpace()
|
|
967
989
|
`);
|
|
968
990
|
}
|
|
969
991
|
};
|
|
@@ -1258,6 +1280,69 @@ var init_generators = __esm({
|
|
|
1258
1280
|
}
|
|
1259
1281
|
});
|
|
1260
1282
|
|
|
1283
|
+
// src/core/navigators/generators/prescribed.ts
|
|
1284
|
+
var prescribed_exports = {};
|
|
1285
|
+
__export(prescribed_exports, {
|
|
1286
|
+
default: () => PrescribedCardsGenerator
|
|
1287
|
+
});
|
|
1288
|
+
var PrescribedCardsGenerator;
|
|
1289
|
+
var init_prescribed = __esm({
|
|
1290
|
+
"src/core/navigators/generators/prescribed.ts"() {
|
|
1291
|
+
"use strict";
|
|
1292
|
+
init_navigators();
|
|
1293
|
+
init_logger();
|
|
1294
|
+
PrescribedCardsGenerator = class extends ContentNavigator {
|
|
1295
|
+
name;
|
|
1296
|
+
config;
|
|
1297
|
+
constructor(user, course, strategyData) {
|
|
1298
|
+
super(user, course, strategyData);
|
|
1299
|
+
this.name = strategyData.name || "Prescribed Cards";
|
|
1300
|
+
try {
|
|
1301
|
+
const parsed = JSON.parse(strategyData.serializedData);
|
|
1302
|
+
this.config = { cardIds: parsed.cardIds || [] };
|
|
1303
|
+
} catch {
|
|
1304
|
+
this.config = { cardIds: [] };
|
|
1305
|
+
}
|
|
1306
|
+
logger.debug(
|
|
1307
|
+
`[Prescribed] Initialized with ${this.config.cardIds.length} prescribed cards`
|
|
1308
|
+
);
|
|
1309
|
+
}
|
|
1310
|
+
async getWeightedCards(limit, _context) {
|
|
1311
|
+
if (this.config.cardIds.length === 0) {
|
|
1312
|
+
return [];
|
|
1313
|
+
}
|
|
1314
|
+
const courseId = this.course.getCourseID();
|
|
1315
|
+
const activeCards = await this.user.getActiveCards();
|
|
1316
|
+
const activeIds = new Set(activeCards.map((ac) => ac.cardID));
|
|
1317
|
+
const eligibleIds = this.config.cardIds.filter((id) => !activeIds.has(id));
|
|
1318
|
+
if (eligibleIds.length === 0) {
|
|
1319
|
+
logger.debug("[Prescribed] All prescribed cards already active, returning empty");
|
|
1320
|
+
return [];
|
|
1321
|
+
}
|
|
1322
|
+
const cards = eligibleIds.slice(0, limit).map((cardId) => ({
|
|
1323
|
+
cardId,
|
|
1324
|
+
courseId,
|
|
1325
|
+
score: 1,
|
|
1326
|
+
provenance: [
|
|
1327
|
+
{
|
|
1328
|
+
strategy: "prescribed",
|
|
1329
|
+
strategyName: this.strategyName || this.name,
|
|
1330
|
+
strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
|
|
1331
|
+
action: "generated",
|
|
1332
|
+
score: 1,
|
|
1333
|
+
reason: `Prescribed card (${eligibleIds.length} eligible of ${this.config.cardIds.length} configured)`
|
|
1334
|
+
}
|
|
1335
|
+
]
|
|
1336
|
+
}));
|
|
1337
|
+
logger.info(
|
|
1338
|
+
`[Prescribed] Emitting ${cards.length} cards (${eligibleIds.length} eligible, ${activeIds.size} already active)`
|
|
1339
|
+
);
|
|
1340
|
+
return cards;
|
|
1341
|
+
}
|
|
1342
|
+
};
|
|
1343
|
+
}
|
|
1344
|
+
});
|
|
1345
|
+
|
|
1261
1346
|
// src/core/navigators/generators/srs.ts
|
|
1262
1347
|
var srs_exports = {};
|
|
1263
1348
|
__export(srs_exports, {
|
|
@@ -1452,6 +1537,7 @@ var init_ = __esm({
|
|
|
1452
1537
|
"./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
|
|
1453
1538
|
"./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
|
|
1454
1539
|
"./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
|
|
1540
|
+
"./generators/prescribed.ts": () => Promise.resolve().then(() => (init_prescribed(), prescribed_exports)),
|
|
1455
1541
|
"./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
|
|
1456
1542
|
"./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports))
|
|
1457
1543
|
});
|
|
@@ -1652,6 +1738,8 @@ var init_hierarchyDefinition = __esm({
|
|
|
1652
1738
|
if (userTagElo.count < minCount) return false;
|
|
1653
1739
|
if (prereq.masteryThreshold?.minElo !== void 0) {
|
|
1654
1740
|
return userTagElo.score >= prereq.masteryThreshold.minElo;
|
|
1741
|
+
} else if (prereq.masteryThreshold?.minCount !== void 0) {
|
|
1742
|
+
return true;
|
|
1655
1743
|
} else {
|
|
1656
1744
|
return userTagElo.score >= userGlobalElo;
|
|
1657
1745
|
}
|
|
@@ -1727,14 +1815,38 @@ var init_hierarchyDefinition = __esm({
|
|
|
1727
1815
|
};
|
|
1728
1816
|
}
|
|
1729
1817
|
}
|
|
1818
|
+
/**
|
|
1819
|
+
* Build a map of prereq tag → max configured boost for all *closed* gates.
|
|
1820
|
+
*
|
|
1821
|
+
* When a gate is closed (prereqs unmet), cards carrying that gate's prereq
|
|
1822
|
+
* tags get boosted — steering the pipeline toward content that helps unlock
|
|
1823
|
+
* the gated material. Once the gate opens, the boost disappears.
|
|
1824
|
+
*/
|
|
1825
|
+
getPreReqBoosts(unlockedTags, masteredTags) {
|
|
1826
|
+
const boosts = /* @__PURE__ */ new Map();
|
|
1827
|
+
for (const [tagId, prereqs] of Object.entries(this.config.prerequisites)) {
|
|
1828
|
+
if (unlockedTags.has(tagId)) continue;
|
|
1829
|
+
for (const prereq of prereqs) {
|
|
1830
|
+
if (!prereq.preReqBoost || prereq.preReqBoost <= 1) continue;
|
|
1831
|
+
if (masteredTags.has(prereq.tag)) continue;
|
|
1832
|
+
const existing = boosts.get(prereq.tag) ?? 1;
|
|
1833
|
+
boosts.set(prereq.tag, Math.max(existing, prereq.preReqBoost));
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
return boosts;
|
|
1837
|
+
}
|
|
1730
1838
|
/**
|
|
1731
1839
|
* CardFilter.transform implementation.
|
|
1732
1840
|
*
|
|
1733
|
-
*
|
|
1841
|
+
* Two effects:
|
|
1842
|
+
* 1. Cards with locked tags receive score * 0.05 (gating penalty)
|
|
1843
|
+
* 2. Cards carrying prereq tags of closed gates receive a configured
|
|
1844
|
+
* boost (preReqBoost), steering toward content that unlocks gates
|
|
1734
1845
|
*/
|
|
1735
1846
|
async transform(cards, context) {
|
|
1736
1847
|
const masteredTags = await this.getMasteredTags(context);
|
|
1737
1848
|
const unlockedTags = this.getUnlockedTags(masteredTags);
|
|
1849
|
+
const preReqBoosts = this.getPreReqBoosts(unlockedTags, masteredTags);
|
|
1738
1850
|
const gated = [];
|
|
1739
1851
|
for (const card of cards) {
|
|
1740
1852
|
const { isUnlocked, reason } = await this.checkCardUnlock(
|
|
@@ -1743,9 +1855,27 @@ var init_hierarchyDefinition = __esm({
|
|
|
1743
1855
|
unlockedTags,
|
|
1744
1856
|
masteredTags
|
|
1745
1857
|
);
|
|
1746
|
-
const LOCKED_PENALTY = 0.
|
|
1747
|
-
|
|
1748
|
-
|
|
1858
|
+
const LOCKED_PENALTY = 0.02;
|
|
1859
|
+
let finalScore = isUnlocked ? card.score : card.score * LOCKED_PENALTY;
|
|
1860
|
+
let action = isUnlocked ? "passed" : "penalized";
|
|
1861
|
+
let finalReason = reason;
|
|
1862
|
+
if (isUnlocked && preReqBoosts.size > 0) {
|
|
1863
|
+
const cardTags = card.tags ?? [];
|
|
1864
|
+
let maxBoost = 1;
|
|
1865
|
+
const boostedPrereqs = [];
|
|
1866
|
+
for (const tag of cardTags) {
|
|
1867
|
+
const boost = preReqBoosts.get(tag);
|
|
1868
|
+
if (boost && boost > maxBoost) {
|
|
1869
|
+
maxBoost = boost;
|
|
1870
|
+
boostedPrereqs.push(tag);
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
if (maxBoost > 1) {
|
|
1874
|
+
finalScore *= maxBoost;
|
|
1875
|
+
action = "boosted";
|
|
1876
|
+
finalReason = `${reason} | preReqBoost \xD7${maxBoost.toFixed(2)} for ${boostedPrereqs.join(", ")}`;
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1749
1879
|
gated.push({
|
|
1750
1880
|
...card,
|
|
1751
1881
|
score: finalScore,
|
|
@@ -1757,7 +1887,7 @@ var init_hierarchyDefinition = __esm({
|
|
|
1757
1887
|
strategyId: this.strategyId || "NAVIGATION_STRATEGY-hierarchy",
|
|
1758
1888
|
action,
|
|
1759
1889
|
score: finalScore,
|
|
1760
|
-
reason
|
|
1890
|
+
reason: finalReason
|
|
1761
1891
|
}
|
|
1762
1892
|
]
|
|
1763
1893
|
});
|
|
@@ -2445,6 +2575,18 @@ var Pipeline_exports = {};
|
|
|
2445
2575
|
__export(Pipeline_exports, {
|
|
2446
2576
|
Pipeline: () => Pipeline
|
|
2447
2577
|
});
|
|
2578
|
+
function globToRegex(pattern) {
|
|
2579
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
2580
|
+
const withWildcards = escaped.replace(/\*/g, ".*");
|
|
2581
|
+
return new RegExp(`^${withWildcards}$`);
|
|
2582
|
+
}
|
|
2583
|
+
function globMatch(value, pattern) {
|
|
2584
|
+
if (!pattern.includes("*")) return value === pattern;
|
|
2585
|
+
return globToRegex(pattern).test(value);
|
|
2586
|
+
}
|
|
2587
|
+
function cardMatchesTagPattern(card, pattern) {
|
|
2588
|
+
return (card.tags ?? []).some((tag) => globMatch(tag, pattern));
|
|
2589
|
+
}
|
|
2448
2590
|
function logPipelineConfig(generator, filters) {
|
|
2449
2591
|
const filterList = filters.length > 0 ? "\n - " + filters.map((f) => f.name).join("\n - ") : " none";
|
|
2450
2592
|
logger.info(
|
|
@@ -2479,6 +2621,21 @@ function logExecutionSummary(generatorName, generatedCount, filterCount, finalCo
|
|
|
2479
2621
|
\u{1F4A1} Inspect: window.skuilder.pipeline`
|
|
2480
2622
|
);
|
|
2481
2623
|
}
|
|
2624
|
+
function logResultCards(cards) {
|
|
2625
|
+
if (!VERBOSE_RESULTS || cards.length === 0) return;
|
|
2626
|
+
logger.info(`[Pipeline] Results (${cards.length} cards):`);
|
|
2627
|
+
for (let i = 0; i < cards.length; i++) {
|
|
2628
|
+
const c = cards[i];
|
|
2629
|
+
const tags = c.tags?.slice(0, 3).join(", ") || "";
|
|
2630
|
+
const filters = c.provenance.filter((p) => p.strategy === "hierarchyDefinition" || p.strategy === "priorityDefinition" || p.strategy === "interferenceFilter" || p.strategy === "letterGating" || p.strategy === "ephemeralHint").map((p) => {
|
|
2631
|
+
const arrow = p.action === "boosted" ? "\u2191" : p.action === "penalized" ? "\u2193" : "=";
|
|
2632
|
+
return `${p.strategyName}${arrow}${p.score.toFixed(2)}`;
|
|
2633
|
+
}).join(" | ");
|
|
2634
|
+
logger.info(
|
|
2635
|
+
`[Pipeline] ${String(i + 1).padStart(2)}. ${c.score.toFixed(4)} ${c.cardId} [${tags}]${filters ? ` {${filters}}` : ""}`
|
|
2636
|
+
);
|
|
2637
|
+
}
|
|
2638
|
+
}
|
|
2482
2639
|
function logCardProvenance(cards, maxCards = 3) {
|
|
2483
2640
|
const cardsToLog = cards.slice(0, maxCards);
|
|
2484
2641
|
logger.debug(`[Pipeline] Provenance for top ${cardsToLog.length} cards:`);
|
|
@@ -2493,7 +2650,7 @@ function logCardProvenance(cards, maxCards = 3) {
|
|
|
2493
2650
|
}
|
|
2494
2651
|
}
|
|
2495
2652
|
}
|
|
2496
|
-
var import_common8, Pipeline;
|
|
2653
|
+
var import_common8, VERBOSE_RESULTS, Pipeline;
|
|
2497
2654
|
var init_Pipeline = __esm({
|
|
2498
2655
|
"src/core/navigators/Pipeline.ts"() {
|
|
2499
2656
|
"use strict";
|
|
@@ -2502,9 +2659,31 @@ var init_Pipeline = __esm({
|
|
|
2502
2659
|
init_logger();
|
|
2503
2660
|
init_orchestration();
|
|
2504
2661
|
init_PipelineDebugger();
|
|
2662
|
+
VERBOSE_RESULTS = true;
|
|
2505
2663
|
Pipeline = class extends ContentNavigator {
|
|
2506
2664
|
generator;
|
|
2507
2665
|
filters;
|
|
2666
|
+
/**
|
|
2667
|
+
* Cached orchestration context. Course config and salt don't change within
|
|
2668
|
+
* a page load, so we build the orchestration context once and reuse it on
|
|
2669
|
+
* subsequent getWeightedCards() calls (e.g. mid-session replans).
|
|
2670
|
+
*
|
|
2671
|
+
* This eliminates a remote getCourseConfig() round trip per pipeline run.
|
|
2672
|
+
*/
|
|
2673
|
+
_cachedOrchestration = null;
|
|
2674
|
+
/**
|
|
2675
|
+
* Persistent tag cache. Maps cardId → tag names.
|
|
2676
|
+
*
|
|
2677
|
+
* Tags are static within a session (they're set at card generation time),
|
|
2678
|
+
* so we cache them across pipeline runs. On replans, many of the same cards
|
|
2679
|
+
* reappear — cache hits avoid redundant remote getAppliedTagsBatch() queries.
|
|
2680
|
+
*/
|
|
2681
|
+
_tagCache = /* @__PURE__ */ new Map();
|
|
2682
|
+
/**
|
|
2683
|
+
* One-shot replan hints. Applied after the filter chain on the next
|
|
2684
|
+
* getWeightedCards() call, then cleared.
|
|
2685
|
+
*/
|
|
2686
|
+
_ephemeralHints = null;
|
|
2508
2687
|
/**
|
|
2509
2688
|
* Create a new pipeline.
|
|
2510
2689
|
*
|
|
@@ -2525,6 +2704,17 @@ var init_Pipeline = __esm({
|
|
|
2525
2704
|
logger.error(`[pipeline] Failed to lookup courseCfg: ${e}`);
|
|
2526
2705
|
});
|
|
2527
2706
|
logPipelineConfig(generator, filters);
|
|
2707
|
+
registerPipelineForDebug(this);
|
|
2708
|
+
}
|
|
2709
|
+
/**
|
|
2710
|
+
* Set one-shot hints for the next pipeline run.
|
|
2711
|
+
* Consumed after one getWeightedCards() call, then cleared.
|
|
2712
|
+
*
|
|
2713
|
+
* Overrides ContentNavigator.setEphemeralHints() no-op.
|
|
2714
|
+
*/
|
|
2715
|
+
setEphemeralHints(hints) {
|
|
2716
|
+
this._ephemeralHints = hints;
|
|
2717
|
+
logger.info(`[Pipeline] Ephemeral hints set: ${JSON.stringify(hints)}`);
|
|
2528
2718
|
}
|
|
2529
2719
|
/**
|
|
2530
2720
|
* Get weighted cards by running generator and applying filters.
|
|
@@ -2541,13 +2731,15 @@ var init_Pipeline = __esm({
|
|
|
2541
2731
|
* @returns Cards sorted by score descending
|
|
2542
2732
|
*/
|
|
2543
2733
|
async getWeightedCards(limit) {
|
|
2734
|
+
const t0 = performance.now();
|
|
2544
2735
|
const context = await this.buildContext();
|
|
2545
|
-
const
|
|
2546
|
-
const fetchLimit =
|
|
2736
|
+
const tContext = performance.now();
|
|
2737
|
+
const fetchLimit = 500;
|
|
2547
2738
|
logger.debug(
|
|
2548
2739
|
`[Pipeline] Fetching ${fetchLimit} candidates from generator '${this.generator.name}'`
|
|
2549
2740
|
);
|
|
2550
2741
|
let cards = await this.generator.getWeightedCards(fetchLimit, context);
|
|
2742
|
+
const tGenerate = performance.now();
|
|
2551
2743
|
const generatedCount = cards.length;
|
|
2552
2744
|
let generatorSummaries;
|
|
2553
2745
|
if (this.generator.generators) {
|
|
@@ -2576,6 +2768,7 @@ var init_Pipeline = __esm({
|
|
|
2576
2768
|
}
|
|
2577
2769
|
logger.debug(`[Pipeline] Generator returned ${generatedCount} candidates`);
|
|
2578
2770
|
cards = await this.hydrateTags(cards);
|
|
2771
|
+
const tHydrate = performance.now();
|
|
2579
2772
|
const allCardsBeforeFiltering = [...cards];
|
|
2580
2773
|
const filterImpacts = [];
|
|
2581
2774
|
for (const filter of this.filters) {
|
|
@@ -2594,8 +2787,17 @@ var init_Pipeline = __esm({
|
|
|
2594
2787
|
logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeScores.size} \u2192 ${cards.length} cards (\u2191${boosted} \u2193${penalized} =${passed})`);
|
|
2595
2788
|
}
|
|
2596
2789
|
cards = cards.filter((c) => c.score > 0);
|
|
2790
|
+
const hints = this._ephemeralHints;
|
|
2791
|
+
if (hints) {
|
|
2792
|
+
this._ephemeralHints = null;
|
|
2793
|
+
cards = this.applyHints(cards, hints, allCardsBeforeFiltering);
|
|
2794
|
+
}
|
|
2597
2795
|
cards.sort((a, b) => b.score - a.score);
|
|
2796
|
+
const tFilter = performance.now();
|
|
2598
2797
|
const result = cards.slice(0, limit);
|
|
2798
|
+
logger.info(
|
|
2799
|
+
`[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)})`
|
|
2800
|
+
);
|
|
2599
2801
|
const topScores = result.slice(0, 3).map((c) => c.score);
|
|
2600
2802
|
logExecutionSummary(
|
|
2601
2803
|
this.generator.name,
|
|
@@ -2605,6 +2807,7 @@ var init_Pipeline = __esm({
|
|
|
2605
2807
|
topScores,
|
|
2606
2808
|
filterImpacts
|
|
2607
2809
|
);
|
|
2810
|
+
logResultCards(result);
|
|
2608
2811
|
logCardProvenance(result, 3);
|
|
2609
2812
|
try {
|
|
2610
2813
|
const courseName = await this.course?.getCourseConfig().then((c) => c.name).catch(() => void 0);
|
|
@@ -2631,6 +2834,10 @@ var init_Pipeline = __esm({
|
|
|
2631
2834
|
* to the WeightedCard objects. Filters can then use card.tags instead of
|
|
2632
2835
|
* making individual getAppliedTags() calls.
|
|
2633
2836
|
*
|
|
2837
|
+
* Uses a persistent tag cache across pipeline runs — tags are static within
|
|
2838
|
+
* a session, so cards seen in a prior run (e.g. before a replan) don't
|
|
2839
|
+
* require a second DB query.
|
|
2840
|
+
*
|
|
2634
2841
|
* @param cards - Cards to hydrate
|
|
2635
2842
|
* @returns Cards with tags populated
|
|
2636
2843
|
*/
|
|
@@ -2638,14 +2845,128 @@ var init_Pipeline = __esm({
|
|
|
2638
2845
|
if (cards.length === 0) {
|
|
2639
2846
|
return cards;
|
|
2640
2847
|
}
|
|
2641
|
-
const
|
|
2642
|
-
const
|
|
2848
|
+
const uncachedIds = [];
|
|
2849
|
+
for (const card of cards) {
|
|
2850
|
+
if (!this._tagCache.has(card.cardId)) {
|
|
2851
|
+
uncachedIds.push(card.cardId);
|
|
2852
|
+
}
|
|
2853
|
+
}
|
|
2854
|
+
if (uncachedIds.length > 0) {
|
|
2855
|
+
const freshTags = await this.course.getAppliedTagsBatch(uncachedIds);
|
|
2856
|
+
for (const [cardId, tags] of freshTags) {
|
|
2857
|
+
this._tagCache.set(cardId, tags);
|
|
2858
|
+
}
|
|
2859
|
+
}
|
|
2860
|
+
const tagsByCard = /* @__PURE__ */ new Map();
|
|
2861
|
+
for (const card of cards) {
|
|
2862
|
+
tagsByCard.set(card.cardId, this._tagCache.get(card.cardId) ?? []);
|
|
2863
|
+
}
|
|
2643
2864
|
logTagHydration(cards, tagsByCard);
|
|
2644
2865
|
return cards.map((card) => ({
|
|
2645
2866
|
...card,
|
|
2646
|
-
tags:
|
|
2867
|
+
tags: this._tagCache.get(card.cardId) ?? []
|
|
2647
2868
|
}));
|
|
2648
2869
|
}
|
|
2870
|
+
// ---------------------------------------------------------------------------
|
|
2871
|
+
// Ephemeral hints application
|
|
2872
|
+
// ---------------------------------------------------------------------------
|
|
2873
|
+
/**
|
|
2874
|
+
* Apply one-shot replan hints to the post-filter card set.
|
|
2875
|
+
*
|
|
2876
|
+
* Order of operations:
|
|
2877
|
+
* 1. Exclude (remove unwanted cards)
|
|
2878
|
+
* 2. Boost (multiply scores)
|
|
2879
|
+
* 3. Require (inject must-have cards from the full pre-filter pool)
|
|
2880
|
+
*
|
|
2881
|
+
* @param cards - Post-filter cards (score > 0)
|
|
2882
|
+
* @param hints - The ephemeral hints to apply
|
|
2883
|
+
* @param allCards - Full pre-filter card pool (for require injection)
|
|
2884
|
+
*/
|
|
2885
|
+
applyHints(cards, hints, allCards) {
|
|
2886
|
+
const beforeCount = cards.length;
|
|
2887
|
+
if (hints.excludeCards?.length) {
|
|
2888
|
+
cards = cards.filter(
|
|
2889
|
+
(c) => !hints.excludeCards.some((pat) => globMatch(c.cardId, pat))
|
|
2890
|
+
);
|
|
2891
|
+
}
|
|
2892
|
+
if (hints.excludeTags?.length) {
|
|
2893
|
+
cards = cards.filter(
|
|
2894
|
+
(c) => !hints.excludeTags.some((pat) => cardMatchesTagPattern(c, pat))
|
|
2895
|
+
);
|
|
2896
|
+
}
|
|
2897
|
+
if (hints.boostTags) {
|
|
2898
|
+
for (const [pattern, factor] of Object.entries(hints.boostTags)) {
|
|
2899
|
+
for (const card of cards) {
|
|
2900
|
+
if (cardMatchesTagPattern(card, pattern)) {
|
|
2901
|
+
card.score *= factor;
|
|
2902
|
+
card.provenance.push({
|
|
2903
|
+
strategy: "ephemeralHint",
|
|
2904
|
+
strategyId: "ephemeral-hint",
|
|
2905
|
+
strategyName: "Replan Hint",
|
|
2906
|
+
action: "boosted",
|
|
2907
|
+
score: card.score,
|
|
2908
|
+
reason: `boostTag ${pattern} \xD7${factor}`
|
|
2909
|
+
});
|
|
2910
|
+
}
|
|
2911
|
+
}
|
|
2912
|
+
}
|
|
2913
|
+
}
|
|
2914
|
+
if (hints.boostCards) {
|
|
2915
|
+
for (const [pattern, factor] of Object.entries(hints.boostCards)) {
|
|
2916
|
+
for (const card of cards) {
|
|
2917
|
+
if (globMatch(card.cardId, pattern)) {
|
|
2918
|
+
card.score *= factor;
|
|
2919
|
+
card.provenance.push({
|
|
2920
|
+
strategy: "ephemeralHint",
|
|
2921
|
+
strategyId: "ephemeral-hint",
|
|
2922
|
+
strategyName: "Replan Hint",
|
|
2923
|
+
action: "boosted",
|
|
2924
|
+
score: card.score,
|
|
2925
|
+
reason: `boostCard ${pattern} \xD7${factor}`
|
|
2926
|
+
});
|
|
2927
|
+
}
|
|
2928
|
+
}
|
|
2929
|
+
}
|
|
2930
|
+
}
|
|
2931
|
+
const cardIds = new Set(cards.map((c) => c.cardId));
|
|
2932
|
+
const inject = (card, reason) => {
|
|
2933
|
+
if (!cardIds.has(card.cardId)) {
|
|
2934
|
+
const floorScore = Math.max(card.score, 1);
|
|
2935
|
+
cards.push({
|
|
2936
|
+
...card,
|
|
2937
|
+
score: floorScore,
|
|
2938
|
+
provenance: [
|
|
2939
|
+
...card.provenance,
|
|
2940
|
+
{
|
|
2941
|
+
strategy: "ephemeralHint",
|
|
2942
|
+
strategyId: "ephemeral-hint",
|
|
2943
|
+
strategyName: "Replan Hint",
|
|
2944
|
+
action: "boosted",
|
|
2945
|
+
score: floorScore,
|
|
2946
|
+
reason
|
|
2947
|
+
}
|
|
2948
|
+
]
|
|
2949
|
+
});
|
|
2950
|
+
cardIds.add(card.cardId);
|
|
2951
|
+
}
|
|
2952
|
+
};
|
|
2953
|
+
if (hints.requireCards?.length) {
|
|
2954
|
+
for (const pattern of hints.requireCards) {
|
|
2955
|
+
for (const card of allCards) {
|
|
2956
|
+
if (globMatch(card.cardId, pattern)) inject(card, `requireCard ${pattern}`);
|
|
2957
|
+
}
|
|
2958
|
+
}
|
|
2959
|
+
}
|
|
2960
|
+
if (hints.requireTags?.length) {
|
|
2961
|
+
for (const pattern of hints.requireTags) {
|
|
2962
|
+
for (const card of allCards) {
|
|
2963
|
+
if (cardMatchesTagPattern(card, pattern)) inject(card, `requireTag ${pattern}`);
|
|
2964
|
+
}
|
|
2965
|
+
}
|
|
2966
|
+
}
|
|
2967
|
+
logger.info(`[Pipeline] Hints applied: ${beforeCount} \u2192 ${cards.length} cards`);
|
|
2968
|
+
return cards;
|
|
2969
|
+
}
|
|
2649
2970
|
/**
|
|
2650
2971
|
* Build shared context for generator and filters.
|
|
2651
2972
|
*
|
|
@@ -2663,7 +2984,10 @@ var init_Pipeline = __esm({
|
|
|
2663
2984
|
} catch (e) {
|
|
2664
2985
|
logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`);
|
|
2665
2986
|
}
|
|
2666
|
-
|
|
2987
|
+
if (!this._cachedOrchestration) {
|
|
2988
|
+
this._cachedOrchestration = await createOrchestrationContext(this.user, this.course);
|
|
2989
|
+
}
|
|
2990
|
+
const orchestration = this._cachedOrchestration;
|
|
2667
2991
|
return {
|
|
2668
2992
|
user: this.user,
|
|
2669
2993
|
course: this.course,
|
|
@@ -2707,6 +3031,87 @@ var init_Pipeline = __esm({
|
|
|
2707
3031
|
}
|
|
2708
3032
|
return [...new Set(ids)];
|
|
2709
3033
|
}
|
|
3034
|
+
// ---------------------------------------------------------------------------
|
|
3035
|
+
// Card-space diagnostic
|
|
3036
|
+
// ---------------------------------------------------------------------------
|
|
3037
|
+
/**
|
|
3038
|
+
* Scan every card in the course through the filter chain and report
|
|
3039
|
+
* how many are "well indicated" (score >= threshold) for the current user.
|
|
3040
|
+
*
|
|
3041
|
+
* Also reports how many well-indicated cards the user has NOT yet encountered.
|
|
3042
|
+
*
|
|
3043
|
+
* Exposed via `window.skuilder.pipeline.diagnoseCardSpace()`.
|
|
3044
|
+
*/
|
|
3045
|
+
async diagnoseCardSpace(opts) {
|
|
3046
|
+
const THRESHOLD = opts?.threshold ?? 0.1;
|
|
3047
|
+
const t0 = performance.now();
|
|
3048
|
+
const allCardIds = await this.course.getAllCardIds();
|
|
3049
|
+
let cards = allCardIds.map((cardId) => ({
|
|
3050
|
+
cardId,
|
|
3051
|
+
courseId: this.course.getCourseID(),
|
|
3052
|
+
score: 1,
|
|
3053
|
+
provenance: []
|
|
3054
|
+
}));
|
|
3055
|
+
cards = await this.hydrateTags(cards);
|
|
3056
|
+
const context = await this.buildContext();
|
|
3057
|
+
const filterBreakdown = [];
|
|
3058
|
+
for (const filter of this.filters) {
|
|
3059
|
+
cards = await filter.transform(cards, context);
|
|
3060
|
+
const wi = cards.filter((c) => c.score >= THRESHOLD).length;
|
|
3061
|
+
filterBreakdown.push({ name: filter.name, wellIndicated: wi });
|
|
3062
|
+
}
|
|
3063
|
+
const wellIndicated = cards.filter((c) => c.score >= THRESHOLD);
|
|
3064
|
+
const wellIndicatedIds = new Set(wellIndicated.map((c) => c.cardId));
|
|
3065
|
+
let encounteredIds;
|
|
3066
|
+
try {
|
|
3067
|
+
const courseId = this.course.getCourseID();
|
|
3068
|
+
const seenCards = await this.user.getSeenCards(courseId);
|
|
3069
|
+
encounteredIds = new Set(seenCards);
|
|
3070
|
+
} catch {
|
|
3071
|
+
encounteredIds = /* @__PURE__ */ new Set();
|
|
3072
|
+
}
|
|
3073
|
+
const wellIndicatedNew = wellIndicated.filter((c) => !encounteredIds.has(c.cardId));
|
|
3074
|
+
const byType = /* @__PURE__ */ new Map();
|
|
3075
|
+
for (const card of cards) {
|
|
3076
|
+
const type = card.cardId.split("-")[1] || "unknown";
|
|
3077
|
+
if (!byType.has(type)) {
|
|
3078
|
+
byType.set(type, { total: 0, wellIndicated: 0, new: 0 });
|
|
3079
|
+
}
|
|
3080
|
+
const entry = byType.get(type);
|
|
3081
|
+
entry.total++;
|
|
3082
|
+
if (card.score >= THRESHOLD) {
|
|
3083
|
+
entry.wellIndicated++;
|
|
3084
|
+
if (!encounteredIds.has(card.cardId)) entry.new++;
|
|
3085
|
+
}
|
|
3086
|
+
}
|
|
3087
|
+
const elapsed = performance.now() - t0;
|
|
3088
|
+
const result = {
|
|
3089
|
+
totalCards: allCardIds.length,
|
|
3090
|
+
threshold: THRESHOLD,
|
|
3091
|
+
wellIndicated: wellIndicatedIds.size,
|
|
3092
|
+
encountered: encounteredIds.size,
|
|
3093
|
+
wellIndicatedNew: wellIndicatedNew.length,
|
|
3094
|
+
byType: Object.fromEntries(byType),
|
|
3095
|
+
filterBreakdown,
|
|
3096
|
+
elapsedMs: Math.round(elapsed)
|
|
3097
|
+
};
|
|
3098
|
+
logger.info(`[Pipeline:diagnose] Card space scan (${result.elapsedMs}ms):`);
|
|
3099
|
+
logger.info(`[Pipeline:diagnose] Total cards: ${result.totalCards}`);
|
|
3100
|
+
logger.info(`[Pipeline:diagnose] Well-indicated (score >= ${THRESHOLD}): ${result.wellIndicated}`);
|
|
3101
|
+
logger.info(`[Pipeline:diagnose] Encountered: ${result.encountered}`);
|
|
3102
|
+
logger.info(`[Pipeline:diagnose] Well-indicated & new: ${result.wellIndicatedNew}`);
|
|
3103
|
+
logger.info(`[Pipeline:diagnose] By type:`);
|
|
3104
|
+
for (const [type, counts] of byType) {
|
|
3105
|
+
logger.info(
|
|
3106
|
+
`[Pipeline:diagnose] ${type}: ${counts.wellIndicated}/${counts.total} well-indicated, ${counts.new} new`
|
|
3107
|
+
);
|
|
3108
|
+
}
|
|
3109
|
+
logger.info(`[Pipeline:diagnose] After each filter:`);
|
|
3110
|
+
for (const fb of filterBreakdown) {
|
|
3111
|
+
logger.info(`[Pipeline:diagnose] ${fb.name}: ${fb.wellIndicated} well-indicated`);
|
|
3112
|
+
}
|
|
3113
|
+
return result;
|
|
3114
|
+
}
|
|
2710
3115
|
};
|
|
2711
3116
|
}
|
|
2712
3117
|
});
|
|
@@ -2811,23 +3216,25 @@ var init_PipelineAssembler = __esm({
|
|
|
2811
3216
|
warnings.push(`Unknown strategy type '${s.implementingClass}', skipping: ${s.name}`);
|
|
2812
3217
|
}
|
|
2813
3218
|
}
|
|
3219
|
+
const courseId = course.getCourseID();
|
|
3220
|
+
const hasElo = generatorStrategies.some((s) => s.implementingClass === "elo" /* ELO */);
|
|
3221
|
+
const hasSrs = generatorStrategies.some((s) => s.implementingClass === "srs" /* SRS */);
|
|
3222
|
+
if (!hasElo) {
|
|
3223
|
+
logger.debug("[PipelineAssembler] No ELO generator configured, adding default");
|
|
3224
|
+
generatorStrategies.push(createDefaultEloStrategy(courseId));
|
|
3225
|
+
}
|
|
3226
|
+
if (!hasSrs) {
|
|
3227
|
+
logger.debug("[PipelineAssembler] No SRS generator configured, adding default");
|
|
3228
|
+
generatorStrategies.push(createDefaultSrsStrategy(courseId));
|
|
3229
|
+
}
|
|
2814
3230
|
if (generatorStrategies.length === 0) {
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
} else {
|
|
2823
|
-
warnings.push("No generator strategy found");
|
|
2824
|
-
return {
|
|
2825
|
-
pipeline: null,
|
|
2826
|
-
generatorStrategies: [],
|
|
2827
|
-
filterStrategies: [],
|
|
2828
|
-
warnings
|
|
2829
|
-
};
|
|
2830
|
-
}
|
|
3231
|
+
warnings.push("No generator strategy found");
|
|
3232
|
+
return {
|
|
3233
|
+
pipeline: null,
|
|
3234
|
+
generatorStrategies: [],
|
|
3235
|
+
filterStrategies: [],
|
|
3236
|
+
warnings
|
|
3237
|
+
};
|
|
2831
3238
|
}
|
|
2832
3239
|
let generator;
|
|
2833
3240
|
if (generatorStrategies.length === 1) {
|
|
@@ -2905,6 +3312,7 @@ var init_3 = __esm({
|
|
|
2905
3312
|
"./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
|
|
2906
3313
|
"./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
|
|
2907
3314
|
"./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
|
|
3315
|
+
"./generators/prescribed.ts": () => Promise.resolve().then(() => (init_prescribed(), prescribed_exports)),
|
|
2908
3316
|
"./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
|
|
2909
3317
|
"./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports)),
|
|
2910
3318
|
"./index.ts": () => Promise.resolve().then(() => (init_navigators(), navigators_exports))
|
|
@@ -2953,8 +3361,10 @@ async function initializeNavigatorRegistry() {
|
|
|
2953
3361
|
Promise.resolve().then(() => (init_elo(), elo_exports)),
|
|
2954
3362
|
Promise.resolve().then(() => (init_srs(), srs_exports))
|
|
2955
3363
|
]);
|
|
3364
|
+
const prescribedModule = await Promise.resolve().then(() => (init_prescribed(), prescribed_exports));
|
|
2956
3365
|
registerNavigator("elo", eloModule.default);
|
|
2957
3366
|
registerNavigator("srs", srsModule.default);
|
|
3367
|
+
registerNavigator("prescribed", prescribedModule.default);
|
|
2958
3368
|
const [
|
|
2959
3369
|
hierarchyModule,
|
|
2960
3370
|
interferenceModule,
|
|
@@ -3009,6 +3419,7 @@ var init_navigators = __esm({
|
|
|
3009
3419
|
Navigators = /* @__PURE__ */ ((Navigators2) => {
|
|
3010
3420
|
Navigators2["ELO"] = "elo";
|
|
3011
3421
|
Navigators2["SRS"] = "srs";
|
|
3422
|
+
Navigators2["PRESCRIBED"] = "prescribed";
|
|
3012
3423
|
Navigators2["HIERARCHY"] = "hierarchyDefinition";
|
|
3013
3424
|
Navigators2["INTERFERENCE"] = "interferenceMitigator";
|
|
3014
3425
|
Navigators2["RELATIVE_PRIORITY"] = "relativePriority";
|
|
@@ -3023,6 +3434,7 @@ var init_navigators = __esm({
|
|
|
3023
3434
|
NavigatorRoles = {
|
|
3024
3435
|
["elo" /* ELO */]: "generator" /* GENERATOR */,
|
|
3025
3436
|
["srs" /* SRS */]: "generator" /* GENERATOR */,
|
|
3437
|
+
["prescribed" /* PRESCRIBED */]: "generator" /* GENERATOR */,
|
|
3026
3438
|
["hierarchyDefinition" /* HIERARCHY */]: "filter" /* FILTER */,
|
|
3027
3439
|
["interferenceMitigator" /* INTERFERENCE */]: "filter" /* FILTER */,
|
|
3028
3440
|
["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */,
|
|
@@ -3187,6 +3599,12 @@ var init_navigators = __esm({
|
|
|
3187
3599
|
async getWeightedCards(_limit) {
|
|
3188
3600
|
throw new Error(`${this.constructor.name} must implement getWeightedCards(). `);
|
|
3189
3601
|
}
|
|
3602
|
+
/**
|
|
3603
|
+
* Set ephemeral hints for the next pipeline run.
|
|
3604
|
+
* No-op for non-Pipeline navigators. Pipeline overrides this.
|
|
3605
|
+
*/
|
|
3606
|
+
setEphemeralHints(_hints) {
|
|
3607
|
+
}
|
|
3190
3608
|
};
|
|
3191
3609
|
}
|
|
3192
3610
|
});
|
|
@@ -3373,15 +3791,42 @@ var init_courseDB = __esm({
|
|
|
3373
3791
|
// private log(msg: string): void {
|
|
3374
3792
|
// log(`CourseLog: ${this.id}\n ${msg}`);
|
|
3375
3793
|
// }
|
|
3794
|
+
/**
|
|
3795
|
+
* Primary database handle used for all **read** operations (queries, gets).
|
|
3796
|
+
*
|
|
3797
|
+
* When local sync is active, this points to the local PouchDB replica for
|
|
3798
|
+
* fast, network-free reads. Otherwise it points to the remote CouchDB.
|
|
3799
|
+
*/
|
|
3376
3800
|
db;
|
|
3801
|
+
/**
|
|
3802
|
+
* Remote database handle used for all **write** operations.
|
|
3803
|
+
*
|
|
3804
|
+
* Always points to the remote CouchDB so that writes (ELO updates, tag
|
|
3805
|
+
* mutations, admin operations) aggregate on the server. The local replica
|
|
3806
|
+
* is a read-only snapshot that refreshes on the next page load.
|
|
3807
|
+
*
|
|
3808
|
+
* When local sync is NOT active, this is the same instance as `this.db`.
|
|
3809
|
+
*/
|
|
3810
|
+
remoteDB;
|
|
3377
3811
|
id;
|
|
3378
3812
|
_getCurrentUser;
|
|
3379
3813
|
updateQueue;
|
|
3380
|
-
|
|
3814
|
+
/**
|
|
3815
|
+
* @param id - Course ID
|
|
3816
|
+
* @param userLookup - Async function returning the current user DB
|
|
3817
|
+
* @param localDB - Optional local PouchDB replica for reads. When provided,
|
|
3818
|
+
* `this.db` uses the local replica and `this.remoteDB` stays remote.
|
|
3819
|
+
* The UpdateQueue reads from remote and writes to remote (local `_rev`
|
|
3820
|
+
* values may be stale, so read-modify-write cycles must go through
|
|
3821
|
+
* the remote DB to avoid conflicts).
|
|
3822
|
+
*/
|
|
3823
|
+
constructor(id, userLookup, localDB) {
|
|
3381
3824
|
this.id = id;
|
|
3382
|
-
|
|
3825
|
+
const remote = getCourseDB2(this.id);
|
|
3826
|
+
this.remoteDB = remote;
|
|
3827
|
+
this.db = localDB ?? remote;
|
|
3383
3828
|
this._getCurrentUser = userLookup;
|
|
3384
|
-
this.updateQueue = new UpdateQueue(this.
|
|
3829
|
+
this.updateQueue = new UpdateQueue(this.remoteDB, this.remoteDB);
|
|
3385
3830
|
}
|
|
3386
3831
|
getCourseID() {
|
|
3387
3832
|
return this.id;
|
|
@@ -3469,7 +3914,7 @@ var init_courseDB = __esm({
|
|
|
3469
3914
|
};
|
|
3470
3915
|
}
|
|
3471
3916
|
async removeCard(id) {
|
|
3472
|
-
const doc = await this.
|
|
3917
|
+
const doc = await this.remoteDB.get(id);
|
|
3473
3918
|
if (!doc.docType || !(doc.docType === "CARD" /* CARD */)) {
|
|
3474
3919
|
throw new Error(`failed to remove ${id} from course ${this.id}. id does not point to a card`);
|
|
3475
3920
|
}
|
|
@@ -3490,7 +3935,7 @@ var init_courseDB = __esm({
|
|
|
3490
3935
|
} catch (error) {
|
|
3491
3936
|
logger.error(`Error removing card ${id} from tags: ${error}`);
|
|
3492
3937
|
}
|
|
3493
|
-
return this.
|
|
3938
|
+
return this.remoteDB.remove(doc);
|
|
3494
3939
|
}
|
|
3495
3940
|
async getCardDisplayableDataIDs(id) {
|
|
3496
3941
|
logger.debug(id.join(", "));
|
|
@@ -3592,8 +4037,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3592
4037
|
if (cardIds.length === 0) {
|
|
3593
4038
|
return /* @__PURE__ */ new Map();
|
|
3594
4039
|
}
|
|
3595
|
-
const
|
|
3596
|
-
const result = await db.query("getTags", {
|
|
4040
|
+
const result = await this.db.query("getTags", {
|
|
3597
4041
|
keys: cardIds,
|
|
3598
4042
|
include_docs: false
|
|
3599
4043
|
});
|
|
@@ -3610,6 +4054,14 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3610
4054
|
}
|
|
3611
4055
|
return tagsByCard;
|
|
3612
4056
|
}
|
|
4057
|
+
async getAllCardIds() {
|
|
4058
|
+
const result = await this.db.allDocs({
|
|
4059
|
+
startkey: "CARD-",
|
|
4060
|
+
endkey: "CARD-\uFFF0",
|
|
4061
|
+
include_docs: false
|
|
4062
|
+
});
|
|
4063
|
+
return result.rows.map((row) => row.id);
|
|
4064
|
+
}
|
|
3613
4065
|
async addTagToCard(cardId, tagId, updateELO) {
|
|
3614
4066
|
return await addTagToCard(
|
|
3615
4067
|
this.id,
|
|
@@ -3676,10 +4128,13 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3676
4128
|
}
|
|
3677
4129
|
}
|
|
3678
4130
|
async getCourseDoc(id, options) {
|
|
3679
|
-
return await
|
|
4131
|
+
return await this.db.get(id, options);
|
|
3680
4132
|
}
|
|
3681
4133
|
async getCourseDocs(ids, options = {}) {
|
|
3682
|
-
return await
|
|
4134
|
+
return await this.db.allDocs({
|
|
4135
|
+
...options,
|
|
4136
|
+
keys: ids
|
|
4137
|
+
});
|
|
3683
4138
|
}
|
|
3684
4139
|
////////////////////////////////////
|
|
3685
4140
|
// NavigationStrategyManager implementation
|
|
@@ -3713,7 +4168,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3713
4168
|
}
|
|
3714
4169
|
async addNavigationStrategy(data) {
|
|
3715
4170
|
logger.debug(`[courseDB] Adding navigation strategy: ${data._id}`);
|
|
3716
|
-
return this.
|
|
4171
|
+
return this.remoteDB.put(data).then(() => {
|
|
3717
4172
|
});
|
|
3718
4173
|
}
|
|
3719
4174
|
updateNavigationStrategy(id, data) {
|
|
@@ -5307,6 +5762,9 @@ Currently logged-in as ${this._username}.`
|
|
|
5307
5762
|
const id = row.id;
|
|
5308
5763
|
return id.startsWith(DocTypePrefixes["CARDRECORD" /* CARDRECORD */]) || // Card interaction history
|
|
5309
5764
|
id.startsWith(DocTypePrefixes["SCHEDULED_CARD" /* SCHEDULED_CARD */]) || // Scheduled reviews
|
|
5765
|
+
id.startsWith(DocTypePrefixes["STRATEGY_STATE" /* STRATEGY_STATE */]) || // Strategy state (user prefs, progression)
|
|
5766
|
+
id.startsWith(DocTypePrefixes["USER_OUTCOME" /* USER_OUTCOME */]) || // Evolutionary orchestration outcomes
|
|
5767
|
+
id.startsWith(DocTypePrefixes["STRATEGY_LEARNING_STATE" /* STRATEGY_LEARNING_STATE */]) || // Strategy learning state
|
|
5310
5768
|
id === _BaseUser.DOC_IDS.COURSE_REGISTRATIONS || // Course registrations
|
|
5311
5769
|
id === _BaseUser.DOC_IDS.CLASSROOM_REGISTRATIONS || // Classroom registrations
|
|
5312
5770
|
id === _BaseUser.DOC_IDS.CONFIG;
|
|
@@ -6184,6 +6642,234 @@ var init_adminDB2 = __esm({
|
|
|
6184
6642
|
}
|
|
6185
6643
|
});
|
|
6186
6644
|
|
|
6645
|
+
// src/impl/couch/CourseSyncService.ts
|
|
6646
|
+
var CourseSyncService;
|
|
6647
|
+
var init_CourseSyncService = __esm({
|
|
6648
|
+
"src/impl/couch/CourseSyncService.ts"() {
|
|
6649
|
+
"use strict";
|
|
6650
|
+
init_pouchdb_setup();
|
|
6651
|
+
init_couch();
|
|
6652
|
+
init_logger();
|
|
6653
|
+
CourseSyncService = class _CourseSyncService {
|
|
6654
|
+
static instance = null;
|
|
6655
|
+
entries = /* @__PURE__ */ new Map();
|
|
6656
|
+
constructor() {
|
|
6657
|
+
}
|
|
6658
|
+
static getInstance() {
|
|
6659
|
+
if (!_CourseSyncService.instance) {
|
|
6660
|
+
_CourseSyncService.instance = new _CourseSyncService();
|
|
6661
|
+
}
|
|
6662
|
+
return _CourseSyncService.instance;
|
|
6663
|
+
}
|
|
6664
|
+
/**
|
|
6665
|
+
* Reset the singleton (for testing).
|
|
6666
|
+
*/
|
|
6667
|
+
static resetInstance() {
|
|
6668
|
+
if (_CourseSyncService.instance) {
|
|
6669
|
+
for (const [, entry] of _CourseSyncService.instance.entries) {
|
|
6670
|
+
if (entry.localDB) {
|
|
6671
|
+
entry.localDB.close().catch(() => {
|
|
6672
|
+
});
|
|
6673
|
+
}
|
|
6674
|
+
}
|
|
6675
|
+
_CourseSyncService.instance.entries.clear();
|
|
6676
|
+
}
|
|
6677
|
+
_CourseSyncService.instance = null;
|
|
6678
|
+
}
|
|
6679
|
+
// --------------------------------------------------------------------------
|
|
6680
|
+
// Public API
|
|
6681
|
+
// --------------------------------------------------------------------------
|
|
6682
|
+
/**
|
|
6683
|
+
* Ensure a course's local replica is synced.
|
|
6684
|
+
*
|
|
6685
|
+
* On first call for a course:
|
|
6686
|
+
* 1. Fetches CourseConfig from remote to check localSync.enabled
|
|
6687
|
+
* 2. If enabled, performs one-shot replication remote → local
|
|
6688
|
+
* 3. Pre-warms PouchDB view indices (elo, getTags)
|
|
6689
|
+
*
|
|
6690
|
+
* On subsequent calls: returns immediately if already synced, or awaits
|
|
6691
|
+
* the in-flight sync if one is in progress.
|
|
6692
|
+
*
|
|
6693
|
+
* Safe to call multiple times — concurrent calls coalesce to one sync.
|
|
6694
|
+
*
|
|
6695
|
+
* @param courseId - The course to sync
|
|
6696
|
+
* @param forceEnabled - Skip the CourseConfig check and sync regardless.
|
|
6697
|
+
* Useful when the caller already knows local sync is desired (e.g.,
|
|
6698
|
+
* LettersPractice hardcodes this).
|
|
6699
|
+
*/
|
|
6700
|
+
async ensureSynced(courseId, forceEnabled) {
|
|
6701
|
+
const existing = this.entries.get(courseId);
|
|
6702
|
+
if (existing?.status.state === "ready") {
|
|
6703
|
+
return;
|
|
6704
|
+
}
|
|
6705
|
+
if (existing?.status.state === "disabled") {
|
|
6706
|
+
return;
|
|
6707
|
+
}
|
|
6708
|
+
if (existing?.readyPromise) {
|
|
6709
|
+
return existing.readyPromise;
|
|
6710
|
+
}
|
|
6711
|
+
const entry = {
|
|
6712
|
+
localDB: null,
|
|
6713
|
+
status: { state: "not-started" },
|
|
6714
|
+
readyPromise: null
|
|
6715
|
+
};
|
|
6716
|
+
this.entries.set(courseId, entry);
|
|
6717
|
+
entry.readyPromise = this.performSync(courseId, entry, forceEnabled);
|
|
6718
|
+
return entry.readyPromise;
|
|
6719
|
+
}
|
|
6720
|
+
/**
|
|
6721
|
+
* Get the local PouchDB for a course, or null if not available.
|
|
6722
|
+
*
|
|
6723
|
+
* Returns null when:
|
|
6724
|
+
* - Local sync is not enabled for this course
|
|
6725
|
+
* - Sync has not been triggered yet
|
|
6726
|
+
* - Sync is still in progress
|
|
6727
|
+
* - Sync failed
|
|
6728
|
+
*/
|
|
6729
|
+
getLocalDB(courseId) {
|
|
6730
|
+
const entry = this.entries.get(courseId);
|
|
6731
|
+
if (entry?.status.state === "ready" && entry.localDB) {
|
|
6732
|
+
return entry.localDB;
|
|
6733
|
+
}
|
|
6734
|
+
return null;
|
|
6735
|
+
}
|
|
6736
|
+
/**
|
|
6737
|
+
* Check whether a course has a ready local replica.
|
|
6738
|
+
*/
|
|
6739
|
+
isReady(courseId) {
|
|
6740
|
+
return this.entries.get(courseId)?.status.state === "ready";
|
|
6741
|
+
}
|
|
6742
|
+
/**
|
|
6743
|
+
* Get detailed sync status for a course.
|
|
6744
|
+
*/
|
|
6745
|
+
getStatus(courseId) {
|
|
6746
|
+
return this.entries.get(courseId)?.status ?? { state: "not-started" };
|
|
6747
|
+
}
|
|
6748
|
+
// --------------------------------------------------------------------------
|
|
6749
|
+
// Internal
|
|
6750
|
+
// --------------------------------------------------------------------------
|
|
6751
|
+
async performSync(courseId, entry, forceEnabled) {
|
|
6752
|
+
try {
|
|
6753
|
+
if (!forceEnabled) {
|
|
6754
|
+
entry.status = { state: "checking-config" };
|
|
6755
|
+
const enabled = await this.checkLocalSyncEnabled(courseId);
|
|
6756
|
+
if (!enabled) {
|
|
6757
|
+
entry.status = { state: "disabled" };
|
|
6758
|
+
entry.readyPromise = null;
|
|
6759
|
+
logger.debug(
|
|
6760
|
+
`[CourseSyncService] Local sync disabled for course ${courseId}`
|
|
6761
|
+
);
|
|
6762
|
+
return;
|
|
6763
|
+
}
|
|
6764
|
+
}
|
|
6765
|
+
entry.status = { state: "syncing" };
|
|
6766
|
+
const localDBName = this.localDBName(courseId);
|
|
6767
|
+
const localDB = new pouchdb_setup_default(localDBName);
|
|
6768
|
+
entry.localDB = localDB;
|
|
6769
|
+
const remoteDB = this.getRemoteDB(courseId);
|
|
6770
|
+
const syncStart = Date.now();
|
|
6771
|
+
logger.info(
|
|
6772
|
+
`[CourseSyncService] Starting one-shot replication for course ${courseId}`
|
|
6773
|
+
);
|
|
6774
|
+
const result = await this.replicate(remoteDB, localDB);
|
|
6775
|
+
const syncTimeMs = Date.now() - syncStart;
|
|
6776
|
+
logger.info(
|
|
6777
|
+
`[CourseSyncService] Replication complete for course ${courseId}: ${result.docs_written} docs in ${syncTimeMs}ms`
|
|
6778
|
+
);
|
|
6779
|
+
entry.status = { state: "warming-views" };
|
|
6780
|
+
const warmStart = Date.now();
|
|
6781
|
+
await this.warmViewIndices(localDB);
|
|
6782
|
+
const viewWarmTimeMs = Date.now() - warmStart;
|
|
6783
|
+
logger.info(
|
|
6784
|
+
`[CourseSyncService] View indices warmed for course ${courseId} in ${viewWarmTimeMs}ms`
|
|
6785
|
+
);
|
|
6786
|
+
entry.status = {
|
|
6787
|
+
state: "ready",
|
|
6788
|
+
docsReplicated: result.docs_written,
|
|
6789
|
+
syncTimeMs,
|
|
6790
|
+
viewWarmTimeMs
|
|
6791
|
+
};
|
|
6792
|
+
} catch (e) {
|
|
6793
|
+
const errorMsg = e instanceof Error ? e.message : String(e);
|
|
6794
|
+
logger.error(
|
|
6795
|
+
`[CourseSyncService] Sync failed for course ${courseId}: ${errorMsg}`
|
|
6796
|
+
);
|
|
6797
|
+
entry.status = { state: "error", error: errorMsg };
|
|
6798
|
+
entry.readyPromise = null;
|
|
6799
|
+
if (entry.localDB) {
|
|
6800
|
+
try {
|
|
6801
|
+
await entry.localDB.destroy();
|
|
6802
|
+
} catch {
|
|
6803
|
+
}
|
|
6804
|
+
entry.localDB = null;
|
|
6805
|
+
}
|
|
6806
|
+
}
|
|
6807
|
+
}
|
|
6808
|
+
/**
|
|
6809
|
+
* Check CourseConfig.localSync.enabled on the remote DB.
|
|
6810
|
+
*/
|
|
6811
|
+
async checkLocalSyncEnabled(courseId) {
|
|
6812
|
+
try {
|
|
6813
|
+
const remoteDB = this.getRemoteDB(courseId);
|
|
6814
|
+
const config = await remoteDB.get("CourseConfig");
|
|
6815
|
+
return config.localSync?.enabled === true;
|
|
6816
|
+
} catch (e) {
|
|
6817
|
+
logger.warn(
|
|
6818
|
+
`[CourseSyncService] Could not read CourseConfig for ${courseId}, assuming local sync disabled: ${e}`
|
|
6819
|
+
);
|
|
6820
|
+
return false;
|
|
6821
|
+
}
|
|
6822
|
+
}
|
|
6823
|
+
/**
|
|
6824
|
+
* One-shot replication from remote to local.
|
|
6825
|
+
*/
|
|
6826
|
+
replicate(source, target) {
|
|
6827
|
+
return new Promise((resolve, reject) => {
|
|
6828
|
+
void pouchdb_setup_default.replicate(source, target, {
|
|
6829
|
+
// One-shot, not live. Local is a read-only snapshot.
|
|
6830
|
+
}).on("complete", (info) => {
|
|
6831
|
+
resolve(info);
|
|
6832
|
+
}).on("error", (err) => {
|
|
6833
|
+
reject(err);
|
|
6834
|
+
});
|
|
6835
|
+
});
|
|
6836
|
+
}
|
|
6837
|
+
/**
|
|
6838
|
+
* Pre-warm PouchDB view indices by running a minimal query against each
|
|
6839
|
+
* design doc. This forces PouchDB to build the MapReduce index now
|
|
6840
|
+
* (during a loading phase) rather than on first pipeline query.
|
|
6841
|
+
*/
|
|
6842
|
+
async warmViewIndices(localDB) {
|
|
6843
|
+
const viewsToWarm = ["elo", "getTags"];
|
|
6844
|
+
for (const viewName of viewsToWarm) {
|
|
6845
|
+
try {
|
|
6846
|
+
await localDB.query(viewName, { limit: 1 });
|
|
6847
|
+
logger.debug(
|
|
6848
|
+
`[CourseSyncService] Warmed view index: ${viewName}`
|
|
6849
|
+
);
|
|
6850
|
+
} catch (e) {
|
|
6851
|
+
logger.debug(
|
|
6852
|
+
`[CourseSyncService] Could not warm view ${viewName}: ${e}`
|
|
6853
|
+
);
|
|
6854
|
+
}
|
|
6855
|
+
}
|
|
6856
|
+
}
|
|
6857
|
+
/**
|
|
6858
|
+
* Get a remote PouchDB handle for a course.
|
|
6859
|
+
*/
|
|
6860
|
+
getRemoteDB(courseId) {
|
|
6861
|
+
return getCourseDB2(courseId);
|
|
6862
|
+
}
|
|
6863
|
+
/**
|
|
6864
|
+
* Local DB naming convention.
|
|
6865
|
+
*/
|
|
6866
|
+
localDBName(courseId) {
|
|
6867
|
+
return `coursedb-local-${courseId}`;
|
|
6868
|
+
}
|
|
6869
|
+
};
|
|
6870
|
+
}
|
|
6871
|
+
});
|
|
6872
|
+
|
|
6187
6873
|
// src/impl/couch/auth.ts
|
|
6188
6874
|
async function getCurrentSession() {
|
|
6189
6875
|
try {
|
|
@@ -6460,6 +7146,7 @@ __export(couch_exports, {
|
|
|
6460
7146
|
ClassroomLookupDB: () => ClassroomLookupDB,
|
|
6461
7147
|
CouchDBSyncStrategy: () => CouchDBSyncStrategy,
|
|
6462
7148
|
CourseDB: () => CourseDB,
|
|
7149
|
+
CourseSyncService: () => CourseSyncService,
|
|
6463
7150
|
CoursesDB: () => CoursesDB,
|
|
6464
7151
|
REVIEW_TIME_FORMAT: () => REVIEW_TIME_FORMAT,
|
|
6465
7152
|
StudentClassroomDB: () => StudentClassroomDB,
|
|
@@ -6676,6 +7363,7 @@ var init_couch = __esm({
|
|
|
6676
7363
|
init_classroomDB2();
|
|
6677
7364
|
init_courseAPI();
|
|
6678
7365
|
init_courseDB();
|
|
7366
|
+
init_CourseSyncService();
|
|
6679
7367
|
init_CouchDBSyncStrategy();
|
|
6680
7368
|
isBrowser = typeof window !== "undefined";
|
|
6681
7369
|
if (isBrowser) {
|
|
@@ -6701,6 +7389,7 @@ init_couch();
|
|
|
6701
7389
|
ClassroomLookupDB,
|
|
6702
7390
|
CouchDBSyncStrategy,
|
|
6703
7391
|
CourseDB,
|
|
7392
|
+
CourseSyncService,
|
|
6704
7393
|
CoursesDB,
|
|
6705
7394
|
REVIEW_TIME_FORMAT,
|
|
6706
7395
|
StudentClassroomDB,
|