@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
|
@@ -627,8 +627,12 @@ __export(PipelineDebugger_exports, {
|
|
|
627
627
|
buildRunReport: () => buildRunReport,
|
|
628
628
|
captureRun: () => captureRun,
|
|
629
629
|
mountPipelineDebugger: () => mountPipelineDebugger,
|
|
630
|
-
pipelineDebugAPI: () => pipelineDebugAPI
|
|
630
|
+
pipelineDebugAPI: () => pipelineDebugAPI,
|
|
631
|
+
registerPipelineForDebug: () => registerPipelineForDebug
|
|
631
632
|
});
|
|
633
|
+
function registerPipelineForDebug(pipeline) {
|
|
634
|
+
_activePipeline = pipeline;
|
|
635
|
+
}
|
|
632
636
|
function getOrigin(card) {
|
|
633
637
|
const firstEntry = card.provenance[0];
|
|
634
638
|
if (!firstEntry) return "unknown";
|
|
@@ -656,6 +660,7 @@ function buildRunReport(courseId, courseName, generatorName, generators, generat
|
|
|
656
660
|
origin: getOrigin(card),
|
|
657
661
|
finalScore: card.score,
|
|
658
662
|
provenance: card.provenance,
|
|
663
|
+
tags: card.tags,
|
|
659
664
|
selected: selectedIds.has(card.cardId)
|
|
660
665
|
}));
|
|
661
666
|
const reviewsSelected = selectedCards.filter((c) => getOrigin(c) === "review").length;
|
|
@@ -711,12 +716,13 @@ function mountPipelineDebugger() {
|
|
|
711
716
|
win.skuilder = win.skuilder || {};
|
|
712
717
|
win.skuilder.pipeline = pipelineDebugAPI;
|
|
713
718
|
}
|
|
714
|
-
var MAX_RUNS, runHistory, pipelineDebugAPI;
|
|
719
|
+
var _activePipeline, MAX_RUNS, runHistory, pipelineDebugAPI;
|
|
715
720
|
var init_PipelineDebugger = __esm({
|
|
716
721
|
"src/core/navigators/PipelineDebugger.ts"() {
|
|
717
722
|
"use strict";
|
|
718
723
|
init_navigators();
|
|
719
724
|
init_logger();
|
|
725
|
+
_activePipeline = null;
|
|
720
726
|
MAX_RUNS = 10;
|
|
721
727
|
runHistory = [];
|
|
722
728
|
pipelineDebugAPI = {
|
|
@@ -918,6 +924,21 @@ var init_PipelineDebugger = __esm({
|
|
|
918
924
|
}
|
|
919
925
|
console.groupEnd();
|
|
920
926
|
},
|
|
927
|
+
/**
|
|
928
|
+
* Scan the full card space through the filter chain for the current user.
|
|
929
|
+
*
|
|
930
|
+
* Reports how many cards are well-indicated and how many are new.
|
|
931
|
+
* Use this to understand how the search space grows during onboarding.
|
|
932
|
+
*
|
|
933
|
+
* @param threshold - Score threshold for "well indicated" (default 0.10)
|
|
934
|
+
*/
|
|
935
|
+
async diagnoseCardSpace(threshold) {
|
|
936
|
+
if (!_activePipeline) {
|
|
937
|
+
logger.info("[Pipeline Debug] No active pipeline. Run a session first.");
|
|
938
|
+
return null;
|
|
939
|
+
}
|
|
940
|
+
return _activePipeline.diagnoseCardSpace({ threshold });
|
|
941
|
+
},
|
|
921
942
|
/**
|
|
922
943
|
* Show help.
|
|
923
944
|
*/
|
|
@@ -930,6 +951,7 @@ Commands:
|
|
|
930
951
|
.showRun(id|index) Show summary of a specific run (by index or ID suffix)
|
|
931
952
|
.showCard(cardId) Show provenance trail for a specific card
|
|
932
953
|
.explainReviews() Analyze why reviews were/weren't selected
|
|
954
|
+
.diagnoseCardSpace() Scan full card space through filters (async)
|
|
933
955
|
.showRegistry() Show navigator registry (classes + roles)
|
|
934
956
|
.showStrategies() Show registry + strategy mapping from last run
|
|
935
957
|
.listRuns() List all captured runs in table format
|
|
@@ -941,7 +963,7 @@ Commands:
|
|
|
941
963
|
Example:
|
|
942
964
|
window.skuilder.pipeline.showLastRun()
|
|
943
965
|
window.skuilder.pipeline.showRun(1)
|
|
944
|
-
window.skuilder.pipeline.
|
|
966
|
+
await window.skuilder.pipeline.diagnoseCardSpace()
|
|
945
967
|
`);
|
|
946
968
|
}
|
|
947
969
|
};
|
|
@@ -1236,6 +1258,69 @@ var init_generators = __esm({
|
|
|
1236
1258
|
}
|
|
1237
1259
|
});
|
|
1238
1260
|
|
|
1261
|
+
// src/core/navigators/generators/prescribed.ts
|
|
1262
|
+
var prescribed_exports = {};
|
|
1263
|
+
__export(prescribed_exports, {
|
|
1264
|
+
default: () => PrescribedCardsGenerator
|
|
1265
|
+
});
|
|
1266
|
+
var PrescribedCardsGenerator;
|
|
1267
|
+
var init_prescribed = __esm({
|
|
1268
|
+
"src/core/navigators/generators/prescribed.ts"() {
|
|
1269
|
+
"use strict";
|
|
1270
|
+
init_navigators();
|
|
1271
|
+
init_logger();
|
|
1272
|
+
PrescribedCardsGenerator = class extends ContentNavigator {
|
|
1273
|
+
name;
|
|
1274
|
+
config;
|
|
1275
|
+
constructor(user, course, strategyData) {
|
|
1276
|
+
super(user, course, strategyData);
|
|
1277
|
+
this.name = strategyData.name || "Prescribed Cards";
|
|
1278
|
+
try {
|
|
1279
|
+
const parsed = JSON.parse(strategyData.serializedData);
|
|
1280
|
+
this.config = { cardIds: parsed.cardIds || [] };
|
|
1281
|
+
} catch {
|
|
1282
|
+
this.config = { cardIds: [] };
|
|
1283
|
+
}
|
|
1284
|
+
logger.debug(
|
|
1285
|
+
`[Prescribed] Initialized with ${this.config.cardIds.length} prescribed cards`
|
|
1286
|
+
);
|
|
1287
|
+
}
|
|
1288
|
+
async getWeightedCards(limit, _context) {
|
|
1289
|
+
if (this.config.cardIds.length === 0) {
|
|
1290
|
+
return [];
|
|
1291
|
+
}
|
|
1292
|
+
const courseId = this.course.getCourseID();
|
|
1293
|
+
const activeCards = await this.user.getActiveCards();
|
|
1294
|
+
const activeIds = new Set(activeCards.map((ac) => ac.cardID));
|
|
1295
|
+
const eligibleIds = this.config.cardIds.filter((id) => !activeIds.has(id));
|
|
1296
|
+
if (eligibleIds.length === 0) {
|
|
1297
|
+
logger.debug("[Prescribed] All prescribed cards already active, returning empty");
|
|
1298
|
+
return [];
|
|
1299
|
+
}
|
|
1300
|
+
const cards = eligibleIds.slice(0, limit).map((cardId) => ({
|
|
1301
|
+
cardId,
|
|
1302
|
+
courseId,
|
|
1303
|
+
score: 1,
|
|
1304
|
+
provenance: [
|
|
1305
|
+
{
|
|
1306
|
+
strategy: "prescribed",
|
|
1307
|
+
strategyName: this.strategyName || this.name,
|
|
1308
|
+
strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
|
|
1309
|
+
action: "generated",
|
|
1310
|
+
score: 1,
|
|
1311
|
+
reason: `Prescribed card (${eligibleIds.length} eligible of ${this.config.cardIds.length} configured)`
|
|
1312
|
+
}
|
|
1313
|
+
]
|
|
1314
|
+
}));
|
|
1315
|
+
logger.info(
|
|
1316
|
+
`[Prescribed] Emitting ${cards.length} cards (${eligibleIds.length} eligible, ${activeIds.size} already active)`
|
|
1317
|
+
);
|
|
1318
|
+
return cards;
|
|
1319
|
+
}
|
|
1320
|
+
};
|
|
1321
|
+
}
|
|
1322
|
+
});
|
|
1323
|
+
|
|
1239
1324
|
// src/core/navigators/generators/srs.ts
|
|
1240
1325
|
var srs_exports = {};
|
|
1241
1326
|
__export(srs_exports, {
|
|
@@ -1430,6 +1515,7 @@ var init_ = __esm({
|
|
|
1430
1515
|
"./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
|
|
1431
1516
|
"./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
|
|
1432
1517
|
"./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
|
|
1518
|
+
"./generators/prescribed.ts": () => Promise.resolve().then(() => (init_prescribed(), prescribed_exports)),
|
|
1433
1519
|
"./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
|
|
1434
1520
|
"./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports))
|
|
1435
1521
|
});
|
|
@@ -1630,6 +1716,8 @@ var init_hierarchyDefinition = __esm({
|
|
|
1630
1716
|
if (userTagElo.count < minCount) return false;
|
|
1631
1717
|
if (prereq.masteryThreshold?.minElo !== void 0) {
|
|
1632
1718
|
return userTagElo.score >= prereq.masteryThreshold.minElo;
|
|
1719
|
+
} else if (prereq.masteryThreshold?.minCount !== void 0) {
|
|
1720
|
+
return true;
|
|
1633
1721
|
} else {
|
|
1634
1722
|
return userTagElo.score >= userGlobalElo;
|
|
1635
1723
|
}
|
|
@@ -1705,14 +1793,38 @@ var init_hierarchyDefinition = __esm({
|
|
|
1705
1793
|
};
|
|
1706
1794
|
}
|
|
1707
1795
|
}
|
|
1796
|
+
/**
|
|
1797
|
+
* Build a map of prereq tag → max configured boost for all *closed* gates.
|
|
1798
|
+
*
|
|
1799
|
+
* When a gate is closed (prereqs unmet), cards carrying that gate's prereq
|
|
1800
|
+
* tags get boosted — steering the pipeline toward content that helps unlock
|
|
1801
|
+
* the gated material. Once the gate opens, the boost disappears.
|
|
1802
|
+
*/
|
|
1803
|
+
getPreReqBoosts(unlockedTags, masteredTags) {
|
|
1804
|
+
const boosts = /* @__PURE__ */ new Map();
|
|
1805
|
+
for (const [tagId, prereqs] of Object.entries(this.config.prerequisites)) {
|
|
1806
|
+
if (unlockedTags.has(tagId)) continue;
|
|
1807
|
+
for (const prereq of prereqs) {
|
|
1808
|
+
if (!prereq.preReqBoost || prereq.preReqBoost <= 1) continue;
|
|
1809
|
+
if (masteredTags.has(prereq.tag)) continue;
|
|
1810
|
+
const existing = boosts.get(prereq.tag) ?? 1;
|
|
1811
|
+
boosts.set(prereq.tag, Math.max(existing, prereq.preReqBoost));
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
return boosts;
|
|
1815
|
+
}
|
|
1708
1816
|
/**
|
|
1709
1817
|
* CardFilter.transform implementation.
|
|
1710
1818
|
*
|
|
1711
|
-
*
|
|
1819
|
+
* Two effects:
|
|
1820
|
+
* 1. Cards with locked tags receive score * 0.05 (gating penalty)
|
|
1821
|
+
* 2. Cards carrying prereq tags of closed gates receive a configured
|
|
1822
|
+
* boost (preReqBoost), steering toward content that unlocks gates
|
|
1712
1823
|
*/
|
|
1713
1824
|
async transform(cards, context) {
|
|
1714
1825
|
const masteredTags = await this.getMasteredTags(context);
|
|
1715
1826
|
const unlockedTags = this.getUnlockedTags(masteredTags);
|
|
1827
|
+
const preReqBoosts = this.getPreReqBoosts(unlockedTags, masteredTags);
|
|
1716
1828
|
const gated = [];
|
|
1717
1829
|
for (const card of cards) {
|
|
1718
1830
|
const { isUnlocked, reason } = await this.checkCardUnlock(
|
|
@@ -1721,9 +1833,27 @@ var init_hierarchyDefinition = __esm({
|
|
|
1721
1833
|
unlockedTags,
|
|
1722
1834
|
masteredTags
|
|
1723
1835
|
);
|
|
1724
|
-
const LOCKED_PENALTY = 0.
|
|
1725
|
-
|
|
1726
|
-
|
|
1836
|
+
const LOCKED_PENALTY = 0.02;
|
|
1837
|
+
let finalScore = isUnlocked ? card.score : card.score * LOCKED_PENALTY;
|
|
1838
|
+
let action = isUnlocked ? "passed" : "penalized";
|
|
1839
|
+
let finalReason = reason;
|
|
1840
|
+
if (isUnlocked && preReqBoosts.size > 0) {
|
|
1841
|
+
const cardTags = card.tags ?? [];
|
|
1842
|
+
let maxBoost = 1;
|
|
1843
|
+
const boostedPrereqs = [];
|
|
1844
|
+
for (const tag of cardTags) {
|
|
1845
|
+
const boost = preReqBoosts.get(tag);
|
|
1846
|
+
if (boost && boost > maxBoost) {
|
|
1847
|
+
maxBoost = boost;
|
|
1848
|
+
boostedPrereqs.push(tag);
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
if (maxBoost > 1) {
|
|
1852
|
+
finalScore *= maxBoost;
|
|
1853
|
+
action = "boosted";
|
|
1854
|
+
finalReason = `${reason} | preReqBoost \xD7${maxBoost.toFixed(2)} for ${boostedPrereqs.join(", ")}`;
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1727
1857
|
gated.push({
|
|
1728
1858
|
...card,
|
|
1729
1859
|
score: finalScore,
|
|
@@ -1735,7 +1865,7 @@ var init_hierarchyDefinition = __esm({
|
|
|
1735
1865
|
strategyId: this.strategyId || "NAVIGATION_STRATEGY-hierarchy",
|
|
1736
1866
|
action,
|
|
1737
1867
|
score: finalScore,
|
|
1738
|
-
reason
|
|
1868
|
+
reason: finalReason
|
|
1739
1869
|
}
|
|
1740
1870
|
]
|
|
1741
1871
|
});
|
|
@@ -2424,6 +2554,18 @@ __export(Pipeline_exports, {
|
|
|
2424
2554
|
Pipeline: () => Pipeline
|
|
2425
2555
|
});
|
|
2426
2556
|
import { toCourseElo as toCourseElo5 } from "@vue-skuilder/common";
|
|
2557
|
+
function globToRegex(pattern) {
|
|
2558
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
2559
|
+
const withWildcards = escaped.replace(/\*/g, ".*");
|
|
2560
|
+
return new RegExp(`^${withWildcards}$`);
|
|
2561
|
+
}
|
|
2562
|
+
function globMatch(value, pattern) {
|
|
2563
|
+
if (!pattern.includes("*")) return value === pattern;
|
|
2564
|
+
return globToRegex(pattern).test(value);
|
|
2565
|
+
}
|
|
2566
|
+
function cardMatchesTagPattern(card, pattern) {
|
|
2567
|
+
return (card.tags ?? []).some((tag) => globMatch(tag, pattern));
|
|
2568
|
+
}
|
|
2427
2569
|
function logPipelineConfig(generator, filters) {
|
|
2428
2570
|
const filterList = filters.length > 0 ? "\n - " + filters.map((f) => f.name).join("\n - ") : " none";
|
|
2429
2571
|
logger.info(
|
|
@@ -2458,6 +2600,21 @@ function logExecutionSummary(generatorName, generatedCount, filterCount, finalCo
|
|
|
2458
2600
|
\u{1F4A1} Inspect: window.skuilder.pipeline`
|
|
2459
2601
|
);
|
|
2460
2602
|
}
|
|
2603
|
+
function logResultCards(cards) {
|
|
2604
|
+
if (!VERBOSE_RESULTS || cards.length === 0) return;
|
|
2605
|
+
logger.info(`[Pipeline] Results (${cards.length} cards):`);
|
|
2606
|
+
for (let i = 0; i < cards.length; i++) {
|
|
2607
|
+
const c = cards[i];
|
|
2608
|
+
const tags = c.tags?.slice(0, 3).join(", ") || "";
|
|
2609
|
+
const filters = c.provenance.filter((p) => p.strategy === "hierarchyDefinition" || p.strategy === "priorityDefinition" || p.strategy === "interferenceFilter" || p.strategy === "letterGating" || p.strategy === "ephemeralHint").map((p) => {
|
|
2610
|
+
const arrow = p.action === "boosted" ? "\u2191" : p.action === "penalized" ? "\u2193" : "=";
|
|
2611
|
+
return `${p.strategyName}${arrow}${p.score.toFixed(2)}`;
|
|
2612
|
+
}).join(" | ");
|
|
2613
|
+
logger.info(
|
|
2614
|
+
`[Pipeline] ${String(i + 1).padStart(2)}. ${c.score.toFixed(4)} ${c.cardId} [${tags}]${filters ? ` {${filters}}` : ""}`
|
|
2615
|
+
);
|
|
2616
|
+
}
|
|
2617
|
+
}
|
|
2461
2618
|
function logCardProvenance(cards, maxCards = 3) {
|
|
2462
2619
|
const cardsToLog = cards.slice(0, maxCards);
|
|
2463
2620
|
logger.debug(`[Pipeline] Provenance for top ${cardsToLog.length} cards:`);
|
|
@@ -2472,7 +2629,7 @@ function logCardProvenance(cards, maxCards = 3) {
|
|
|
2472
2629
|
}
|
|
2473
2630
|
}
|
|
2474
2631
|
}
|
|
2475
|
-
var Pipeline;
|
|
2632
|
+
var VERBOSE_RESULTS, Pipeline;
|
|
2476
2633
|
var init_Pipeline = __esm({
|
|
2477
2634
|
"src/core/navigators/Pipeline.ts"() {
|
|
2478
2635
|
"use strict";
|
|
@@ -2480,9 +2637,31 @@ var init_Pipeline = __esm({
|
|
|
2480
2637
|
init_logger();
|
|
2481
2638
|
init_orchestration();
|
|
2482
2639
|
init_PipelineDebugger();
|
|
2640
|
+
VERBOSE_RESULTS = true;
|
|
2483
2641
|
Pipeline = class extends ContentNavigator {
|
|
2484
2642
|
generator;
|
|
2485
2643
|
filters;
|
|
2644
|
+
/**
|
|
2645
|
+
* Cached orchestration context. Course config and salt don't change within
|
|
2646
|
+
* a page load, so we build the orchestration context once and reuse it on
|
|
2647
|
+
* subsequent getWeightedCards() calls (e.g. mid-session replans).
|
|
2648
|
+
*
|
|
2649
|
+
* This eliminates a remote getCourseConfig() round trip per pipeline run.
|
|
2650
|
+
*/
|
|
2651
|
+
_cachedOrchestration = null;
|
|
2652
|
+
/**
|
|
2653
|
+
* Persistent tag cache. Maps cardId → tag names.
|
|
2654
|
+
*
|
|
2655
|
+
* Tags are static within a session (they're set at card generation time),
|
|
2656
|
+
* so we cache them across pipeline runs. On replans, many of the same cards
|
|
2657
|
+
* reappear — cache hits avoid redundant remote getAppliedTagsBatch() queries.
|
|
2658
|
+
*/
|
|
2659
|
+
_tagCache = /* @__PURE__ */ new Map();
|
|
2660
|
+
/**
|
|
2661
|
+
* One-shot replan hints. Applied after the filter chain on the next
|
|
2662
|
+
* getWeightedCards() call, then cleared.
|
|
2663
|
+
*/
|
|
2664
|
+
_ephemeralHints = null;
|
|
2486
2665
|
/**
|
|
2487
2666
|
* Create a new pipeline.
|
|
2488
2667
|
*
|
|
@@ -2503,6 +2682,17 @@ var init_Pipeline = __esm({
|
|
|
2503
2682
|
logger.error(`[pipeline] Failed to lookup courseCfg: ${e}`);
|
|
2504
2683
|
});
|
|
2505
2684
|
logPipelineConfig(generator, filters);
|
|
2685
|
+
registerPipelineForDebug(this);
|
|
2686
|
+
}
|
|
2687
|
+
/**
|
|
2688
|
+
* Set one-shot hints for the next pipeline run.
|
|
2689
|
+
* Consumed after one getWeightedCards() call, then cleared.
|
|
2690
|
+
*
|
|
2691
|
+
* Overrides ContentNavigator.setEphemeralHints() no-op.
|
|
2692
|
+
*/
|
|
2693
|
+
setEphemeralHints(hints) {
|
|
2694
|
+
this._ephemeralHints = hints;
|
|
2695
|
+
logger.info(`[Pipeline] Ephemeral hints set: ${JSON.stringify(hints)}`);
|
|
2506
2696
|
}
|
|
2507
2697
|
/**
|
|
2508
2698
|
* Get weighted cards by running generator and applying filters.
|
|
@@ -2519,13 +2709,15 @@ var init_Pipeline = __esm({
|
|
|
2519
2709
|
* @returns Cards sorted by score descending
|
|
2520
2710
|
*/
|
|
2521
2711
|
async getWeightedCards(limit) {
|
|
2712
|
+
const t0 = performance.now();
|
|
2522
2713
|
const context = await this.buildContext();
|
|
2523
|
-
const
|
|
2524
|
-
const fetchLimit =
|
|
2714
|
+
const tContext = performance.now();
|
|
2715
|
+
const fetchLimit = 500;
|
|
2525
2716
|
logger.debug(
|
|
2526
2717
|
`[Pipeline] Fetching ${fetchLimit} candidates from generator '${this.generator.name}'`
|
|
2527
2718
|
);
|
|
2528
2719
|
let cards = await this.generator.getWeightedCards(fetchLimit, context);
|
|
2720
|
+
const tGenerate = performance.now();
|
|
2529
2721
|
const generatedCount = cards.length;
|
|
2530
2722
|
let generatorSummaries;
|
|
2531
2723
|
if (this.generator.generators) {
|
|
@@ -2554,6 +2746,7 @@ var init_Pipeline = __esm({
|
|
|
2554
2746
|
}
|
|
2555
2747
|
logger.debug(`[Pipeline] Generator returned ${generatedCount} candidates`);
|
|
2556
2748
|
cards = await this.hydrateTags(cards);
|
|
2749
|
+
const tHydrate = performance.now();
|
|
2557
2750
|
const allCardsBeforeFiltering = [...cards];
|
|
2558
2751
|
const filterImpacts = [];
|
|
2559
2752
|
for (const filter of this.filters) {
|
|
@@ -2572,8 +2765,17 @@ var init_Pipeline = __esm({
|
|
|
2572
2765
|
logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeScores.size} \u2192 ${cards.length} cards (\u2191${boosted} \u2193${penalized} =${passed})`);
|
|
2573
2766
|
}
|
|
2574
2767
|
cards = cards.filter((c) => c.score > 0);
|
|
2768
|
+
const hints = this._ephemeralHints;
|
|
2769
|
+
if (hints) {
|
|
2770
|
+
this._ephemeralHints = null;
|
|
2771
|
+
cards = this.applyHints(cards, hints, allCardsBeforeFiltering);
|
|
2772
|
+
}
|
|
2575
2773
|
cards.sort((a, b) => b.score - a.score);
|
|
2774
|
+
const tFilter = performance.now();
|
|
2576
2775
|
const result = cards.slice(0, limit);
|
|
2776
|
+
logger.info(
|
|
2777
|
+
`[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)})`
|
|
2778
|
+
);
|
|
2577
2779
|
const topScores = result.slice(0, 3).map((c) => c.score);
|
|
2578
2780
|
logExecutionSummary(
|
|
2579
2781
|
this.generator.name,
|
|
@@ -2583,6 +2785,7 @@ var init_Pipeline = __esm({
|
|
|
2583
2785
|
topScores,
|
|
2584
2786
|
filterImpacts
|
|
2585
2787
|
);
|
|
2788
|
+
logResultCards(result);
|
|
2586
2789
|
logCardProvenance(result, 3);
|
|
2587
2790
|
try {
|
|
2588
2791
|
const courseName = await this.course?.getCourseConfig().then((c) => c.name).catch(() => void 0);
|
|
@@ -2609,6 +2812,10 @@ var init_Pipeline = __esm({
|
|
|
2609
2812
|
* to the WeightedCard objects. Filters can then use card.tags instead of
|
|
2610
2813
|
* making individual getAppliedTags() calls.
|
|
2611
2814
|
*
|
|
2815
|
+
* Uses a persistent tag cache across pipeline runs — tags are static within
|
|
2816
|
+
* a session, so cards seen in a prior run (e.g. before a replan) don't
|
|
2817
|
+
* require a second DB query.
|
|
2818
|
+
*
|
|
2612
2819
|
* @param cards - Cards to hydrate
|
|
2613
2820
|
* @returns Cards with tags populated
|
|
2614
2821
|
*/
|
|
@@ -2616,14 +2823,128 @@ var init_Pipeline = __esm({
|
|
|
2616
2823
|
if (cards.length === 0) {
|
|
2617
2824
|
return cards;
|
|
2618
2825
|
}
|
|
2619
|
-
const
|
|
2620
|
-
const
|
|
2826
|
+
const uncachedIds = [];
|
|
2827
|
+
for (const card of cards) {
|
|
2828
|
+
if (!this._tagCache.has(card.cardId)) {
|
|
2829
|
+
uncachedIds.push(card.cardId);
|
|
2830
|
+
}
|
|
2831
|
+
}
|
|
2832
|
+
if (uncachedIds.length > 0) {
|
|
2833
|
+
const freshTags = await this.course.getAppliedTagsBatch(uncachedIds);
|
|
2834
|
+
for (const [cardId, tags] of freshTags) {
|
|
2835
|
+
this._tagCache.set(cardId, tags);
|
|
2836
|
+
}
|
|
2837
|
+
}
|
|
2838
|
+
const tagsByCard = /* @__PURE__ */ new Map();
|
|
2839
|
+
for (const card of cards) {
|
|
2840
|
+
tagsByCard.set(card.cardId, this._tagCache.get(card.cardId) ?? []);
|
|
2841
|
+
}
|
|
2621
2842
|
logTagHydration(cards, tagsByCard);
|
|
2622
2843
|
return cards.map((card) => ({
|
|
2623
2844
|
...card,
|
|
2624
|
-
tags:
|
|
2845
|
+
tags: this._tagCache.get(card.cardId) ?? []
|
|
2625
2846
|
}));
|
|
2626
2847
|
}
|
|
2848
|
+
// ---------------------------------------------------------------------------
|
|
2849
|
+
// Ephemeral hints application
|
|
2850
|
+
// ---------------------------------------------------------------------------
|
|
2851
|
+
/**
|
|
2852
|
+
* Apply one-shot replan hints to the post-filter card set.
|
|
2853
|
+
*
|
|
2854
|
+
* Order of operations:
|
|
2855
|
+
* 1. Exclude (remove unwanted cards)
|
|
2856
|
+
* 2. Boost (multiply scores)
|
|
2857
|
+
* 3. Require (inject must-have cards from the full pre-filter pool)
|
|
2858
|
+
*
|
|
2859
|
+
* @param cards - Post-filter cards (score > 0)
|
|
2860
|
+
* @param hints - The ephemeral hints to apply
|
|
2861
|
+
* @param allCards - Full pre-filter card pool (for require injection)
|
|
2862
|
+
*/
|
|
2863
|
+
applyHints(cards, hints, allCards) {
|
|
2864
|
+
const beforeCount = cards.length;
|
|
2865
|
+
if (hints.excludeCards?.length) {
|
|
2866
|
+
cards = cards.filter(
|
|
2867
|
+
(c) => !hints.excludeCards.some((pat) => globMatch(c.cardId, pat))
|
|
2868
|
+
);
|
|
2869
|
+
}
|
|
2870
|
+
if (hints.excludeTags?.length) {
|
|
2871
|
+
cards = cards.filter(
|
|
2872
|
+
(c) => !hints.excludeTags.some((pat) => cardMatchesTagPattern(c, pat))
|
|
2873
|
+
);
|
|
2874
|
+
}
|
|
2875
|
+
if (hints.boostTags) {
|
|
2876
|
+
for (const [pattern, factor] of Object.entries(hints.boostTags)) {
|
|
2877
|
+
for (const card of cards) {
|
|
2878
|
+
if (cardMatchesTagPattern(card, pattern)) {
|
|
2879
|
+
card.score *= factor;
|
|
2880
|
+
card.provenance.push({
|
|
2881
|
+
strategy: "ephemeralHint",
|
|
2882
|
+
strategyId: "ephemeral-hint",
|
|
2883
|
+
strategyName: "Replan Hint",
|
|
2884
|
+
action: "boosted",
|
|
2885
|
+
score: card.score,
|
|
2886
|
+
reason: `boostTag ${pattern} \xD7${factor}`
|
|
2887
|
+
});
|
|
2888
|
+
}
|
|
2889
|
+
}
|
|
2890
|
+
}
|
|
2891
|
+
}
|
|
2892
|
+
if (hints.boostCards) {
|
|
2893
|
+
for (const [pattern, factor] of Object.entries(hints.boostCards)) {
|
|
2894
|
+
for (const card of cards) {
|
|
2895
|
+
if (globMatch(card.cardId, pattern)) {
|
|
2896
|
+
card.score *= factor;
|
|
2897
|
+
card.provenance.push({
|
|
2898
|
+
strategy: "ephemeralHint",
|
|
2899
|
+
strategyId: "ephemeral-hint",
|
|
2900
|
+
strategyName: "Replan Hint",
|
|
2901
|
+
action: "boosted",
|
|
2902
|
+
score: card.score,
|
|
2903
|
+
reason: `boostCard ${pattern} \xD7${factor}`
|
|
2904
|
+
});
|
|
2905
|
+
}
|
|
2906
|
+
}
|
|
2907
|
+
}
|
|
2908
|
+
}
|
|
2909
|
+
const cardIds = new Set(cards.map((c) => c.cardId));
|
|
2910
|
+
const inject = (card, reason) => {
|
|
2911
|
+
if (!cardIds.has(card.cardId)) {
|
|
2912
|
+
const floorScore = Math.max(card.score, 1);
|
|
2913
|
+
cards.push({
|
|
2914
|
+
...card,
|
|
2915
|
+
score: floorScore,
|
|
2916
|
+
provenance: [
|
|
2917
|
+
...card.provenance,
|
|
2918
|
+
{
|
|
2919
|
+
strategy: "ephemeralHint",
|
|
2920
|
+
strategyId: "ephemeral-hint",
|
|
2921
|
+
strategyName: "Replan Hint",
|
|
2922
|
+
action: "boosted",
|
|
2923
|
+
score: floorScore,
|
|
2924
|
+
reason
|
|
2925
|
+
}
|
|
2926
|
+
]
|
|
2927
|
+
});
|
|
2928
|
+
cardIds.add(card.cardId);
|
|
2929
|
+
}
|
|
2930
|
+
};
|
|
2931
|
+
if (hints.requireCards?.length) {
|
|
2932
|
+
for (const pattern of hints.requireCards) {
|
|
2933
|
+
for (const card of allCards) {
|
|
2934
|
+
if (globMatch(card.cardId, pattern)) inject(card, `requireCard ${pattern}`);
|
|
2935
|
+
}
|
|
2936
|
+
}
|
|
2937
|
+
}
|
|
2938
|
+
if (hints.requireTags?.length) {
|
|
2939
|
+
for (const pattern of hints.requireTags) {
|
|
2940
|
+
for (const card of allCards) {
|
|
2941
|
+
if (cardMatchesTagPattern(card, pattern)) inject(card, `requireTag ${pattern}`);
|
|
2942
|
+
}
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
logger.info(`[Pipeline] Hints applied: ${beforeCount} \u2192 ${cards.length} cards`);
|
|
2946
|
+
return cards;
|
|
2947
|
+
}
|
|
2627
2948
|
/**
|
|
2628
2949
|
* Build shared context for generator and filters.
|
|
2629
2950
|
*
|
|
@@ -2641,7 +2962,10 @@ var init_Pipeline = __esm({
|
|
|
2641
2962
|
} catch (e) {
|
|
2642
2963
|
logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`);
|
|
2643
2964
|
}
|
|
2644
|
-
|
|
2965
|
+
if (!this._cachedOrchestration) {
|
|
2966
|
+
this._cachedOrchestration = await createOrchestrationContext(this.user, this.course);
|
|
2967
|
+
}
|
|
2968
|
+
const orchestration = this._cachedOrchestration;
|
|
2645
2969
|
return {
|
|
2646
2970
|
user: this.user,
|
|
2647
2971
|
course: this.course,
|
|
@@ -2685,6 +3009,87 @@ var init_Pipeline = __esm({
|
|
|
2685
3009
|
}
|
|
2686
3010
|
return [...new Set(ids)];
|
|
2687
3011
|
}
|
|
3012
|
+
// ---------------------------------------------------------------------------
|
|
3013
|
+
// Card-space diagnostic
|
|
3014
|
+
// ---------------------------------------------------------------------------
|
|
3015
|
+
/**
|
|
3016
|
+
* Scan every card in the course through the filter chain and report
|
|
3017
|
+
* how many are "well indicated" (score >= threshold) for the current user.
|
|
3018
|
+
*
|
|
3019
|
+
* Also reports how many well-indicated cards the user has NOT yet encountered.
|
|
3020
|
+
*
|
|
3021
|
+
* Exposed via `window.skuilder.pipeline.diagnoseCardSpace()`.
|
|
3022
|
+
*/
|
|
3023
|
+
async diagnoseCardSpace(opts) {
|
|
3024
|
+
const THRESHOLD = opts?.threshold ?? 0.1;
|
|
3025
|
+
const t0 = performance.now();
|
|
3026
|
+
const allCardIds = await this.course.getAllCardIds();
|
|
3027
|
+
let cards = allCardIds.map((cardId) => ({
|
|
3028
|
+
cardId,
|
|
3029
|
+
courseId: this.course.getCourseID(),
|
|
3030
|
+
score: 1,
|
|
3031
|
+
provenance: []
|
|
3032
|
+
}));
|
|
3033
|
+
cards = await this.hydrateTags(cards);
|
|
3034
|
+
const context = await this.buildContext();
|
|
3035
|
+
const filterBreakdown = [];
|
|
3036
|
+
for (const filter of this.filters) {
|
|
3037
|
+
cards = await filter.transform(cards, context);
|
|
3038
|
+
const wi = cards.filter((c) => c.score >= THRESHOLD).length;
|
|
3039
|
+
filterBreakdown.push({ name: filter.name, wellIndicated: wi });
|
|
3040
|
+
}
|
|
3041
|
+
const wellIndicated = cards.filter((c) => c.score >= THRESHOLD);
|
|
3042
|
+
const wellIndicatedIds = new Set(wellIndicated.map((c) => c.cardId));
|
|
3043
|
+
let encounteredIds;
|
|
3044
|
+
try {
|
|
3045
|
+
const courseId = this.course.getCourseID();
|
|
3046
|
+
const seenCards = await this.user.getSeenCards(courseId);
|
|
3047
|
+
encounteredIds = new Set(seenCards);
|
|
3048
|
+
} catch {
|
|
3049
|
+
encounteredIds = /* @__PURE__ */ new Set();
|
|
3050
|
+
}
|
|
3051
|
+
const wellIndicatedNew = wellIndicated.filter((c) => !encounteredIds.has(c.cardId));
|
|
3052
|
+
const byType = /* @__PURE__ */ new Map();
|
|
3053
|
+
for (const card of cards) {
|
|
3054
|
+
const type = card.cardId.split("-")[1] || "unknown";
|
|
3055
|
+
if (!byType.has(type)) {
|
|
3056
|
+
byType.set(type, { total: 0, wellIndicated: 0, new: 0 });
|
|
3057
|
+
}
|
|
3058
|
+
const entry = byType.get(type);
|
|
3059
|
+
entry.total++;
|
|
3060
|
+
if (card.score >= THRESHOLD) {
|
|
3061
|
+
entry.wellIndicated++;
|
|
3062
|
+
if (!encounteredIds.has(card.cardId)) entry.new++;
|
|
3063
|
+
}
|
|
3064
|
+
}
|
|
3065
|
+
const elapsed = performance.now() - t0;
|
|
3066
|
+
const result = {
|
|
3067
|
+
totalCards: allCardIds.length,
|
|
3068
|
+
threshold: THRESHOLD,
|
|
3069
|
+
wellIndicated: wellIndicatedIds.size,
|
|
3070
|
+
encountered: encounteredIds.size,
|
|
3071
|
+
wellIndicatedNew: wellIndicatedNew.length,
|
|
3072
|
+
byType: Object.fromEntries(byType),
|
|
3073
|
+
filterBreakdown,
|
|
3074
|
+
elapsedMs: Math.round(elapsed)
|
|
3075
|
+
};
|
|
3076
|
+
logger.info(`[Pipeline:diagnose] Card space scan (${result.elapsedMs}ms):`);
|
|
3077
|
+
logger.info(`[Pipeline:diagnose] Total cards: ${result.totalCards}`);
|
|
3078
|
+
logger.info(`[Pipeline:diagnose] Well-indicated (score >= ${THRESHOLD}): ${result.wellIndicated}`);
|
|
3079
|
+
logger.info(`[Pipeline:diagnose] Encountered: ${result.encountered}`);
|
|
3080
|
+
logger.info(`[Pipeline:diagnose] Well-indicated & new: ${result.wellIndicatedNew}`);
|
|
3081
|
+
logger.info(`[Pipeline:diagnose] By type:`);
|
|
3082
|
+
for (const [type, counts] of byType) {
|
|
3083
|
+
logger.info(
|
|
3084
|
+
`[Pipeline:diagnose] ${type}: ${counts.wellIndicated}/${counts.total} well-indicated, ${counts.new} new`
|
|
3085
|
+
);
|
|
3086
|
+
}
|
|
3087
|
+
logger.info(`[Pipeline:diagnose] After each filter:`);
|
|
3088
|
+
for (const fb of filterBreakdown) {
|
|
3089
|
+
logger.info(`[Pipeline:diagnose] ${fb.name}: ${fb.wellIndicated} well-indicated`);
|
|
3090
|
+
}
|
|
3091
|
+
return result;
|
|
3092
|
+
}
|
|
2688
3093
|
};
|
|
2689
3094
|
}
|
|
2690
3095
|
});
|
|
@@ -2789,23 +3194,25 @@ var init_PipelineAssembler = __esm({
|
|
|
2789
3194
|
warnings.push(`Unknown strategy type '${s.implementingClass}', skipping: ${s.name}`);
|
|
2790
3195
|
}
|
|
2791
3196
|
}
|
|
3197
|
+
const courseId = course.getCourseID();
|
|
3198
|
+
const hasElo = generatorStrategies.some((s) => s.implementingClass === "elo" /* ELO */);
|
|
3199
|
+
const hasSrs = generatorStrategies.some((s) => s.implementingClass === "srs" /* SRS */);
|
|
3200
|
+
if (!hasElo) {
|
|
3201
|
+
logger.debug("[PipelineAssembler] No ELO generator configured, adding default");
|
|
3202
|
+
generatorStrategies.push(createDefaultEloStrategy(courseId));
|
|
3203
|
+
}
|
|
3204
|
+
if (!hasSrs) {
|
|
3205
|
+
logger.debug("[PipelineAssembler] No SRS generator configured, adding default");
|
|
3206
|
+
generatorStrategies.push(createDefaultSrsStrategy(courseId));
|
|
3207
|
+
}
|
|
2792
3208
|
if (generatorStrategies.length === 0) {
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
} else {
|
|
2801
|
-
warnings.push("No generator strategy found");
|
|
2802
|
-
return {
|
|
2803
|
-
pipeline: null,
|
|
2804
|
-
generatorStrategies: [],
|
|
2805
|
-
filterStrategies: [],
|
|
2806
|
-
warnings
|
|
2807
|
-
};
|
|
2808
|
-
}
|
|
3209
|
+
warnings.push("No generator strategy found");
|
|
3210
|
+
return {
|
|
3211
|
+
pipeline: null,
|
|
3212
|
+
generatorStrategies: [],
|
|
3213
|
+
filterStrategies: [],
|
|
3214
|
+
warnings
|
|
3215
|
+
};
|
|
2809
3216
|
}
|
|
2810
3217
|
let generator;
|
|
2811
3218
|
if (generatorStrategies.length === 1) {
|
|
@@ -2883,6 +3290,7 @@ var init_3 = __esm({
|
|
|
2883
3290
|
"./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
|
|
2884
3291
|
"./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
|
|
2885
3292
|
"./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
|
|
3293
|
+
"./generators/prescribed.ts": () => Promise.resolve().then(() => (init_prescribed(), prescribed_exports)),
|
|
2886
3294
|
"./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
|
|
2887
3295
|
"./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports)),
|
|
2888
3296
|
"./index.ts": () => Promise.resolve().then(() => (init_navigators(), navigators_exports))
|
|
@@ -2931,8 +3339,10 @@ async function initializeNavigatorRegistry() {
|
|
|
2931
3339
|
Promise.resolve().then(() => (init_elo(), elo_exports)),
|
|
2932
3340
|
Promise.resolve().then(() => (init_srs(), srs_exports))
|
|
2933
3341
|
]);
|
|
3342
|
+
const prescribedModule = await Promise.resolve().then(() => (init_prescribed(), prescribed_exports));
|
|
2934
3343
|
registerNavigator("elo", eloModule.default);
|
|
2935
3344
|
registerNavigator("srs", srsModule.default);
|
|
3345
|
+
registerNavigator("prescribed", prescribedModule.default);
|
|
2936
3346
|
const [
|
|
2937
3347
|
hierarchyModule,
|
|
2938
3348
|
interferenceModule,
|
|
@@ -2987,6 +3397,7 @@ var init_navigators = __esm({
|
|
|
2987
3397
|
Navigators = /* @__PURE__ */ ((Navigators2) => {
|
|
2988
3398
|
Navigators2["ELO"] = "elo";
|
|
2989
3399
|
Navigators2["SRS"] = "srs";
|
|
3400
|
+
Navigators2["PRESCRIBED"] = "prescribed";
|
|
2990
3401
|
Navigators2["HIERARCHY"] = "hierarchyDefinition";
|
|
2991
3402
|
Navigators2["INTERFERENCE"] = "interferenceMitigator";
|
|
2992
3403
|
Navigators2["RELATIVE_PRIORITY"] = "relativePriority";
|
|
@@ -3001,6 +3412,7 @@ var init_navigators = __esm({
|
|
|
3001
3412
|
NavigatorRoles = {
|
|
3002
3413
|
["elo" /* ELO */]: "generator" /* GENERATOR */,
|
|
3003
3414
|
["srs" /* SRS */]: "generator" /* GENERATOR */,
|
|
3415
|
+
["prescribed" /* PRESCRIBED */]: "generator" /* GENERATOR */,
|
|
3004
3416
|
["hierarchyDefinition" /* HIERARCHY */]: "filter" /* FILTER */,
|
|
3005
3417
|
["interferenceMitigator" /* INTERFERENCE */]: "filter" /* FILTER */,
|
|
3006
3418
|
["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */,
|
|
@@ -3165,6 +3577,12 @@ var init_navigators = __esm({
|
|
|
3165
3577
|
async getWeightedCards(_limit) {
|
|
3166
3578
|
throw new Error(`${this.constructor.name} must implement getWeightedCards(). `);
|
|
3167
3579
|
}
|
|
3580
|
+
/**
|
|
3581
|
+
* Set ephemeral hints for the next pipeline run.
|
|
3582
|
+
* No-op for non-Pipeline navigators. Pipeline overrides this.
|
|
3583
|
+
*/
|
|
3584
|
+
setEphemeralHints(_hints) {
|
|
3585
|
+
}
|
|
3168
3586
|
};
|
|
3169
3587
|
}
|
|
3170
3588
|
});
|
|
@@ -3356,15 +3774,42 @@ var init_courseDB = __esm({
|
|
|
3356
3774
|
// private log(msg: string): void {
|
|
3357
3775
|
// log(`CourseLog: ${this.id}\n ${msg}`);
|
|
3358
3776
|
// }
|
|
3777
|
+
/**
|
|
3778
|
+
* Primary database handle used for all **read** operations (queries, gets).
|
|
3779
|
+
*
|
|
3780
|
+
* When local sync is active, this points to the local PouchDB replica for
|
|
3781
|
+
* fast, network-free reads. Otherwise it points to the remote CouchDB.
|
|
3782
|
+
*/
|
|
3359
3783
|
db;
|
|
3784
|
+
/**
|
|
3785
|
+
* Remote database handle used for all **write** operations.
|
|
3786
|
+
*
|
|
3787
|
+
* Always points to the remote CouchDB so that writes (ELO updates, tag
|
|
3788
|
+
* mutations, admin operations) aggregate on the server. The local replica
|
|
3789
|
+
* is a read-only snapshot that refreshes on the next page load.
|
|
3790
|
+
*
|
|
3791
|
+
* When local sync is NOT active, this is the same instance as `this.db`.
|
|
3792
|
+
*/
|
|
3793
|
+
remoteDB;
|
|
3360
3794
|
id;
|
|
3361
3795
|
_getCurrentUser;
|
|
3362
3796
|
updateQueue;
|
|
3363
|
-
|
|
3797
|
+
/**
|
|
3798
|
+
* @param id - Course ID
|
|
3799
|
+
* @param userLookup - Async function returning the current user DB
|
|
3800
|
+
* @param localDB - Optional local PouchDB replica for reads. When provided,
|
|
3801
|
+
* `this.db` uses the local replica and `this.remoteDB` stays remote.
|
|
3802
|
+
* The UpdateQueue reads from remote and writes to remote (local `_rev`
|
|
3803
|
+
* values may be stale, so read-modify-write cycles must go through
|
|
3804
|
+
* the remote DB to avoid conflicts).
|
|
3805
|
+
*/
|
|
3806
|
+
constructor(id, userLookup, localDB) {
|
|
3364
3807
|
this.id = id;
|
|
3365
|
-
|
|
3808
|
+
const remote = getCourseDB2(this.id);
|
|
3809
|
+
this.remoteDB = remote;
|
|
3810
|
+
this.db = localDB ?? remote;
|
|
3366
3811
|
this._getCurrentUser = userLookup;
|
|
3367
|
-
this.updateQueue = new UpdateQueue(this.
|
|
3812
|
+
this.updateQueue = new UpdateQueue(this.remoteDB, this.remoteDB);
|
|
3368
3813
|
}
|
|
3369
3814
|
getCourseID() {
|
|
3370
3815
|
return this.id;
|
|
@@ -3452,7 +3897,7 @@ var init_courseDB = __esm({
|
|
|
3452
3897
|
};
|
|
3453
3898
|
}
|
|
3454
3899
|
async removeCard(id) {
|
|
3455
|
-
const doc = await this.
|
|
3900
|
+
const doc = await this.remoteDB.get(id);
|
|
3456
3901
|
if (!doc.docType || !(doc.docType === "CARD" /* CARD */)) {
|
|
3457
3902
|
throw new Error(`failed to remove ${id} from course ${this.id}. id does not point to a card`);
|
|
3458
3903
|
}
|
|
@@ -3473,7 +3918,7 @@ var init_courseDB = __esm({
|
|
|
3473
3918
|
} catch (error) {
|
|
3474
3919
|
logger.error(`Error removing card ${id} from tags: ${error}`);
|
|
3475
3920
|
}
|
|
3476
|
-
return this.
|
|
3921
|
+
return this.remoteDB.remove(doc);
|
|
3477
3922
|
}
|
|
3478
3923
|
async getCardDisplayableDataIDs(id) {
|
|
3479
3924
|
logger.debug(id.join(", "));
|
|
@@ -3575,8 +4020,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3575
4020
|
if (cardIds.length === 0) {
|
|
3576
4021
|
return /* @__PURE__ */ new Map();
|
|
3577
4022
|
}
|
|
3578
|
-
const
|
|
3579
|
-
const result = await db.query("getTags", {
|
|
4023
|
+
const result = await this.db.query("getTags", {
|
|
3580
4024
|
keys: cardIds,
|
|
3581
4025
|
include_docs: false
|
|
3582
4026
|
});
|
|
@@ -3593,6 +4037,14 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3593
4037
|
}
|
|
3594
4038
|
return tagsByCard;
|
|
3595
4039
|
}
|
|
4040
|
+
async getAllCardIds() {
|
|
4041
|
+
const result = await this.db.allDocs({
|
|
4042
|
+
startkey: "CARD-",
|
|
4043
|
+
endkey: "CARD-\uFFF0",
|
|
4044
|
+
include_docs: false
|
|
4045
|
+
});
|
|
4046
|
+
return result.rows.map((row) => row.id);
|
|
4047
|
+
}
|
|
3596
4048
|
async addTagToCard(cardId, tagId, updateELO) {
|
|
3597
4049
|
return await addTagToCard(
|
|
3598
4050
|
this.id,
|
|
@@ -3659,10 +4111,13 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3659
4111
|
}
|
|
3660
4112
|
}
|
|
3661
4113
|
async getCourseDoc(id, options) {
|
|
3662
|
-
return await
|
|
4114
|
+
return await this.db.get(id, options);
|
|
3663
4115
|
}
|
|
3664
4116
|
async getCourseDocs(ids, options = {}) {
|
|
3665
|
-
return await
|
|
4117
|
+
return await this.db.allDocs({
|
|
4118
|
+
...options,
|
|
4119
|
+
keys: ids
|
|
4120
|
+
});
|
|
3666
4121
|
}
|
|
3667
4122
|
////////////////////////////////////
|
|
3668
4123
|
// NavigationStrategyManager implementation
|
|
@@ -3696,7 +4151,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3696
4151
|
}
|
|
3697
4152
|
async addNavigationStrategy(data) {
|
|
3698
4153
|
logger.debug(`[courseDB] Adding navigation strategy: ${data._id}`);
|
|
3699
|
-
return this.
|
|
4154
|
+
return this.remoteDB.put(data).then(() => {
|
|
3700
4155
|
});
|
|
3701
4156
|
}
|
|
3702
4157
|
updateNavigationStrategy(id, data) {
|
|
@@ -5287,6 +5742,9 @@ Currently logged-in as ${this._username}.`
|
|
|
5287
5742
|
const id = row.id;
|
|
5288
5743
|
return id.startsWith(DocTypePrefixes["CARDRECORD" /* CARDRECORD */]) || // Card interaction history
|
|
5289
5744
|
id.startsWith(DocTypePrefixes["SCHEDULED_CARD" /* SCHEDULED_CARD */]) || // Scheduled reviews
|
|
5745
|
+
id.startsWith(DocTypePrefixes["STRATEGY_STATE" /* STRATEGY_STATE */]) || // Strategy state (user prefs, progression)
|
|
5746
|
+
id.startsWith(DocTypePrefixes["USER_OUTCOME" /* USER_OUTCOME */]) || // Evolutionary orchestration outcomes
|
|
5747
|
+
id.startsWith(DocTypePrefixes["STRATEGY_LEARNING_STATE" /* STRATEGY_LEARNING_STATE */]) || // Strategy learning state
|
|
5290
5748
|
id === _BaseUser.DOC_IDS.COURSE_REGISTRATIONS || // Course registrations
|
|
5291
5749
|
id === _BaseUser.DOC_IDS.CLASSROOM_REGISTRATIONS || // Classroom registrations
|
|
5292
5750
|
id === _BaseUser.DOC_IDS.CONFIG;
|
|
@@ -6164,6 +6622,234 @@ var init_adminDB2 = __esm({
|
|
|
6164
6622
|
}
|
|
6165
6623
|
});
|
|
6166
6624
|
|
|
6625
|
+
// src/impl/couch/CourseSyncService.ts
|
|
6626
|
+
var CourseSyncService;
|
|
6627
|
+
var init_CourseSyncService = __esm({
|
|
6628
|
+
"src/impl/couch/CourseSyncService.ts"() {
|
|
6629
|
+
"use strict";
|
|
6630
|
+
init_pouchdb_setup();
|
|
6631
|
+
init_couch();
|
|
6632
|
+
init_logger();
|
|
6633
|
+
CourseSyncService = class _CourseSyncService {
|
|
6634
|
+
static instance = null;
|
|
6635
|
+
entries = /* @__PURE__ */ new Map();
|
|
6636
|
+
constructor() {
|
|
6637
|
+
}
|
|
6638
|
+
static getInstance() {
|
|
6639
|
+
if (!_CourseSyncService.instance) {
|
|
6640
|
+
_CourseSyncService.instance = new _CourseSyncService();
|
|
6641
|
+
}
|
|
6642
|
+
return _CourseSyncService.instance;
|
|
6643
|
+
}
|
|
6644
|
+
/**
|
|
6645
|
+
* Reset the singleton (for testing).
|
|
6646
|
+
*/
|
|
6647
|
+
static resetInstance() {
|
|
6648
|
+
if (_CourseSyncService.instance) {
|
|
6649
|
+
for (const [, entry] of _CourseSyncService.instance.entries) {
|
|
6650
|
+
if (entry.localDB) {
|
|
6651
|
+
entry.localDB.close().catch(() => {
|
|
6652
|
+
});
|
|
6653
|
+
}
|
|
6654
|
+
}
|
|
6655
|
+
_CourseSyncService.instance.entries.clear();
|
|
6656
|
+
}
|
|
6657
|
+
_CourseSyncService.instance = null;
|
|
6658
|
+
}
|
|
6659
|
+
// --------------------------------------------------------------------------
|
|
6660
|
+
// Public API
|
|
6661
|
+
// --------------------------------------------------------------------------
|
|
6662
|
+
/**
|
|
6663
|
+
* Ensure a course's local replica is synced.
|
|
6664
|
+
*
|
|
6665
|
+
* On first call for a course:
|
|
6666
|
+
* 1. Fetches CourseConfig from remote to check localSync.enabled
|
|
6667
|
+
* 2. If enabled, performs one-shot replication remote → local
|
|
6668
|
+
* 3. Pre-warms PouchDB view indices (elo, getTags)
|
|
6669
|
+
*
|
|
6670
|
+
* On subsequent calls: returns immediately if already synced, or awaits
|
|
6671
|
+
* the in-flight sync if one is in progress.
|
|
6672
|
+
*
|
|
6673
|
+
* Safe to call multiple times — concurrent calls coalesce to one sync.
|
|
6674
|
+
*
|
|
6675
|
+
* @param courseId - The course to sync
|
|
6676
|
+
* @param forceEnabled - Skip the CourseConfig check and sync regardless.
|
|
6677
|
+
* Useful when the caller already knows local sync is desired (e.g.,
|
|
6678
|
+
* LettersPractice hardcodes this).
|
|
6679
|
+
*/
|
|
6680
|
+
async ensureSynced(courseId, forceEnabled) {
|
|
6681
|
+
const existing = this.entries.get(courseId);
|
|
6682
|
+
if (existing?.status.state === "ready") {
|
|
6683
|
+
return;
|
|
6684
|
+
}
|
|
6685
|
+
if (existing?.status.state === "disabled") {
|
|
6686
|
+
return;
|
|
6687
|
+
}
|
|
6688
|
+
if (existing?.readyPromise) {
|
|
6689
|
+
return existing.readyPromise;
|
|
6690
|
+
}
|
|
6691
|
+
const entry = {
|
|
6692
|
+
localDB: null,
|
|
6693
|
+
status: { state: "not-started" },
|
|
6694
|
+
readyPromise: null
|
|
6695
|
+
};
|
|
6696
|
+
this.entries.set(courseId, entry);
|
|
6697
|
+
entry.readyPromise = this.performSync(courseId, entry, forceEnabled);
|
|
6698
|
+
return entry.readyPromise;
|
|
6699
|
+
}
|
|
6700
|
+
/**
|
|
6701
|
+
* Get the local PouchDB for a course, or null if not available.
|
|
6702
|
+
*
|
|
6703
|
+
* Returns null when:
|
|
6704
|
+
* - Local sync is not enabled for this course
|
|
6705
|
+
* - Sync has not been triggered yet
|
|
6706
|
+
* - Sync is still in progress
|
|
6707
|
+
* - Sync failed
|
|
6708
|
+
*/
|
|
6709
|
+
getLocalDB(courseId) {
|
|
6710
|
+
const entry = this.entries.get(courseId);
|
|
6711
|
+
if (entry?.status.state === "ready" && entry.localDB) {
|
|
6712
|
+
return entry.localDB;
|
|
6713
|
+
}
|
|
6714
|
+
return null;
|
|
6715
|
+
}
|
|
6716
|
+
/**
|
|
6717
|
+
* Check whether a course has a ready local replica.
|
|
6718
|
+
*/
|
|
6719
|
+
isReady(courseId) {
|
|
6720
|
+
return this.entries.get(courseId)?.status.state === "ready";
|
|
6721
|
+
}
|
|
6722
|
+
/**
|
|
6723
|
+
* Get detailed sync status for a course.
|
|
6724
|
+
*/
|
|
6725
|
+
getStatus(courseId) {
|
|
6726
|
+
return this.entries.get(courseId)?.status ?? { state: "not-started" };
|
|
6727
|
+
}
|
|
6728
|
+
// --------------------------------------------------------------------------
|
|
6729
|
+
// Internal
|
|
6730
|
+
// --------------------------------------------------------------------------
|
|
6731
|
+
async performSync(courseId, entry, forceEnabled) {
|
|
6732
|
+
try {
|
|
6733
|
+
if (!forceEnabled) {
|
|
6734
|
+
entry.status = { state: "checking-config" };
|
|
6735
|
+
const enabled = await this.checkLocalSyncEnabled(courseId);
|
|
6736
|
+
if (!enabled) {
|
|
6737
|
+
entry.status = { state: "disabled" };
|
|
6738
|
+
entry.readyPromise = null;
|
|
6739
|
+
logger.debug(
|
|
6740
|
+
`[CourseSyncService] Local sync disabled for course ${courseId}`
|
|
6741
|
+
);
|
|
6742
|
+
return;
|
|
6743
|
+
}
|
|
6744
|
+
}
|
|
6745
|
+
entry.status = { state: "syncing" };
|
|
6746
|
+
const localDBName = this.localDBName(courseId);
|
|
6747
|
+
const localDB = new pouchdb_setup_default(localDBName);
|
|
6748
|
+
entry.localDB = localDB;
|
|
6749
|
+
const remoteDB = this.getRemoteDB(courseId);
|
|
6750
|
+
const syncStart = Date.now();
|
|
6751
|
+
logger.info(
|
|
6752
|
+
`[CourseSyncService] Starting one-shot replication for course ${courseId}`
|
|
6753
|
+
);
|
|
6754
|
+
const result = await this.replicate(remoteDB, localDB);
|
|
6755
|
+
const syncTimeMs = Date.now() - syncStart;
|
|
6756
|
+
logger.info(
|
|
6757
|
+
`[CourseSyncService] Replication complete for course ${courseId}: ${result.docs_written} docs in ${syncTimeMs}ms`
|
|
6758
|
+
);
|
|
6759
|
+
entry.status = { state: "warming-views" };
|
|
6760
|
+
const warmStart = Date.now();
|
|
6761
|
+
await this.warmViewIndices(localDB);
|
|
6762
|
+
const viewWarmTimeMs = Date.now() - warmStart;
|
|
6763
|
+
logger.info(
|
|
6764
|
+
`[CourseSyncService] View indices warmed for course ${courseId} in ${viewWarmTimeMs}ms`
|
|
6765
|
+
);
|
|
6766
|
+
entry.status = {
|
|
6767
|
+
state: "ready",
|
|
6768
|
+
docsReplicated: result.docs_written,
|
|
6769
|
+
syncTimeMs,
|
|
6770
|
+
viewWarmTimeMs
|
|
6771
|
+
};
|
|
6772
|
+
} catch (e) {
|
|
6773
|
+
const errorMsg = e instanceof Error ? e.message : String(e);
|
|
6774
|
+
logger.error(
|
|
6775
|
+
`[CourseSyncService] Sync failed for course ${courseId}: ${errorMsg}`
|
|
6776
|
+
);
|
|
6777
|
+
entry.status = { state: "error", error: errorMsg };
|
|
6778
|
+
entry.readyPromise = null;
|
|
6779
|
+
if (entry.localDB) {
|
|
6780
|
+
try {
|
|
6781
|
+
await entry.localDB.destroy();
|
|
6782
|
+
} catch {
|
|
6783
|
+
}
|
|
6784
|
+
entry.localDB = null;
|
|
6785
|
+
}
|
|
6786
|
+
}
|
|
6787
|
+
}
|
|
6788
|
+
/**
|
|
6789
|
+
* Check CourseConfig.localSync.enabled on the remote DB.
|
|
6790
|
+
*/
|
|
6791
|
+
async checkLocalSyncEnabled(courseId) {
|
|
6792
|
+
try {
|
|
6793
|
+
const remoteDB = this.getRemoteDB(courseId);
|
|
6794
|
+
const config = await remoteDB.get("CourseConfig");
|
|
6795
|
+
return config.localSync?.enabled === true;
|
|
6796
|
+
} catch (e) {
|
|
6797
|
+
logger.warn(
|
|
6798
|
+
`[CourseSyncService] Could not read CourseConfig for ${courseId}, assuming local sync disabled: ${e}`
|
|
6799
|
+
);
|
|
6800
|
+
return false;
|
|
6801
|
+
}
|
|
6802
|
+
}
|
|
6803
|
+
/**
|
|
6804
|
+
* One-shot replication from remote to local.
|
|
6805
|
+
*/
|
|
6806
|
+
replicate(source, target) {
|
|
6807
|
+
return new Promise((resolve, reject) => {
|
|
6808
|
+
void pouchdb_setup_default.replicate(source, target, {
|
|
6809
|
+
// One-shot, not live. Local is a read-only snapshot.
|
|
6810
|
+
}).on("complete", (info) => {
|
|
6811
|
+
resolve(info);
|
|
6812
|
+
}).on("error", (err) => {
|
|
6813
|
+
reject(err);
|
|
6814
|
+
});
|
|
6815
|
+
});
|
|
6816
|
+
}
|
|
6817
|
+
/**
|
|
6818
|
+
* Pre-warm PouchDB view indices by running a minimal query against each
|
|
6819
|
+
* design doc. This forces PouchDB to build the MapReduce index now
|
|
6820
|
+
* (during a loading phase) rather than on first pipeline query.
|
|
6821
|
+
*/
|
|
6822
|
+
async warmViewIndices(localDB) {
|
|
6823
|
+
const viewsToWarm = ["elo", "getTags"];
|
|
6824
|
+
for (const viewName of viewsToWarm) {
|
|
6825
|
+
try {
|
|
6826
|
+
await localDB.query(viewName, { limit: 1 });
|
|
6827
|
+
logger.debug(
|
|
6828
|
+
`[CourseSyncService] Warmed view index: ${viewName}`
|
|
6829
|
+
);
|
|
6830
|
+
} catch (e) {
|
|
6831
|
+
logger.debug(
|
|
6832
|
+
`[CourseSyncService] Could not warm view ${viewName}: ${e}`
|
|
6833
|
+
);
|
|
6834
|
+
}
|
|
6835
|
+
}
|
|
6836
|
+
}
|
|
6837
|
+
/**
|
|
6838
|
+
* Get a remote PouchDB handle for a course.
|
|
6839
|
+
*/
|
|
6840
|
+
getRemoteDB(courseId) {
|
|
6841
|
+
return getCourseDB2(courseId);
|
|
6842
|
+
}
|
|
6843
|
+
/**
|
|
6844
|
+
* Local DB naming convention.
|
|
6845
|
+
*/
|
|
6846
|
+
localDBName(courseId) {
|
|
6847
|
+
return `coursedb-local-${courseId}`;
|
|
6848
|
+
}
|
|
6849
|
+
};
|
|
6850
|
+
}
|
|
6851
|
+
});
|
|
6852
|
+
|
|
6167
6853
|
// src/impl/couch/auth.ts
|
|
6168
6854
|
import fetch from "cross-fetch";
|
|
6169
6855
|
async function getCurrentSession() {
|
|
@@ -6606,6 +7292,7 @@ var init_couch = __esm({
|
|
|
6606
7292
|
init_classroomDB2();
|
|
6607
7293
|
init_courseAPI();
|
|
6608
7294
|
init_courseDB();
|
|
7295
|
+
init_CourseSyncService();
|
|
6609
7296
|
init_CouchDBSyncStrategy();
|
|
6610
7297
|
isBrowser = typeof window !== "undefined";
|
|
6611
7298
|
if (isBrowser) {
|
|
@@ -6630,6 +7317,7 @@ export {
|
|
|
6630
7317
|
ClassroomLookupDB,
|
|
6631
7318
|
CouchDBSyncStrategy,
|
|
6632
7319
|
CourseDB,
|
|
7320
|
+
CourseSyncService,
|
|
6633
7321
|
CoursesDB,
|
|
6634
7322
|
REVIEW_TIME_FORMAT,
|
|
6635
7323
|
StudentClassroomDB,
|