@vue-skuilder/db 0.1.32-a → 0.1.32-c
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/core/index.d.cts +16 -12
- package/dist/core/index.d.ts +16 -12
- package/dist/core/index.js +2279 -227
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +2256 -200
- package/dist/core/index.mjs.map +1 -1
- package/dist/{contentSource-Bdwkvqa8.d.ts → dataLayerProvider-BAn-LRh5.d.ts} +626 -83
- package/dist/{contentSource-DF1nUbPQ.d.cts → dataLayerProvider-BJqBlMIl.d.cts} +626 -83
- package/dist/impl/couch/index.d.cts +18 -3
- package/dist/impl/couch/index.d.ts +18 -3
- package/dist/impl/couch/index.js +2323 -224
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +2311 -208
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.d.cts +5 -4
- package/dist/impl/static/index.d.ts +5 -4
- package/dist/impl/static/index.js +2283 -231
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +2268 -212
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/{index-BWvO-_rJ.d.ts → index-X6wHrURm.d.ts} +1 -1
- package/dist/{index-Ba7hYbHj.d.cts → index-m8MMGxxR.d.cts} +1 -1
- package/dist/index.d.cts +9 -381
- package/dist/index.d.ts +9 -381
- package/dist/index.js +9626 -8815
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +9559 -8748
- package/dist/index.mjs.map +1 -1
- package/dist/{types-CJrLM1Ew.d.ts → types-DZ5dUqbL.d.ts} +1 -1
- package/dist/{types-W8n-B6HG.d.cts → types-ZL8tOPQZ.d.cts} +1 -1
- package/dist/{types-legacy-JXDxinpU.d.cts → types-legacy-C7r0T4OV.d.cts} +1 -1
- package/dist/{types-legacy-JXDxinpU.d.ts → types-legacy-C7r0T4OV.d.ts} +1 -1
- package/dist/util/packer/index.d.cts +3 -3
- package/dist/util/packer/index.d.ts +3 -3
- package/docs/navigators-architecture.md +2 -2
- package/package.json +2 -2
- package/src/core/interfaces/contentSource.ts +2 -1
- package/src/core/navigators/Pipeline.ts +51 -25
- package/src/core/navigators/PipelineDebugger.ts +49 -1
- package/src/core/navigators/filters/hierarchyDefinition.ts +92 -5
- package/src/core/navigators/filters/relativePriority.ts +7 -1
- package/src/core/navigators/generators/prescribed.ts +618 -43
- package/src/core/navigators/index.ts +2 -1
- package/src/impl/couch/CourseSyncService.ts +72 -4
- package/src/impl/couch/courseDB.ts +11 -0
- package/src/impl/static/courseDB.ts +13 -0
- package/src/study/SessionController.ts +276 -24
- package/src/study/services/EloService.ts +22 -3
- package/src/study/services/ResponseProcessor.ts +7 -3
- package/dist/dataLayerProvider-BKmVoyJR.d.ts +0 -67
- package/dist/dataLayerProvider-BQdfJuBN.d.cts +0 -67
|
@@ -652,13 +652,20 @@ function captureRun(report) {
|
|
|
652
652
|
runHistory.pop();
|
|
653
653
|
}
|
|
654
654
|
}
|
|
655
|
-
function
|
|
655
|
+
function parseCardElo(provenance) {
|
|
656
|
+
const eloEntry = provenance.find((p) => p.strategy === "elo");
|
|
657
|
+
if (!eloEntry?.reason) return void 0;
|
|
658
|
+
const match = eloEntry.reason.match(/card:\s*(\d+)/);
|
|
659
|
+
return match ? parseInt(match[1], 10) : void 0;
|
|
660
|
+
}
|
|
661
|
+
function buildRunReport(courseId, courseName, generatorName, generators, generatedCount, filters, allCards, selectedCards, userElo) {
|
|
656
662
|
const selectedIds = new Set(selectedCards.map((c) => c.cardId));
|
|
657
663
|
const cards = allCards.map((card) => ({
|
|
658
664
|
cardId: card.cardId,
|
|
659
665
|
courseId: card.courseId,
|
|
660
666
|
origin: getOrigin(card),
|
|
661
667
|
finalScore: card.score,
|
|
668
|
+
cardElo: parseCardElo(card.provenance),
|
|
662
669
|
provenance: card.provenance,
|
|
663
670
|
tags: card.tags,
|
|
664
671
|
selected: selectedIds.has(card.cardId)
|
|
@@ -668,6 +675,7 @@ function buildRunReport(courseId, courseName, generatorName, generators, generat
|
|
|
668
675
|
return {
|
|
669
676
|
courseId,
|
|
670
677
|
courseName,
|
|
678
|
+
userElo,
|
|
671
679
|
generatorName,
|
|
672
680
|
generators,
|
|
673
681
|
generatedCount,
|
|
@@ -688,6 +696,7 @@ function printRunSummary(run) {
|
|
|
688
696
|
console.group(`\u{1F50D} Pipeline Run: ${run.courseId} (${run.courseName || "unnamed"})`);
|
|
689
697
|
logger.info(`Run ID: ${run.runId}`);
|
|
690
698
|
logger.info(`Time: ${run.timestamp.toISOString()}`);
|
|
699
|
+
logger.info(`User ELO: ${run.userElo ?? "unknown"}`);
|
|
691
700
|
logger.info(`Generator: ${run.generatorName} \u2192 ${run.generatedCount} candidates`);
|
|
692
701
|
if (run.generators && run.generators.length > 0) {
|
|
693
702
|
console.group("Generator breakdown:");
|
|
@@ -774,8 +783,12 @@ var init_PipelineDebugger = __esm({
|
|
|
774
783
|
console.group(`\u{1F3B4} Card: ${cardId}`);
|
|
775
784
|
logger.info(`Course: ${card.courseId}`);
|
|
776
785
|
logger.info(`Origin: ${card.origin}`);
|
|
786
|
+
logger.info(`Card ELO: ${card.cardElo ?? "unknown"} | User ELO: ${run.userElo ?? "unknown"}`);
|
|
777
787
|
logger.info(`Final score: ${card.finalScore.toFixed(3)}`);
|
|
778
788
|
logger.info(`Selected: ${card.selected ? "Yes \u2705" : "No \u274C"}`);
|
|
789
|
+
if (card.tags && card.tags.length > 0) {
|
|
790
|
+
logger.info(`Tags (${card.tags.length}): ${card.tags.join(", ")}`);
|
|
791
|
+
}
|
|
779
792
|
logger.info("Provenance:");
|
|
780
793
|
logger.info(formatProvenance(card.provenance));
|
|
781
794
|
console.groupEnd();
|
|
@@ -939,6 +952,27 @@ var init_PipelineDebugger = __esm({
|
|
|
939
952
|
}
|
|
940
953
|
return _activePipeline.diagnoseCardSpace({ threshold });
|
|
941
954
|
},
|
|
955
|
+
/**
|
|
956
|
+
* Show user's per-tag ELO data. Useful for diagnosing hierarchy gate status.
|
|
957
|
+
*
|
|
958
|
+
* @param tagFilter - Optional glob pattern(s) to filter tags.
|
|
959
|
+
* Examples: 'gpc:expose:*', 'gpc:intro:t-T', ['gpc:expose:t-*', 'gpc:intro:t-*']
|
|
960
|
+
*/
|
|
961
|
+
async showTagElo(tagFilter) {
|
|
962
|
+
if (!_activePipeline) {
|
|
963
|
+
logger.info("[Pipeline Debug] No active pipeline. Run a session first.");
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
const status = await _activePipeline.getTagEloStatus(tagFilter);
|
|
967
|
+
const entries = Object.entries(status).sort(([a], [b]) => a.localeCompare(b));
|
|
968
|
+
if (entries.length === 0) {
|
|
969
|
+
logger.info(`[Pipeline Debug] No tag ELO data found${tagFilter ? ` for pattern: ${tagFilter}` : ""}.`);
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
console.table(
|
|
973
|
+
Object.fromEntries(entries.map(([tag, data]) => [tag, { score: Math.round(data.score), count: data.count }]))
|
|
974
|
+
);
|
|
975
|
+
},
|
|
942
976
|
/**
|
|
943
977
|
* Show help.
|
|
944
978
|
*/
|
|
@@ -950,6 +984,7 @@ Commands:
|
|
|
950
984
|
.showLastRun() Show summary of most recent pipeline run
|
|
951
985
|
.showRun(id|index) Show summary of a specific run (by index or ID suffix)
|
|
952
986
|
.showCard(cardId) Show provenance trail for a specific card
|
|
987
|
+
.showTagElo(pattern) Show user's tag ELO data (async). E.g. 'gpc:expose:*'
|
|
953
988
|
.explainReviews() Analyze why reviews were/weren't selected
|
|
954
989
|
.diagnoseCardSpace() Scan full card space through filters (async)
|
|
955
990
|
.showRegistry() Show navigator registry (classes + roles)
|
|
@@ -1263,60 +1298,423 @@ var prescribed_exports = {};
|
|
|
1263
1298
|
__export(prescribed_exports, {
|
|
1264
1299
|
default: () => PrescribedCardsGenerator
|
|
1265
1300
|
});
|
|
1266
|
-
|
|
1301
|
+
function dedupe(arr) {
|
|
1302
|
+
return [...new Set(arr)];
|
|
1303
|
+
}
|
|
1304
|
+
function isoNow() {
|
|
1305
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
1306
|
+
}
|
|
1307
|
+
function clamp(value, min, max) {
|
|
1308
|
+
return Math.max(min, Math.min(max, value));
|
|
1309
|
+
}
|
|
1310
|
+
function matchesTagPattern(tag, pattern) {
|
|
1311
|
+
if (pattern === "*") return true;
|
|
1312
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
|
1313
|
+
const re = new RegExp(`^${escaped}$`);
|
|
1314
|
+
return re.test(tag);
|
|
1315
|
+
}
|
|
1316
|
+
function pickTopByScore(cards, limit) {
|
|
1317
|
+
return [...cards].sort((a, b) => b.score - a.score || a.cardId.localeCompare(b.cardId)).slice(0, limit);
|
|
1318
|
+
}
|
|
1319
|
+
var DEFAULT_FRESHNESS_WINDOW, DEFAULT_MAX_DIRECT_PER_RUN, DEFAULT_MAX_SUPPORT_PER_RUN, DEFAULT_HIERARCHY_DEPTH, DEFAULT_MIN_COUNT, BASE_TARGET_SCORE, BASE_SUPPORT_SCORE, MAX_TARGET_MULTIPLIER, MAX_SUPPORT_MULTIPLIER, LOCKED_TAG_PREFIXES, LESSON_GATE_PENALTY_TAG_HINT, PrescribedCardsGenerator;
|
|
1267
1320
|
var init_prescribed = __esm({
|
|
1268
1321
|
"src/core/navigators/generators/prescribed.ts"() {
|
|
1269
1322
|
"use strict";
|
|
1270
1323
|
init_navigators();
|
|
1271
1324
|
init_logger();
|
|
1325
|
+
DEFAULT_FRESHNESS_WINDOW = 3;
|
|
1326
|
+
DEFAULT_MAX_DIRECT_PER_RUN = 3;
|
|
1327
|
+
DEFAULT_MAX_SUPPORT_PER_RUN = 3;
|
|
1328
|
+
DEFAULT_HIERARCHY_DEPTH = 2;
|
|
1329
|
+
DEFAULT_MIN_COUNT = 3;
|
|
1330
|
+
BASE_TARGET_SCORE = 1;
|
|
1331
|
+
BASE_SUPPORT_SCORE = 0.8;
|
|
1332
|
+
MAX_TARGET_MULTIPLIER = 8;
|
|
1333
|
+
MAX_SUPPORT_MULTIPLIER = 4;
|
|
1334
|
+
LOCKED_TAG_PREFIXES = ["concept:"];
|
|
1335
|
+
LESSON_GATE_PENALTY_TAG_HINT = "concept:";
|
|
1272
1336
|
PrescribedCardsGenerator = class extends ContentNavigator {
|
|
1273
1337
|
name;
|
|
1274
1338
|
config;
|
|
1275
1339
|
constructor(user, course, strategyData) {
|
|
1276
1340
|
super(user, course, strategyData);
|
|
1277
1341
|
this.name = strategyData.name || "Prescribed Cards";
|
|
1278
|
-
|
|
1279
|
-
const parsed = JSON.parse(strategyData.serializedData);
|
|
1280
|
-
this.config = { cardIds: parsed.cardIds || [] };
|
|
1281
|
-
} catch {
|
|
1282
|
-
this.config = { cardIds: [] };
|
|
1283
|
-
}
|
|
1342
|
+
this.config = this.parseConfig(strategyData.serializedData);
|
|
1284
1343
|
logger.debug(
|
|
1285
|
-
`[Prescribed] Initialized with ${this.config.
|
|
1344
|
+
`[Prescribed] Initialized with ${this.config.groups.length} groups and ${this.config.groups.reduce((n, g) => n + g.targetCardIds.length, 0)} targets`
|
|
1286
1345
|
);
|
|
1287
1346
|
}
|
|
1288
|
-
|
|
1289
|
-
|
|
1347
|
+
get strategyKey() {
|
|
1348
|
+
return "PrescribedProgress";
|
|
1349
|
+
}
|
|
1350
|
+
async getWeightedCards(limit, context) {
|
|
1351
|
+
if (this.config.groups.length === 0 || limit <= 0) {
|
|
1290
1352
|
return [];
|
|
1291
1353
|
}
|
|
1292
1354
|
const courseId = this.course.getCourseID();
|
|
1293
1355
|
const activeCards = await this.user.getActiveCards();
|
|
1294
1356
|
const activeIds = new Set(activeCards.map((ac) => ac.cardID));
|
|
1295
|
-
const
|
|
1296
|
-
|
|
1297
|
-
|
|
1357
|
+
const seenCards = await this.user.getSeenCards(courseId).catch(() => []);
|
|
1358
|
+
const seenIds = new Set(seenCards);
|
|
1359
|
+
const progress = await this.getStrategyState() ?? {
|
|
1360
|
+
updatedAt: isoNow(),
|
|
1361
|
+
groups: {}
|
|
1362
|
+
};
|
|
1363
|
+
const hierarchyConfigs = await this.loadHierarchyConfigs();
|
|
1364
|
+
const courseReg = await this.user.getCourseRegDoc(courseId).catch(() => null);
|
|
1365
|
+
const userGlobalElo = typeof courseReg?.elo === "number" ? courseReg.elo : courseReg?.elo?.global?.score ?? context?.userElo ?? 1e3;
|
|
1366
|
+
const userTagElo = typeof courseReg?.elo === "number" ? {} : courseReg?.elo?.tags ?? {};
|
|
1367
|
+
const allTargetIds = dedupe(this.config.groups.flatMap((g) => g.targetCardIds));
|
|
1368
|
+
const allSupportIds = dedupe(this.config.groups.flatMap((g) => g.supportCardIds ?? []));
|
|
1369
|
+
const allRelevantIds = dedupe([...allTargetIds, ...allSupportIds]);
|
|
1370
|
+
const tagsByCard = allRelevantIds.length > 0 ? await this.course.getAppliedTagsBatch(allRelevantIds) : /* @__PURE__ */ new Map();
|
|
1371
|
+
const nextState = {
|
|
1372
|
+
updatedAt: isoNow(),
|
|
1373
|
+
groups: {}
|
|
1374
|
+
};
|
|
1375
|
+
const emitted = [];
|
|
1376
|
+
const emittedIds = /* @__PURE__ */ new Set();
|
|
1377
|
+
for (const group of this.config.groups) {
|
|
1378
|
+
const runtime = this.buildGroupRuntimeState({
|
|
1379
|
+
group,
|
|
1380
|
+
priorState: progress.groups[group.id],
|
|
1381
|
+
activeIds,
|
|
1382
|
+
seenIds,
|
|
1383
|
+
tagsByCard,
|
|
1384
|
+
hierarchyConfigs,
|
|
1385
|
+
userTagElo,
|
|
1386
|
+
userGlobalElo
|
|
1387
|
+
});
|
|
1388
|
+
nextState.groups[group.id] = this.buildNextGroupState(runtime, progress.groups[group.id]);
|
|
1389
|
+
const directCards = this.buildDirectTargetCards(
|
|
1390
|
+
runtime,
|
|
1391
|
+
courseId,
|
|
1392
|
+
emittedIds
|
|
1393
|
+
);
|
|
1394
|
+
const supportCards = this.buildSupportCards(
|
|
1395
|
+
runtime,
|
|
1396
|
+
courseId,
|
|
1397
|
+
emittedIds
|
|
1398
|
+
);
|
|
1399
|
+
emitted.push(...directCards, ...supportCards);
|
|
1400
|
+
}
|
|
1401
|
+
if (emitted.length === 0) {
|
|
1402
|
+
logger.debug("[Prescribed] No prescribed targets/support emitted this run");
|
|
1403
|
+
await this.putStrategyState(nextState).catch((e) => {
|
|
1404
|
+
logger.debug(`[Prescribed] Failed to persist empty-state update: ${e}`);
|
|
1405
|
+
});
|
|
1298
1406
|
return [];
|
|
1299
1407
|
}
|
|
1300
|
-
const
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1408
|
+
const finalCards = pickTopByScore(emitted, limit);
|
|
1409
|
+
const surfacedByGroup = /* @__PURE__ */ new Map();
|
|
1410
|
+
for (const card of finalCards) {
|
|
1411
|
+
const prov = card.provenance[0];
|
|
1412
|
+
const groupId = prov?.reason.match(/group=([^;]+)/)?.[1];
|
|
1413
|
+
const mode = prov?.reason.includes("mode=support") ? "supportIds" : "targetIds";
|
|
1414
|
+
if (!groupId) continue;
|
|
1415
|
+
if (!surfacedByGroup.has(groupId)) {
|
|
1416
|
+
surfacedByGroup.set(groupId, { targetIds: [], supportIds: [] });
|
|
1417
|
+
}
|
|
1418
|
+
surfacedByGroup.get(groupId)[mode].push(card.cardId);
|
|
1419
|
+
}
|
|
1420
|
+
for (const group of this.config.groups) {
|
|
1421
|
+
const groupState = nextState.groups[group.id];
|
|
1422
|
+
const surfaced = surfacedByGroup.get(group.id);
|
|
1423
|
+
if (surfaced && (surfaced.targetIds.length > 0 || surfaced.supportIds.length > 0)) {
|
|
1424
|
+
groupState.lastSurfacedAt = isoNow();
|
|
1425
|
+
groupState.sessionsSinceSurfaced = 0;
|
|
1426
|
+
if (surfaced.supportIds.length > 0) {
|
|
1427
|
+
groupState.lastSupportAt = isoNow();
|
|
1312
1428
|
}
|
|
1313
|
-
|
|
1314
|
-
}
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
await this.putStrategyState(nextState).catch((e) => {
|
|
1432
|
+
logger.debug(`[Prescribed] Failed to persist prescribed progress: ${e}`);
|
|
1433
|
+
});
|
|
1315
1434
|
logger.info(
|
|
1316
|
-
`[Prescribed] Emitting ${
|
|
1435
|
+
`[Prescribed] Emitting ${finalCards.length} cards (${finalCards.filter((c) => c.provenance[0]?.reason.includes("mode=target")).length} target, ${finalCards.filter((c) => c.provenance[0]?.reason.includes("mode=support")).length} support)`
|
|
1317
1436
|
);
|
|
1437
|
+
return finalCards;
|
|
1438
|
+
}
|
|
1439
|
+
parseConfig(serializedData) {
|
|
1440
|
+
try {
|
|
1441
|
+
const parsed = JSON.parse(serializedData);
|
|
1442
|
+
const groupsRaw = Array.isArray(parsed.groups) ? parsed.groups : [];
|
|
1443
|
+
const groups = groupsRaw.map((raw, i) => ({
|
|
1444
|
+
id: typeof raw.id === "string" && raw.id.trim().length > 0 ? raw.id : `group-${i + 1}`,
|
|
1445
|
+
targetCardIds: dedupe(Array.isArray(raw.targetCardIds) ? raw.targetCardIds.filter((v) => typeof v === "string") : []),
|
|
1446
|
+
supportCardIds: dedupe(Array.isArray(raw.supportCardIds) ? raw.supportCardIds.filter((v) => typeof v === "string") : []),
|
|
1447
|
+
supportTagPatterns: dedupe(Array.isArray(raw.supportTagPatterns) ? raw.supportTagPatterns.filter((v) => typeof v === "string") : []),
|
|
1448
|
+
freshnessWindowSessions: typeof raw.freshnessWindowSessions === "number" ? raw.freshnessWindowSessions : DEFAULT_FRESHNESS_WINDOW,
|
|
1449
|
+
maxDirectTargetsPerRun: typeof raw.maxDirectTargetsPerRun === "number" ? raw.maxDirectTargetsPerRun : DEFAULT_MAX_DIRECT_PER_RUN,
|
|
1450
|
+
maxSupportCardsPerRun: typeof raw.maxSupportCardsPerRun === "number" ? raw.maxSupportCardsPerRun : DEFAULT_MAX_SUPPORT_PER_RUN,
|
|
1451
|
+
hierarchyWalk: {
|
|
1452
|
+
enabled: raw.hierarchyWalk?.enabled !== false,
|
|
1453
|
+
maxDepth: typeof raw.hierarchyWalk?.maxDepth === "number" ? raw.hierarchyWalk.maxDepth : DEFAULT_HIERARCHY_DEPTH
|
|
1454
|
+
},
|
|
1455
|
+
retireOnEncounter: raw.retireOnEncounter !== false
|
|
1456
|
+
})).filter((g) => g.targetCardIds.length > 0);
|
|
1457
|
+
return { groups };
|
|
1458
|
+
} catch {
|
|
1459
|
+
return { groups: [] };
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
async loadHierarchyConfigs() {
|
|
1463
|
+
try {
|
|
1464
|
+
const strategies = await this.course.getNavigationStrategies();
|
|
1465
|
+
return strategies.filter((s) => s.implementingClass === "hierarchyDefinition").map((s) => {
|
|
1466
|
+
try {
|
|
1467
|
+
const parsed = JSON.parse(s.serializedData);
|
|
1468
|
+
return {
|
|
1469
|
+
prerequisites: parsed.prerequisites || {}
|
|
1470
|
+
};
|
|
1471
|
+
} catch {
|
|
1472
|
+
return { prerequisites: {} };
|
|
1473
|
+
}
|
|
1474
|
+
});
|
|
1475
|
+
} catch (e) {
|
|
1476
|
+
logger.debug(`[Prescribed] Failed to load hierarchy configs: ${e}`);
|
|
1477
|
+
return [];
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
buildGroupRuntimeState(args) {
|
|
1481
|
+
const {
|
|
1482
|
+
group,
|
|
1483
|
+
priorState,
|
|
1484
|
+
activeIds,
|
|
1485
|
+
seenIds,
|
|
1486
|
+
tagsByCard,
|
|
1487
|
+
hierarchyConfigs,
|
|
1488
|
+
userTagElo,
|
|
1489
|
+
userGlobalElo
|
|
1490
|
+
} = args;
|
|
1491
|
+
const encounteredTargets = /* @__PURE__ */ new Set();
|
|
1492
|
+
for (const cardId of group.targetCardIds) {
|
|
1493
|
+
if (activeIds.has(cardId) || seenIds.has(cardId)) {
|
|
1494
|
+
encounteredTargets.add(cardId);
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
if (priorState?.encounteredCardIds?.length) {
|
|
1498
|
+
for (const cardId of priorState.encounteredCardIds) {
|
|
1499
|
+
encounteredTargets.add(cardId);
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
const pendingTargets = group.targetCardIds.filter((id) => !encounteredTargets.has(id));
|
|
1503
|
+
const targetTags = /* @__PURE__ */ new Map();
|
|
1504
|
+
for (const cardId of pendingTargets) {
|
|
1505
|
+
targetTags.set(cardId, tagsByCard.get(cardId) ?? []);
|
|
1506
|
+
}
|
|
1507
|
+
const blockedTargets = [];
|
|
1508
|
+
const surfaceableTargets = [];
|
|
1509
|
+
const supportTags = /* @__PURE__ */ new Set();
|
|
1510
|
+
for (const cardId of pendingTargets) {
|
|
1511
|
+
const tags = targetTags.get(cardId) ?? [];
|
|
1512
|
+
const resolution = this.resolveBlockedSupportTags(
|
|
1513
|
+
tags,
|
|
1514
|
+
hierarchyConfigs,
|
|
1515
|
+
userTagElo,
|
|
1516
|
+
userGlobalElo,
|
|
1517
|
+
group.hierarchyWalk?.enabled !== false,
|
|
1518
|
+
group.hierarchyWalk?.maxDepth ?? DEFAULT_HIERARCHY_DEPTH
|
|
1519
|
+
);
|
|
1520
|
+
if (resolution.blocked) {
|
|
1521
|
+
blockedTargets.push(cardId);
|
|
1522
|
+
resolution.supportTags.forEach((t) => supportTags.add(t));
|
|
1523
|
+
} else {
|
|
1524
|
+
surfaceableTargets.push(cardId);
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
const supportCandidates = dedupe([
|
|
1528
|
+
...group.supportCardIds ?? [],
|
|
1529
|
+
...this.findSupportCardsByTags(
|
|
1530
|
+
group,
|
|
1531
|
+
tagsByCard,
|
|
1532
|
+
[...supportTags]
|
|
1533
|
+
)
|
|
1534
|
+
]).filter((id) => !activeIds.has(id) && !seenIds.has(id));
|
|
1535
|
+
const sessionsSinceSurfaced = priorState?.sessionsSinceSurfaced ?? 0;
|
|
1536
|
+
const freshnessWindow = group.freshnessWindowSessions ?? DEFAULT_FRESHNESS_WINDOW;
|
|
1537
|
+
const staleSessions = Math.max(0, sessionsSinceSurfaced - freshnessWindow);
|
|
1538
|
+
const pressureMultiplier = pendingTargets.length === 0 ? 1 : clamp(1 + staleSessions * 0.75 + Math.min(2, pendingTargets.length * 0.1), 1, MAX_TARGET_MULTIPLIER);
|
|
1539
|
+
const supportMultiplier = blockedTargets.length === 0 ? 1 : clamp(1 + staleSessions * 0.5 + Math.min(1.5, blockedTargets.length * 0.15), 1, MAX_SUPPORT_MULTIPLIER);
|
|
1540
|
+
return {
|
|
1541
|
+
group,
|
|
1542
|
+
encounteredTargets,
|
|
1543
|
+
pendingTargets,
|
|
1544
|
+
blockedTargets,
|
|
1545
|
+
surfaceableTargets,
|
|
1546
|
+
targetTags,
|
|
1547
|
+
supportCandidates,
|
|
1548
|
+
supportTags: [...supportTags],
|
|
1549
|
+
pressureMultiplier,
|
|
1550
|
+
supportMultiplier
|
|
1551
|
+
};
|
|
1552
|
+
}
|
|
1553
|
+
buildNextGroupState(runtime, prior) {
|
|
1554
|
+
const carriedSessions = prior?.sessionsSinceSurfaced ?? 0;
|
|
1555
|
+
const surfacedThisRun = false;
|
|
1556
|
+
return {
|
|
1557
|
+
encounteredCardIds: [...runtime.encounteredTargets].sort(),
|
|
1558
|
+
lastSurfacedAt: prior?.lastSurfacedAt ?? null,
|
|
1559
|
+
sessionsSinceSurfaced: surfacedThisRun ? 0 : carriedSessions + 1,
|
|
1560
|
+
lastSupportAt: prior?.lastSupportAt ?? null,
|
|
1561
|
+
blockedTargetIds: [...runtime.blockedTargets].sort(),
|
|
1562
|
+
lastResolvedSupportTags: [...runtime.supportTags].sort()
|
|
1563
|
+
};
|
|
1564
|
+
}
|
|
1565
|
+
buildDirectTargetCards(runtime, courseId, emittedIds) {
|
|
1566
|
+
const maxDirect = runtime.group.maxDirectTargetsPerRun ?? DEFAULT_MAX_DIRECT_PER_RUN;
|
|
1567
|
+
const directIds = runtime.surfaceableTargets.filter((id) => !emittedIds.has(id)).slice(0, maxDirect);
|
|
1568
|
+
const cards = [];
|
|
1569
|
+
for (const cardId of directIds) {
|
|
1570
|
+
emittedIds.add(cardId);
|
|
1571
|
+
cards.push({
|
|
1572
|
+
cardId,
|
|
1573
|
+
courseId,
|
|
1574
|
+
score: BASE_TARGET_SCORE * runtime.pressureMultiplier,
|
|
1575
|
+
provenance: [
|
|
1576
|
+
{
|
|
1577
|
+
strategy: "prescribed",
|
|
1578
|
+
strategyName: this.strategyName || this.name,
|
|
1579
|
+
strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
|
|
1580
|
+
action: "generated",
|
|
1581
|
+
score: BASE_TARGET_SCORE * runtime.pressureMultiplier,
|
|
1582
|
+
reason: `mode=target;group=${runtime.group.id};pending=${runtime.pendingTargets.length};surfaceable=${runtime.surfaceableTargets.length};blocked=${runtime.blockedTargets.length};multiplier=${runtime.pressureMultiplier.toFixed(2)}`
|
|
1583
|
+
}
|
|
1584
|
+
]
|
|
1585
|
+
});
|
|
1586
|
+
}
|
|
1587
|
+
return cards;
|
|
1588
|
+
}
|
|
1589
|
+
buildSupportCards(runtime, courseId, emittedIds) {
|
|
1590
|
+
if (runtime.blockedTargets.length === 0 || runtime.supportCandidates.length === 0) {
|
|
1591
|
+
return [];
|
|
1592
|
+
}
|
|
1593
|
+
const maxSupport = runtime.group.maxSupportCardsPerRun ?? DEFAULT_MAX_SUPPORT_PER_RUN;
|
|
1594
|
+
const supportIds = runtime.supportCandidates.filter((id) => !emittedIds.has(id)).slice(0, maxSupport);
|
|
1595
|
+
const cards = [];
|
|
1596
|
+
for (const cardId of supportIds) {
|
|
1597
|
+
emittedIds.add(cardId);
|
|
1598
|
+
cards.push({
|
|
1599
|
+
cardId,
|
|
1600
|
+
courseId,
|
|
1601
|
+
score: BASE_SUPPORT_SCORE * runtime.supportMultiplier,
|
|
1602
|
+
provenance: [
|
|
1603
|
+
{
|
|
1604
|
+
strategy: "prescribed",
|
|
1605
|
+
strategyName: this.strategyName || this.name,
|
|
1606
|
+
strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
|
|
1607
|
+
action: "generated",
|
|
1608
|
+
score: BASE_SUPPORT_SCORE * runtime.supportMultiplier,
|
|
1609
|
+
reason: `mode=support;group=${runtime.group.id};blocked=${runtime.blockedTargets.length};supportTags=${runtime.supportTags.join("|") || "none"};multiplier=${runtime.supportMultiplier.toFixed(2)}`
|
|
1610
|
+
}
|
|
1611
|
+
]
|
|
1612
|
+
});
|
|
1613
|
+
}
|
|
1318
1614
|
return cards;
|
|
1319
1615
|
}
|
|
1616
|
+
findSupportCardsByTags(group, tagsByCard, supportTags) {
|
|
1617
|
+
if (supportTags.length === 0) {
|
|
1618
|
+
return [];
|
|
1619
|
+
}
|
|
1620
|
+
const explicitSupportIds = group.supportCardIds ?? [];
|
|
1621
|
+
const explicitPatterns = group.supportTagPatterns ?? [];
|
|
1622
|
+
if (explicitSupportIds.length === 0 && explicitPatterns.length === 0) {
|
|
1623
|
+
return [];
|
|
1624
|
+
}
|
|
1625
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
1626
|
+
for (const cardId of explicitSupportIds) {
|
|
1627
|
+
const cardTags = tagsByCard.get(cardId) ?? [];
|
|
1628
|
+
const matchesResolved = supportTags.some((supportTag) => cardTags.includes(supportTag));
|
|
1629
|
+
const matchesPattern = explicitPatterns.some(
|
|
1630
|
+
(pattern) => cardTags.some((tag) => matchesTagPattern(tag, pattern))
|
|
1631
|
+
);
|
|
1632
|
+
if (matchesResolved || matchesPattern) {
|
|
1633
|
+
candidates.add(cardId);
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
return [...candidates];
|
|
1637
|
+
}
|
|
1638
|
+
resolveBlockedSupportTags(targetTags, hierarchyConfigs, userTagElo, userGlobalElo, hierarchyWalkEnabled, maxDepth) {
|
|
1639
|
+
if (!hierarchyWalkEnabled || targetTags.length === 0 || hierarchyConfigs.length === 0) {
|
|
1640
|
+
return {
|
|
1641
|
+
blocked: false,
|
|
1642
|
+
supportTags: []
|
|
1643
|
+
};
|
|
1644
|
+
}
|
|
1645
|
+
const supportTags = /* @__PURE__ */ new Set();
|
|
1646
|
+
let blocked = false;
|
|
1647
|
+
for (const targetTag of targetTags) {
|
|
1648
|
+
for (const hierarchy of hierarchyConfigs) {
|
|
1649
|
+
const prereqs = hierarchy.prerequisites[targetTag];
|
|
1650
|
+
if (!prereqs || prereqs.length === 0) continue;
|
|
1651
|
+
const unmet = prereqs.filter(
|
|
1652
|
+
(pr) => !this.isPrerequisiteMet(pr, userTagElo[pr.tag], userGlobalElo)
|
|
1653
|
+
);
|
|
1654
|
+
if (unmet.length === 0) {
|
|
1655
|
+
continue;
|
|
1656
|
+
}
|
|
1657
|
+
blocked = true;
|
|
1658
|
+
for (const prereq of unmet) {
|
|
1659
|
+
this.collectSupportTagsRecursive(
|
|
1660
|
+
prereq.tag,
|
|
1661
|
+
hierarchyConfigs,
|
|
1662
|
+
userTagElo,
|
|
1663
|
+
userGlobalElo,
|
|
1664
|
+
maxDepth,
|
|
1665
|
+
/* @__PURE__ */ new Set(),
|
|
1666
|
+
supportTags
|
|
1667
|
+
);
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
return { blocked, supportTags: [...supportTags] };
|
|
1672
|
+
}
|
|
1673
|
+
collectSupportTagsRecursive(tag, hierarchyConfigs, userTagElo, userGlobalElo, depth, visited, out) {
|
|
1674
|
+
if (depth < 0 || visited.has(tag)) return;
|
|
1675
|
+
if (this.isHardGatedTag(tag)) return;
|
|
1676
|
+
visited.add(tag);
|
|
1677
|
+
let walkedFurther = false;
|
|
1678
|
+
for (const hierarchy of hierarchyConfigs) {
|
|
1679
|
+
const prereqs = hierarchy.prerequisites[tag];
|
|
1680
|
+
if (!prereqs || prereqs.length === 0) continue;
|
|
1681
|
+
const unmet = prereqs.filter(
|
|
1682
|
+
(pr) => !this.isPrerequisiteMet(pr, userTagElo[pr.tag], userGlobalElo)
|
|
1683
|
+
);
|
|
1684
|
+
if (unmet.length > 0 && depth > 0) {
|
|
1685
|
+
walkedFurther = true;
|
|
1686
|
+
for (const prereq of unmet) {
|
|
1687
|
+
this.collectSupportTagsRecursive(
|
|
1688
|
+
prereq.tag,
|
|
1689
|
+
hierarchyConfigs,
|
|
1690
|
+
userTagElo,
|
|
1691
|
+
userGlobalElo,
|
|
1692
|
+
depth - 1,
|
|
1693
|
+
visited,
|
|
1694
|
+
out
|
|
1695
|
+
);
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
if (!walkedFurther) {
|
|
1700
|
+
out.add(tag);
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
isHardGatedTag(tag) {
|
|
1704
|
+
return LOCKED_TAG_PREFIXES.some((prefix) => tag.startsWith(prefix)) && tag.startsWith(LESSON_GATE_PENALTY_TAG_HINT);
|
|
1705
|
+
}
|
|
1706
|
+
isPrerequisiteMet(prereq, userTagElo, userGlobalElo) {
|
|
1707
|
+
if (!userTagElo) return false;
|
|
1708
|
+
const minCount = prereq.masteryThreshold?.minCount ?? DEFAULT_MIN_COUNT;
|
|
1709
|
+
if (userTagElo.count < minCount) return false;
|
|
1710
|
+
if (prereq.masteryThreshold?.minElo !== void 0) {
|
|
1711
|
+
return userTagElo.score >= prereq.masteryThreshold.minElo;
|
|
1712
|
+
}
|
|
1713
|
+
if (prereq.masteryThreshold?.minCount !== void 0) {
|
|
1714
|
+
return true;
|
|
1715
|
+
}
|
|
1716
|
+
return userTagElo.score >= userGlobalElo;
|
|
1717
|
+
}
|
|
1320
1718
|
};
|
|
1321
1719
|
}
|
|
1322
1720
|
});
|
|
@@ -1680,12 +2078,13 @@ __export(hierarchyDefinition_exports, {
|
|
|
1680
2078
|
default: () => HierarchyDefinitionNavigator
|
|
1681
2079
|
});
|
|
1682
2080
|
import { toCourseElo as toCourseElo3 } from "@vue-skuilder/common";
|
|
1683
|
-
var
|
|
2081
|
+
var DEFAULT_MIN_COUNT2, HierarchyDefinitionNavigator;
|
|
1684
2082
|
var init_hierarchyDefinition = __esm({
|
|
1685
2083
|
"src/core/navigators/filters/hierarchyDefinition.ts"() {
|
|
1686
2084
|
"use strict";
|
|
1687
2085
|
init_navigators();
|
|
1688
|
-
|
|
2086
|
+
init_logger();
|
|
2087
|
+
DEFAULT_MIN_COUNT2 = 3;
|
|
1689
2088
|
HierarchyDefinitionNavigator = class extends ContentNavigator {
|
|
1690
2089
|
config;
|
|
1691
2090
|
/** Human-readable name for CardFilter interface */
|
|
@@ -1712,7 +2111,7 @@ var init_hierarchyDefinition = __esm({
|
|
|
1712
2111
|
*/
|
|
1713
2112
|
isPrerequisiteMet(prereq, userTagElo, userGlobalElo) {
|
|
1714
2113
|
if (!userTagElo) return false;
|
|
1715
|
-
const minCount = prereq.masteryThreshold?.minCount ??
|
|
2114
|
+
const minCount = prereq.masteryThreshold?.minCount ?? DEFAULT_MIN_COUNT2;
|
|
1716
2115
|
if (userTagElo.count < minCount) return false;
|
|
1717
2116
|
if (prereq.masteryThreshold?.minElo !== void 0) {
|
|
1718
2117
|
return userTagElo.score >= prereq.masteryThreshold.minElo;
|
|
@@ -1813,18 +2212,55 @@ var init_hierarchyDefinition = __esm({
|
|
|
1813
2212
|
}
|
|
1814
2213
|
return boosts;
|
|
1815
2214
|
}
|
|
2215
|
+
/**
|
|
2216
|
+
* Build a map of gated tag → max configured targetBoost for all *open* gates.
|
|
2217
|
+
*
|
|
2218
|
+
* When a gate opens (prereqs met), cards carrying the gated tag get boosted —
|
|
2219
|
+
* ensuring newly-unlocked content surfaces promptly. The boost is a static
|
|
2220
|
+
* multiplier; natural ELO/SRS deprioritization after interaction handles decay.
|
|
2221
|
+
*/
|
|
2222
|
+
getTargetBoosts(unlockedTags) {
|
|
2223
|
+
const boosts = /* @__PURE__ */ new Map();
|
|
2224
|
+
const configKeys = Object.keys(this.config.prerequisites);
|
|
2225
|
+
const unlockedArr = [...unlockedTags];
|
|
2226
|
+
logger.info(
|
|
2227
|
+
`[HierarchyDefinition:targetBoost:trace] ${this.name} | configKeys=${configKeys.length}, unlocked=${unlockedArr.length} (${unlockedArr.slice(0, 5).join(", ")}${unlockedArr.length > 5 ? "..." : ""})`
|
|
2228
|
+
);
|
|
2229
|
+
for (const [tagId, prereqs] of Object.entries(this.config.prerequisites)) {
|
|
2230
|
+
if (!unlockedTags.has(tagId)) continue;
|
|
2231
|
+
logger.info(
|
|
2232
|
+
`[HierarchyDefinition:targetBoost:trace] UNLOCKED ${tagId}: ${prereqs.length} prereqs, raw=${JSON.stringify(prereqs.map((p) => ({ tag: p.tag, tb: p.targetBoost })))}`
|
|
2233
|
+
);
|
|
2234
|
+
for (const prereq of prereqs) {
|
|
2235
|
+
if (!prereq.targetBoost || prereq.targetBoost <= 1) continue;
|
|
2236
|
+
const existing = boosts.get(tagId) ?? 1;
|
|
2237
|
+
boosts.set(tagId, Math.max(existing, prereq.targetBoost));
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
if (boosts.size > 0) {
|
|
2241
|
+
logger.info(
|
|
2242
|
+
`[HierarchyDefinition] targetBoosts active: ${[...boosts.entries()].map(([t, b]) => `${t}=\xD7${b}`).join(", ")}`
|
|
2243
|
+
);
|
|
2244
|
+
} else {
|
|
2245
|
+
logger.info(
|
|
2246
|
+
`[HierarchyDefinition:targetBoost:trace] no targetBoosts found despite ${unlockedArr.length} unlocked tags`
|
|
2247
|
+
);
|
|
2248
|
+
}
|
|
2249
|
+
return boosts;
|
|
2250
|
+
}
|
|
1816
2251
|
/**
|
|
1817
2252
|
* CardFilter.transform implementation.
|
|
1818
2253
|
*
|
|
1819
|
-
*
|
|
1820
|
-
* 1. Cards with locked tags receive score * 0.
|
|
1821
|
-
* 2. Cards carrying prereq tags of closed gates receive
|
|
1822
|
-
*
|
|
2254
|
+
* Three effects:
|
|
2255
|
+
* 1. Cards with locked tags receive score * 0.02 (gating penalty)
|
|
2256
|
+
* 2. Cards carrying prereq tags of closed gates receive preReqBoost
|
|
2257
|
+
* 3. Cards carrying gated tags of open gates receive targetBoost
|
|
1823
2258
|
*/
|
|
1824
2259
|
async transform(cards, context) {
|
|
1825
2260
|
const masteredTags = await this.getMasteredTags(context);
|
|
1826
2261
|
const unlockedTags = this.getUnlockedTags(masteredTags);
|
|
1827
2262
|
const preReqBoosts = this.getPreReqBoosts(unlockedTags, masteredTags);
|
|
2263
|
+
const targetBoosts = this.getTargetBoosts(unlockedTags);
|
|
1828
2264
|
const gated = [];
|
|
1829
2265
|
for (const card of cards) {
|
|
1830
2266
|
const { isUnlocked, reason } = await this.checkCardUnlock(
|
|
@@ -1852,6 +2288,29 @@ var init_hierarchyDefinition = __esm({
|
|
|
1852
2288
|
finalScore *= maxBoost;
|
|
1853
2289
|
action = "boosted";
|
|
1854
2290
|
finalReason = `${reason} | preReqBoost \xD7${maxBoost.toFixed(2)} for ${boostedPrereqs.join(", ")}`;
|
|
2291
|
+
logger.info(
|
|
2292
|
+
`[HierarchyDefinition] preReqBoost \xD7${maxBoost.toFixed(2)} applied to card ${card.cardId} via tags [${boostedPrereqs.join(", ")}] (score: ${card.score.toFixed(3)} \u2192 ${finalScore.toFixed(3)})`
|
|
2293
|
+
);
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
if (isUnlocked && targetBoosts.size > 0) {
|
|
2297
|
+
const cardTags = card.tags ?? [];
|
|
2298
|
+
let maxTargetBoost = 1;
|
|
2299
|
+
const boostedTargets = [];
|
|
2300
|
+
for (const tag of cardTags) {
|
|
2301
|
+
const boost = targetBoosts.get(tag);
|
|
2302
|
+
if (boost && boost > maxTargetBoost) {
|
|
2303
|
+
maxTargetBoost = boost;
|
|
2304
|
+
boostedTargets.push(tag);
|
|
2305
|
+
}
|
|
2306
|
+
}
|
|
2307
|
+
if (maxTargetBoost > 1) {
|
|
2308
|
+
finalScore *= maxTargetBoost;
|
|
2309
|
+
action = "boosted";
|
|
2310
|
+
finalReason = `${finalReason} | targetBoost \xD7${maxTargetBoost.toFixed(2)} for ${boostedTargets.join(", ")}`;
|
|
2311
|
+
logger.info(
|
|
2312
|
+
`[HierarchyDefinition] targetBoost \xD7${maxTargetBoost.toFixed(2)} applied to card ${card.cardId} via tags [${boostedTargets.join(", ")}] (score: ${card.score.toFixed(3)} \u2192 ${finalScore.toFixed(3)})`
|
|
2313
|
+
);
|
|
1855
2314
|
}
|
|
1856
2315
|
}
|
|
1857
2316
|
gated.push({
|
|
@@ -2039,12 +2498,12 @@ __export(interferenceMitigator_exports, {
|
|
|
2039
2498
|
default: () => InterferenceMitigatorNavigator
|
|
2040
2499
|
});
|
|
2041
2500
|
import { toCourseElo as toCourseElo4 } from "@vue-skuilder/common";
|
|
2042
|
-
var
|
|
2501
|
+
var DEFAULT_MIN_COUNT3, DEFAULT_MIN_ELAPSED_DAYS, DEFAULT_INTERFERENCE_DECAY, InterferenceMitigatorNavigator;
|
|
2043
2502
|
var init_interferenceMitigator = __esm({
|
|
2044
2503
|
"src/core/navigators/filters/interferenceMitigator.ts"() {
|
|
2045
2504
|
"use strict";
|
|
2046
2505
|
init_navigators();
|
|
2047
|
-
|
|
2506
|
+
DEFAULT_MIN_COUNT3 = 10;
|
|
2048
2507
|
DEFAULT_MIN_ELAPSED_DAYS = 3;
|
|
2049
2508
|
DEFAULT_INTERFERENCE_DECAY = 0.8;
|
|
2050
2509
|
InterferenceMitigatorNavigator = class extends ContentNavigator {
|
|
@@ -2069,7 +2528,7 @@ var init_interferenceMitigator = __esm({
|
|
|
2069
2528
|
return {
|
|
2070
2529
|
interferenceSets: sets,
|
|
2071
2530
|
maturityThreshold: {
|
|
2072
|
-
minCount: parsed.maturityThreshold?.minCount ??
|
|
2531
|
+
minCount: parsed.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT3,
|
|
2073
2532
|
minElo: parsed.maturityThreshold?.minElo,
|
|
2074
2533
|
minElapsedDays: parsed.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS
|
|
2075
2534
|
},
|
|
@@ -2079,7 +2538,7 @@ var init_interferenceMitigator = __esm({
|
|
|
2079
2538
|
return {
|
|
2080
2539
|
interferenceSets: [],
|
|
2081
2540
|
maturityThreshold: {
|
|
2082
|
-
minCount:
|
|
2541
|
+
minCount: DEFAULT_MIN_COUNT3,
|
|
2083
2542
|
minElapsedDays: DEFAULT_MIN_ELAPSED_DAYS
|
|
2084
2543
|
},
|
|
2085
2544
|
defaultDecay: DEFAULT_INTERFERENCE_DECAY
|
|
@@ -2126,7 +2585,7 @@ var init_interferenceMitigator = __esm({
|
|
|
2126
2585
|
try {
|
|
2127
2586
|
const courseReg = await context.user.getCourseRegDoc(context.course.getCourseID());
|
|
2128
2587
|
const userElo = toCourseElo4(courseReg.elo);
|
|
2129
|
-
const minCount = this.config.maturityThreshold?.minCount ??
|
|
2588
|
+
const minCount = this.config.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT3;
|
|
2130
2589
|
const minElo = this.config.maturityThreshold?.minElo;
|
|
2131
2590
|
const minElapsedDays = this.config.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS;
|
|
2132
2591
|
const minCountForElapsed = minElapsedDays * 2;
|
|
@@ -2361,7 +2820,7 @@ var init_relativePriority = __esm({
|
|
|
2361
2820
|
const cardTags = card.tags ?? [];
|
|
2362
2821
|
const priority = this.computeCardPriority(cardTags);
|
|
2363
2822
|
const boostFactor = this.computeBoostFactor(priority);
|
|
2364
|
-
const finalScore = Math.max(0,
|
|
2823
|
+
const finalScore = Math.max(0, card.score * boostFactor);
|
|
2365
2824
|
const action = boostFactor > 1 ? "boosted" : boostFactor < 1 ? "penalized" : "passed";
|
|
2366
2825
|
const reason = this.buildPriorityReason(cardTags, priority, boostFactor, finalScore);
|
|
2367
2826
|
return {
|
|
@@ -2437,114 +2896,1704 @@ var init_2 = __esm({
|
|
|
2437
2896
|
}
|
|
2438
2897
|
});
|
|
2439
2898
|
|
|
2440
|
-
// src/core/orchestration/gradient.ts
|
|
2441
|
-
var init_gradient = __esm({
|
|
2442
|
-
"src/core/orchestration/gradient.ts"() {
|
|
2443
|
-
"use strict";
|
|
2444
|
-
init_logger();
|
|
2445
|
-
}
|
|
2446
|
-
});
|
|
2899
|
+
// src/core/orchestration/gradient.ts
|
|
2900
|
+
var init_gradient = __esm({
|
|
2901
|
+
"src/core/orchestration/gradient.ts"() {
|
|
2902
|
+
"use strict";
|
|
2903
|
+
init_logger();
|
|
2904
|
+
}
|
|
2905
|
+
});
|
|
2906
|
+
|
|
2907
|
+
// src/core/orchestration/learning.ts
|
|
2908
|
+
var init_learning = __esm({
|
|
2909
|
+
"src/core/orchestration/learning.ts"() {
|
|
2910
|
+
"use strict";
|
|
2911
|
+
init_contentNavigationStrategy();
|
|
2912
|
+
init_types_legacy();
|
|
2913
|
+
init_logger();
|
|
2914
|
+
}
|
|
2915
|
+
});
|
|
2916
|
+
|
|
2917
|
+
// src/core/orchestration/signal.ts
|
|
2918
|
+
var init_signal = __esm({
|
|
2919
|
+
"src/core/orchestration/signal.ts"() {
|
|
2920
|
+
"use strict";
|
|
2921
|
+
}
|
|
2922
|
+
});
|
|
2923
|
+
|
|
2924
|
+
// src/core/orchestration/recording.ts
|
|
2925
|
+
var init_recording = __esm({
|
|
2926
|
+
"src/core/orchestration/recording.ts"() {
|
|
2927
|
+
"use strict";
|
|
2928
|
+
init_signal();
|
|
2929
|
+
init_types_legacy();
|
|
2930
|
+
init_logger();
|
|
2931
|
+
}
|
|
2932
|
+
});
|
|
2933
|
+
|
|
2934
|
+
// src/core/orchestration/index.ts
|
|
2935
|
+
function fnv1a(str) {
|
|
2936
|
+
let hash = 2166136261;
|
|
2937
|
+
for (let i = 0; i < str.length; i++) {
|
|
2938
|
+
hash ^= str.charCodeAt(i);
|
|
2939
|
+
hash = Math.imul(hash, 16777619);
|
|
2940
|
+
}
|
|
2941
|
+
return hash >>> 0;
|
|
2942
|
+
}
|
|
2943
|
+
function computeDeviation(userId, strategyId, salt) {
|
|
2944
|
+
const input = `${userId}:${strategyId}:${salt}`;
|
|
2945
|
+
const hash = fnv1a(input);
|
|
2946
|
+
const normalized = hash / 4294967296;
|
|
2947
|
+
return normalized * 2 - 1;
|
|
2948
|
+
}
|
|
2949
|
+
function computeSpread(confidence) {
|
|
2950
|
+
const clampedConfidence = Math.max(0, Math.min(1, confidence));
|
|
2951
|
+
return MAX_SPREAD - clampedConfidence * (MAX_SPREAD - MIN_SPREAD);
|
|
2952
|
+
}
|
|
2953
|
+
function computeEffectiveWeight(learnable, userId, strategyId, salt) {
|
|
2954
|
+
const deviation = computeDeviation(userId, strategyId, salt);
|
|
2955
|
+
const spread = computeSpread(learnable.confidence);
|
|
2956
|
+
const adjustment = deviation * spread * learnable.weight;
|
|
2957
|
+
const effective = learnable.weight + adjustment;
|
|
2958
|
+
return Math.max(MIN_WEIGHT, Math.min(MAX_WEIGHT, effective));
|
|
2959
|
+
}
|
|
2960
|
+
async function createOrchestrationContext(user, course) {
|
|
2961
|
+
let courseConfig;
|
|
2962
|
+
try {
|
|
2963
|
+
courseConfig = await course.getCourseConfig();
|
|
2964
|
+
} catch (e) {
|
|
2965
|
+
logger.error(`[Orchestration] Failed to load course config: ${e}`);
|
|
2966
|
+
courseConfig = {
|
|
2967
|
+
name: "Unknown",
|
|
2968
|
+
description: "",
|
|
2969
|
+
public: false,
|
|
2970
|
+
deleted: false,
|
|
2971
|
+
creator: "",
|
|
2972
|
+
admins: [],
|
|
2973
|
+
moderators: [],
|
|
2974
|
+
dataShapes: [],
|
|
2975
|
+
questionTypes: [],
|
|
2976
|
+
orchestration: { salt: "default" }
|
|
2977
|
+
};
|
|
2978
|
+
}
|
|
2979
|
+
const userId = user.getUsername();
|
|
2980
|
+
const salt = courseConfig.orchestration?.salt || "default_salt";
|
|
2981
|
+
return {
|
|
2982
|
+
user,
|
|
2983
|
+
course,
|
|
2984
|
+
userId,
|
|
2985
|
+
courseConfig,
|
|
2986
|
+
getEffectiveWeight(strategyId, learnable) {
|
|
2987
|
+
return computeEffectiveWeight(learnable, userId, strategyId, salt);
|
|
2988
|
+
},
|
|
2989
|
+
getDeviation(strategyId) {
|
|
2990
|
+
return computeDeviation(userId, strategyId, salt);
|
|
2991
|
+
}
|
|
2992
|
+
};
|
|
2993
|
+
}
|
|
2994
|
+
var MIN_SPREAD, MAX_SPREAD, MIN_WEIGHT, MAX_WEIGHT;
|
|
2995
|
+
var init_orchestration = __esm({
|
|
2996
|
+
"src/core/orchestration/index.ts"() {
|
|
2997
|
+
"use strict";
|
|
2998
|
+
init_logger();
|
|
2999
|
+
init_gradient();
|
|
3000
|
+
init_learning();
|
|
3001
|
+
init_signal();
|
|
3002
|
+
init_recording();
|
|
3003
|
+
MIN_SPREAD = 0.1;
|
|
3004
|
+
MAX_SPREAD = 0.5;
|
|
3005
|
+
MIN_WEIGHT = 0.1;
|
|
3006
|
+
MAX_WEIGHT = 3;
|
|
3007
|
+
}
|
|
3008
|
+
});
|
|
3009
|
+
|
|
3010
|
+
// src/core/util/index.ts
|
|
3011
|
+
function getCardHistoryID(courseID, cardID) {
|
|
3012
|
+
return `${DocTypePrefixes["CARDRECORD" /* CARDRECORD */]}-${courseID}-${cardID}`;
|
|
3013
|
+
}
|
|
3014
|
+
var init_util = __esm({
|
|
3015
|
+
"src/core/util/index.ts"() {
|
|
3016
|
+
"use strict";
|
|
3017
|
+
init_types_legacy();
|
|
3018
|
+
}
|
|
3019
|
+
});
|
|
3020
|
+
|
|
3021
|
+
// src/study/SpacedRepetition.ts
|
|
3022
|
+
import moment2 from "moment";
|
|
3023
|
+
import { isTaggedPerformance } from "@vue-skuilder/common";
|
|
3024
|
+
var duration;
|
|
3025
|
+
var init_SpacedRepetition = __esm({
|
|
3026
|
+
"src/study/SpacedRepetition.ts"() {
|
|
3027
|
+
"use strict";
|
|
3028
|
+
init_util();
|
|
3029
|
+
init_logger();
|
|
3030
|
+
duration = moment2.duration;
|
|
3031
|
+
}
|
|
3032
|
+
});
|
|
3033
|
+
|
|
3034
|
+
// src/study/services/SrsService.ts
|
|
3035
|
+
import moment3 from "moment";
|
|
3036
|
+
var init_SrsService = __esm({
|
|
3037
|
+
"src/study/services/SrsService.ts"() {
|
|
3038
|
+
"use strict";
|
|
3039
|
+
init_couch();
|
|
3040
|
+
init_SpacedRepetition();
|
|
3041
|
+
init_logger();
|
|
3042
|
+
}
|
|
3043
|
+
});
|
|
3044
|
+
|
|
3045
|
+
// src/study/services/EloService.ts
|
|
3046
|
+
import {
|
|
3047
|
+
adjustCourseScores,
|
|
3048
|
+
adjustCourseScoresPerTag,
|
|
3049
|
+
toCourseElo as toCourseElo5
|
|
3050
|
+
} from "@vue-skuilder/common";
|
|
3051
|
+
var init_EloService = __esm({
|
|
3052
|
+
"src/study/services/EloService.ts"() {
|
|
3053
|
+
"use strict";
|
|
3054
|
+
init_logger();
|
|
3055
|
+
}
|
|
3056
|
+
});
|
|
3057
|
+
|
|
3058
|
+
// src/study/services/ResponseProcessor.ts
|
|
3059
|
+
import { isTaggedPerformance as isTaggedPerformance2 } from "@vue-skuilder/common";
|
|
3060
|
+
var init_ResponseProcessor = __esm({
|
|
3061
|
+
"src/study/services/ResponseProcessor.ts"() {
|
|
3062
|
+
"use strict";
|
|
3063
|
+
init_core();
|
|
3064
|
+
init_logger();
|
|
3065
|
+
}
|
|
3066
|
+
});
|
|
3067
|
+
|
|
3068
|
+
// src/study/services/CardHydrationService.ts
|
|
3069
|
+
import {
|
|
3070
|
+
displayableDataToViewData,
|
|
3071
|
+
isCourseElo,
|
|
3072
|
+
toCourseElo as toCourseElo6
|
|
3073
|
+
} from "@vue-skuilder/common";
|
|
3074
|
+
var init_CardHydrationService = __esm({
|
|
3075
|
+
"src/study/services/CardHydrationService.ts"() {
|
|
3076
|
+
"use strict";
|
|
3077
|
+
init_logger();
|
|
3078
|
+
}
|
|
3079
|
+
});
|
|
3080
|
+
|
|
3081
|
+
// src/study/ItemQueue.ts
|
|
3082
|
+
var init_ItemQueue = __esm({
|
|
3083
|
+
"src/study/ItemQueue.ts"() {
|
|
3084
|
+
"use strict";
|
|
3085
|
+
}
|
|
3086
|
+
});
|
|
3087
|
+
|
|
3088
|
+
// src/util/packer/types.ts
|
|
3089
|
+
var init_types3 = __esm({
|
|
3090
|
+
"src/util/packer/types.ts"() {
|
|
3091
|
+
"use strict";
|
|
3092
|
+
}
|
|
3093
|
+
});
|
|
3094
|
+
|
|
3095
|
+
// src/util/packer/CouchDBToStaticPacker.ts
|
|
3096
|
+
var init_CouchDBToStaticPacker = __esm({
|
|
3097
|
+
"src/util/packer/CouchDBToStaticPacker.ts"() {
|
|
3098
|
+
"use strict";
|
|
3099
|
+
init_types_legacy();
|
|
3100
|
+
init_logger();
|
|
3101
|
+
}
|
|
3102
|
+
});
|
|
3103
|
+
|
|
3104
|
+
// src/util/packer/index.ts
|
|
3105
|
+
var init_packer = __esm({
|
|
3106
|
+
"src/util/packer/index.ts"() {
|
|
3107
|
+
"use strict";
|
|
3108
|
+
init_types3();
|
|
3109
|
+
init_CouchDBToStaticPacker();
|
|
3110
|
+
}
|
|
3111
|
+
});
|
|
3112
|
+
|
|
3113
|
+
// src/util/migrator/types.ts
|
|
3114
|
+
var DEFAULT_MIGRATION_OPTIONS;
|
|
3115
|
+
var init_types4 = __esm({
|
|
3116
|
+
"src/util/migrator/types.ts"() {
|
|
3117
|
+
"use strict";
|
|
3118
|
+
DEFAULT_MIGRATION_OPTIONS = {
|
|
3119
|
+
chunkBatchSize: 100,
|
|
3120
|
+
validateRoundTrip: false,
|
|
3121
|
+
cleanupOnFailure: true,
|
|
3122
|
+
timeout: 3e5
|
|
3123
|
+
// 5 minutes
|
|
3124
|
+
};
|
|
3125
|
+
}
|
|
3126
|
+
});
|
|
3127
|
+
|
|
3128
|
+
// src/util/migrator/FileSystemAdapter.ts
|
|
3129
|
+
var FileSystemError;
|
|
3130
|
+
var init_FileSystemAdapter = __esm({
|
|
3131
|
+
"src/util/migrator/FileSystemAdapter.ts"() {
|
|
3132
|
+
"use strict";
|
|
3133
|
+
FileSystemError = class extends Error {
|
|
3134
|
+
constructor(message, operation, filePath, cause) {
|
|
3135
|
+
super(message);
|
|
3136
|
+
this.operation = operation;
|
|
3137
|
+
this.filePath = filePath;
|
|
3138
|
+
this.cause = cause;
|
|
3139
|
+
this.name = "FileSystemError";
|
|
3140
|
+
}
|
|
3141
|
+
};
|
|
3142
|
+
}
|
|
3143
|
+
});
|
|
3144
|
+
|
|
3145
|
+
// src/util/migrator/validation.ts
|
|
3146
|
+
async function validateStaticCourse(staticPath, fs) {
|
|
3147
|
+
const validation = {
|
|
3148
|
+
valid: true,
|
|
3149
|
+
manifestExists: false,
|
|
3150
|
+
chunksExist: false,
|
|
3151
|
+
attachmentsExist: false,
|
|
3152
|
+
errors: [],
|
|
3153
|
+
warnings: []
|
|
3154
|
+
};
|
|
3155
|
+
try {
|
|
3156
|
+
if (fs) {
|
|
3157
|
+
const stats = await fs.stat(staticPath);
|
|
3158
|
+
if (!stats.isDirectory()) {
|
|
3159
|
+
validation.errors.push(`Path is not a directory: ${staticPath}`);
|
|
3160
|
+
validation.valid = false;
|
|
3161
|
+
return validation;
|
|
3162
|
+
}
|
|
3163
|
+
} else if (!nodeFS) {
|
|
3164
|
+
validation.errors.push("File system access not available - validation skipped");
|
|
3165
|
+
validation.valid = false;
|
|
3166
|
+
return validation;
|
|
3167
|
+
} else {
|
|
3168
|
+
const stats = await nodeFS.promises.stat(staticPath);
|
|
3169
|
+
if (!stats.isDirectory()) {
|
|
3170
|
+
validation.errors.push(`Path is not a directory: ${staticPath}`);
|
|
3171
|
+
validation.valid = false;
|
|
3172
|
+
return validation;
|
|
3173
|
+
}
|
|
3174
|
+
}
|
|
3175
|
+
let manifestPath = `${staticPath}/manifest.json`;
|
|
3176
|
+
try {
|
|
3177
|
+
if (fs) {
|
|
3178
|
+
manifestPath = fs.joinPath(staticPath, "manifest.json");
|
|
3179
|
+
if (await fs.exists(manifestPath)) {
|
|
3180
|
+
validation.manifestExists = true;
|
|
3181
|
+
const manifestContent = await fs.readFile(manifestPath);
|
|
3182
|
+
const manifest = JSON.parse(manifestContent);
|
|
3183
|
+
validation.courseId = manifest.courseId;
|
|
3184
|
+
validation.courseName = manifest.courseName;
|
|
3185
|
+
if (!manifest.version || !manifest.courseId || !manifest.chunks || !Array.isArray(manifest.chunks)) {
|
|
3186
|
+
validation.errors.push("Invalid manifest structure");
|
|
3187
|
+
validation.valid = false;
|
|
3188
|
+
}
|
|
3189
|
+
} else {
|
|
3190
|
+
validation.errors.push(`Manifest not found: ${manifestPath}`);
|
|
3191
|
+
validation.valid = false;
|
|
3192
|
+
}
|
|
3193
|
+
} else {
|
|
3194
|
+
manifestPath = `${staticPath}/manifest.json`;
|
|
3195
|
+
await nodeFS.promises.access(manifestPath);
|
|
3196
|
+
validation.manifestExists = true;
|
|
3197
|
+
const manifestContent = await nodeFS.promises.readFile(manifestPath, "utf8");
|
|
3198
|
+
const manifest = JSON.parse(manifestContent);
|
|
3199
|
+
validation.courseId = manifest.courseId;
|
|
3200
|
+
validation.courseName = manifest.courseName;
|
|
3201
|
+
if (!manifest.version || !manifest.courseId || !manifest.chunks || !Array.isArray(manifest.chunks)) {
|
|
3202
|
+
validation.errors.push("Invalid manifest structure");
|
|
3203
|
+
validation.valid = false;
|
|
3204
|
+
}
|
|
3205
|
+
}
|
|
3206
|
+
} catch (error) {
|
|
3207
|
+
const errorMessage = error instanceof FileSystemError ? error.message : `Manifest not found or invalid: ${manifestPath}`;
|
|
3208
|
+
validation.errors.push(errorMessage);
|
|
3209
|
+
validation.valid = false;
|
|
3210
|
+
}
|
|
3211
|
+
let chunksPath = `${staticPath}/chunks`;
|
|
3212
|
+
try {
|
|
3213
|
+
if (fs) {
|
|
3214
|
+
chunksPath = fs.joinPath(staticPath, "chunks");
|
|
3215
|
+
if (await fs.exists(chunksPath)) {
|
|
3216
|
+
const chunksStats = await fs.stat(chunksPath);
|
|
3217
|
+
if (chunksStats.isDirectory()) {
|
|
3218
|
+
validation.chunksExist = true;
|
|
3219
|
+
} else {
|
|
3220
|
+
validation.errors.push(`Chunks path is not a directory: ${chunksPath}`);
|
|
3221
|
+
validation.valid = false;
|
|
3222
|
+
}
|
|
3223
|
+
} else {
|
|
3224
|
+
validation.errors.push(`Chunks directory not found: ${chunksPath}`);
|
|
3225
|
+
validation.valid = false;
|
|
3226
|
+
}
|
|
3227
|
+
} else {
|
|
3228
|
+
chunksPath = `${staticPath}/chunks`;
|
|
3229
|
+
const chunksStats = await nodeFS.promises.stat(chunksPath);
|
|
3230
|
+
if (chunksStats.isDirectory()) {
|
|
3231
|
+
validation.chunksExist = true;
|
|
3232
|
+
} else {
|
|
3233
|
+
validation.errors.push(`Chunks path is not a directory: ${chunksPath}`);
|
|
3234
|
+
validation.valid = false;
|
|
3235
|
+
}
|
|
3236
|
+
}
|
|
3237
|
+
} catch (error) {
|
|
3238
|
+
const errorMessage = error instanceof FileSystemError ? error.message : `Chunks directory not found: ${chunksPath}`;
|
|
3239
|
+
validation.errors.push(errorMessage);
|
|
3240
|
+
validation.valid = false;
|
|
3241
|
+
}
|
|
3242
|
+
let attachmentsPath;
|
|
3243
|
+
try {
|
|
3244
|
+
if (fs) {
|
|
3245
|
+
attachmentsPath = fs.joinPath(staticPath, "attachments");
|
|
3246
|
+
if (await fs.exists(attachmentsPath)) {
|
|
3247
|
+
const attachmentsStats = await fs.stat(attachmentsPath);
|
|
3248
|
+
if (attachmentsStats.isDirectory()) {
|
|
3249
|
+
validation.attachmentsExist = true;
|
|
3250
|
+
}
|
|
3251
|
+
} else {
|
|
3252
|
+
validation.warnings.push(
|
|
3253
|
+
`Attachments directory not found: ${attachmentsPath} (this is OK if course has no attachments)`
|
|
3254
|
+
);
|
|
3255
|
+
}
|
|
3256
|
+
} else {
|
|
3257
|
+
attachmentsPath = `${staticPath}/attachments`;
|
|
3258
|
+
const attachmentsStats = await nodeFS.promises.stat(attachmentsPath);
|
|
3259
|
+
if (attachmentsStats.isDirectory()) {
|
|
3260
|
+
validation.attachmentsExist = true;
|
|
3261
|
+
}
|
|
3262
|
+
}
|
|
3263
|
+
} catch (error) {
|
|
3264
|
+
attachmentsPath = attachmentsPath || `${staticPath}/attachments`;
|
|
3265
|
+
const warningMessage = error instanceof FileSystemError ? error.message : `Attachments directory not found: ${attachmentsPath} (this is OK if course has no attachments)`;
|
|
3266
|
+
validation.warnings.push(warningMessage);
|
|
3267
|
+
}
|
|
3268
|
+
} catch (error) {
|
|
3269
|
+
validation.errors.push(
|
|
3270
|
+
`Failed to validate static course: ${error instanceof Error ? error.message : String(error)}`
|
|
3271
|
+
);
|
|
3272
|
+
validation.valid = false;
|
|
3273
|
+
}
|
|
3274
|
+
return validation;
|
|
3275
|
+
}
|
|
3276
|
+
async function validateMigration(targetDB, expectedCounts, manifest) {
|
|
3277
|
+
const validation = {
|
|
3278
|
+
valid: true,
|
|
3279
|
+
documentCountMatch: false,
|
|
3280
|
+
attachmentIntegrity: false,
|
|
3281
|
+
viewFunctionality: false,
|
|
3282
|
+
issues: []
|
|
3283
|
+
};
|
|
3284
|
+
try {
|
|
3285
|
+
logger.info("Starting migration validation...");
|
|
3286
|
+
const actualCounts = await getActualDocumentCounts(targetDB);
|
|
3287
|
+
validation.documentCountMatch = compareDocumentCounts(
|
|
3288
|
+
expectedCounts,
|
|
3289
|
+
actualCounts,
|
|
3290
|
+
validation.issues
|
|
3291
|
+
);
|
|
3292
|
+
await validateCourseConfig(targetDB, manifest, validation.issues);
|
|
3293
|
+
validation.viewFunctionality = await validateViews(targetDB, manifest, validation.issues);
|
|
3294
|
+
validation.attachmentIntegrity = await validateAttachmentIntegrity(targetDB, validation.issues);
|
|
3295
|
+
validation.valid = validation.documentCountMatch && validation.viewFunctionality && validation.attachmentIntegrity;
|
|
3296
|
+
logger.info(`Migration validation completed. Valid: ${validation.valid}`);
|
|
3297
|
+
if (validation.issues.length > 0) {
|
|
3298
|
+
logger.info(`Validation issues: ${validation.issues.length}`);
|
|
3299
|
+
validation.issues.forEach((issue) => {
|
|
3300
|
+
if (issue.type === "error") {
|
|
3301
|
+
logger.error(`${issue.category}: ${issue.message}`);
|
|
3302
|
+
} else {
|
|
3303
|
+
logger.warn(`${issue.category}: ${issue.message}`);
|
|
3304
|
+
}
|
|
3305
|
+
});
|
|
3306
|
+
}
|
|
3307
|
+
} catch (error) {
|
|
3308
|
+
validation.valid = false;
|
|
3309
|
+
validation.issues.push({
|
|
3310
|
+
type: "error",
|
|
3311
|
+
category: "metadata",
|
|
3312
|
+
message: `Validation failed: ${error instanceof Error ? error.message : String(error)}`
|
|
3313
|
+
});
|
|
3314
|
+
}
|
|
3315
|
+
return validation;
|
|
3316
|
+
}
|
|
3317
|
+
async function getActualDocumentCounts(db) {
|
|
3318
|
+
const counts = {};
|
|
3319
|
+
try {
|
|
3320
|
+
const allDocs = await db.allDocs({ include_docs: true });
|
|
3321
|
+
for (const row of allDocs.rows) {
|
|
3322
|
+
if (row.id.startsWith("_design/")) {
|
|
3323
|
+
counts["_design"] = (counts["_design"] || 0) + 1;
|
|
3324
|
+
continue;
|
|
3325
|
+
}
|
|
3326
|
+
const doc = row.doc;
|
|
3327
|
+
if (doc && doc.docType) {
|
|
3328
|
+
counts[doc.docType] = (counts[doc.docType] || 0) + 1;
|
|
3329
|
+
} else {
|
|
3330
|
+
counts["unknown"] = (counts["unknown"] || 0) + 1;
|
|
3331
|
+
}
|
|
3332
|
+
}
|
|
3333
|
+
} catch (error) {
|
|
3334
|
+
logger.error("Failed to get actual document counts:", error);
|
|
3335
|
+
}
|
|
3336
|
+
return counts;
|
|
3337
|
+
}
|
|
3338
|
+
function compareDocumentCounts(expected, actual, issues) {
|
|
3339
|
+
let countsMatch = true;
|
|
3340
|
+
for (const [docType, expectedCount] of Object.entries(expected)) {
|
|
3341
|
+
const actualCount = actual[docType] || 0;
|
|
3342
|
+
if (actualCount !== expectedCount) {
|
|
3343
|
+
countsMatch = false;
|
|
3344
|
+
issues.push({
|
|
3345
|
+
type: "error",
|
|
3346
|
+
category: "documents",
|
|
3347
|
+
message: `Document count mismatch for ${docType}: expected ${expectedCount}, got ${actualCount}`
|
|
3348
|
+
});
|
|
3349
|
+
}
|
|
3350
|
+
}
|
|
3351
|
+
for (const [docType, actualCount] of Object.entries(actual)) {
|
|
3352
|
+
if (!expected[docType] && docType !== "_design") {
|
|
3353
|
+
issues.push({
|
|
3354
|
+
type: "warning",
|
|
3355
|
+
category: "documents",
|
|
3356
|
+
message: `Unexpected document type found: ${docType} (${actualCount} documents)`
|
|
3357
|
+
});
|
|
3358
|
+
}
|
|
3359
|
+
}
|
|
3360
|
+
return countsMatch;
|
|
3361
|
+
}
|
|
3362
|
+
async function validateCourseConfig(db, manifest, issues) {
|
|
3363
|
+
try {
|
|
3364
|
+
const courseConfig = await db.get("CourseConfig");
|
|
3365
|
+
if (!courseConfig) {
|
|
3366
|
+
issues.push({
|
|
3367
|
+
type: "error",
|
|
3368
|
+
category: "course_config",
|
|
3369
|
+
message: "CourseConfig document not found after migration"
|
|
3370
|
+
});
|
|
3371
|
+
return;
|
|
3372
|
+
}
|
|
3373
|
+
if (!courseConfig.courseID) {
|
|
3374
|
+
issues.push({
|
|
3375
|
+
type: "warning",
|
|
3376
|
+
category: "course_config",
|
|
3377
|
+
message: "CourseConfig document missing courseID field"
|
|
3378
|
+
});
|
|
3379
|
+
}
|
|
3380
|
+
if (courseConfig.courseID !== manifest.courseId) {
|
|
3381
|
+
issues.push({
|
|
3382
|
+
type: "warning",
|
|
3383
|
+
category: "course_config",
|
|
3384
|
+
message: `CourseConfig courseID mismatch: expected ${manifest.courseId}, got ${courseConfig.courseID}`
|
|
3385
|
+
});
|
|
3386
|
+
}
|
|
3387
|
+
logger.debug("CourseConfig document validation passed");
|
|
3388
|
+
} catch (error) {
|
|
3389
|
+
if (error.status === 404) {
|
|
3390
|
+
issues.push({
|
|
3391
|
+
type: "error",
|
|
3392
|
+
category: "course_config",
|
|
3393
|
+
message: "CourseConfig document not found in database"
|
|
3394
|
+
});
|
|
3395
|
+
} else {
|
|
3396
|
+
issues.push({
|
|
3397
|
+
type: "error",
|
|
3398
|
+
category: "course_config",
|
|
3399
|
+
message: `Failed to validate CourseConfig document: ${error instanceof Error ? error.message : String(error)}`
|
|
3400
|
+
});
|
|
3401
|
+
}
|
|
3402
|
+
}
|
|
3403
|
+
}
|
|
3404
|
+
async function validateViews(db, manifest, issues) {
|
|
3405
|
+
let viewsValid = true;
|
|
3406
|
+
try {
|
|
3407
|
+
for (const designDoc of manifest.designDocs) {
|
|
3408
|
+
try {
|
|
3409
|
+
const doc = await db.get(designDoc._id);
|
|
3410
|
+
if (!doc) {
|
|
3411
|
+
viewsValid = false;
|
|
3412
|
+
issues.push({
|
|
3413
|
+
type: "error",
|
|
3414
|
+
category: "views",
|
|
3415
|
+
message: `Design document not found: ${designDoc._id}`
|
|
3416
|
+
});
|
|
3417
|
+
continue;
|
|
3418
|
+
}
|
|
3419
|
+
for (const viewName of Object.keys(designDoc.views)) {
|
|
3420
|
+
try {
|
|
3421
|
+
const viewPath = `${designDoc._id}/${viewName}`;
|
|
3422
|
+
await db.query(viewPath, { limit: 1 });
|
|
3423
|
+
} catch (viewError) {
|
|
3424
|
+
viewsValid = false;
|
|
3425
|
+
issues.push({
|
|
3426
|
+
type: "error",
|
|
3427
|
+
category: "views",
|
|
3428
|
+
message: `View not accessible: ${designDoc._id}/${viewName} - ${viewError}`
|
|
3429
|
+
});
|
|
3430
|
+
}
|
|
3431
|
+
}
|
|
3432
|
+
} catch (error) {
|
|
3433
|
+
viewsValid = false;
|
|
3434
|
+
issues.push({
|
|
3435
|
+
type: "error",
|
|
3436
|
+
category: "views",
|
|
3437
|
+
message: `Failed to validate design document ${designDoc._id}: ${error}`
|
|
3438
|
+
});
|
|
3439
|
+
}
|
|
3440
|
+
}
|
|
3441
|
+
} catch (error) {
|
|
3442
|
+
viewsValid = false;
|
|
3443
|
+
issues.push({
|
|
3444
|
+
type: "error",
|
|
3445
|
+
category: "views",
|
|
3446
|
+
message: `View validation failed: ${error instanceof Error ? error.message : String(error)}`
|
|
3447
|
+
});
|
|
3448
|
+
}
|
|
3449
|
+
return viewsValid;
|
|
3450
|
+
}
|
|
3451
|
+
async function validateAttachmentIntegrity(db, issues) {
|
|
3452
|
+
let attachmentsValid = true;
|
|
3453
|
+
try {
|
|
3454
|
+
const allDocs = await db.allDocs({
|
|
3455
|
+
include_docs: true,
|
|
3456
|
+
limit: 10
|
|
3457
|
+
// Sample first 10 documents for performance
|
|
3458
|
+
});
|
|
3459
|
+
let attachmentCount = 0;
|
|
3460
|
+
let validAttachments = 0;
|
|
3461
|
+
for (const row of allDocs.rows) {
|
|
3462
|
+
const doc = row.doc;
|
|
3463
|
+
if (doc && doc._attachments) {
|
|
3464
|
+
for (const [attachmentName, _attachmentMeta] of Object.entries(doc._attachments)) {
|
|
3465
|
+
attachmentCount++;
|
|
3466
|
+
try {
|
|
3467
|
+
const attachment = await db.getAttachment(doc._id, attachmentName);
|
|
3468
|
+
if (attachment) {
|
|
3469
|
+
validAttachments++;
|
|
3470
|
+
}
|
|
3471
|
+
} catch (attachmentError) {
|
|
3472
|
+
attachmentsValid = false;
|
|
3473
|
+
issues.push({
|
|
3474
|
+
type: "error",
|
|
3475
|
+
category: "attachments",
|
|
3476
|
+
message: `Attachment not accessible: ${doc._id}/${attachmentName} - ${attachmentError}`
|
|
3477
|
+
});
|
|
3478
|
+
}
|
|
3479
|
+
}
|
|
3480
|
+
}
|
|
3481
|
+
}
|
|
3482
|
+
if (attachmentCount === 0) {
|
|
3483
|
+
issues.push({
|
|
3484
|
+
type: "warning",
|
|
3485
|
+
category: "attachments",
|
|
3486
|
+
message: "No attachments found in sampled documents"
|
|
3487
|
+
});
|
|
3488
|
+
} else {
|
|
3489
|
+
logger.info(`Validated ${validAttachments}/${attachmentCount} sampled attachments`);
|
|
3490
|
+
}
|
|
3491
|
+
} catch (error) {
|
|
3492
|
+
attachmentsValid = false;
|
|
3493
|
+
issues.push({
|
|
3494
|
+
type: "error",
|
|
3495
|
+
category: "attachments",
|
|
3496
|
+
message: `Attachment validation failed: ${error instanceof Error ? error.message : String(error)}`
|
|
3497
|
+
});
|
|
3498
|
+
}
|
|
3499
|
+
return attachmentsValid;
|
|
3500
|
+
}
|
|
3501
|
+
var nodeFS;
|
|
3502
|
+
var init_validation = __esm({
|
|
3503
|
+
"src/util/migrator/validation.ts"() {
|
|
3504
|
+
"use strict";
|
|
3505
|
+
init_logger();
|
|
3506
|
+
init_FileSystemAdapter();
|
|
3507
|
+
nodeFS = null;
|
|
3508
|
+
try {
|
|
3509
|
+
if (typeof window === "undefined" && typeof process !== "undefined" && process.versions?.node) {
|
|
3510
|
+
nodeFS = eval("require")("fs");
|
|
3511
|
+
nodeFS.promises = nodeFS.promises || eval("require")("fs").promises;
|
|
3512
|
+
}
|
|
3513
|
+
} catch {
|
|
3514
|
+
}
|
|
3515
|
+
}
|
|
3516
|
+
});
|
|
3517
|
+
|
|
3518
|
+
// src/util/migrator/StaticToCouchDBMigrator.ts
|
|
3519
|
+
var nodeFS2, nodePath, StaticToCouchDBMigrator;
|
|
3520
|
+
var init_StaticToCouchDBMigrator = __esm({
|
|
3521
|
+
"src/util/migrator/StaticToCouchDBMigrator.ts"() {
|
|
3522
|
+
"use strict";
|
|
3523
|
+
init_logger();
|
|
3524
|
+
init_types4();
|
|
3525
|
+
init_validation();
|
|
3526
|
+
init_FileSystemAdapter();
|
|
3527
|
+
nodeFS2 = null;
|
|
3528
|
+
nodePath = null;
|
|
3529
|
+
try {
|
|
3530
|
+
if (typeof window === "undefined" && typeof process !== "undefined" && process.versions?.node) {
|
|
3531
|
+
nodeFS2 = eval("require")("fs");
|
|
3532
|
+
nodePath = eval("require")("path");
|
|
3533
|
+
nodeFS2.promises = nodeFS2.promises || eval("require")("fs").promises;
|
|
3534
|
+
}
|
|
3535
|
+
} catch {
|
|
3536
|
+
}
|
|
3537
|
+
StaticToCouchDBMigrator = class {
|
|
3538
|
+
options;
|
|
3539
|
+
progressCallback;
|
|
3540
|
+
fs;
|
|
3541
|
+
constructor(options = {}, fileSystemAdapter) {
|
|
3542
|
+
this.options = {
|
|
3543
|
+
...DEFAULT_MIGRATION_OPTIONS,
|
|
3544
|
+
...options
|
|
3545
|
+
};
|
|
3546
|
+
this.fs = fileSystemAdapter;
|
|
3547
|
+
}
|
|
3548
|
+
/**
|
|
3549
|
+
* Set a progress callback to receive updates during migration
|
|
3550
|
+
*/
|
|
3551
|
+
setProgressCallback(callback) {
|
|
3552
|
+
this.progressCallback = callback;
|
|
3553
|
+
}
|
|
3554
|
+
/**
|
|
3555
|
+
* Migrate a static course to CouchDB
|
|
3556
|
+
*/
|
|
3557
|
+
async migrateCourse(staticPath, targetDB) {
|
|
3558
|
+
const startTime = Date.now();
|
|
3559
|
+
const result = {
|
|
3560
|
+
success: false,
|
|
3561
|
+
documentsRestored: 0,
|
|
3562
|
+
attachmentsRestored: 0,
|
|
3563
|
+
designDocsRestored: 0,
|
|
3564
|
+
courseConfigRestored: 0,
|
|
3565
|
+
errors: [],
|
|
3566
|
+
warnings: [],
|
|
3567
|
+
migrationTime: 0
|
|
3568
|
+
};
|
|
3569
|
+
try {
|
|
3570
|
+
logger.info(`Starting migration from ${staticPath} to CouchDB`);
|
|
3571
|
+
this.reportProgress("manifest", 0, 1, "Validating static course...");
|
|
3572
|
+
const validation = await validateStaticCourse(staticPath, this.fs);
|
|
3573
|
+
if (!validation.valid) {
|
|
3574
|
+
result.errors.push(...validation.errors);
|
|
3575
|
+
throw new Error(`Static course validation failed: ${validation.errors.join(", ")}`);
|
|
3576
|
+
}
|
|
3577
|
+
result.warnings.push(...validation.warnings);
|
|
3578
|
+
this.reportProgress("manifest", 1, 1, "Loading course manifest...");
|
|
3579
|
+
const manifest = await this.loadManifest(staticPath);
|
|
3580
|
+
logger.info(`Loaded manifest for course: ${manifest.courseId} (${manifest.courseName})`);
|
|
3581
|
+
this.reportProgress(
|
|
3582
|
+
"design_docs",
|
|
3583
|
+
0,
|
|
3584
|
+
manifest.designDocs.length,
|
|
3585
|
+
"Restoring design documents..."
|
|
3586
|
+
);
|
|
3587
|
+
const designDocResults = await this.restoreDesignDocuments(manifest.designDocs, targetDB);
|
|
3588
|
+
result.designDocsRestored = designDocResults.restored;
|
|
3589
|
+
result.errors.push(...designDocResults.errors);
|
|
3590
|
+
result.warnings.push(...designDocResults.warnings);
|
|
3591
|
+
this.reportProgress("course_config", 0, 1, "Restoring CourseConfig document...");
|
|
3592
|
+
const courseConfigResults = await this.restoreCourseConfig(manifest, targetDB);
|
|
3593
|
+
result.courseConfigRestored = courseConfigResults.restored;
|
|
3594
|
+
result.errors.push(...courseConfigResults.errors);
|
|
3595
|
+
result.warnings.push(...courseConfigResults.warnings);
|
|
3596
|
+
this.reportProgress("course_config", 1, 1, "CourseConfig document restored");
|
|
3597
|
+
const expectedCounts = this.calculateExpectedCounts(manifest);
|
|
3598
|
+
this.reportProgress(
|
|
3599
|
+
"documents",
|
|
3600
|
+
0,
|
|
3601
|
+
manifest.documentCount,
|
|
3602
|
+
"Aggregating documents from chunks..."
|
|
3603
|
+
);
|
|
3604
|
+
const documents = await this.aggregateDocuments(staticPath, manifest);
|
|
3605
|
+
const filteredDocuments = documents.filter((doc) => doc._id !== "CourseConfig");
|
|
3606
|
+
if (documents.length !== filteredDocuments.length) {
|
|
3607
|
+
result.warnings.push(
|
|
3608
|
+
`Filtered out ${documents.length - filteredDocuments.length} CourseConfig document(s) from chunks to prevent conflicts`
|
|
3609
|
+
);
|
|
3610
|
+
}
|
|
3611
|
+
this.reportProgress(
|
|
3612
|
+
"documents",
|
|
3613
|
+
filteredDocuments.length,
|
|
3614
|
+
manifest.documentCount,
|
|
3615
|
+
"Uploading documents to CouchDB..."
|
|
3616
|
+
);
|
|
3617
|
+
const docResults = await this.uploadDocuments(filteredDocuments, targetDB);
|
|
3618
|
+
result.documentsRestored = docResults.restored;
|
|
3619
|
+
result.errors.push(...docResults.errors);
|
|
3620
|
+
result.warnings.push(...docResults.warnings);
|
|
3621
|
+
const docsWithAttachments = documents.filter(
|
|
3622
|
+
(doc) => doc._attachments && Object.keys(doc._attachments).length > 0
|
|
3623
|
+
);
|
|
3624
|
+
this.reportProgress("attachments", 0, docsWithAttachments.length, "Uploading attachments...");
|
|
3625
|
+
const attachmentResults = await this.uploadAttachments(
|
|
3626
|
+
staticPath,
|
|
3627
|
+
docsWithAttachments,
|
|
3628
|
+
targetDB
|
|
3629
|
+
);
|
|
3630
|
+
result.attachmentsRestored = attachmentResults.restored;
|
|
3631
|
+
result.errors.push(...attachmentResults.errors);
|
|
3632
|
+
result.warnings.push(...attachmentResults.warnings);
|
|
3633
|
+
if (this.options.validateRoundTrip) {
|
|
3634
|
+
this.reportProgress("validation", 0, 1, "Validating migration...");
|
|
3635
|
+
const validationResult = await validateMigration(targetDB, expectedCounts, manifest);
|
|
3636
|
+
if (!validationResult.valid) {
|
|
3637
|
+
result.warnings.push("Migration validation found issues");
|
|
3638
|
+
validationResult.issues.forEach((issue) => {
|
|
3639
|
+
if (issue.type === "error") {
|
|
3640
|
+
result.errors.push(`Validation: ${issue.message}`);
|
|
3641
|
+
} else {
|
|
3642
|
+
result.warnings.push(`Validation: ${issue.message}`);
|
|
3643
|
+
}
|
|
3644
|
+
});
|
|
3645
|
+
}
|
|
3646
|
+
this.reportProgress("validation", 1, 1, "Migration validation completed");
|
|
3647
|
+
}
|
|
3648
|
+
result.success = result.errors.length === 0;
|
|
3649
|
+
result.migrationTime = Date.now() - startTime;
|
|
3650
|
+
logger.info(`Migration completed in ${result.migrationTime}ms`);
|
|
3651
|
+
logger.info(`Documents restored: ${result.documentsRestored}`);
|
|
3652
|
+
logger.info(`Attachments restored: ${result.attachmentsRestored}`);
|
|
3653
|
+
logger.info(`Design docs restored: ${result.designDocsRestored}`);
|
|
3654
|
+
logger.info(`CourseConfig restored: ${result.courseConfigRestored}`);
|
|
3655
|
+
if (result.errors.length > 0) {
|
|
3656
|
+
logger.error(`Migration completed with ${result.errors.length} errors`);
|
|
3657
|
+
}
|
|
3658
|
+
if (result.warnings.length > 0) {
|
|
3659
|
+
logger.warn(`Migration completed with ${result.warnings.length} warnings`);
|
|
3660
|
+
}
|
|
3661
|
+
} catch (error) {
|
|
3662
|
+
result.success = false;
|
|
3663
|
+
result.migrationTime = Date.now() - startTime;
|
|
3664
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
3665
|
+
result.errors.push(`Migration failed: ${errorMessage}`);
|
|
3666
|
+
logger.error("Migration failed:", error);
|
|
3667
|
+
if (this.options.cleanupOnFailure) {
|
|
3668
|
+
try {
|
|
3669
|
+
await this.cleanupFailedMigration(targetDB);
|
|
3670
|
+
} catch (cleanupError) {
|
|
3671
|
+
logger.error("Failed to cleanup after migration failure:", cleanupError);
|
|
3672
|
+
result.warnings.push("Failed to cleanup after migration failure");
|
|
3673
|
+
}
|
|
3674
|
+
}
|
|
3675
|
+
}
|
|
3676
|
+
return result;
|
|
3677
|
+
}
|
|
3678
|
+
/**
|
|
3679
|
+
* Load and parse the manifest file
|
|
3680
|
+
*/
|
|
3681
|
+
async loadManifest(staticPath) {
|
|
3682
|
+
try {
|
|
3683
|
+
let manifestContent;
|
|
3684
|
+
let manifestPath;
|
|
3685
|
+
if (this.fs) {
|
|
3686
|
+
manifestPath = this.fs.joinPath(staticPath, "manifest.json");
|
|
3687
|
+
manifestContent = await this.fs.readFile(manifestPath);
|
|
3688
|
+
} else {
|
|
3689
|
+
manifestPath = nodeFS2 && nodePath ? nodePath.join(staticPath, "manifest.json") : `${staticPath}/manifest.json`;
|
|
3690
|
+
if (nodeFS2 && this.isLocalPath(staticPath)) {
|
|
3691
|
+
manifestContent = await nodeFS2.promises.readFile(manifestPath, "utf8");
|
|
3692
|
+
} else {
|
|
3693
|
+
const response = await fetch(manifestPath);
|
|
3694
|
+
if (!response.ok) {
|
|
3695
|
+
throw new Error(`Failed to fetch manifest: ${response.status} ${response.statusText}`);
|
|
3696
|
+
}
|
|
3697
|
+
manifestContent = await response.text();
|
|
3698
|
+
}
|
|
3699
|
+
}
|
|
3700
|
+
const manifest = JSON.parse(manifestContent);
|
|
3701
|
+
if (!manifest.version || !manifest.courseId || !manifest.chunks) {
|
|
3702
|
+
throw new Error("Invalid manifest structure");
|
|
3703
|
+
}
|
|
3704
|
+
return manifest;
|
|
3705
|
+
} catch (error) {
|
|
3706
|
+
const errorMessage = error instanceof FileSystemError ? error.message : `Failed to load manifest: ${error instanceof Error ? error.message : String(error)}`;
|
|
3707
|
+
throw new Error(errorMessage);
|
|
3708
|
+
}
|
|
3709
|
+
}
|
|
3710
|
+
/**
|
|
3711
|
+
* Restore design documents to CouchDB
|
|
3712
|
+
*/
|
|
3713
|
+
async restoreDesignDocuments(designDocs, db) {
|
|
3714
|
+
const result = { restored: 0, errors: [], warnings: [] };
|
|
3715
|
+
for (let i = 0; i < designDocs.length; i++) {
|
|
3716
|
+
const designDoc = designDocs[i];
|
|
3717
|
+
this.reportProgress("design_docs", i, designDocs.length, `Restoring ${designDoc._id}...`);
|
|
3718
|
+
try {
|
|
3719
|
+
let existingDoc;
|
|
3720
|
+
try {
|
|
3721
|
+
existingDoc = await db.get(designDoc._id);
|
|
3722
|
+
} catch {
|
|
3723
|
+
}
|
|
3724
|
+
const docToInsert = {
|
|
3725
|
+
_id: designDoc._id,
|
|
3726
|
+
views: designDoc.views
|
|
3727
|
+
};
|
|
3728
|
+
if (existingDoc) {
|
|
3729
|
+
docToInsert._rev = existingDoc._rev;
|
|
3730
|
+
logger.debug(`Updating existing design document: ${designDoc._id}`);
|
|
3731
|
+
} else {
|
|
3732
|
+
logger.debug(`Creating new design document: ${designDoc._id}`);
|
|
3733
|
+
}
|
|
3734
|
+
await db.put(docToInsert);
|
|
3735
|
+
result.restored++;
|
|
3736
|
+
} catch (error) {
|
|
3737
|
+
const errorMessage = `Failed to restore design document ${designDoc._id}: ${error instanceof Error ? error.message : String(error)}`;
|
|
3738
|
+
result.errors.push(errorMessage);
|
|
3739
|
+
logger.error(errorMessage);
|
|
3740
|
+
}
|
|
3741
|
+
}
|
|
3742
|
+
this.reportProgress(
|
|
3743
|
+
"design_docs",
|
|
3744
|
+
designDocs.length,
|
|
3745
|
+
designDocs.length,
|
|
3746
|
+
`Restored ${result.restored} design documents`
|
|
3747
|
+
);
|
|
3748
|
+
return result;
|
|
3749
|
+
}
|
|
3750
|
+
/**
|
|
3751
|
+
* Aggregate documents from all chunks
|
|
3752
|
+
*/
|
|
3753
|
+
async aggregateDocuments(staticPath, manifest) {
|
|
3754
|
+
const allDocuments = [];
|
|
3755
|
+
const documentMap = /* @__PURE__ */ new Map();
|
|
3756
|
+
for (let i = 0; i < manifest.chunks.length; i++) {
|
|
3757
|
+
const chunk = manifest.chunks[i];
|
|
3758
|
+
this.reportProgress(
|
|
3759
|
+
"documents",
|
|
3760
|
+
allDocuments.length,
|
|
3761
|
+
manifest.documentCount,
|
|
3762
|
+
`Loading chunk ${chunk.id}...`
|
|
3763
|
+
);
|
|
3764
|
+
try {
|
|
3765
|
+
const documents = await this.loadChunk(staticPath, chunk);
|
|
3766
|
+
for (const doc of documents) {
|
|
3767
|
+
if (!doc._id) {
|
|
3768
|
+
logger.warn(`Document without _id found in chunk ${chunk.id}, skipping`);
|
|
3769
|
+
continue;
|
|
3770
|
+
}
|
|
3771
|
+
if (documentMap.has(doc._id)) {
|
|
3772
|
+
logger.warn(`Duplicate document ID found: ${doc._id}, using latest version`);
|
|
3773
|
+
}
|
|
3774
|
+
documentMap.set(doc._id, doc);
|
|
3775
|
+
}
|
|
3776
|
+
} catch (error) {
|
|
3777
|
+
throw new Error(
|
|
3778
|
+
`Failed to load chunk ${chunk.id}: ${error instanceof Error ? error.message : String(error)}`
|
|
3779
|
+
);
|
|
3780
|
+
}
|
|
3781
|
+
}
|
|
3782
|
+
allDocuments.push(...documentMap.values());
|
|
3783
|
+
logger.info(
|
|
3784
|
+
`Aggregated ${allDocuments.length} unique documents from ${manifest.chunks.length} chunks`
|
|
3785
|
+
);
|
|
3786
|
+
return allDocuments;
|
|
3787
|
+
}
|
|
3788
|
+
/**
|
|
3789
|
+
* Load documents from a single chunk file
|
|
3790
|
+
*/
|
|
3791
|
+
async loadChunk(staticPath, chunk) {
|
|
3792
|
+
try {
|
|
3793
|
+
let chunkContent;
|
|
3794
|
+
let chunkPath;
|
|
3795
|
+
if (this.fs) {
|
|
3796
|
+
chunkPath = this.fs.joinPath(staticPath, chunk.path);
|
|
3797
|
+
chunkContent = await this.fs.readFile(chunkPath);
|
|
3798
|
+
} else {
|
|
3799
|
+
chunkPath = nodeFS2 && nodePath ? nodePath.join(staticPath, chunk.path) : `${staticPath}/${chunk.path}`;
|
|
3800
|
+
if (nodeFS2 && this.isLocalPath(staticPath)) {
|
|
3801
|
+
chunkContent = await nodeFS2.promises.readFile(chunkPath, "utf8");
|
|
3802
|
+
} else {
|
|
3803
|
+
const response = await fetch(chunkPath);
|
|
3804
|
+
if (!response.ok) {
|
|
3805
|
+
throw new Error(`Failed to fetch chunk: ${response.status} ${response.statusText}`);
|
|
3806
|
+
}
|
|
3807
|
+
chunkContent = await response.text();
|
|
3808
|
+
}
|
|
3809
|
+
}
|
|
3810
|
+
const documents = JSON.parse(chunkContent);
|
|
3811
|
+
if (!Array.isArray(documents)) {
|
|
3812
|
+
throw new Error("Chunk file does not contain an array of documents");
|
|
3813
|
+
}
|
|
3814
|
+
return documents;
|
|
3815
|
+
} catch (error) {
|
|
3816
|
+
const errorMessage = error instanceof FileSystemError ? error.message : `Failed to load chunk: ${error instanceof Error ? error.message : String(error)}`;
|
|
3817
|
+
throw new Error(errorMessage);
|
|
3818
|
+
}
|
|
3819
|
+
}
|
|
3820
|
+
/**
|
|
3821
|
+
* Upload documents to CouchDB in batches
|
|
3822
|
+
*/
|
|
3823
|
+
async uploadDocuments(documents, db) {
|
|
3824
|
+
const result = { restored: 0, errors: [], warnings: [] };
|
|
3825
|
+
const batchSize = this.options.chunkBatchSize;
|
|
3826
|
+
for (let i = 0; i < documents.length; i += batchSize) {
|
|
3827
|
+
const batch = documents.slice(i, i + batchSize);
|
|
3828
|
+
this.reportProgress(
|
|
3829
|
+
"documents",
|
|
3830
|
+
i,
|
|
3831
|
+
documents.length,
|
|
3832
|
+
`Uploading batch ${Math.floor(i / batchSize) + 1}...`
|
|
3833
|
+
);
|
|
3834
|
+
try {
|
|
3835
|
+
const docsToInsert = batch.map((doc) => {
|
|
3836
|
+
const cleanDoc = { ...doc };
|
|
3837
|
+
delete cleanDoc._rev;
|
|
3838
|
+
delete cleanDoc._attachments;
|
|
3839
|
+
return cleanDoc;
|
|
3840
|
+
});
|
|
3841
|
+
const bulkResult = await db.bulkDocs(docsToInsert);
|
|
3842
|
+
for (let j = 0; j < bulkResult.length; j++) {
|
|
3843
|
+
const docResult = bulkResult[j];
|
|
3844
|
+
const originalDoc = batch[j];
|
|
3845
|
+
if ("error" in docResult) {
|
|
3846
|
+
const errorMessage = `Failed to upload document ${originalDoc._id}: ${docResult.error} - ${docResult.reason}`;
|
|
3847
|
+
result.errors.push(errorMessage);
|
|
3848
|
+
logger.error(errorMessage);
|
|
3849
|
+
} else {
|
|
3850
|
+
result.restored++;
|
|
3851
|
+
}
|
|
3852
|
+
}
|
|
3853
|
+
} catch (error) {
|
|
3854
|
+
let errorMessage;
|
|
3855
|
+
if (error instanceof Error) {
|
|
3856
|
+
errorMessage = `Failed to upload document batch starting at index ${i}: ${error.message}`;
|
|
3857
|
+
} else if (error && typeof error === "object" && "message" in error) {
|
|
3858
|
+
errorMessage = `Failed to upload document batch starting at index ${i}: ${error.message}`;
|
|
3859
|
+
} else {
|
|
3860
|
+
errorMessage = `Failed to upload document batch starting at index ${i}: ${JSON.stringify(error)}`;
|
|
3861
|
+
}
|
|
3862
|
+
result.errors.push(errorMessage);
|
|
3863
|
+
logger.error(errorMessage);
|
|
3864
|
+
}
|
|
3865
|
+
}
|
|
3866
|
+
this.reportProgress(
|
|
3867
|
+
"documents",
|
|
3868
|
+
documents.length,
|
|
3869
|
+
documents.length,
|
|
3870
|
+
`Uploaded ${result.restored} documents`
|
|
3871
|
+
);
|
|
3872
|
+
return result;
|
|
3873
|
+
}
|
|
3874
|
+
/**
|
|
3875
|
+
* Upload attachments from filesystem to CouchDB
|
|
3876
|
+
*/
|
|
3877
|
+
async uploadAttachments(staticPath, documents, db) {
|
|
3878
|
+
const result = { restored: 0, errors: [], warnings: [] };
|
|
3879
|
+
let processedDocs = 0;
|
|
3880
|
+
for (const doc of documents) {
|
|
3881
|
+
this.reportProgress(
|
|
3882
|
+
"attachments",
|
|
3883
|
+
processedDocs,
|
|
3884
|
+
documents.length,
|
|
3885
|
+
`Processing attachments for ${doc._id}...`
|
|
3886
|
+
);
|
|
3887
|
+
processedDocs++;
|
|
3888
|
+
if (!doc._attachments) {
|
|
3889
|
+
continue;
|
|
3890
|
+
}
|
|
3891
|
+
for (const [attachmentName, attachmentMeta] of Object.entries(doc._attachments)) {
|
|
3892
|
+
try {
|
|
3893
|
+
const uploadResult = await this.uploadSingleAttachment(
|
|
3894
|
+
staticPath,
|
|
3895
|
+
doc._id,
|
|
3896
|
+
attachmentName,
|
|
3897
|
+
attachmentMeta,
|
|
3898
|
+
db
|
|
3899
|
+
);
|
|
3900
|
+
if (uploadResult.success) {
|
|
3901
|
+
result.restored++;
|
|
3902
|
+
} else {
|
|
3903
|
+
result.errors.push(uploadResult.error || "Unknown attachment upload error");
|
|
3904
|
+
}
|
|
3905
|
+
} catch (error) {
|
|
3906
|
+
const errorMessage = `Failed to upload attachment ${doc._id}/${attachmentName}: ${error instanceof Error ? error.message : String(error)}`;
|
|
3907
|
+
result.errors.push(errorMessage);
|
|
3908
|
+
logger.error(errorMessage);
|
|
3909
|
+
}
|
|
3910
|
+
}
|
|
3911
|
+
}
|
|
3912
|
+
this.reportProgress(
|
|
3913
|
+
"attachments",
|
|
3914
|
+
documents.length,
|
|
3915
|
+
documents.length,
|
|
3916
|
+
`Uploaded ${result.restored} attachments`
|
|
3917
|
+
);
|
|
3918
|
+
return result;
|
|
3919
|
+
}
|
|
3920
|
+
/**
|
|
3921
|
+
* Upload a single attachment file
|
|
3922
|
+
*/
|
|
3923
|
+
async uploadSingleAttachment(staticPath, docId, attachmentName, attachmentMeta, db) {
|
|
3924
|
+
const result = {
|
|
3925
|
+
success: false,
|
|
3926
|
+
attachmentName,
|
|
3927
|
+
docId
|
|
3928
|
+
};
|
|
3929
|
+
try {
|
|
3930
|
+
if (!attachmentMeta.path) {
|
|
3931
|
+
result.error = "Attachment metadata missing file path";
|
|
3932
|
+
return result;
|
|
3933
|
+
}
|
|
3934
|
+
let attachmentData;
|
|
3935
|
+
let attachmentPath;
|
|
3936
|
+
if (this.fs) {
|
|
3937
|
+
attachmentPath = this.fs.joinPath(staticPath, attachmentMeta.path);
|
|
3938
|
+
attachmentData = await this.fs.readBinary(attachmentPath);
|
|
3939
|
+
} else {
|
|
3940
|
+
attachmentPath = nodeFS2 && nodePath ? nodePath.join(staticPath, attachmentMeta.path) : `${staticPath}/${attachmentMeta.path}`;
|
|
3941
|
+
if (nodeFS2 && this.isLocalPath(staticPath)) {
|
|
3942
|
+
attachmentData = await nodeFS2.promises.readFile(attachmentPath);
|
|
3943
|
+
} else {
|
|
3944
|
+
const response = await fetch(attachmentPath);
|
|
3945
|
+
if (!response.ok) {
|
|
3946
|
+
result.error = `Failed to fetch attachment: ${response.status} ${response.statusText}`;
|
|
3947
|
+
return result;
|
|
3948
|
+
}
|
|
3949
|
+
attachmentData = await response.arrayBuffer();
|
|
3950
|
+
}
|
|
3951
|
+
}
|
|
3952
|
+
const doc = await db.get(docId);
|
|
3953
|
+
await db.putAttachment(
|
|
3954
|
+
docId,
|
|
3955
|
+
attachmentName,
|
|
3956
|
+
doc._rev,
|
|
3957
|
+
attachmentData,
|
|
3958
|
+
// PouchDB accepts both ArrayBuffer and Buffer
|
|
3959
|
+
attachmentMeta.content_type
|
|
3960
|
+
);
|
|
3961
|
+
result.success = true;
|
|
3962
|
+
} catch (error) {
|
|
3963
|
+
result.error = error instanceof Error ? error.message : String(error);
|
|
3964
|
+
}
|
|
3965
|
+
return result;
|
|
3966
|
+
}
|
|
3967
|
+
/**
|
|
3968
|
+
* Restore CourseConfig document from manifest
|
|
3969
|
+
*/
|
|
3970
|
+
async restoreCourseConfig(manifest, targetDB) {
|
|
3971
|
+
const results = {
|
|
3972
|
+
restored: 0,
|
|
3973
|
+
errors: [],
|
|
3974
|
+
warnings: []
|
|
3975
|
+
};
|
|
3976
|
+
try {
|
|
3977
|
+
if (!manifest.courseConfig) {
|
|
3978
|
+
results.warnings.push(
|
|
3979
|
+
"No courseConfig found in manifest, skipping CourseConfig document creation"
|
|
3980
|
+
);
|
|
3981
|
+
return results;
|
|
3982
|
+
}
|
|
3983
|
+
const courseConfigDoc = {
|
|
3984
|
+
_id: "CourseConfig",
|
|
3985
|
+
...manifest.courseConfig,
|
|
3986
|
+
courseID: manifest.courseId
|
|
3987
|
+
};
|
|
3988
|
+
delete courseConfigDoc._rev;
|
|
3989
|
+
await targetDB.put(courseConfigDoc);
|
|
3990
|
+
results.restored = 1;
|
|
3991
|
+
logger.info(`CourseConfig document created for course: ${manifest.courseId}`);
|
|
3992
|
+
} catch (error) {
|
|
3993
|
+
const errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
|
|
3994
|
+
results.errors.push(`Failed to restore CourseConfig: ${errorMessage}`);
|
|
3995
|
+
logger.error("CourseConfig restoration failed:", error);
|
|
3996
|
+
}
|
|
3997
|
+
return results;
|
|
3998
|
+
}
|
|
3999
|
+
/**
|
|
4000
|
+
* Calculate expected document counts from manifest
|
|
4001
|
+
*/
|
|
4002
|
+
calculateExpectedCounts(manifest) {
|
|
4003
|
+
const counts = {};
|
|
4004
|
+
for (const chunk of manifest.chunks) {
|
|
4005
|
+
counts[chunk.docType] = (counts[chunk.docType] || 0) + chunk.documentCount;
|
|
4006
|
+
}
|
|
4007
|
+
if (manifest.designDocs.length > 0) {
|
|
4008
|
+
counts["_design"] = manifest.designDocs.length;
|
|
4009
|
+
}
|
|
4010
|
+
return counts;
|
|
4011
|
+
}
|
|
4012
|
+
/**
|
|
4013
|
+
* Clean up database after failed migration
|
|
4014
|
+
*/
|
|
4015
|
+
async cleanupFailedMigration(db) {
|
|
4016
|
+
logger.info("Cleaning up failed migration...");
|
|
4017
|
+
try {
|
|
4018
|
+
const allDocs = await db.allDocs();
|
|
4019
|
+
const docsToDelete = allDocs.rows.map((row) => ({
|
|
4020
|
+
_id: row.id,
|
|
4021
|
+
_rev: row.value.rev,
|
|
4022
|
+
_deleted: true
|
|
4023
|
+
}));
|
|
4024
|
+
if (docsToDelete.length > 0) {
|
|
4025
|
+
await db.bulkDocs(docsToDelete);
|
|
4026
|
+
logger.info(`Cleaned up ${docsToDelete.length} documents from failed migration`);
|
|
4027
|
+
}
|
|
4028
|
+
} catch (error) {
|
|
4029
|
+
logger.error("Failed to cleanup documents:", error);
|
|
4030
|
+
throw error;
|
|
4031
|
+
}
|
|
4032
|
+
}
|
|
4033
|
+
/**
|
|
4034
|
+
* Report progress to callback if available
|
|
4035
|
+
*/
|
|
4036
|
+
reportProgress(phase, current, total, message) {
|
|
4037
|
+
if (this.progressCallback) {
|
|
4038
|
+
this.progressCallback({
|
|
4039
|
+
phase,
|
|
4040
|
+
current,
|
|
4041
|
+
total,
|
|
4042
|
+
message
|
|
4043
|
+
});
|
|
4044
|
+
}
|
|
4045
|
+
}
|
|
4046
|
+
/**
|
|
4047
|
+
* Check if a path is a local file path (vs URL)
|
|
4048
|
+
*/
|
|
4049
|
+
isLocalPath(path2) {
|
|
4050
|
+
return !path2.startsWith("http://") && !path2.startsWith("https://");
|
|
4051
|
+
}
|
|
4052
|
+
};
|
|
4053
|
+
}
|
|
4054
|
+
});
|
|
4055
|
+
|
|
4056
|
+
// src/util/migrator/index.ts
|
|
4057
|
+
var init_migrator = __esm({
|
|
4058
|
+
"src/util/migrator/index.ts"() {
|
|
4059
|
+
"use strict";
|
|
4060
|
+
init_StaticToCouchDBMigrator();
|
|
4061
|
+
init_validation();
|
|
4062
|
+
init_FileSystemAdapter();
|
|
4063
|
+
}
|
|
4064
|
+
});
|
|
4065
|
+
|
|
4066
|
+
// src/util/dataDirectory.ts
|
|
4067
|
+
import * as path from "path";
|
|
4068
|
+
import * as os from "os";
|
|
4069
|
+
function getAppDataDirectory() {
|
|
4070
|
+
if (ENV.LOCAL_STORAGE_PREFIX) {
|
|
4071
|
+
return path.join(os.homedir(), `.tuilder`, ENV.LOCAL_STORAGE_PREFIX);
|
|
4072
|
+
} else {
|
|
4073
|
+
return path.join(os.homedir(), ".tuilder");
|
|
4074
|
+
}
|
|
4075
|
+
}
|
|
4076
|
+
function getDbPath(dbName) {
|
|
4077
|
+
return path.join(getAppDataDirectory(), dbName);
|
|
4078
|
+
}
|
|
4079
|
+
var init_dataDirectory = __esm({
|
|
4080
|
+
"src/util/dataDirectory.ts"() {
|
|
4081
|
+
"use strict";
|
|
4082
|
+
init_logger();
|
|
4083
|
+
init_factory();
|
|
4084
|
+
}
|
|
4085
|
+
});
|
|
4086
|
+
|
|
4087
|
+
// src/util/index.ts
|
|
4088
|
+
var init_util2 = __esm({
|
|
4089
|
+
"src/util/index.ts"() {
|
|
4090
|
+
"use strict";
|
|
4091
|
+
init_Loggable();
|
|
4092
|
+
init_packer();
|
|
4093
|
+
init_migrator();
|
|
4094
|
+
init_dataDirectory();
|
|
4095
|
+
}
|
|
4096
|
+
});
|
|
4097
|
+
|
|
4098
|
+
// src/study/SourceMixer.ts
|
|
4099
|
+
var init_SourceMixer = __esm({
|
|
4100
|
+
"src/study/SourceMixer.ts"() {
|
|
4101
|
+
"use strict";
|
|
4102
|
+
}
|
|
4103
|
+
});
|
|
4104
|
+
|
|
4105
|
+
// src/study/MixerDebugger.ts
|
|
4106
|
+
function printMixerSummary(run) {
|
|
4107
|
+
console.group(`\u{1F3A8} Mixer Run: ${run.mixerType}`);
|
|
4108
|
+
logger.info(`Run ID: ${run.runId}`);
|
|
4109
|
+
logger.info(`Time: ${run.timestamp.toISOString()}`);
|
|
4110
|
+
logger.info(
|
|
4111
|
+
`Config: limit=${run.requestedLimit}${run.quotaPerSource ? `, quota/source=${run.quotaPerSource}` : ""}`
|
|
4112
|
+
);
|
|
4113
|
+
console.group(`\u{1F4E5} Input: ${run.sourceSummaries.length} sources`);
|
|
4114
|
+
for (const src of run.sourceSummaries) {
|
|
4115
|
+
logger.info(
|
|
4116
|
+
` ${src.sourceName || src.sourceId}: ${src.totalCards} cards (${src.reviewCount} reviews, ${src.newCount} new)`
|
|
4117
|
+
);
|
|
4118
|
+
logger.info(` Score range: [${src.scoreRange[0].toFixed(2)}, ${src.scoreRange[1].toFixed(2)}], avg: ${src.avgScore.toFixed(2)}`);
|
|
4119
|
+
}
|
|
4120
|
+
console.groupEnd();
|
|
4121
|
+
console.group(`\u{1F4E4} Output: ${run.finalCount} cards selected (${run.reviewsSelected} reviews, ${run.newSelected} new)`);
|
|
4122
|
+
for (const breakdown of run.sourceBreakdowns) {
|
|
4123
|
+
const name = breakdown.sourceName || breakdown.sourceId;
|
|
4124
|
+
logger.info(
|
|
4125
|
+
` ${name}: ${breakdown.totalSelected} selected (${breakdown.reviewsSelected} reviews, ${breakdown.newSelected} new) - ${breakdown.selectionRate.toFixed(1)}% selection rate`
|
|
4126
|
+
);
|
|
4127
|
+
}
|
|
4128
|
+
console.groupEnd();
|
|
4129
|
+
console.groupEnd();
|
|
4130
|
+
}
|
|
4131
|
+
function mountMixerDebugger() {
|
|
4132
|
+
if (typeof window === "undefined") return;
|
|
4133
|
+
const win = window;
|
|
4134
|
+
win.skuilder = win.skuilder || {};
|
|
4135
|
+
win.skuilder.mixer = mixerDebugAPI;
|
|
4136
|
+
}
|
|
4137
|
+
var runHistory2, mixerDebugAPI;
|
|
4138
|
+
var init_MixerDebugger = __esm({
|
|
4139
|
+
"src/study/MixerDebugger.ts"() {
|
|
4140
|
+
"use strict";
|
|
4141
|
+
init_logger();
|
|
4142
|
+
init_navigators();
|
|
4143
|
+
runHistory2 = [];
|
|
4144
|
+
mixerDebugAPI = {
|
|
4145
|
+
/**
|
|
4146
|
+
* Get raw run history for programmatic access.
|
|
4147
|
+
*/
|
|
4148
|
+
get runs() {
|
|
4149
|
+
return [...runHistory2];
|
|
4150
|
+
},
|
|
4151
|
+
/**
|
|
4152
|
+
* Show summary of a specific mixer run.
|
|
4153
|
+
*/
|
|
4154
|
+
showRun(idOrIndex = 0) {
|
|
4155
|
+
if (runHistory2.length === 0) {
|
|
4156
|
+
logger.info("[Mixer Debug] No runs captured yet.");
|
|
4157
|
+
return;
|
|
4158
|
+
}
|
|
4159
|
+
let run;
|
|
4160
|
+
if (typeof idOrIndex === "number") {
|
|
4161
|
+
run = runHistory2[idOrIndex];
|
|
4162
|
+
if (!run) {
|
|
4163
|
+
logger.info(`[Mixer Debug] No run found at index ${idOrIndex}. History length: ${runHistory2.length}`);
|
|
4164
|
+
return;
|
|
4165
|
+
}
|
|
4166
|
+
} else {
|
|
4167
|
+
run = runHistory2.find((r) => r.runId.endsWith(idOrIndex));
|
|
4168
|
+
if (!run) {
|
|
4169
|
+
logger.info(`[Mixer Debug] No run found matching ID '${idOrIndex}'.`);
|
|
4170
|
+
return;
|
|
4171
|
+
}
|
|
4172
|
+
}
|
|
4173
|
+
printMixerSummary(run);
|
|
4174
|
+
},
|
|
4175
|
+
/**
|
|
4176
|
+
* Show summary of the last mixer run.
|
|
4177
|
+
*/
|
|
4178
|
+
showLastMix() {
|
|
4179
|
+
this.showRun(0);
|
|
4180
|
+
},
|
|
4181
|
+
/**
|
|
4182
|
+
* Explain source balance in the last run.
|
|
4183
|
+
*/
|
|
4184
|
+
explainSourceBalance() {
|
|
4185
|
+
if (runHistory2.length === 0) {
|
|
4186
|
+
logger.info("[Mixer Debug] No runs captured yet.");
|
|
4187
|
+
return;
|
|
4188
|
+
}
|
|
4189
|
+
const run = runHistory2[0];
|
|
4190
|
+
console.group("\u2696\uFE0F Source Balance Analysis");
|
|
4191
|
+
logger.info(`Mixer: ${run.mixerType}`);
|
|
4192
|
+
logger.info(`Requested limit: ${run.requestedLimit}`);
|
|
4193
|
+
if (run.quotaPerSource) {
|
|
4194
|
+
logger.info(`Quota per source: ${run.quotaPerSource}`);
|
|
4195
|
+
}
|
|
4196
|
+
console.group("Input Distribution:");
|
|
4197
|
+
for (const src of run.sourceSummaries) {
|
|
4198
|
+
const name = src.sourceName || src.sourceId;
|
|
4199
|
+
logger.info(`${name}:`);
|
|
4200
|
+
logger.info(` Provided: ${src.totalCards} cards (${src.reviewCount} reviews, ${src.newCount} new)`);
|
|
4201
|
+
logger.info(` Score range: [${src.scoreRange[0].toFixed(2)}, ${src.scoreRange[1].toFixed(2)}]`);
|
|
4202
|
+
}
|
|
4203
|
+
console.groupEnd();
|
|
4204
|
+
console.group("Selection Results:");
|
|
4205
|
+
for (const breakdown of run.sourceBreakdowns) {
|
|
4206
|
+
const name = breakdown.sourceName || breakdown.sourceId;
|
|
4207
|
+
logger.info(`${name}:`);
|
|
4208
|
+
logger.info(
|
|
4209
|
+
` Selected: ${breakdown.totalSelected}/${breakdown.reviewsProvided + breakdown.newProvided} (${breakdown.selectionRate.toFixed(1)}%)`
|
|
4210
|
+
);
|
|
4211
|
+
logger.info(` Reviews: ${breakdown.reviewsSelected}/${breakdown.reviewsProvided}`);
|
|
4212
|
+
logger.info(` New: ${breakdown.newSelected}/${breakdown.newProvided}`);
|
|
4213
|
+
if (breakdown.reviewsProvided > 0 && breakdown.reviewsSelected === 0) {
|
|
4214
|
+
logger.info(` \u26A0\uFE0F Had reviews but none selected!`);
|
|
4215
|
+
}
|
|
4216
|
+
if (breakdown.totalSelected === 0 && breakdown.reviewsProvided + breakdown.newProvided > 0) {
|
|
4217
|
+
logger.info(` \u26A0\uFE0F Had cards but none selected!`);
|
|
4218
|
+
}
|
|
4219
|
+
}
|
|
4220
|
+
console.groupEnd();
|
|
4221
|
+
const selectionRates = run.sourceBreakdowns.map((b) => b.selectionRate);
|
|
4222
|
+
const avgRate = selectionRates.reduce((a, b) => a + b, 0) / selectionRates.length;
|
|
4223
|
+
const maxDeviation = Math.max(...selectionRates.map((r) => Math.abs(r - avgRate)));
|
|
4224
|
+
if (maxDeviation > 20) {
|
|
4225
|
+
logger.info(`
|
|
4226
|
+
\u26A0\uFE0F Significant imbalance detected (max deviation: ${maxDeviation.toFixed(1)}%)`);
|
|
4227
|
+
logger.info("Possible causes:");
|
|
4228
|
+
logger.info(" - Score range differences between sources");
|
|
4229
|
+
logger.info(" - One source has much better quality cards");
|
|
4230
|
+
logger.info(" - Different card availability (reviews vs new)");
|
|
4231
|
+
}
|
|
4232
|
+
console.groupEnd();
|
|
4233
|
+
},
|
|
4234
|
+
/**
|
|
4235
|
+
* Compare score distributions across sources.
|
|
4236
|
+
*/
|
|
4237
|
+
compareScores() {
|
|
4238
|
+
if (runHistory2.length === 0) {
|
|
4239
|
+
logger.info("[Mixer Debug] No runs captured yet.");
|
|
4240
|
+
return;
|
|
4241
|
+
}
|
|
4242
|
+
const run = runHistory2[0];
|
|
4243
|
+
console.group("\u{1F4CA} Score Distribution Comparison");
|
|
4244
|
+
console.table(
|
|
4245
|
+
run.sourceSummaries.map((src) => ({
|
|
4246
|
+
source: src.sourceName || src.sourceId,
|
|
4247
|
+
cards: src.totalCards,
|
|
4248
|
+
min: src.bottomScore.toFixed(3),
|
|
4249
|
+
max: src.topScore.toFixed(3),
|
|
4250
|
+
avg: src.avgScore.toFixed(3),
|
|
4251
|
+
range: (src.topScore - src.bottomScore).toFixed(3)
|
|
4252
|
+
}))
|
|
4253
|
+
);
|
|
4254
|
+
const ranges = run.sourceSummaries.map((s) => s.topScore - s.bottomScore);
|
|
4255
|
+
const avgScores = run.sourceSummaries.map((s) => s.avgScore);
|
|
4256
|
+
const rangeDiff = Math.max(...ranges) - Math.min(...ranges);
|
|
4257
|
+
const avgDiff = Math.max(...avgScores) - Math.min(...avgScores);
|
|
4258
|
+
if (rangeDiff > 0.3 || avgDiff > 0.2) {
|
|
4259
|
+
logger.info("\n\u26A0\uFE0F Significant score distribution differences detected");
|
|
4260
|
+
logger.info(
|
|
4261
|
+
"This may cause one source to dominate selection if using global sorting (not quota-based)"
|
|
4262
|
+
);
|
|
4263
|
+
}
|
|
4264
|
+
console.groupEnd();
|
|
4265
|
+
},
|
|
4266
|
+
/**
|
|
4267
|
+
* Show detailed information for a specific card.
|
|
4268
|
+
*/
|
|
4269
|
+
showCard(cardId) {
|
|
4270
|
+
for (const run of runHistory2) {
|
|
4271
|
+
const card = run.cards.find((c) => c.cardId === cardId);
|
|
4272
|
+
if (card) {
|
|
4273
|
+
const source = run.sourceSummaries.find((s) => s.sourceIndex === card.sourceIndex);
|
|
4274
|
+
console.group(`\u{1F3B4} Card: ${cardId}`);
|
|
4275
|
+
logger.info(`Course: ${card.courseId}`);
|
|
4276
|
+
logger.info(`Source: ${source?.sourceName || source?.sourceId || "unknown"}`);
|
|
4277
|
+
logger.info(`Origin: ${card.origin}`);
|
|
4278
|
+
logger.info(`Score: ${card.score.toFixed(3)}`);
|
|
4279
|
+
if (card.rankInSource) {
|
|
4280
|
+
logger.info(`Rank in source: #${card.rankInSource}`);
|
|
4281
|
+
}
|
|
4282
|
+
if (card.rankInMix) {
|
|
4283
|
+
logger.info(`Rank in mixed results: #${card.rankInMix}`);
|
|
4284
|
+
}
|
|
4285
|
+
logger.info(`Selected: ${card.selected ? "Yes \u2705" : "No \u274C"}`);
|
|
4286
|
+
if (!card.selected && card.rankInSource) {
|
|
4287
|
+
logger.info("\nWhy not selected:");
|
|
4288
|
+
if (run.quotaPerSource && card.rankInSource > run.quotaPerSource) {
|
|
4289
|
+
logger.info(` - Ranked #${card.rankInSource} in source, but quota was ${run.quotaPerSource}`);
|
|
4290
|
+
}
|
|
4291
|
+
logger.info(" - Check score compared to selected cards using .showRun()");
|
|
4292
|
+
}
|
|
4293
|
+
console.groupEnd();
|
|
4294
|
+
return;
|
|
4295
|
+
}
|
|
4296
|
+
}
|
|
4297
|
+
logger.info(`[Mixer Debug] Card '${cardId}' not found in recent runs.`);
|
|
4298
|
+
},
|
|
4299
|
+
/**
|
|
4300
|
+
* Show all runs in compact format.
|
|
4301
|
+
*/
|
|
4302
|
+
listRuns() {
|
|
4303
|
+
if (runHistory2.length === 0) {
|
|
4304
|
+
logger.info("[Mixer Debug] No runs captured yet.");
|
|
4305
|
+
return;
|
|
4306
|
+
}
|
|
4307
|
+
console.table(
|
|
4308
|
+
runHistory2.map((r) => ({
|
|
4309
|
+
id: r.runId.slice(-8),
|
|
4310
|
+
time: r.timestamp.toLocaleTimeString(),
|
|
4311
|
+
mixer: r.mixerType,
|
|
4312
|
+
sources: r.sourceSummaries.length,
|
|
4313
|
+
selected: r.finalCount,
|
|
4314
|
+
reviews: r.reviewsSelected,
|
|
4315
|
+
new: r.newSelected
|
|
4316
|
+
}))
|
|
4317
|
+
);
|
|
4318
|
+
},
|
|
4319
|
+
/**
|
|
4320
|
+
* Export run history as JSON for bug reports.
|
|
4321
|
+
*/
|
|
4322
|
+
export() {
|
|
4323
|
+
const json = JSON.stringify(runHistory2, null, 2);
|
|
4324
|
+
logger.info("[Mixer Debug] Run history exported. Copy the returned string or use:");
|
|
4325
|
+
logger.info(" copy(window.skuilder.mixer.export())");
|
|
4326
|
+
return json;
|
|
4327
|
+
},
|
|
4328
|
+
/**
|
|
4329
|
+
* Clear run history.
|
|
4330
|
+
*/
|
|
4331
|
+
clear() {
|
|
4332
|
+
runHistory2.length = 0;
|
|
4333
|
+
logger.info("[Mixer Debug] Run history cleared.");
|
|
4334
|
+
},
|
|
4335
|
+
/**
|
|
4336
|
+
* Show help.
|
|
4337
|
+
*/
|
|
4338
|
+
help() {
|
|
4339
|
+
logger.info(`
|
|
4340
|
+
\u{1F3A8} Mixer Debug API
|
|
4341
|
+
|
|
4342
|
+
Commands:
|
|
4343
|
+
.showLastMix() Show summary of most recent mixer run
|
|
4344
|
+
.showRun(id|index) Show summary of a specific run (by index or ID suffix)
|
|
4345
|
+
.explainSourceBalance() Analyze source balance and selection patterns
|
|
4346
|
+
.compareScores() Compare score distributions across sources
|
|
4347
|
+
.showCard(cardId) Show mixer decisions for a specific card
|
|
4348
|
+
.listRuns() List all captured runs in table format
|
|
4349
|
+
.export() Export run history as JSON for bug reports
|
|
4350
|
+
.clear() Clear run history
|
|
4351
|
+
.runs Access raw run history array
|
|
4352
|
+
.help() Show this help message
|
|
2447
4353
|
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
4354
|
+
Example:
|
|
4355
|
+
window.skuilder.mixer.showLastMix()
|
|
4356
|
+
window.skuilder.mixer.explainSourceBalance()
|
|
4357
|
+
window.skuilder.mixer.compareScores()
|
|
4358
|
+
`);
|
|
4359
|
+
}
|
|
4360
|
+
};
|
|
4361
|
+
mountMixerDebugger();
|
|
2455
4362
|
}
|
|
2456
4363
|
});
|
|
2457
4364
|
|
|
2458
|
-
// src/
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
"
|
|
4365
|
+
// src/study/SessionDebugger.ts
|
|
4366
|
+
function showCurrentQueue() {
|
|
4367
|
+
if (!activeSession) {
|
|
4368
|
+
logger.info("[Session Debug] No active session.");
|
|
4369
|
+
return;
|
|
2462
4370
|
}
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
"use strict";
|
|
2469
|
-
init_signal();
|
|
2470
|
-
init_types_legacy();
|
|
2471
|
-
init_logger();
|
|
4371
|
+
const latest = activeSession.queueSnapshots[activeSession.queueSnapshots.length - 1] || activeSession.initialQueues;
|
|
4372
|
+
console.group("\u{1F4CA} Current Queue State");
|
|
4373
|
+
logger.info(`Review Queue: ${latest.reviewQLength} cards`);
|
|
4374
|
+
if (latest.reviewQNext3 && latest.reviewQNext3.length > 0) {
|
|
4375
|
+
logger.info(` Next: ${latest.reviewQNext3.join(", ")}`);
|
|
2472
4376
|
}
|
|
2473
|
-
});
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
function fnv1a(str) {
|
|
2477
|
-
let hash = 2166136261;
|
|
2478
|
-
for (let i = 0; i < str.length; i++) {
|
|
2479
|
-
hash ^= str.charCodeAt(i);
|
|
2480
|
-
hash = Math.imul(hash, 16777619);
|
|
4377
|
+
logger.info(`New Queue: ${latest.newQLength} cards`);
|
|
4378
|
+
if (latest.newQNext3 && latest.newQNext3.length > 0) {
|
|
4379
|
+
logger.info(` Next: ${latest.newQNext3.join(", ")}`);
|
|
2481
4380
|
}
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
function computeDeviation(userId, strategyId, salt) {
|
|
2485
|
-
const input = `${userId}:${strategyId}:${salt}`;
|
|
2486
|
-
const hash = fnv1a(input);
|
|
2487
|
-
const normalized = hash / 4294967296;
|
|
2488
|
-
return normalized * 2 - 1;
|
|
2489
|
-
}
|
|
2490
|
-
function computeSpread(confidence) {
|
|
2491
|
-
const clampedConfidence = Math.max(0, Math.min(1, confidence));
|
|
2492
|
-
return MAX_SPREAD - clampedConfidence * (MAX_SPREAD - MIN_SPREAD);
|
|
4381
|
+
logger.info(`Failed Queue: ${latest.failedQLength} cards`);
|
|
4382
|
+
console.groupEnd();
|
|
2493
4383
|
}
|
|
2494
|
-
function
|
|
2495
|
-
const
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
4384
|
+
function showPresentationHistory(sessionIndex = 0) {
|
|
4385
|
+
const session = sessionIndex === 0 && activeSession ? activeSession : sessionHistory[sessionIndex];
|
|
4386
|
+
if (!session) {
|
|
4387
|
+
logger.info(`[Session Debug] No session found at index ${sessionIndex}`);
|
|
4388
|
+
return;
|
|
4389
|
+
}
|
|
4390
|
+
console.group(`\u{1F4DC} Session History: ${session.sessionId}`);
|
|
4391
|
+
logger.info(`Started: ${session.startTime.toLocaleTimeString()}`);
|
|
4392
|
+
if (session.endTime) {
|
|
4393
|
+
logger.info(`Ended: ${session.endTime.toLocaleTimeString()}`);
|
|
4394
|
+
}
|
|
4395
|
+
logger.info(`Cards presented: ${session.presentations.length}`);
|
|
4396
|
+
if (session.presentations.length > 0) {
|
|
4397
|
+
console.table(
|
|
4398
|
+
session.presentations.map((p) => ({
|
|
4399
|
+
"#": p.sequenceNumber,
|
|
4400
|
+
course: p.courseName || p.courseId.slice(0, 8),
|
|
4401
|
+
origin: p.origin,
|
|
4402
|
+
queue: p.queueSource,
|
|
4403
|
+
score: p.score?.toFixed(3) || "-",
|
|
4404
|
+
time: p.timestamp.toLocaleTimeString()
|
|
4405
|
+
}))
|
|
4406
|
+
);
|
|
4407
|
+
}
|
|
4408
|
+
console.groupEnd();
|
|
2500
4409
|
}
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
logger.error(`[Orchestration] Failed to load course config: ${e}`);
|
|
2507
|
-
courseConfig = {
|
|
2508
|
-
name: "Unknown",
|
|
2509
|
-
description: "",
|
|
2510
|
-
public: false,
|
|
2511
|
-
deleted: false,
|
|
2512
|
-
creator: "",
|
|
2513
|
-
admins: [],
|
|
2514
|
-
moderators: [],
|
|
2515
|
-
dataShapes: [],
|
|
2516
|
-
questionTypes: [],
|
|
2517
|
-
orchestration: { salt: "default" }
|
|
2518
|
-
};
|
|
4410
|
+
function showInterleaving(sessionIndex = 0) {
|
|
4411
|
+
const session = sessionIndex === 0 && activeSession ? activeSession : sessionHistory[sessionIndex];
|
|
4412
|
+
if (!session) {
|
|
4413
|
+
logger.info(`[Session Debug] No session found at index ${sessionIndex}`);
|
|
4414
|
+
return;
|
|
2519
4415
|
}
|
|
2520
|
-
|
|
2521
|
-
const
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
return computeEffectiveWeight(learnable, userId, strategyId, salt);
|
|
2529
|
-
},
|
|
2530
|
-
getDeviation(strategyId) {
|
|
2531
|
-
return computeDeviation(userId, strategyId, salt);
|
|
4416
|
+
console.group("\u{1F500} Interleaving Analysis");
|
|
4417
|
+
const courseCounts = /* @__PURE__ */ new Map();
|
|
4418
|
+
const courseOrigins = /* @__PURE__ */ new Map();
|
|
4419
|
+
session.presentations.forEach((p) => {
|
|
4420
|
+
const name = p.courseName || p.courseId;
|
|
4421
|
+
courseCounts.set(name, (courseCounts.get(name) || 0) + 1);
|
|
4422
|
+
if (!courseOrigins.has(name)) {
|
|
4423
|
+
courseOrigins.set(name, { review: 0, new: 0, failed: 0 });
|
|
2532
4424
|
}
|
|
2533
|
-
|
|
4425
|
+
const origins = courseOrigins.get(name);
|
|
4426
|
+
origins[p.origin]++;
|
|
4427
|
+
});
|
|
4428
|
+
logger.info("Course distribution:");
|
|
4429
|
+
console.table(
|
|
4430
|
+
Array.from(courseCounts.entries()).map(([course, count]) => {
|
|
4431
|
+
const origins = courseOrigins.get(course);
|
|
4432
|
+
return {
|
|
4433
|
+
course,
|
|
4434
|
+
total: count,
|
|
4435
|
+
reviews: origins.review,
|
|
4436
|
+
new: origins.new,
|
|
4437
|
+
failed: origins.failed,
|
|
4438
|
+
percentage: (count / session.presentations.length * 100).toFixed(1) + "%"
|
|
4439
|
+
};
|
|
4440
|
+
})
|
|
4441
|
+
);
|
|
4442
|
+
if (session.presentations.length > 0) {
|
|
4443
|
+
logger.info("\nPresentation sequence (first 20):");
|
|
4444
|
+
const sequence = session.presentations.slice(0, 20).map((p, idx) => `${idx + 1}. ${p.courseName || p.courseId.slice(0, 8)} (${p.origin})`).join("\n");
|
|
4445
|
+
logger.info(sequence);
|
|
4446
|
+
}
|
|
4447
|
+
let maxCluster = 0;
|
|
4448
|
+
let currentCluster = 1;
|
|
4449
|
+
let currentCourse = session.presentations[0]?.courseId;
|
|
4450
|
+
for (let i = 1; i < session.presentations.length; i++) {
|
|
4451
|
+
if (session.presentations[i].courseId === currentCourse) {
|
|
4452
|
+
currentCluster++;
|
|
4453
|
+
maxCluster = Math.max(maxCluster, currentCluster);
|
|
4454
|
+
} else {
|
|
4455
|
+
currentCourse = session.presentations[i].courseId;
|
|
4456
|
+
currentCluster = 1;
|
|
4457
|
+
}
|
|
4458
|
+
}
|
|
4459
|
+
if (maxCluster > 3) {
|
|
4460
|
+
logger.info(`
|
|
4461
|
+
\u26A0\uFE0F Detected clustering: max ${maxCluster} cards from same course in a row`);
|
|
4462
|
+
logger.info("This suggests cards are sorted by score rather than round-robin by course.");
|
|
4463
|
+
}
|
|
4464
|
+
console.groupEnd();
|
|
2534
4465
|
}
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
4466
|
+
function mountSessionDebugger() {
|
|
4467
|
+
if (typeof window === "undefined") return;
|
|
4468
|
+
const win = window;
|
|
4469
|
+
win.skuilder = win.skuilder || {};
|
|
4470
|
+
win.skuilder.session = sessionDebugAPI;
|
|
4471
|
+
}
|
|
4472
|
+
var activeSession, sessionHistory, sessionDebugAPI;
|
|
4473
|
+
var init_SessionDebugger = __esm({
|
|
4474
|
+
"src/study/SessionDebugger.ts"() {
|
|
2538
4475
|
"use strict";
|
|
2539
4476
|
init_logger();
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
4477
|
+
activeSession = null;
|
|
4478
|
+
sessionHistory = [];
|
|
4479
|
+
sessionDebugAPI = {
|
|
4480
|
+
/**
|
|
4481
|
+
* Get raw session history for programmatic access.
|
|
4482
|
+
*/
|
|
4483
|
+
get sessions() {
|
|
4484
|
+
return [...sessionHistory];
|
|
4485
|
+
},
|
|
4486
|
+
/**
|
|
4487
|
+
* Get active session if any.
|
|
4488
|
+
*/
|
|
4489
|
+
get active() {
|
|
4490
|
+
return activeSession;
|
|
4491
|
+
},
|
|
4492
|
+
/**
|
|
4493
|
+
* Show current queue state.
|
|
4494
|
+
*/
|
|
4495
|
+
showQueue() {
|
|
4496
|
+
showCurrentQueue();
|
|
4497
|
+
},
|
|
4498
|
+
/**
|
|
4499
|
+
* Show presentation history for current or past session.
|
|
4500
|
+
*/
|
|
4501
|
+
showHistory(sessionIndex = 0) {
|
|
4502
|
+
showPresentationHistory(sessionIndex);
|
|
4503
|
+
},
|
|
4504
|
+
/**
|
|
4505
|
+
* Analyze course interleaving pattern.
|
|
4506
|
+
*/
|
|
4507
|
+
showInterleaving(sessionIndex = 0) {
|
|
4508
|
+
showInterleaving(sessionIndex);
|
|
4509
|
+
},
|
|
4510
|
+
/**
|
|
4511
|
+
* List all tracked sessions.
|
|
4512
|
+
*/
|
|
4513
|
+
listSessions() {
|
|
4514
|
+
if (activeSession) {
|
|
4515
|
+
logger.info(`Active session: ${activeSession.sessionId} (${activeSession.presentations.length} cards presented)`);
|
|
4516
|
+
}
|
|
4517
|
+
if (sessionHistory.length === 0) {
|
|
4518
|
+
logger.info("[Session Debug] No completed sessions in history.");
|
|
4519
|
+
return;
|
|
4520
|
+
}
|
|
4521
|
+
console.table(
|
|
4522
|
+
sessionHistory.map((s, idx) => ({
|
|
4523
|
+
index: idx,
|
|
4524
|
+
id: s.sessionId.slice(-8),
|
|
4525
|
+
started: s.startTime.toLocaleTimeString(),
|
|
4526
|
+
ended: s.endTime?.toLocaleTimeString() || "incomplete",
|
|
4527
|
+
cards: s.presentations.length
|
|
4528
|
+
}))
|
|
4529
|
+
);
|
|
4530
|
+
},
|
|
4531
|
+
/**
|
|
4532
|
+
* Export session history as JSON for bug reports.
|
|
4533
|
+
*/
|
|
4534
|
+
export() {
|
|
4535
|
+
const data = {
|
|
4536
|
+
active: activeSession,
|
|
4537
|
+
history: sessionHistory
|
|
4538
|
+
};
|
|
4539
|
+
const json = JSON.stringify(data, null, 2);
|
|
4540
|
+
logger.info("[Session Debug] Session data exported. Copy the returned string or use:");
|
|
4541
|
+
logger.info(" copy(window.skuilder.session.export())");
|
|
4542
|
+
return json;
|
|
4543
|
+
},
|
|
4544
|
+
/**
|
|
4545
|
+
* Clear session history.
|
|
4546
|
+
*/
|
|
4547
|
+
clear() {
|
|
4548
|
+
sessionHistory.length = 0;
|
|
4549
|
+
logger.info("[Session Debug] Session history cleared.");
|
|
4550
|
+
},
|
|
4551
|
+
/**
|
|
4552
|
+
* Show help.
|
|
4553
|
+
*/
|
|
4554
|
+
help() {
|
|
4555
|
+
logger.info(`
|
|
4556
|
+
\u{1F3AF} Session Debug API
|
|
4557
|
+
|
|
4558
|
+
Commands:
|
|
4559
|
+
.showQueue() Show current queue state (active session only)
|
|
4560
|
+
.showHistory(index?) Show presentation history (0=current/last, 1=previous, etc)
|
|
4561
|
+
.showInterleaving(index?) Analyze course interleaving pattern
|
|
4562
|
+
.listSessions() List all tracked sessions
|
|
4563
|
+
.export() Export session data as JSON for bug reports
|
|
4564
|
+
.clear() Clear session history
|
|
4565
|
+
.sessions Access raw session history array
|
|
4566
|
+
.active Access active session (if any)
|
|
4567
|
+
.help() Show this help message
|
|
4568
|
+
|
|
4569
|
+
Example:
|
|
4570
|
+
window.skuilder.session.showHistory()
|
|
4571
|
+
window.skuilder.session.showInterleaving()
|
|
4572
|
+
window.skuilder.session.showQueue()
|
|
4573
|
+
`);
|
|
4574
|
+
}
|
|
4575
|
+
};
|
|
4576
|
+
mountSessionDebugger();
|
|
4577
|
+
}
|
|
4578
|
+
});
|
|
4579
|
+
|
|
4580
|
+
// src/study/SessionController.ts
|
|
4581
|
+
var init_SessionController = __esm({
|
|
4582
|
+
"src/study/SessionController.ts"() {
|
|
4583
|
+
"use strict";
|
|
4584
|
+
init_SrsService();
|
|
4585
|
+
init_EloService();
|
|
4586
|
+
init_ResponseProcessor();
|
|
4587
|
+
init_CardHydrationService();
|
|
4588
|
+
init_ItemQueue();
|
|
4589
|
+
init_couch();
|
|
2543
4590
|
init_recording();
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
4591
|
+
init_util2();
|
|
4592
|
+
init_navigators();
|
|
4593
|
+
init_SourceMixer();
|
|
4594
|
+
init_MixerDebugger();
|
|
4595
|
+
init_SessionDebugger();
|
|
4596
|
+
init_logger();
|
|
2548
4597
|
}
|
|
2549
4598
|
});
|
|
2550
4599
|
|
|
@@ -2553,7 +4602,7 @@ var Pipeline_exports = {};
|
|
|
2553
4602
|
__export(Pipeline_exports, {
|
|
2554
4603
|
Pipeline: () => Pipeline
|
|
2555
4604
|
});
|
|
2556
|
-
import { toCourseElo as
|
|
4605
|
+
import { toCourseElo as toCourseElo7 } from "@vue-skuilder/common";
|
|
2557
4606
|
function globToRegex(pattern) {
|
|
2558
4607
|
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
2559
4608
|
const withWildcards = escaped.replace(/\*/g, ".*");
|
|
@@ -2637,6 +4686,7 @@ var init_Pipeline = __esm({
|
|
|
2637
4686
|
init_logger();
|
|
2638
4687
|
init_orchestration();
|
|
2639
4688
|
init_PipelineDebugger();
|
|
4689
|
+
init_SessionController();
|
|
2640
4690
|
VERBOSE_RESULTS = true;
|
|
2641
4691
|
Pipeline = class extends ContentNavigator {
|
|
2642
4692
|
generator;
|
|
@@ -2796,8 +4846,9 @@ var init_Pipeline = __esm({
|
|
|
2796
4846
|
generatorSummaries,
|
|
2797
4847
|
generatedCount,
|
|
2798
4848
|
filterImpacts,
|
|
2799
|
-
|
|
2800
|
-
result
|
|
4849
|
+
cards,
|
|
4850
|
+
result,
|
|
4851
|
+
context.userElo
|
|
2801
4852
|
);
|
|
2802
4853
|
captureRun(report);
|
|
2803
4854
|
} catch (e) {
|
|
@@ -2880,7 +4931,7 @@ var init_Pipeline = __esm({
|
|
|
2880
4931
|
card.provenance.push({
|
|
2881
4932
|
strategy: "ephemeralHint",
|
|
2882
4933
|
strategyId: "ephemeral-hint",
|
|
2883
|
-
strategyName: "Replan Hint",
|
|
4934
|
+
strategyName: hints._label ? `Replan Hint (${hints._label})` : "Replan Hint",
|
|
2884
4935
|
action: "boosted",
|
|
2885
4936
|
score: card.score,
|
|
2886
4937
|
reason: `boostTag ${pattern} \xD7${factor}`
|
|
@@ -2897,7 +4948,7 @@ var init_Pipeline = __esm({
|
|
|
2897
4948
|
card.provenance.push({
|
|
2898
4949
|
strategy: "ephemeralHint",
|
|
2899
4950
|
strategyId: "ephemeral-hint",
|
|
2900
|
-
strategyName: "Replan Hint",
|
|
4951
|
+
strategyName: hints._label ? `Replan Hint (${hints._label})` : "Replan Hint",
|
|
2901
4952
|
action: "boosted",
|
|
2902
4953
|
score: card.score,
|
|
2903
4954
|
reason: `boostCard ${pattern} \xD7${factor}`
|
|
@@ -2907,6 +4958,7 @@ var init_Pipeline = __esm({
|
|
|
2907
4958
|
}
|
|
2908
4959
|
}
|
|
2909
4960
|
const cardIds = new Set(cards.map((c) => c.cardId));
|
|
4961
|
+
const hintLabel = hints._label ? `Replan Hint (${hints._label})` : "Replan Hint";
|
|
2910
4962
|
const inject = (card, reason) => {
|
|
2911
4963
|
if (!cardIds.has(card.cardId)) {
|
|
2912
4964
|
const floorScore = Math.max(card.score, 1);
|
|
@@ -2918,7 +4970,7 @@ var init_Pipeline = __esm({
|
|
|
2918
4970
|
{
|
|
2919
4971
|
strategy: "ephemeralHint",
|
|
2920
4972
|
strategyId: "ephemeral-hint",
|
|
2921
|
-
strategyName:
|
|
4973
|
+
strategyName: hintLabel,
|
|
2922
4974
|
action: "boosted",
|
|
2923
4975
|
score: floorScore,
|
|
2924
4976
|
reason
|
|
@@ -2957,7 +5009,7 @@ var init_Pipeline = __esm({
|
|
|
2957
5009
|
let userElo = 1e3;
|
|
2958
5010
|
try {
|
|
2959
5011
|
const courseReg = await this.user.getCourseRegDoc(this.course.getCourseID());
|
|
2960
|
-
const courseElo =
|
|
5012
|
+
const courseElo = toCourseElo7(courseReg.elo);
|
|
2961
5013
|
userElo = courseElo.global.score;
|
|
2962
5014
|
} catch (e) {
|
|
2963
5015
|
logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`);
|
|
@@ -3010,6 +5062,34 @@ var init_Pipeline = __esm({
|
|
|
3010
5062
|
return [...new Set(ids)];
|
|
3011
5063
|
}
|
|
3012
5064
|
// ---------------------------------------------------------------------------
|
|
5065
|
+
// Tag ELO diagnostic
|
|
5066
|
+
// ---------------------------------------------------------------------------
|
|
5067
|
+
/**
|
|
5068
|
+
* Get the user's per-tag ELO data for specified tags (or all tags).
|
|
5069
|
+
* Useful for diagnosing why hierarchy gates are open/closed.
|
|
5070
|
+
*/
|
|
5071
|
+
async getTagEloStatus(tagFilter) {
|
|
5072
|
+
const courseReg = await this.user.getCourseRegDoc(this.course.getCourseID());
|
|
5073
|
+
const courseElo = toCourseElo7(courseReg.elo);
|
|
5074
|
+
const result = {};
|
|
5075
|
+
if (!tagFilter) {
|
|
5076
|
+
for (const [tag, data] of Object.entries(courseElo.tags)) {
|
|
5077
|
+
result[tag] = { score: data.score, count: data.count };
|
|
5078
|
+
}
|
|
5079
|
+
} else {
|
|
5080
|
+
const patterns = Array.isArray(tagFilter) ? tagFilter : [tagFilter];
|
|
5081
|
+
for (const pattern of patterns) {
|
|
5082
|
+
const regex = globToRegex(pattern);
|
|
5083
|
+
for (const [tag, data] of Object.entries(courseElo.tags)) {
|
|
5084
|
+
if (regex.test(tag)) {
|
|
5085
|
+
result[tag] = { score: data.score, count: data.count };
|
|
5086
|
+
}
|
|
5087
|
+
}
|
|
5088
|
+
}
|
|
5089
|
+
}
|
|
5090
|
+
return result;
|
|
5091
|
+
}
|
|
5092
|
+
// ---------------------------------------------------------------------------
|
|
3013
5093
|
// Card-space diagnostic
|
|
3014
5094
|
// ---------------------------------------------------------------------------
|
|
3015
5095
|
/**
|
|
@@ -3592,7 +5672,7 @@ import {
|
|
|
3592
5672
|
EloToNumber,
|
|
3593
5673
|
Status,
|
|
3594
5674
|
blankCourseElo as blankCourseElo2,
|
|
3595
|
-
toCourseElo as
|
|
5675
|
+
toCourseElo as toCourseElo8
|
|
3596
5676
|
} from "@vue-skuilder/common";
|
|
3597
5677
|
function randIntWeightedTowardZero(n) {
|
|
3598
5678
|
return Math.floor(Math.random() * Math.random() * Math.random() * n);
|
|
@@ -3863,7 +5943,7 @@ var init_courseDB = __esm({
|
|
|
3863
5943
|
docs.rows.forEach((r) => {
|
|
3864
5944
|
if (isSuccessRow(r)) {
|
|
3865
5945
|
if (r.doc && r.doc.elo) {
|
|
3866
|
-
ret.push(
|
|
5946
|
+
ret.push(toCourseElo8(r.doc.elo));
|
|
3867
5947
|
} else {
|
|
3868
5948
|
logger.warn("no elo data for card: " + r.id);
|
|
3869
5949
|
ret.push(blankCourseElo2());
|
|
@@ -4214,10 +6294,18 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
4214
6294
|
* @param limit - Maximum number of cards to return
|
|
4215
6295
|
* @returns Cards sorted by score descending
|
|
4216
6296
|
*/
|
|
6297
|
+
_pendingHints = null;
|
|
6298
|
+
setEphemeralHints(hints) {
|
|
6299
|
+
this._pendingHints = hints;
|
|
6300
|
+
}
|
|
4217
6301
|
async getWeightedCards(limit) {
|
|
4218
6302
|
const u = await this._getCurrentUser();
|
|
4219
6303
|
try {
|
|
4220
6304
|
const navigator = await this.createNavigator(u);
|
|
6305
|
+
if (this._pendingHints) {
|
|
6306
|
+
navigator.setEphemeralHints(this._pendingHints);
|
|
6307
|
+
this._pendingHints = null;
|
|
6308
|
+
}
|
|
4221
6309
|
return navigator.getWeightedCards(limit);
|
|
4222
6310
|
} catch (e) {
|
|
4223
6311
|
logger.error(`[courseDB] Error getting weighted cards: ${e}`);
|
|
@@ -4360,7 +6448,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
4360
6448
|
});
|
|
4361
6449
|
|
|
4362
6450
|
// src/impl/couch/classroomDB.ts
|
|
4363
|
-
import
|
|
6451
|
+
import moment4 from "moment";
|
|
4364
6452
|
function getClassroomDB(classID, version) {
|
|
4365
6453
|
const dbName = `classdb-${version}-${classID}`;
|
|
4366
6454
|
logger.info(`Retrieving classroom db: ${dbName}`);
|
|
@@ -4490,9 +6578,9 @@ var init_classroomDB2 = __esm({
|
|
|
4490
6578
|
}
|
|
4491
6579
|
const activeCards = await this._user.getActiveCards();
|
|
4492
6580
|
const activeCardIds = new Set(activeCards.map((ac) => ac.cardID));
|
|
4493
|
-
const now =
|
|
6581
|
+
const now = moment4.utc();
|
|
4494
6582
|
const assigned = await this.getAssignedContent();
|
|
4495
|
-
const due = assigned.filter((c) => now.isAfter(
|
|
6583
|
+
const due = assigned.filter((c) => now.isAfter(moment4.utc(c.activeOn, REVIEW_TIME_FORMAT)));
|
|
4496
6584
|
logger.info(`[StudentClassroomDB] Due content: ${JSON.stringify(due)}`);
|
|
4497
6585
|
for (const content of due) {
|
|
4498
6586
|
if (content.type === "course") {
|
|
@@ -4618,8 +6706,8 @@ var init_classroomDB2 = __esm({
|
|
|
4618
6706
|
type: "tag",
|
|
4619
6707
|
_id: id,
|
|
4620
6708
|
assignedBy: content.assignedBy,
|
|
4621
|
-
assignedOn:
|
|
4622
|
-
activeOn: content.activeOn ||
|
|
6709
|
+
assignedOn: moment4.utc(),
|
|
6710
|
+
activeOn: content.activeOn || moment4.utc()
|
|
4623
6711
|
});
|
|
4624
6712
|
} else {
|
|
4625
6713
|
put = await this._db.put({
|
|
@@ -4627,8 +6715,8 @@ var init_classroomDB2 = __esm({
|
|
|
4627
6715
|
type: "course",
|
|
4628
6716
|
_id: id,
|
|
4629
6717
|
assignedBy: content.assignedBy,
|
|
4630
|
-
assignedOn:
|
|
4631
|
-
activeOn: content.activeOn ||
|
|
6718
|
+
assignedOn: moment4.utc(),
|
|
6719
|
+
activeOn: content.activeOn || moment4.utc()
|
|
4632
6720
|
});
|
|
4633
6721
|
}
|
|
4634
6722
|
if (put.ok) {
|
|
@@ -4900,17 +6988,6 @@ var init_userOutcome = __esm({
|
|
|
4900
6988
|
}
|
|
4901
6989
|
});
|
|
4902
6990
|
|
|
4903
|
-
// src/core/util/index.ts
|
|
4904
|
-
function getCardHistoryID(courseID, cardID) {
|
|
4905
|
-
return `${DocTypePrefixes["CARDRECORD" /* CARDRECORD */]}-${courseID}-${cardID}`;
|
|
4906
|
-
}
|
|
4907
|
-
var init_util = __esm({
|
|
4908
|
-
"src/core/util/index.ts"() {
|
|
4909
|
-
"use strict";
|
|
4910
|
-
init_types_legacy();
|
|
4911
|
-
}
|
|
4912
|
-
});
|
|
4913
|
-
|
|
4914
6991
|
// src/core/bulkImport/cardProcessor.ts
|
|
4915
6992
|
import { Status as Status2 } from "@vue-skuilder/common";
|
|
4916
6993
|
var init_cardProcessor = __esm({
|
|
@@ -4921,7 +6998,7 @@ var init_cardProcessor = __esm({
|
|
|
4921
6998
|
});
|
|
4922
6999
|
|
|
4923
7000
|
// src/core/bulkImport/types.ts
|
|
4924
|
-
var
|
|
7001
|
+
var init_types5 = __esm({
|
|
4925
7002
|
"src/core/bulkImport/types.ts"() {
|
|
4926
7003
|
"use strict";
|
|
4927
7004
|
}
|
|
@@ -4932,33 +7009,12 @@ var init_bulkImport = __esm({
|
|
|
4932
7009
|
"src/core/bulkImport/index.ts"() {
|
|
4933
7010
|
"use strict";
|
|
4934
7011
|
init_cardProcessor();
|
|
4935
|
-
|
|
4936
|
-
}
|
|
4937
|
-
});
|
|
4938
|
-
|
|
4939
|
-
// src/util/dataDirectory.ts
|
|
4940
|
-
import * as path from "path";
|
|
4941
|
-
import * as os from "os";
|
|
4942
|
-
function getAppDataDirectory() {
|
|
4943
|
-
if (ENV.LOCAL_STORAGE_PREFIX) {
|
|
4944
|
-
return path.join(os.homedir(), `.tuilder`, ENV.LOCAL_STORAGE_PREFIX);
|
|
4945
|
-
} else {
|
|
4946
|
-
return path.join(os.homedir(), ".tuilder");
|
|
4947
|
-
}
|
|
4948
|
-
}
|
|
4949
|
-
function getDbPath(dbName) {
|
|
4950
|
-
return path.join(getAppDataDirectory(), dbName);
|
|
4951
|
-
}
|
|
4952
|
-
var init_dataDirectory = __esm({
|
|
4953
|
-
"src/util/dataDirectory.ts"() {
|
|
4954
|
-
"use strict";
|
|
4955
|
-
init_logger();
|
|
4956
|
-
init_factory();
|
|
7012
|
+
init_types5();
|
|
4957
7013
|
}
|
|
4958
7014
|
});
|
|
4959
7015
|
|
|
4960
7016
|
// src/impl/common/userDBHelpers.ts
|
|
4961
|
-
import
|
|
7017
|
+
import moment5 from "moment";
|
|
4962
7018
|
function hexEncode(str) {
|
|
4963
7019
|
let hex;
|
|
4964
7020
|
let returnStr = "";
|
|
@@ -4986,7 +7042,7 @@ function getStartAndEndKeys2(key) {
|
|
|
4986
7042
|
};
|
|
4987
7043
|
}
|
|
4988
7044
|
function updateGuestAccountExpirationDate(guestDB) {
|
|
4989
|
-
const currentTime =
|
|
7045
|
+
const currentTime = moment5.utc();
|
|
4990
7046
|
const expirationDate = currentTime.add(2, "months").toISOString();
|
|
4991
7047
|
const expiryDocID2 = "GuestAccountExpirationDate";
|
|
4992
7048
|
void guestDB.get(expiryDocID2).then((doc) => {
|
|
@@ -5011,7 +7067,7 @@ function getLocalUserDB(username) {
|
|
|
5011
7067
|
}
|
|
5012
7068
|
}
|
|
5013
7069
|
function scheduleCardReviewLocal(userDB, review) {
|
|
5014
|
-
const now =
|
|
7070
|
+
const now = moment5.utc();
|
|
5015
7071
|
logger.info(`Scheduling for review in: ${review.time.diff(now, "h") / 24} days`);
|
|
5016
7072
|
void userDB.put({
|
|
5017
7073
|
_id: DocTypePrefixes["SCHEDULED_CARD" /* SCHEDULED_CARD */] + review.time.format(REVIEW_TIME_FORMAT2),
|
|
@@ -5397,7 +7453,7 @@ var init_core = __esm({
|
|
|
5397
7453
|
});
|
|
5398
7454
|
|
|
5399
7455
|
// src/impl/couch/user-course-relDB.ts
|
|
5400
|
-
import
|
|
7456
|
+
import moment6 from "moment";
|
|
5401
7457
|
var UsrCrsData;
|
|
5402
7458
|
var init_user_course_relDB = __esm({
|
|
5403
7459
|
"src/impl/couch/user-course-relDB.ts"() {
|
|
@@ -5411,11 +7467,11 @@ var init_user_course_relDB = __esm({
|
|
|
5411
7467
|
this._courseId = courseId;
|
|
5412
7468
|
}
|
|
5413
7469
|
async getReviewsForcast(daysCount) {
|
|
5414
|
-
const time =
|
|
7470
|
+
const time = moment6.utc().add(daysCount, "days");
|
|
5415
7471
|
return this.getReviewstoDate(time);
|
|
5416
7472
|
}
|
|
5417
7473
|
async getPendingReviews() {
|
|
5418
|
-
const now =
|
|
7474
|
+
const now = moment6.utc();
|
|
5419
7475
|
return this.getReviewstoDate(now);
|
|
5420
7476
|
}
|
|
5421
7477
|
async getScheduledReviewCount() {
|
|
@@ -5451,7 +7507,7 @@ var init_user_course_relDB = __esm({
|
|
|
5451
7507
|
`Fetching ${this.user.getUsername()}'s scheduled reviews for course ${this._courseId}.`
|
|
5452
7508
|
);
|
|
5453
7509
|
return allReviews.filter((review) => {
|
|
5454
|
-
const reviewTime =
|
|
7510
|
+
const reviewTime = moment6.utc(review.reviewTime);
|
|
5455
7511
|
return targetDate.isAfter(reviewTime);
|
|
5456
7512
|
});
|
|
5457
7513
|
}
|
|
@@ -5461,7 +7517,7 @@ var init_user_course_relDB = __esm({
|
|
|
5461
7517
|
|
|
5462
7518
|
// src/impl/common/BaseUserDB.ts
|
|
5463
7519
|
import { Status as Status3 } from "@vue-skuilder/common";
|
|
5464
|
-
import
|
|
7520
|
+
import moment7 from "moment";
|
|
5465
7521
|
function accomodateGuest() {
|
|
5466
7522
|
logger.log("[funnel] accomodateGuest() called");
|
|
5467
7523
|
if (typeof localStorage === "undefined") {
|
|
@@ -5904,7 +7960,7 @@ Currently logged-in as ${this._username}.`
|
|
|
5904
7960
|
);
|
|
5905
7961
|
return reviews.rows.filter((r) => {
|
|
5906
7962
|
if (r.id.startsWith(DocTypePrefixes["SCHEDULED_CARD" /* SCHEDULED_CARD */])) {
|
|
5907
|
-
const date =
|
|
7963
|
+
const date = moment7.utc(
|
|
5908
7964
|
r.id.substr(DocTypePrefixes["SCHEDULED_CARD" /* SCHEDULED_CARD */].length),
|
|
5909
7965
|
REVIEW_TIME_FORMAT2
|
|
5910
7966
|
);
|
|
@@ -5917,11 +7973,11 @@ Currently logged-in as ${this._username}.`
|
|
|
5917
7973
|
}).map((r) => r.doc);
|
|
5918
7974
|
}
|
|
5919
7975
|
async getReviewsForcast(daysCount) {
|
|
5920
|
-
const time =
|
|
7976
|
+
const time = moment7.utc().add(daysCount, "days");
|
|
5921
7977
|
return this.getReviewstoDate(time);
|
|
5922
7978
|
}
|
|
5923
7979
|
async getPendingReviews(course_id) {
|
|
5924
|
-
const now =
|
|
7980
|
+
const now = moment7.utc();
|
|
5925
7981
|
return this.getReviewstoDate(now, course_id);
|
|
5926
7982
|
}
|
|
5927
7983
|
async getScheduledReviewCount(course_id) {
|
|
@@ -6208,7 +8264,7 @@ Currently logged-in as ${this._username}.`
|
|
|
6208
8264
|
*/
|
|
6209
8265
|
async putCardRecord(record) {
|
|
6210
8266
|
const cardHistoryID = getCardHistoryID(record.courseID, record.cardID);
|
|
6211
|
-
record.timeStamp =
|
|
8267
|
+
record.timeStamp = moment7.utc(record.timeStamp).toString();
|
|
6212
8268
|
try {
|
|
6213
8269
|
const cardHistory = await this.update(
|
|
6214
8270
|
cardHistoryID,
|
|
@@ -6224,7 +8280,7 @@ Currently logged-in as ${this._username}.`
|
|
|
6224
8280
|
const ret = {
|
|
6225
8281
|
...record2
|
|
6226
8282
|
};
|
|
6227
|
-
ret.timeStamp =
|
|
8283
|
+
ret.timeStamp = moment7.utc(record2.timeStamp);
|
|
6228
8284
|
return ret;
|
|
6229
8285
|
});
|
|
6230
8286
|
return cardHistory;
|
|
@@ -6679,8 +8735,20 @@ var init_CourseSyncService = __esm({
|
|
|
6679
8735
|
*/
|
|
6680
8736
|
async ensureSynced(courseId, forceEnabled) {
|
|
6681
8737
|
const existing = this.entries.get(courseId);
|
|
6682
|
-
if (existing?.status.state === "ready") {
|
|
6683
|
-
|
|
8738
|
+
if (existing?.status.state === "ready" && existing.localDB) {
|
|
8739
|
+
const stale = await this.isLocalEpochStale(courseId, existing.localDB);
|
|
8740
|
+
if (!stale) {
|
|
8741
|
+
return;
|
|
8742
|
+
}
|
|
8743
|
+
logger.info(
|
|
8744
|
+
`[CourseSyncService] Remote DB epoch changed for course ${courseId} \u2014 destroying stale local replica`
|
|
8745
|
+
);
|
|
8746
|
+
try {
|
|
8747
|
+
await existing.localDB.destroy();
|
|
8748
|
+
} catch {
|
|
8749
|
+
}
|
|
8750
|
+
existing.localDB = null;
|
|
8751
|
+
existing.readyPromise = null;
|
|
6684
8752
|
}
|
|
6685
8753
|
if (existing?.status.state === "disabled") {
|
|
6686
8754
|
return;
|
|
@@ -6744,7 +8812,15 @@ var init_CourseSyncService = __esm({
|
|
|
6744
8812
|
}
|
|
6745
8813
|
entry.status = { state: "syncing" };
|
|
6746
8814
|
const localDBName = this.localDBName(courseId);
|
|
6747
|
-
|
|
8815
|
+
let localDB = new pouchdb_setup_default(localDBName);
|
|
8816
|
+
const stale = await this.isLocalEpochStale(courseId, localDB);
|
|
8817
|
+
if (stale) {
|
|
8818
|
+
logger.info(
|
|
8819
|
+
`[CourseSyncService] Stale local DB detected for course ${courseId} \u2014 destroying before sync`
|
|
8820
|
+
);
|
|
8821
|
+
await localDB.destroy();
|
|
8822
|
+
localDB = new pouchdb_setup_default(localDBName);
|
|
8823
|
+
}
|
|
6748
8824
|
entry.localDB = localDB;
|
|
6749
8825
|
const remoteDB = this.getRemoteDB(courseId);
|
|
6750
8826
|
const syncStart = Date.now();
|
|
@@ -6834,6 +8910,33 @@ var init_CourseSyncService = __esm({
|
|
|
6834
8910
|
}
|
|
6835
8911
|
}
|
|
6836
8912
|
}
|
|
8913
|
+
/**
|
|
8914
|
+
* Check whether the local replica's `db-epoch` doc matches the remote.
|
|
8915
|
+
*
|
|
8916
|
+
* The seed script (and optionally upload-cards) writes a `db-epoch`
|
|
8917
|
+
* document with a numeric timestamp. If the remote epoch differs from
|
|
8918
|
+
* the local copy, the remote DB was recreated (e.g., `yarn db:seed`)
|
|
8919
|
+
* and the local PouchDB is stale.
|
|
8920
|
+
*
|
|
8921
|
+
* Returns `true` if stale (epoch mismatch or remote has epoch but local
|
|
8922
|
+
* doesn't). Returns `false` (not stale) if epochs match, or if the
|
|
8923
|
+
* remote doesn't have an epoch doc at all (backwards compat).
|
|
8924
|
+
*/
|
|
8925
|
+
async isLocalEpochStale(courseId, localDB) {
|
|
8926
|
+
try {
|
|
8927
|
+
const remoteDB = this.getRemoteDB(courseId);
|
|
8928
|
+
const remoteEpoch = await remoteDB.get("db-epoch");
|
|
8929
|
+
let localEpoch = null;
|
|
8930
|
+
try {
|
|
8931
|
+
localEpoch = await localDB.get("db-epoch");
|
|
8932
|
+
} catch {
|
|
8933
|
+
return true;
|
|
8934
|
+
}
|
|
8935
|
+
return remoteEpoch.epoch !== localEpoch.epoch;
|
|
8936
|
+
} catch {
|
|
8937
|
+
return false;
|
|
8938
|
+
}
|
|
8939
|
+
}
|
|
6837
8940
|
/**
|
|
6838
8941
|
* Get a remote PouchDB handle for a course.
|
|
6839
8942
|
*/
|
|
@@ -6851,7 +8954,7 @@ var init_CourseSyncService = __esm({
|
|
|
6851
8954
|
});
|
|
6852
8955
|
|
|
6853
8956
|
// src/impl/couch/auth.ts
|
|
6854
|
-
import
|
|
8957
|
+
import fetch2 from "cross-fetch";
|
|
6855
8958
|
async function getCurrentSession() {
|
|
6856
8959
|
try {
|
|
6857
8960
|
if (ENV.COUCHDB_SERVER_URL === NOT_SET || ENV.COUCHDB_SERVER_PROTOCOL === NOT_SET) {
|
|
@@ -6860,7 +8963,7 @@ async function getCurrentSession() {
|
|
|
6860
8963
|
const baseUrl = ENV.COUCHDB_SERVER_URL.endsWith("/") ? ENV.COUCHDB_SERVER_URL.slice(0, -1) : ENV.COUCHDB_SERVER_URL;
|
|
6861
8964
|
const url = `${ENV.COUCHDB_SERVER_PROTOCOL}://${baseUrl}/_session`;
|
|
6862
8965
|
logger.debug(`Attempting session check at: ${url}`);
|
|
6863
|
-
const response = await
|
|
8966
|
+
const response = await fetch2(url, {
|
|
6864
8967
|
method: "GET",
|
|
6865
8968
|
credentials: "include"
|
|
6866
8969
|
});
|
|
@@ -7118,8 +9221,8 @@ var init_CouchDBSyncStrategy = __esm({
|
|
|
7118
9221
|
});
|
|
7119
9222
|
|
|
7120
9223
|
// src/impl/couch/index.ts
|
|
7121
|
-
import
|
|
7122
|
-
import
|
|
9224
|
+
import fetch3 from "cross-fetch";
|
|
9225
|
+
import moment8 from "moment";
|
|
7123
9226
|
import process2 from "process";
|
|
7124
9227
|
function hexEncode2(str) {
|
|
7125
9228
|
let hex;
|
|
@@ -7180,7 +9283,7 @@ async function usernameIsAvailable(username) {
|
|
|
7180
9283
|
log(`Checking availability of ${username}`);
|
|
7181
9284
|
try {
|
|
7182
9285
|
const url = ENV.COUCHDB_SERVER_URL + "userdb-" + hexEncode2(username);
|
|
7183
|
-
const response = await
|
|
9286
|
+
const response = await fetch3(url, { method: "HEAD" });
|
|
7184
9287
|
return response.status === 404;
|
|
7185
9288
|
} catch (error) {
|
|
7186
9289
|
log(`Error checking username availability: ${error}`);
|
|
@@ -7188,7 +9291,7 @@ async function usernameIsAvailable(username) {
|
|
|
7188
9291
|
}
|
|
7189
9292
|
}
|
|
7190
9293
|
function updateGuestAccountExpirationDate2(guestDB) {
|
|
7191
|
-
const currentTime =
|
|
9294
|
+
const currentTime = moment8.utc();
|
|
7192
9295
|
const expirationDate = currentTime.add(2, "months").toISOString();
|
|
7193
9296
|
void guestDB.get(expiryDocID).then((doc) => {
|
|
7194
9297
|
return guestDB.put({
|
|
@@ -7251,7 +9354,7 @@ function getCouchUserDB(username) {
|
|
|
7251
9354
|
return ret;
|
|
7252
9355
|
}
|
|
7253
9356
|
function scheduleCardReview(review) {
|
|
7254
|
-
const now =
|
|
9357
|
+
const now = moment8.utc();
|
|
7255
9358
|
logger.info(`Scheduling for review in: ${review.time.diff(now, "h") / 24} days`);
|
|
7256
9359
|
void getCouchUserDB(review.user).put({
|
|
7257
9360
|
_id: DocTypePrefixes["SCHEDULED_CARD" /* SCHEDULED_CARD */] + review.time.format(REVIEW_TIME_FORMAT),
|