@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
package/dist/core/index.mjs
CHANGED
|
@@ -727,13 +727,20 @@ function captureRun(report) {
|
|
|
727
727
|
runHistory.pop();
|
|
728
728
|
}
|
|
729
729
|
}
|
|
730
|
-
function
|
|
730
|
+
function parseCardElo(provenance) {
|
|
731
|
+
const eloEntry = provenance.find((p) => p.strategy === "elo");
|
|
732
|
+
if (!eloEntry?.reason) return void 0;
|
|
733
|
+
const match = eloEntry.reason.match(/card:\s*(\d+)/);
|
|
734
|
+
return match ? parseInt(match[1], 10) : void 0;
|
|
735
|
+
}
|
|
736
|
+
function buildRunReport(courseId, courseName, generatorName, generators, generatedCount, filters, allCards, selectedCards, userElo) {
|
|
731
737
|
const selectedIds = new Set(selectedCards.map((c) => c.cardId));
|
|
732
738
|
const cards = allCards.map((card) => ({
|
|
733
739
|
cardId: card.cardId,
|
|
734
740
|
courseId: card.courseId,
|
|
735
741
|
origin: getOrigin(card),
|
|
736
742
|
finalScore: card.score,
|
|
743
|
+
cardElo: parseCardElo(card.provenance),
|
|
737
744
|
provenance: card.provenance,
|
|
738
745
|
tags: card.tags,
|
|
739
746
|
selected: selectedIds.has(card.cardId)
|
|
@@ -743,6 +750,7 @@ function buildRunReport(courseId, courseName, generatorName, generators, generat
|
|
|
743
750
|
return {
|
|
744
751
|
courseId,
|
|
745
752
|
courseName,
|
|
753
|
+
userElo,
|
|
746
754
|
generatorName,
|
|
747
755
|
generators,
|
|
748
756
|
generatedCount,
|
|
@@ -763,6 +771,7 @@ function printRunSummary(run) {
|
|
|
763
771
|
console.group(`\u{1F50D} Pipeline Run: ${run.courseId} (${run.courseName || "unnamed"})`);
|
|
764
772
|
logger.info(`Run ID: ${run.runId}`);
|
|
765
773
|
logger.info(`Time: ${run.timestamp.toISOString()}`);
|
|
774
|
+
logger.info(`User ELO: ${run.userElo ?? "unknown"}`);
|
|
766
775
|
logger.info(`Generator: ${run.generatorName} \u2192 ${run.generatedCount} candidates`);
|
|
767
776
|
if (run.generators && run.generators.length > 0) {
|
|
768
777
|
console.group("Generator breakdown:");
|
|
@@ -849,8 +858,12 @@ var init_PipelineDebugger = __esm({
|
|
|
849
858
|
console.group(`\u{1F3B4} Card: ${cardId}`);
|
|
850
859
|
logger.info(`Course: ${card.courseId}`);
|
|
851
860
|
logger.info(`Origin: ${card.origin}`);
|
|
861
|
+
logger.info(`Card ELO: ${card.cardElo ?? "unknown"} | User ELO: ${run.userElo ?? "unknown"}`);
|
|
852
862
|
logger.info(`Final score: ${card.finalScore.toFixed(3)}`);
|
|
853
863
|
logger.info(`Selected: ${card.selected ? "Yes \u2705" : "No \u274C"}`);
|
|
864
|
+
if (card.tags && card.tags.length > 0) {
|
|
865
|
+
logger.info(`Tags (${card.tags.length}): ${card.tags.join(", ")}`);
|
|
866
|
+
}
|
|
854
867
|
logger.info("Provenance:");
|
|
855
868
|
logger.info(formatProvenance(card.provenance));
|
|
856
869
|
console.groupEnd();
|
|
@@ -1014,6 +1027,27 @@ var init_PipelineDebugger = __esm({
|
|
|
1014
1027
|
}
|
|
1015
1028
|
return _activePipeline.diagnoseCardSpace({ threshold });
|
|
1016
1029
|
},
|
|
1030
|
+
/**
|
|
1031
|
+
* Show user's per-tag ELO data. Useful for diagnosing hierarchy gate status.
|
|
1032
|
+
*
|
|
1033
|
+
* @param tagFilter - Optional glob pattern(s) to filter tags.
|
|
1034
|
+
* Examples: 'gpc:expose:*', 'gpc:intro:t-T', ['gpc:expose:t-*', 'gpc:intro:t-*']
|
|
1035
|
+
*/
|
|
1036
|
+
async showTagElo(tagFilter) {
|
|
1037
|
+
if (!_activePipeline) {
|
|
1038
|
+
logger.info("[Pipeline Debug] No active pipeline. Run a session first.");
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
const status = await _activePipeline.getTagEloStatus(tagFilter);
|
|
1042
|
+
const entries = Object.entries(status).sort(([a], [b]) => a.localeCompare(b));
|
|
1043
|
+
if (entries.length === 0) {
|
|
1044
|
+
logger.info(`[Pipeline Debug] No tag ELO data found${tagFilter ? ` for pattern: ${tagFilter}` : ""}.`);
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
console.table(
|
|
1048
|
+
Object.fromEntries(entries.map(([tag, data]) => [tag, { score: Math.round(data.score), count: data.count }]))
|
|
1049
|
+
);
|
|
1050
|
+
},
|
|
1017
1051
|
/**
|
|
1018
1052
|
* Show help.
|
|
1019
1053
|
*/
|
|
@@ -1025,6 +1059,7 @@ Commands:
|
|
|
1025
1059
|
.showLastRun() Show summary of most recent pipeline run
|
|
1026
1060
|
.showRun(id|index) Show summary of a specific run (by index or ID suffix)
|
|
1027
1061
|
.showCard(cardId) Show provenance trail for a specific card
|
|
1062
|
+
.showTagElo(pattern) Show user's tag ELO data (async). E.g. 'gpc:expose:*'
|
|
1028
1063
|
.explainReviews() Analyze why reviews were/weren't selected
|
|
1029
1064
|
.diagnoseCardSpace() Scan full card space through filters (async)
|
|
1030
1065
|
.showRegistry() Show navigator registry (classes + roles)
|
|
@@ -1338,60 +1373,423 @@ var prescribed_exports = {};
|
|
|
1338
1373
|
__export(prescribed_exports, {
|
|
1339
1374
|
default: () => PrescribedCardsGenerator
|
|
1340
1375
|
});
|
|
1341
|
-
|
|
1376
|
+
function dedupe(arr) {
|
|
1377
|
+
return [...new Set(arr)];
|
|
1378
|
+
}
|
|
1379
|
+
function isoNow() {
|
|
1380
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
1381
|
+
}
|
|
1382
|
+
function clamp(value, min, max) {
|
|
1383
|
+
return Math.max(min, Math.min(max, value));
|
|
1384
|
+
}
|
|
1385
|
+
function matchesTagPattern(tag, pattern) {
|
|
1386
|
+
if (pattern === "*") return true;
|
|
1387
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
|
1388
|
+
const re = new RegExp(`^${escaped}$`);
|
|
1389
|
+
return re.test(tag);
|
|
1390
|
+
}
|
|
1391
|
+
function pickTopByScore(cards, limit) {
|
|
1392
|
+
return [...cards].sort((a, b) => b.score - a.score || a.cardId.localeCompare(b.cardId)).slice(0, limit);
|
|
1393
|
+
}
|
|
1394
|
+
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;
|
|
1342
1395
|
var init_prescribed = __esm({
|
|
1343
1396
|
"src/core/navigators/generators/prescribed.ts"() {
|
|
1344
1397
|
"use strict";
|
|
1345
1398
|
init_navigators();
|
|
1346
1399
|
init_logger();
|
|
1400
|
+
DEFAULT_FRESHNESS_WINDOW = 3;
|
|
1401
|
+
DEFAULT_MAX_DIRECT_PER_RUN = 3;
|
|
1402
|
+
DEFAULT_MAX_SUPPORT_PER_RUN = 3;
|
|
1403
|
+
DEFAULT_HIERARCHY_DEPTH = 2;
|
|
1404
|
+
DEFAULT_MIN_COUNT = 3;
|
|
1405
|
+
BASE_TARGET_SCORE = 1;
|
|
1406
|
+
BASE_SUPPORT_SCORE = 0.8;
|
|
1407
|
+
MAX_TARGET_MULTIPLIER = 8;
|
|
1408
|
+
MAX_SUPPORT_MULTIPLIER = 4;
|
|
1409
|
+
LOCKED_TAG_PREFIXES = ["concept:"];
|
|
1410
|
+
LESSON_GATE_PENALTY_TAG_HINT = "concept:";
|
|
1347
1411
|
PrescribedCardsGenerator = class extends ContentNavigator {
|
|
1348
1412
|
name;
|
|
1349
1413
|
config;
|
|
1350
1414
|
constructor(user, course, strategyData) {
|
|
1351
1415
|
super(user, course, strategyData);
|
|
1352
1416
|
this.name = strategyData.name || "Prescribed Cards";
|
|
1353
|
-
|
|
1354
|
-
const parsed = JSON.parse(strategyData.serializedData);
|
|
1355
|
-
this.config = { cardIds: parsed.cardIds || [] };
|
|
1356
|
-
} catch {
|
|
1357
|
-
this.config = { cardIds: [] };
|
|
1358
|
-
}
|
|
1417
|
+
this.config = this.parseConfig(strategyData.serializedData);
|
|
1359
1418
|
logger.debug(
|
|
1360
|
-
`[Prescribed] Initialized with ${this.config.
|
|
1419
|
+
`[Prescribed] Initialized with ${this.config.groups.length} groups and ${this.config.groups.reduce((n, g) => n + g.targetCardIds.length, 0)} targets`
|
|
1361
1420
|
);
|
|
1362
1421
|
}
|
|
1363
|
-
|
|
1364
|
-
|
|
1422
|
+
get strategyKey() {
|
|
1423
|
+
return "PrescribedProgress";
|
|
1424
|
+
}
|
|
1425
|
+
async getWeightedCards(limit, context) {
|
|
1426
|
+
if (this.config.groups.length === 0 || limit <= 0) {
|
|
1365
1427
|
return [];
|
|
1366
1428
|
}
|
|
1367
1429
|
const courseId = this.course.getCourseID();
|
|
1368
1430
|
const activeCards = await this.user.getActiveCards();
|
|
1369
1431
|
const activeIds = new Set(activeCards.map((ac) => ac.cardID));
|
|
1370
|
-
const
|
|
1371
|
-
|
|
1372
|
-
|
|
1432
|
+
const seenCards = await this.user.getSeenCards(courseId).catch(() => []);
|
|
1433
|
+
const seenIds = new Set(seenCards);
|
|
1434
|
+
const progress = await this.getStrategyState() ?? {
|
|
1435
|
+
updatedAt: isoNow(),
|
|
1436
|
+
groups: {}
|
|
1437
|
+
};
|
|
1438
|
+
const hierarchyConfigs = await this.loadHierarchyConfigs();
|
|
1439
|
+
const courseReg = await this.user.getCourseRegDoc(courseId).catch(() => null);
|
|
1440
|
+
const userGlobalElo = typeof courseReg?.elo === "number" ? courseReg.elo : courseReg?.elo?.global?.score ?? context?.userElo ?? 1e3;
|
|
1441
|
+
const userTagElo = typeof courseReg?.elo === "number" ? {} : courseReg?.elo?.tags ?? {};
|
|
1442
|
+
const allTargetIds = dedupe(this.config.groups.flatMap((g) => g.targetCardIds));
|
|
1443
|
+
const allSupportIds = dedupe(this.config.groups.flatMap((g) => g.supportCardIds ?? []));
|
|
1444
|
+
const allRelevantIds = dedupe([...allTargetIds, ...allSupportIds]);
|
|
1445
|
+
const tagsByCard = allRelevantIds.length > 0 ? await this.course.getAppliedTagsBatch(allRelevantIds) : /* @__PURE__ */ new Map();
|
|
1446
|
+
const nextState = {
|
|
1447
|
+
updatedAt: isoNow(),
|
|
1448
|
+
groups: {}
|
|
1449
|
+
};
|
|
1450
|
+
const emitted = [];
|
|
1451
|
+
const emittedIds = /* @__PURE__ */ new Set();
|
|
1452
|
+
for (const group of this.config.groups) {
|
|
1453
|
+
const runtime = this.buildGroupRuntimeState({
|
|
1454
|
+
group,
|
|
1455
|
+
priorState: progress.groups[group.id],
|
|
1456
|
+
activeIds,
|
|
1457
|
+
seenIds,
|
|
1458
|
+
tagsByCard,
|
|
1459
|
+
hierarchyConfigs,
|
|
1460
|
+
userTagElo,
|
|
1461
|
+
userGlobalElo
|
|
1462
|
+
});
|
|
1463
|
+
nextState.groups[group.id] = this.buildNextGroupState(runtime, progress.groups[group.id]);
|
|
1464
|
+
const directCards = this.buildDirectTargetCards(
|
|
1465
|
+
runtime,
|
|
1466
|
+
courseId,
|
|
1467
|
+
emittedIds
|
|
1468
|
+
);
|
|
1469
|
+
const supportCards = this.buildSupportCards(
|
|
1470
|
+
runtime,
|
|
1471
|
+
courseId,
|
|
1472
|
+
emittedIds
|
|
1473
|
+
);
|
|
1474
|
+
emitted.push(...directCards, ...supportCards);
|
|
1475
|
+
}
|
|
1476
|
+
if (emitted.length === 0) {
|
|
1477
|
+
logger.debug("[Prescribed] No prescribed targets/support emitted this run");
|
|
1478
|
+
await this.putStrategyState(nextState).catch((e) => {
|
|
1479
|
+
logger.debug(`[Prescribed] Failed to persist empty-state update: ${e}`);
|
|
1480
|
+
});
|
|
1373
1481
|
return [];
|
|
1374
1482
|
}
|
|
1375
|
-
const
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1483
|
+
const finalCards = pickTopByScore(emitted, limit);
|
|
1484
|
+
const surfacedByGroup = /* @__PURE__ */ new Map();
|
|
1485
|
+
for (const card of finalCards) {
|
|
1486
|
+
const prov = card.provenance[0];
|
|
1487
|
+
const groupId = prov?.reason.match(/group=([^;]+)/)?.[1];
|
|
1488
|
+
const mode = prov?.reason.includes("mode=support") ? "supportIds" : "targetIds";
|
|
1489
|
+
if (!groupId) continue;
|
|
1490
|
+
if (!surfacedByGroup.has(groupId)) {
|
|
1491
|
+
surfacedByGroup.set(groupId, { targetIds: [], supportIds: [] });
|
|
1492
|
+
}
|
|
1493
|
+
surfacedByGroup.get(groupId)[mode].push(card.cardId);
|
|
1494
|
+
}
|
|
1495
|
+
for (const group of this.config.groups) {
|
|
1496
|
+
const groupState = nextState.groups[group.id];
|
|
1497
|
+
const surfaced = surfacedByGroup.get(group.id);
|
|
1498
|
+
if (surfaced && (surfaced.targetIds.length > 0 || surfaced.supportIds.length > 0)) {
|
|
1499
|
+
groupState.lastSurfacedAt = isoNow();
|
|
1500
|
+
groupState.sessionsSinceSurfaced = 0;
|
|
1501
|
+
if (surfaced.supportIds.length > 0) {
|
|
1502
|
+
groupState.lastSupportAt = isoNow();
|
|
1387
1503
|
}
|
|
1388
|
-
|
|
1389
|
-
}
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
await this.putStrategyState(nextState).catch((e) => {
|
|
1507
|
+
logger.debug(`[Prescribed] Failed to persist prescribed progress: ${e}`);
|
|
1508
|
+
});
|
|
1390
1509
|
logger.info(
|
|
1391
|
-
`[Prescribed] Emitting ${
|
|
1510
|
+
`[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)`
|
|
1392
1511
|
);
|
|
1512
|
+
return finalCards;
|
|
1513
|
+
}
|
|
1514
|
+
parseConfig(serializedData) {
|
|
1515
|
+
try {
|
|
1516
|
+
const parsed = JSON.parse(serializedData);
|
|
1517
|
+
const groupsRaw = Array.isArray(parsed.groups) ? parsed.groups : [];
|
|
1518
|
+
const groups = groupsRaw.map((raw, i) => ({
|
|
1519
|
+
id: typeof raw.id === "string" && raw.id.trim().length > 0 ? raw.id : `group-${i + 1}`,
|
|
1520
|
+
targetCardIds: dedupe(Array.isArray(raw.targetCardIds) ? raw.targetCardIds.filter((v) => typeof v === "string") : []),
|
|
1521
|
+
supportCardIds: dedupe(Array.isArray(raw.supportCardIds) ? raw.supportCardIds.filter((v) => typeof v === "string") : []),
|
|
1522
|
+
supportTagPatterns: dedupe(Array.isArray(raw.supportTagPatterns) ? raw.supportTagPatterns.filter((v) => typeof v === "string") : []),
|
|
1523
|
+
freshnessWindowSessions: typeof raw.freshnessWindowSessions === "number" ? raw.freshnessWindowSessions : DEFAULT_FRESHNESS_WINDOW,
|
|
1524
|
+
maxDirectTargetsPerRun: typeof raw.maxDirectTargetsPerRun === "number" ? raw.maxDirectTargetsPerRun : DEFAULT_MAX_DIRECT_PER_RUN,
|
|
1525
|
+
maxSupportCardsPerRun: typeof raw.maxSupportCardsPerRun === "number" ? raw.maxSupportCardsPerRun : DEFAULT_MAX_SUPPORT_PER_RUN,
|
|
1526
|
+
hierarchyWalk: {
|
|
1527
|
+
enabled: raw.hierarchyWalk?.enabled !== false,
|
|
1528
|
+
maxDepth: typeof raw.hierarchyWalk?.maxDepth === "number" ? raw.hierarchyWalk.maxDepth : DEFAULT_HIERARCHY_DEPTH
|
|
1529
|
+
},
|
|
1530
|
+
retireOnEncounter: raw.retireOnEncounter !== false
|
|
1531
|
+
})).filter((g) => g.targetCardIds.length > 0);
|
|
1532
|
+
return { groups };
|
|
1533
|
+
} catch {
|
|
1534
|
+
return { groups: [] };
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
async loadHierarchyConfigs() {
|
|
1538
|
+
try {
|
|
1539
|
+
const strategies = await this.course.getNavigationStrategies();
|
|
1540
|
+
return strategies.filter((s) => s.implementingClass === "hierarchyDefinition").map((s) => {
|
|
1541
|
+
try {
|
|
1542
|
+
const parsed = JSON.parse(s.serializedData);
|
|
1543
|
+
return {
|
|
1544
|
+
prerequisites: parsed.prerequisites || {}
|
|
1545
|
+
};
|
|
1546
|
+
} catch {
|
|
1547
|
+
return { prerequisites: {} };
|
|
1548
|
+
}
|
|
1549
|
+
});
|
|
1550
|
+
} catch (e) {
|
|
1551
|
+
logger.debug(`[Prescribed] Failed to load hierarchy configs: ${e}`);
|
|
1552
|
+
return [];
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
buildGroupRuntimeState(args) {
|
|
1556
|
+
const {
|
|
1557
|
+
group,
|
|
1558
|
+
priorState,
|
|
1559
|
+
activeIds,
|
|
1560
|
+
seenIds,
|
|
1561
|
+
tagsByCard,
|
|
1562
|
+
hierarchyConfigs,
|
|
1563
|
+
userTagElo,
|
|
1564
|
+
userGlobalElo
|
|
1565
|
+
} = args;
|
|
1566
|
+
const encounteredTargets = /* @__PURE__ */ new Set();
|
|
1567
|
+
for (const cardId of group.targetCardIds) {
|
|
1568
|
+
if (activeIds.has(cardId) || seenIds.has(cardId)) {
|
|
1569
|
+
encounteredTargets.add(cardId);
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
if (priorState?.encounteredCardIds?.length) {
|
|
1573
|
+
for (const cardId of priorState.encounteredCardIds) {
|
|
1574
|
+
encounteredTargets.add(cardId);
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
const pendingTargets = group.targetCardIds.filter((id) => !encounteredTargets.has(id));
|
|
1578
|
+
const targetTags = /* @__PURE__ */ new Map();
|
|
1579
|
+
for (const cardId of pendingTargets) {
|
|
1580
|
+
targetTags.set(cardId, tagsByCard.get(cardId) ?? []);
|
|
1581
|
+
}
|
|
1582
|
+
const blockedTargets = [];
|
|
1583
|
+
const surfaceableTargets = [];
|
|
1584
|
+
const supportTags = /* @__PURE__ */ new Set();
|
|
1585
|
+
for (const cardId of pendingTargets) {
|
|
1586
|
+
const tags = targetTags.get(cardId) ?? [];
|
|
1587
|
+
const resolution = this.resolveBlockedSupportTags(
|
|
1588
|
+
tags,
|
|
1589
|
+
hierarchyConfigs,
|
|
1590
|
+
userTagElo,
|
|
1591
|
+
userGlobalElo,
|
|
1592
|
+
group.hierarchyWalk?.enabled !== false,
|
|
1593
|
+
group.hierarchyWalk?.maxDepth ?? DEFAULT_HIERARCHY_DEPTH
|
|
1594
|
+
);
|
|
1595
|
+
if (resolution.blocked) {
|
|
1596
|
+
blockedTargets.push(cardId);
|
|
1597
|
+
resolution.supportTags.forEach((t) => supportTags.add(t));
|
|
1598
|
+
} else {
|
|
1599
|
+
surfaceableTargets.push(cardId);
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
const supportCandidates = dedupe([
|
|
1603
|
+
...group.supportCardIds ?? [],
|
|
1604
|
+
...this.findSupportCardsByTags(
|
|
1605
|
+
group,
|
|
1606
|
+
tagsByCard,
|
|
1607
|
+
[...supportTags]
|
|
1608
|
+
)
|
|
1609
|
+
]).filter((id) => !activeIds.has(id) && !seenIds.has(id));
|
|
1610
|
+
const sessionsSinceSurfaced = priorState?.sessionsSinceSurfaced ?? 0;
|
|
1611
|
+
const freshnessWindow = group.freshnessWindowSessions ?? DEFAULT_FRESHNESS_WINDOW;
|
|
1612
|
+
const staleSessions = Math.max(0, sessionsSinceSurfaced - freshnessWindow);
|
|
1613
|
+
const pressureMultiplier = pendingTargets.length === 0 ? 1 : clamp(1 + staleSessions * 0.75 + Math.min(2, pendingTargets.length * 0.1), 1, MAX_TARGET_MULTIPLIER);
|
|
1614
|
+
const supportMultiplier = blockedTargets.length === 0 ? 1 : clamp(1 + staleSessions * 0.5 + Math.min(1.5, blockedTargets.length * 0.15), 1, MAX_SUPPORT_MULTIPLIER);
|
|
1615
|
+
return {
|
|
1616
|
+
group,
|
|
1617
|
+
encounteredTargets,
|
|
1618
|
+
pendingTargets,
|
|
1619
|
+
blockedTargets,
|
|
1620
|
+
surfaceableTargets,
|
|
1621
|
+
targetTags,
|
|
1622
|
+
supportCandidates,
|
|
1623
|
+
supportTags: [...supportTags],
|
|
1624
|
+
pressureMultiplier,
|
|
1625
|
+
supportMultiplier
|
|
1626
|
+
};
|
|
1627
|
+
}
|
|
1628
|
+
buildNextGroupState(runtime, prior) {
|
|
1629
|
+
const carriedSessions = prior?.sessionsSinceSurfaced ?? 0;
|
|
1630
|
+
const surfacedThisRun = false;
|
|
1631
|
+
return {
|
|
1632
|
+
encounteredCardIds: [...runtime.encounteredTargets].sort(),
|
|
1633
|
+
lastSurfacedAt: prior?.lastSurfacedAt ?? null,
|
|
1634
|
+
sessionsSinceSurfaced: surfacedThisRun ? 0 : carriedSessions + 1,
|
|
1635
|
+
lastSupportAt: prior?.lastSupportAt ?? null,
|
|
1636
|
+
blockedTargetIds: [...runtime.blockedTargets].sort(),
|
|
1637
|
+
lastResolvedSupportTags: [...runtime.supportTags].sort()
|
|
1638
|
+
};
|
|
1639
|
+
}
|
|
1640
|
+
buildDirectTargetCards(runtime, courseId, emittedIds) {
|
|
1641
|
+
const maxDirect = runtime.group.maxDirectTargetsPerRun ?? DEFAULT_MAX_DIRECT_PER_RUN;
|
|
1642
|
+
const directIds = runtime.surfaceableTargets.filter((id) => !emittedIds.has(id)).slice(0, maxDirect);
|
|
1643
|
+
const cards = [];
|
|
1644
|
+
for (const cardId of directIds) {
|
|
1645
|
+
emittedIds.add(cardId);
|
|
1646
|
+
cards.push({
|
|
1647
|
+
cardId,
|
|
1648
|
+
courseId,
|
|
1649
|
+
score: BASE_TARGET_SCORE * runtime.pressureMultiplier,
|
|
1650
|
+
provenance: [
|
|
1651
|
+
{
|
|
1652
|
+
strategy: "prescribed",
|
|
1653
|
+
strategyName: this.strategyName || this.name,
|
|
1654
|
+
strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
|
|
1655
|
+
action: "generated",
|
|
1656
|
+
score: BASE_TARGET_SCORE * runtime.pressureMultiplier,
|
|
1657
|
+
reason: `mode=target;group=${runtime.group.id};pending=${runtime.pendingTargets.length};surfaceable=${runtime.surfaceableTargets.length};blocked=${runtime.blockedTargets.length};multiplier=${runtime.pressureMultiplier.toFixed(2)}`
|
|
1658
|
+
}
|
|
1659
|
+
]
|
|
1660
|
+
});
|
|
1661
|
+
}
|
|
1662
|
+
return cards;
|
|
1663
|
+
}
|
|
1664
|
+
buildSupportCards(runtime, courseId, emittedIds) {
|
|
1665
|
+
if (runtime.blockedTargets.length === 0 || runtime.supportCandidates.length === 0) {
|
|
1666
|
+
return [];
|
|
1667
|
+
}
|
|
1668
|
+
const maxSupport = runtime.group.maxSupportCardsPerRun ?? DEFAULT_MAX_SUPPORT_PER_RUN;
|
|
1669
|
+
const supportIds = runtime.supportCandidates.filter((id) => !emittedIds.has(id)).slice(0, maxSupport);
|
|
1670
|
+
const cards = [];
|
|
1671
|
+
for (const cardId of supportIds) {
|
|
1672
|
+
emittedIds.add(cardId);
|
|
1673
|
+
cards.push({
|
|
1674
|
+
cardId,
|
|
1675
|
+
courseId,
|
|
1676
|
+
score: BASE_SUPPORT_SCORE * runtime.supportMultiplier,
|
|
1677
|
+
provenance: [
|
|
1678
|
+
{
|
|
1679
|
+
strategy: "prescribed",
|
|
1680
|
+
strategyName: this.strategyName || this.name,
|
|
1681
|
+
strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
|
|
1682
|
+
action: "generated",
|
|
1683
|
+
score: BASE_SUPPORT_SCORE * runtime.supportMultiplier,
|
|
1684
|
+
reason: `mode=support;group=${runtime.group.id};blocked=${runtime.blockedTargets.length};supportTags=${runtime.supportTags.join("|") || "none"};multiplier=${runtime.supportMultiplier.toFixed(2)}`
|
|
1685
|
+
}
|
|
1686
|
+
]
|
|
1687
|
+
});
|
|
1688
|
+
}
|
|
1393
1689
|
return cards;
|
|
1394
1690
|
}
|
|
1691
|
+
findSupportCardsByTags(group, tagsByCard, supportTags) {
|
|
1692
|
+
if (supportTags.length === 0) {
|
|
1693
|
+
return [];
|
|
1694
|
+
}
|
|
1695
|
+
const explicitSupportIds = group.supportCardIds ?? [];
|
|
1696
|
+
const explicitPatterns = group.supportTagPatterns ?? [];
|
|
1697
|
+
if (explicitSupportIds.length === 0 && explicitPatterns.length === 0) {
|
|
1698
|
+
return [];
|
|
1699
|
+
}
|
|
1700
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
1701
|
+
for (const cardId of explicitSupportIds) {
|
|
1702
|
+
const cardTags = tagsByCard.get(cardId) ?? [];
|
|
1703
|
+
const matchesResolved = supportTags.some((supportTag) => cardTags.includes(supportTag));
|
|
1704
|
+
const matchesPattern = explicitPatterns.some(
|
|
1705
|
+
(pattern) => cardTags.some((tag) => matchesTagPattern(tag, pattern))
|
|
1706
|
+
);
|
|
1707
|
+
if (matchesResolved || matchesPattern) {
|
|
1708
|
+
candidates.add(cardId);
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
return [...candidates];
|
|
1712
|
+
}
|
|
1713
|
+
resolveBlockedSupportTags(targetTags, hierarchyConfigs, userTagElo, userGlobalElo, hierarchyWalkEnabled, maxDepth) {
|
|
1714
|
+
if (!hierarchyWalkEnabled || targetTags.length === 0 || hierarchyConfigs.length === 0) {
|
|
1715
|
+
return {
|
|
1716
|
+
blocked: false,
|
|
1717
|
+
supportTags: []
|
|
1718
|
+
};
|
|
1719
|
+
}
|
|
1720
|
+
const supportTags = /* @__PURE__ */ new Set();
|
|
1721
|
+
let blocked = false;
|
|
1722
|
+
for (const targetTag of targetTags) {
|
|
1723
|
+
for (const hierarchy of hierarchyConfigs) {
|
|
1724
|
+
const prereqs = hierarchy.prerequisites[targetTag];
|
|
1725
|
+
if (!prereqs || prereqs.length === 0) continue;
|
|
1726
|
+
const unmet = prereqs.filter(
|
|
1727
|
+
(pr) => !this.isPrerequisiteMet(pr, userTagElo[pr.tag], userGlobalElo)
|
|
1728
|
+
);
|
|
1729
|
+
if (unmet.length === 0) {
|
|
1730
|
+
continue;
|
|
1731
|
+
}
|
|
1732
|
+
blocked = true;
|
|
1733
|
+
for (const prereq of unmet) {
|
|
1734
|
+
this.collectSupportTagsRecursive(
|
|
1735
|
+
prereq.tag,
|
|
1736
|
+
hierarchyConfigs,
|
|
1737
|
+
userTagElo,
|
|
1738
|
+
userGlobalElo,
|
|
1739
|
+
maxDepth,
|
|
1740
|
+
/* @__PURE__ */ new Set(),
|
|
1741
|
+
supportTags
|
|
1742
|
+
);
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
return { blocked, supportTags: [...supportTags] };
|
|
1747
|
+
}
|
|
1748
|
+
collectSupportTagsRecursive(tag, hierarchyConfigs, userTagElo, userGlobalElo, depth, visited, out) {
|
|
1749
|
+
if (depth < 0 || visited.has(tag)) return;
|
|
1750
|
+
if (this.isHardGatedTag(tag)) return;
|
|
1751
|
+
visited.add(tag);
|
|
1752
|
+
let walkedFurther = false;
|
|
1753
|
+
for (const hierarchy of hierarchyConfigs) {
|
|
1754
|
+
const prereqs = hierarchy.prerequisites[tag];
|
|
1755
|
+
if (!prereqs || prereqs.length === 0) continue;
|
|
1756
|
+
const unmet = prereqs.filter(
|
|
1757
|
+
(pr) => !this.isPrerequisiteMet(pr, userTagElo[pr.tag], userGlobalElo)
|
|
1758
|
+
);
|
|
1759
|
+
if (unmet.length > 0 && depth > 0) {
|
|
1760
|
+
walkedFurther = true;
|
|
1761
|
+
for (const prereq of unmet) {
|
|
1762
|
+
this.collectSupportTagsRecursive(
|
|
1763
|
+
prereq.tag,
|
|
1764
|
+
hierarchyConfigs,
|
|
1765
|
+
userTagElo,
|
|
1766
|
+
userGlobalElo,
|
|
1767
|
+
depth - 1,
|
|
1768
|
+
visited,
|
|
1769
|
+
out
|
|
1770
|
+
);
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
if (!walkedFurther) {
|
|
1775
|
+
out.add(tag);
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
isHardGatedTag(tag) {
|
|
1779
|
+
return LOCKED_TAG_PREFIXES.some((prefix) => tag.startsWith(prefix)) && tag.startsWith(LESSON_GATE_PENALTY_TAG_HINT);
|
|
1780
|
+
}
|
|
1781
|
+
isPrerequisiteMet(prereq, userTagElo, userGlobalElo) {
|
|
1782
|
+
if (!userTagElo) return false;
|
|
1783
|
+
const minCount = prereq.masteryThreshold?.minCount ?? DEFAULT_MIN_COUNT;
|
|
1784
|
+
if (userTagElo.count < minCount) return false;
|
|
1785
|
+
if (prereq.masteryThreshold?.minElo !== void 0) {
|
|
1786
|
+
return userTagElo.score >= prereq.masteryThreshold.minElo;
|
|
1787
|
+
}
|
|
1788
|
+
if (prereq.masteryThreshold?.minCount !== void 0) {
|
|
1789
|
+
return true;
|
|
1790
|
+
}
|
|
1791
|
+
return userTagElo.score >= userGlobalElo;
|
|
1792
|
+
}
|
|
1395
1793
|
};
|
|
1396
1794
|
}
|
|
1397
1795
|
});
|
|
@@ -1755,12 +2153,13 @@ __export(hierarchyDefinition_exports, {
|
|
|
1755
2153
|
default: () => HierarchyDefinitionNavigator
|
|
1756
2154
|
});
|
|
1757
2155
|
import { toCourseElo as toCourseElo3 } from "@vue-skuilder/common";
|
|
1758
|
-
var
|
|
2156
|
+
var DEFAULT_MIN_COUNT2, HierarchyDefinitionNavigator;
|
|
1759
2157
|
var init_hierarchyDefinition = __esm({
|
|
1760
2158
|
"src/core/navigators/filters/hierarchyDefinition.ts"() {
|
|
1761
2159
|
"use strict";
|
|
1762
2160
|
init_navigators();
|
|
1763
|
-
|
|
2161
|
+
init_logger();
|
|
2162
|
+
DEFAULT_MIN_COUNT2 = 3;
|
|
1764
2163
|
HierarchyDefinitionNavigator = class extends ContentNavigator {
|
|
1765
2164
|
config;
|
|
1766
2165
|
/** Human-readable name for CardFilter interface */
|
|
@@ -1787,7 +2186,7 @@ var init_hierarchyDefinition = __esm({
|
|
|
1787
2186
|
*/
|
|
1788
2187
|
isPrerequisiteMet(prereq, userTagElo, userGlobalElo) {
|
|
1789
2188
|
if (!userTagElo) return false;
|
|
1790
|
-
const minCount = prereq.masteryThreshold?.minCount ??
|
|
2189
|
+
const minCount = prereq.masteryThreshold?.minCount ?? DEFAULT_MIN_COUNT2;
|
|
1791
2190
|
if (userTagElo.count < minCount) return false;
|
|
1792
2191
|
if (prereq.masteryThreshold?.minElo !== void 0) {
|
|
1793
2192
|
return userTagElo.score >= prereq.masteryThreshold.minElo;
|
|
@@ -1888,18 +2287,55 @@ var init_hierarchyDefinition = __esm({
|
|
|
1888
2287
|
}
|
|
1889
2288
|
return boosts;
|
|
1890
2289
|
}
|
|
2290
|
+
/**
|
|
2291
|
+
* Build a map of gated tag → max configured targetBoost for all *open* gates.
|
|
2292
|
+
*
|
|
2293
|
+
* When a gate opens (prereqs met), cards carrying the gated tag get boosted —
|
|
2294
|
+
* ensuring newly-unlocked content surfaces promptly. The boost is a static
|
|
2295
|
+
* multiplier; natural ELO/SRS deprioritization after interaction handles decay.
|
|
2296
|
+
*/
|
|
2297
|
+
getTargetBoosts(unlockedTags) {
|
|
2298
|
+
const boosts = /* @__PURE__ */ new Map();
|
|
2299
|
+
const configKeys = Object.keys(this.config.prerequisites);
|
|
2300
|
+
const unlockedArr = [...unlockedTags];
|
|
2301
|
+
logger.info(
|
|
2302
|
+
`[HierarchyDefinition:targetBoost:trace] ${this.name} | configKeys=${configKeys.length}, unlocked=${unlockedArr.length} (${unlockedArr.slice(0, 5).join(", ")}${unlockedArr.length > 5 ? "..." : ""})`
|
|
2303
|
+
);
|
|
2304
|
+
for (const [tagId, prereqs] of Object.entries(this.config.prerequisites)) {
|
|
2305
|
+
if (!unlockedTags.has(tagId)) continue;
|
|
2306
|
+
logger.info(
|
|
2307
|
+
`[HierarchyDefinition:targetBoost:trace] UNLOCKED ${tagId}: ${prereqs.length} prereqs, raw=${JSON.stringify(prereqs.map((p) => ({ tag: p.tag, tb: p.targetBoost })))}`
|
|
2308
|
+
);
|
|
2309
|
+
for (const prereq of prereqs) {
|
|
2310
|
+
if (!prereq.targetBoost || prereq.targetBoost <= 1) continue;
|
|
2311
|
+
const existing = boosts.get(tagId) ?? 1;
|
|
2312
|
+
boosts.set(tagId, Math.max(existing, prereq.targetBoost));
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
if (boosts.size > 0) {
|
|
2316
|
+
logger.info(
|
|
2317
|
+
`[HierarchyDefinition] targetBoosts active: ${[...boosts.entries()].map(([t, b]) => `${t}=\xD7${b}`).join(", ")}`
|
|
2318
|
+
);
|
|
2319
|
+
} else {
|
|
2320
|
+
logger.info(
|
|
2321
|
+
`[HierarchyDefinition:targetBoost:trace] no targetBoosts found despite ${unlockedArr.length} unlocked tags`
|
|
2322
|
+
);
|
|
2323
|
+
}
|
|
2324
|
+
return boosts;
|
|
2325
|
+
}
|
|
1891
2326
|
/**
|
|
1892
2327
|
* CardFilter.transform implementation.
|
|
1893
2328
|
*
|
|
1894
|
-
*
|
|
1895
|
-
* 1. Cards with locked tags receive score * 0.
|
|
1896
|
-
* 2. Cards carrying prereq tags of closed gates receive
|
|
1897
|
-
*
|
|
2329
|
+
* Three effects:
|
|
2330
|
+
* 1. Cards with locked tags receive score * 0.02 (gating penalty)
|
|
2331
|
+
* 2. Cards carrying prereq tags of closed gates receive preReqBoost
|
|
2332
|
+
* 3. Cards carrying gated tags of open gates receive targetBoost
|
|
1898
2333
|
*/
|
|
1899
2334
|
async transform(cards, context) {
|
|
1900
2335
|
const masteredTags = await this.getMasteredTags(context);
|
|
1901
2336
|
const unlockedTags = this.getUnlockedTags(masteredTags);
|
|
1902
2337
|
const preReqBoosts = this.getPreReqBoosts(unlockedTags, masteredTags);
|
|
2338
|
+
const targetBoosts = this.getTargetBoosts(unlockedTags);
|
|
1903
2339
|
const gated = [];
|
|
1904
2340
|
for (const card of cards) {
|
|
1905
2341
|
const { isUnlocked, reason } = await this.checkCardUnlock(
|
|
@@ -1927,6 +2363,29 @@ var init_hierarchyDefinition = __esm({
|
|
|
1927
2363
|
finalScore *= maxBoost;
|
|
1928
2364
|
action = "boosted";
|
|
1929
2365
|
finalReason = `${reason} | preReqBoost \xD7${maxBoost.toFixed(2)} for ${boostedPrereqs.join(", ")}`;
|
|
2366
|
+
logger.info(
|
|
2367
|
+
`[HierarchyDefinition] preReqBoost \xD7${maxBoost.toFixed(2)} applied to card ${card.cardId} via tags [${boostedPrereqs.join(", ")}] (score: ${card.score.toFixed(3)} \u2192 ${finalScore.toFixed(3)})`
|
|
2368
|
+
);
|
|
2369
|
+
}
|
|
2370
|
+
}
|
|
2371
|
+
if (isUnlocked && targetBoosts.size > 0) {
|
|
2372
|
+
const cardTags = card.tags ?? [];
|
|
2373
|
+
let maxTargetBoost = 1;
|
|
2374
|
+
const boostedTargets = [];
|
|
2375
|
+
for (const tag of cardTags) {
|
|
2376
|
+
const boost = targetBoosts.get(tag);
|
|
2377
|
+
if (boost && boost > maxTargetBoost) {
|
|
2378
|
+
maxTargetBoost = boost;
|
|
2379
|
+
boostedTargets.push(tag);
|
|
2380
|
+
}
|
|
2381
|
+
}
|
|
2382
|
+
if (maxTargetBoost > 1) {
|
|
2383
|
+
finalScore *= maxTargetBoost;
|
|
2384
|
+
action = "boosted";
|
|
2385
|
+
finalReason = `${finalReason} | targetBoost \xD7${maxTargetBoost.toFixed(2)} for ${boostedTargets.join(", ")}`;
|
|
2386
|
+
logger.info(
|
|
2387
|
+
`[HierarchyDefinition] targetBoost \xD7${maxTargetBoost.toFixed(2)} applied to card ${card.cardId} via tags [${boostedTargets.join(", ")}] (score: ${card.score.toFixed(3)} \u2192 ${finalScore.toFixed(3)})`
|
|
2388
|
+
);
|
|
1930
2389
|
}
|
|
1931
2390
|
}
|
|
1932
2391
|
gated.push({
|
|
@@ -2114,12 +2573,12 @@ __export(interferenceMitigator_exports, {
|
|
|
2114
2573
|
default: () => InterferenceMitigatorNavigator
|
|
2115
2574
|
});
|
|
2116
2575
|
import { toCourseElo as toCourseElo4 } from "@vue-skuilder/common";
|
|
2117
|
-
var
|
|
2576
|
+
var DEFAULT_MIN_COUNT3, DEFAULT_MIN_ELAPSED_DAYS, DEFAULT_INTERFERENCE_DECAY, InterferenceMitigatorNavigator;
|
|
2118
2577
|
var init_interferenceMitigator = __esm({
|
|
2119
2578
|
"src/core/navigators/filters/interferenceMitigator.ts"() {
|
|
2120
2579
|
"use strict";
|
|
2121
2580
|
init_navigators();
|
|
2122
|
-
|
|
2581
|
+
DEFAULT_MIN_COUNT3 = 10;
|
|
2123
2582
|
DEFAULT_MIN_ELAPSED_DAYS = 3;
|
|
2124
2583
|
DEFAULT_INTERFERENCE_DECAY = 0.8;
|
|
2125
2584
|
InterferenceMitigatorNavigator = class extends ContentNavigator {
|
|
@@ -2144,7 +2603,7 @@ var init_interferenceMitigator = __esm({
|
|
|
2144
2603
|
return {
|
|
2145
2604
|
interferenceSets: sets,
|
|
2146
2605
|
maturityThreshold: {
|
|
2147
|
-
minCount: parsed.maturityThreshold?.minCount ??
|
|
2606
|
+
minCount: parsed.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT3,
|
|
2148
2607
|
minElo: parsed.maturityThreshold?.minElo,
|
|
2149
2608
|
minElapsedDays: parsed.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS
|
|
2150
2609
|
},
|
|
@@ -2154,7 +2613,7 @@ var init_interferenceMitigator = __esm({
|
|
|
2154
2613
|
return {
|
|
2155
2614
|
interferenceSets: [],
|
|
2156
2615
|
maturityThreshold: {
|
|
2157
|
-
minCount:
|
|
2616
|
+
minCount: DEFAULT_MIN_COUNT3,
|
|
2158
2617
|
minElapsedDays: DEFAULT_MIN_ELAPSED_DAYS
|
|
2159
2618
|
},
|
|
2160
2619
|
defaultDecay: DEFAULT_INTERFERENCE_DECAY
|
|
@@ -2201,7 +2660,7 @@ var init_interferenceMitigator = __esm({
|
|
|
2201
2660
|
try {
|
|
2202
2661
|
const courseReg = await context.user.getCourseRegDoc(context.course.getCourseID());
|
|
2203
2662
|
const userElo = toCourseElo4(courseReg.elo);
|
|
2204
|
-
const minCount = this.config.maturityThreshold?.minCount ??
|
|
2663
|
+
const minCount = this.config.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT3;
|
|
2205
2664
|
const minElo = this.config.maturityThreshold?.minElo;
|
|
2206
2665
|
const minElapsedDays = this.config.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS;
|
|
2207
2666
|
const minCountForElapsed = minElapsedDays * 2;
|
|
@@ -2436,7 +2895,7 @@ var init_relativePriority = __esm({
|
|
|
2436
2895
|
const cardTags = card.tags ?? [];
|
|
2437
2896
|
const priority = this.computeCardPriority(cardTags);
|
|
2438
2897
|
const boostFactor = this.computeBoostFactor(priority);
|
|
2439
|
-
const finalScore = Math.max(0,
|
|
2898
|
+
const finalScore = Math.max(0, card.score * boostFactor);
|
|
2440
2899
|
const action = boostFactor > 1 ? "boosted" : boostFactor < 1 ? "penalized" : "passed";
|
|
2441
2900
|
const reason = this.buildPriorityReason(cardTags, priority, boostFactor, finalScore);
|
|
2442
2901
|
return {
|
|
@@ -2712,160 +3171,1718 @@ var init_learning = __esm({
|
|
|
2712
3171
|
}
|
|
2713
3172
|
});
|
|
2714
3173
|
|
|
2715
|
-
// src/core/orchestration/signal.ts
|
|
2716
|
-
function computeOutcomeSignal(records, config = {}) {
|
|
2717
|
-
if (!records || records.length === 0) {
|
|
2718
|
-
return null;
|
|
3174
|
+
// src/core/orchestration/signal.ts
|
|
3175
|
+
function computeOutcomeSignal(records, config = {}) {
|
|
3176
|
+
if (!records || records.length === 0) {
|
|
3177
|
+
return null;
|
|
3178
|
+
}
|
|
3179
|
+
const target = config.targetAccuracy ?? 0.85;
|
|
3180
|
+
const tolerance = config.tolerance ?? 0.05;
|
|
3181
|
+
let correct = 0;
|
|
3182
|
+
for (const r of records) {
|
|
3183
|
+
if (r.isCorrect) correct++;
|
|
3184
|
+
}
|
|
3185
|
+
const accuracy = correct / records.length;
|
|
3186
|
+
return scoreAccuracyInZone(accuracy, target, tolerance);
|
|
3187
|
+
}
|
|
3188
|
+
function scoreAccuracyInZone(accuracy, target, tolerance) {
|
|
3189
|
+
const dist = Math.abs(accuracy - target);
|
|
3190
|
+
if (dist <= tolerance) {
|
|
3191
|
+
return 1;
|
|
3192
|
+
}
|
|
3193
|
+
const excess = dist - tolerance;
|
|
3194
|
+
const slope = 2.5;
|
|
3195
|
+
return Math.max(0, 1 - excess * slope);
|
|
3196
|
+
}
|
|
3197
|
+
var init_signal = __esm({
|
|
3198
|
+
"src/core/orchestration/signal.ts"() {
|
|
3199
|
+
"use strict";
|
|
3200
|
+
}
|
|
3201
|
+
});
|
|
3202
|
+
|
|
3203
|
+
// src/core/orchestration/recording.ts
|
|
3204
|
+
async function recordUserOutcome(context, periodStart, periodEnd, records, activeStrategyIds, eloStart = 0, eloEnd = 0, config) {
|
|
3205
|
+
const { user, course, userId } = context;
|
|
3206
|
+
const courseId = course.getCourseID();
|
|
3207
|
+
const outcomeValue = computeOutcomeSignal(records, config);
|
|
3208
|
+
if (outcomeValue === null) {
|
|
3209
|
+
logger.debug(
|
|
3210
|
+
`[Orchestration] No outcome signal computed for ${userId} (insufficient data). Skipping record.`
|
|
3211
|
+
);
|
|
3212
|
+
return;
|
|
3213
|
+
}
|
|
3214
|
+
const deviations = {};
|
|
3215
|
+
for (const strategyId of activeStrategyIds) {
|
|
3216
|
+
deviations[strategyId] = context.getDeviation(strategyId);
|
|
3217
|
+
}
|
|
3218
|
+
const id = `USER_OUTCOME::${courseId}::${userId}::${periodEnd}`;
|
|
3219
|
+
const record = {
|
|
3220
|
+
_id: id,
|
|
3221
|
+
docType: "USER_OUTCOME" /* USER_OUTCOME */,
|
|
3222
|
+
courseId,
|
|
3223
|
+
userId,
|
|
3224
|
+
periodStart,
|
|
3225
|
+
periodEnd,
|
|
3226
|
+
outcomeValue,
|
|
3227
|
+
deviations,
|
|
3228
|
+
metadata: {
|
|
3229
|
+
sessionsCount: 1,
|
|
3230
|
+
// Assumes recording is triggered per-session currently
|
|
3231
|
+
cardsSeen: records.length,
|
|
3232
|
+
eloStart,
|
|
3233
|
+
eloEnd,
|
|
3234
|
+
signalType: "accuracy_in_zone"
|
|
3235
|
+
}
|
|
3236
|
+
};
|
|
3237
|
+
try {
|
|
3238
|
+
await user.putUserOutcome(record);
|
|
3239
|
+
logger.debug(
|
|
3240
|
+
`[Orchestration] Recorded outcome ${outcomeValue.toFixed(3)} for ${userId} (doc: ${id})`
|
|
3241
|
+
);
|
|
3242
|
+
} catch (e) {
|
|
3243
|
+
logger.error(`[Orchestration] Failed to record outcome: ${e}`);
|
|
3244
|
+
}
|
|
3245
|
+
}
|
|
3246
|
+
var init_recording = __esm({
|
|
3247
|
+
"src/core/orchestration/recording.ts"() {
|
|
3248
|
+
"use strict";
|
|
3249
|
+
init_signal();
|
|
3250
|
+
init_types_legacy();
|
|
3251
|
+
init_logger();
|
|
3252
|
+
}
|
|
3253
|
+
});
|
|
3254
|
+
|
|
3255
|
+
// src/core/orchestration/index.ts
|
|
3256
|
+
function fnv1a(str) {
|
|
3257
|
+
let hash = 2166136261;
|
|
3258
|
+
for (let i = 0; i < str.length; i++) {
|
|
3259
|
+
hash ^= str.charCodeAt(i);
|
|
3260
|
+
hash = Math.imul(hash, 16777619);
|
|
3261
|
+
}
|
|
3262
|
+
return hash >>> 0;
|
|
3263
|
+
}
|
|
3264
|
+
function computeDeviation(userId, strategyId, salt) {
|
|
3265
|
+
const input = `${userId}:${strategyId}:${salt}`;
|
|
3266
|
+
const hash = fnv1a(input);
|
|
3267
|
+
const normalized = hash / 4294967296;
|
|
3268
|
+
return normalized * 2 - 1;
|
|
3269
|
+
}
|
|
3270
|
+
function computeSpread(confidence) {
|
|
3271
|
+
const clampedConfidence = Math.max(0, Math.min(1, confidence));
|
|
3272
|
+
return MAX_SPREAD - clampedConfidence * (MAX_SPREAD - MIN_SPREAD);
|
|
3273
|
+
}
|
|
3274
|
+
function computeEffectiveWeight(learnable, userId, strategyId, salt) {
|
|
3275
|
+
const deviation = computeDeviation(userId, strategyId, salt);
|
|
3276
|
+
const spread = computeSpread(learnable.confidence);
|
|
3277
|
+
const adjustment = deviation * spread * learnable.weight;
|
|
3278
|
+
const effective = learnable.weight + adjustment;
|
|
3279
|
+
return Math.max(MIN_WEIGHT, Math.min(MAX_WEIGHT, effective));
|
|
3280
|
+
}
|
|
3281
|
+
async function createOrchestrationContext(user, course) {
|
|
3282
|
+
let courseConfig;
|
|
3283
|
+
try {
|
|
3284
|
+
courseConfig = await course.getCourseConfig();
|
|
3285
|
+
} catch (e) {
|
|
3286
|
+
logger.error(`[Orchestration] Failed to load course config: ${e}`);
|
|
3287
|
+
courseConfig = {
|
|
3288
|
+
name: "Unknown",
|
|
3289
|
+
description: "",
|
|
3290
|
+
public: false,
|
|
3291
|
+
deleted: false,
|
|
3292
|
+
creator: "",
|
|
3293
|
+
admins: [],
|
|
3294
|
+
moderators: [],
|
|
3295
|
+
dataShapes: [],
|
|
3296
|
+
questionTypes: [],
|
|
3297
|
+
orchestration: { salt: "default" }
|
|
3298
|
+
};
|
|
3299
|
+
}
|
|
3300
|
+
const userId = user.getUsername();
|
|
3301
|
+
const salt = courseConfig.orchestration?.salt || "default_salt";
|
|
3302
|
+
return {
|
|
3303
|
+
user,
|
|
3304
|
+
course,
|
|
3305
|
+
userId,
|
|
3306
|
+
courseConfig,
|
|
3307
|
+
getEffectiveWeight(strategyId, learnable) {
|
|
3308
|
+
return computeEffectiveWeight(learnable, userId, strategyId, salt);
|
|
3309
|
+
},
|
|
3310
|
+
getDeviation(strategyId) {
|
|
3311
|
+
return computeDeviation(userId, strategyId, salt);
|
|
3312
|
+
}
|
|
3313
|
+
};
|
|
3314
|
+
}
|
|
3315
|
+
var MIN_SPREAD, MAX_SPREAD, MIN_WEIGHT, MAX_WEIGHT;
|
|
3316
|
+
var init_orchestration = __esm({
|
|
3317
|
+
"src/core/orchestration/index.ts"() {
|
|
3318
|
+
"use strict";
|
|
3319
|
+
init_logger();
|
|
3320
|
+
init_gradient();
|
|
3321
|
+
init_learning();
|
|
3322
|
+
init_signal();
|
|
3323
|
+
init_recording();
|
|
3324
|
+
MIN_SPREAD = 0.1;
|
|
3325
|
+
MAX_SPREAD = 0.5;
|
|
3326
|
+
MIN_WEIGHT = 0.1;
|
|
3327
|
+
MAX_WEIGHT = 3;
|
|
3328
|
+
}
|
|
3329
|
+
});
|
|
3330
|
+
|
|
3331
|
+
// src/study/SpacedRepetition.ts
|
|
3332
|
+
import moment4 from "moment";
|
|
3333
|
+
import { isTaggedPerformance } from "@vue-skuilder/common";
|
|
3334
|
+
var duration;
|
|
3335
|
+
var init_SpacedRepetition = __esm({
|
|
3336
|
+
"src/study/SpacedRepetition.ts"() {
|
|
3337
|
+
"use strict";
|
|
3338
|
+
init_util();
|
|
3339
|
+
init_logger();
|
|
3340
|
+
duration = moment4.duration;
|
|
3341
|
+
}
|
|
3342
|
+
});
|
|
3343
|
+
|
|
3344
|
+
// src/study/services/SrsService.ts
|
|
3345
|
+
import moment5 from "moment";
|
|
3346
|
+
var init_SrsService = __esm({
|
|
3347
|
+
"src/study/services/SrsService.ts"() {
|
|
3348
|
+
"use strict";
|
|
3349
|
+
init_couch();
|
|
3350
|
+
init_SpacedRepetition();
|
|
3351
|
+
init_logger();
|
|
3352
|
+
}
|
|
3353
|
+
});
|
|
3354
|
+
|
|
3355
|
+
// src/study/services/EloService.ts
|
|
3356
|
+
import {
|
|
3357
|
+
adjustCourseScores,
|
|
3358
|
+
adjustCourseScoresPerTag,
|
|
3359
|
+
toCourseElo as toCourseElo5
|
|
3360
|
+
} from "@vue-skuilder/common";
|
|
3361
|
+
var init_EloService = __esm({
|
|
3362
|
+
"src/study/services/EloService.ts"() {
|
|
3363
|
+
"use strict";
|
|
3364
|
+
init_logger();
|
|
3365
|
+
}
|
|
3366
|
+
});
|
|
3367
|
+
|
|
3368
|
+
// src/study/services/ResponseProcessor.ts
|
|
3369
|
+
import { isTaggedPerformance as isTaggedPerformance2 } from "@vue-skuilder/common";
|
|
3370
|
+
var init_ResponseProcessor = __esm({
|
|
3371
|
+
"src/study/services/ResponseProcessor.ts"() {
|
|
3372
|
+
"use strict";
|
|
3373
|
+
init_core();
|
|
3374
|
+
init_logger();
|
|
3375
|
+
}
|
|
3376
|
+
});
|
|
3377
|
+
|
|
3378
|
+
// src/study/services/CardHydrationService.ts
|
|
3379
|
+
import {
|
|
3380
|
+
displayableDataToViewData,
|
|
3381
|
+
isCourseElo,
|
|
3382
|
+
toCourseElo as toCourseElo6
|
|
3383
|
+
} from "@vue-skuilder/common";
|
|
3384
|
+
var init_CardHydrationService = __esm({
|
|
3385
|
+
"src/study/services/CardHydrationService.ts"() {
|
|
3386
|
+
"use strict";
|
|
3387
|
+
init_logger();
|
|
3388
|
+
}
|
|
3389
|
+
});
|
|
3390
|
+
|
|
3391
|
+
// src/study/ItemQueue.ts
|
|
3392
|
+
var init_ItemQueue = __esm({
|
|
3393
|
+
"src/study/ItemQueue.ts"() {
|
|
3394
|
+
"use strict";
|
|
3395
|
+
}
|
|
3396
|
+
});
|
|
3397
|
+
|
|
3398
|
+
// src/util/packer/types.ts
|
|
3399
|
+
var init_types3 = __esm({
|
|
3400
|
+
"src/util/packer/types.ts"() {
|
|
3401
|
+
"use strict";
|
|
3402
|
+
}
|
|
3403
|
+
});
|
|
3404
|
+
|
|
3405
|
+
// src/util/packer/CouchDBToStaticPacker.ts
|
|
3406
|
+
var init_CouchDBToStaticPacker = __esm({
|
|
3407
|
+
"src/util/packer/CouchDBToStaticPacker.ts"() {
|
|
3408
|
+
"use strict";
|
|
3409
|
+
init_types_legacy();
|
|
3410
|
+
init_logger();
|
|
3411
|
+
}
|
|
3412
|
+
});
|
|
3413
|
+
|
|
3414
|
+
// src/util/packer/index.ts
|
|
3415
|
+
var init_packer = __esm({
|
|
3416
|
+
"src/util/packer/index.ts"() {
|
|
3417
|
+
"use strict";
|
|
3418
|
+
init_types3();
|
|
3419
|
+
init_CouchDBToStaticPacker();
|
|
3420
|
+
}
|
|
3421
|
+
});
|
|
3422
|
+
|
|
3423
|
+
// src/util/migrator/types.ts
|
|
3424
|
+
var DEFAULT_MIGRATION_OPTIONS;
|
|
3425
|
+
var init_types4 = __esm({
|
|
3426
|
+
"src/util/migrator/types.ts"() {
|
|
3427
|
+
"use strict";
|
|
3428
|
+
DEFAULT_MIGRATION_OPTIONS = {
|
|
3429
|
+
chunkBatchSize: 100,
|
|
3430
|
+
validateRoundTrip: false,
|
|
3431
|
+
cleanupOnFailure: true,
|
|
3432
|
+
timeout: 3e5
|
|
3433
|
+
// 5 minutes
|
|
3434
|
+
};
|
|
3435
|
+
}
|
|
3436
|
+
});
|
|
3437
|
+
|
|
3438
|
+
// src/util/migrator/FileSystemAdapter.ts
|
|
3439
|
+
var FileSystemError;
|
|
3440
|
+
var init_FileSystemAdapter = __esm({
|
|
3441
|
+
"src/util/migrator/FileSystemAdapter.ts"() {
|
|
3442
|
+
"use strict";
|
|
3443
|
+
FileSystemError = class extends Error {
|
|
3444
|
+
constructor(message, operation, filePath, cause) {
|
|
3445
|
+
super(message);
|
|
3446
|
+
this.operation = operation;
|
|
3447
|
+
this.filePath = filePath;
|
|
3448
|
+
this.cause = cause;
|
|
3449
|
+
this.name = "FileSystemError";
|
|
3450
|
+
}
|
|
3451
|
+
};
|
|
3452
|
+
}
|
|
3453
|
+
});
|
|
3454
|
+
|
|
3455
|
+
// src/util/migrator/validation.ts
|
|
3456
|
+
async function validateStaticCourse(staticPath, fs) {
|
|
3457
|
+
const validation = {
|
|
3458
|
+
valid: true,
|
|
3459
|
+
manifestExists: false,
|
|
3460
|
+
chunksExist: false,
|
|
3461
|
+
attachmentsExist: false,
|
|
3462
|
+
errors: [],
|
|
3463
|
+
warnings: []
|
|
3464
|
+
};
|
|
3465
|
+
try {
|
|
3466
|
+
if (fs) {
|
|
3467
|
+
const stats = await fs.stat(staticPath);
|
|
3468
|
+
if (!stats.isDirectory()) {
|
|
3469
|
+
validation.errors.push(`Path is not a directory: ${staticPath}`);
|
|
3470
|
+
validation.valid = false;
|
|
3471
|
+
return validation;
|
|
3472
|
+
}
|
|
3473
|
+
} else if (!nodeFS) {
|
|
3474
|
+
validation.errors.push("File system access not available - validation skipped");
|
|
3475
|
+
validation.valid = false;
|
|
3476
|
+
return validation;
|
|
3477
|
+
} else {
|
|
3478
|
+
const stats = await nodeFS.promises.stat(staticPath);
|
|
3479
|
+
if (!stats.isDirectory()) {
|
|
3480
|
+
validation.errors.push(`Path is not a directory: ${staticPath}`);
|
|
3481
|
+
validation.valid = false;
|
|
3482
|
+
return validation;
|
|
3483
|
+
}
|
|
3484
|
+
}
|
|
3485
|
+
let manifestPath = `${staticPath}/manifest.json`;
|
|
3486
|
+
try {
|
|
3487
|
+
if (fs) {
|
|
3488
|
+
manifestPath = fs.joinPath(staticPath, "manifest.json");
|
|
3489
|
+
if (await fs.exists(manifestPath)) {
|
|
3490
|
+
validation.manifestExists = true;
|
|
3491
|
+
const manifestContent = await fs.readFile(manifestPath);
|
|
3492
|
+
const manifest = JSON.parse(manifestContent);
|
|
3493
|
+
validation.courseId = manifest.courseId;
|
|
3494
|
+
validation.courseName = manifest.courseName;
|
|
3495
|
+
if (!manifest.version || !manifest.courseId || !manifest.chunks || !Array.isArray(manifest.chunks)) {
|
|
3496
|
+
validation.errors.push("Invalid manifest structure");
|
|
3497
|
+
validation.valid = false;
|
|
3498
|
+
}
|
|
3499
|
+
} else {
|
|
3500
|
+
validation.errors.push(`Manifest not found: ${manifestPath}`);
|
|
3501
|
+
validation.valid = false;
|
|
3502
|
+
}
|
|
3503
|
+
} else {
|
|
3504
|
+
manifestPath = `${staticPath}/manifest.json`;
|
|
3505
|
+
await nodeFS.promises.access(manifestPath);
|
|
3506
|
+
validation.manifestExists = true;
|
|
3507
|
+
const manifestContent = await nodeFS.promises.readFile(manifestPath, "utf8");
|
|
3508
|
+
const manifest = JSON.parse(manifestContent);
|
|
3509
|
+
validation.courseId = manifest.courseId;
|
|
3510
|
+
validation.courseName = manifest.courseName;
|
|
3511
|
+
if (!manifest.version || !manifest.courseId || !manifest.chunks || !Array.isArray(manifest.chunks)) {
|
|
3512
|
+
validation.errors.push("Invalid manifest structure");
|
|
3513
|
+
validation.valid = false;
|
|
3514
|
+
}
|
|
3515
|
+
}
|
|
3516
|
+
} catch (error) {
|
|
3517
|
+
const errorMessage = error instanceof FileSystemError ? error.message : `Manifest not found or invalid: ${manifestPath}`;
|
|
3518
|
+
validation.errors.push(errorMessage);
|
|
3519
|
+
validation.valid = false;
|
|
3520
|
+
}
|
|
3521
|
+
let chunksPath = `${staticPath}/chunks`;
|
|
3522
|
+
try {
|
|
3523
|
+
if (fs) {
|
|
3524
|
+
chunksPath = fs.joinPath(staticPath, "chunks");
|
|
3525
|
+
if (await fs.exists(chunksPath)) {
|
|
3526
|
+
const chunksStats = await fs.stat(chunksPath);
|
|
3527
|
+
if (chunksStats.isDirectory()) {
|
|
3528
|
+
validation.chunksExist = true;
|
|
3529
|
+
} else {
|
|
3530
|
+
validation.errors.push(`Chunks path is not a directory: ${chunksPath}`);
|
|
3531
|
+
validation.valid = false;
|
|
3532
|
+
}
|
|
3533
|
+
} else {
|
|
3534
|
+
validation.errors.push(`Chunks directory not found: ${chunksPath}`);
|
|
3535
|
+
validation.valid = false;
|
|
3536
|
+
}
|
|
3537
|
+
} else {
|
|
3538
|
+
chunksPath = `${staticPath}/chunks`;
|
|
3539
|
+
const chunksStats = await nodeFS.promises.stat(chunksPath);
|
|
3540
|
+
if (chunksStats.isDirectory()) {
|
|
3541
|
+
validation.chunksExist = true;
|
|
3542
|
+
} else {
|
|
3543
|
+
validation.errors.push(`Chunks path is not a directory: ${chunksPath}`);
|
|
3544
|
+
validation.valid = false;
|
|
3545
|
+
}
|
|
3546
|
+
}
|
|
3547
|
+
} catch (error) {
|
|
3548
|
+
const errorMessage = error instanceof FileSystemError ? error.message : `Chunks directory not found: ${chunksPath}`;
|
|
3549
|
+
validation.errors.push(errorMessage);
|
|
3550
|
+
validation.valid = false;
|
|
3551
|
+
}
|
|
3552
|
+
let attachmentsPath;
|
|
3553
|
+
try {
|
|
3554
|
+
if (fs) {
|
|
3555
|
+
attachmentsPath = fs.joinPath(staticPath, "attachments");
|
|
3556
|
+
if (await fs.exists(attachmentsPath)) {
|
|
3557
|
+
const attachmentsStats = await fs.stat(attachmentsPath);
|
|
3558
|
+
if (attachmentsStats.isDirectory()) {
|
|
3559
|
+
validation.attachmentsExist = true;
|
|
3560
|
+
}
|
|
3561
|
+
} else {
|
|
3562
|
+
validation.warnings.push(
|
|
3563
|
+
`Attachments directory not found: ${attachmentsPath} (this is OK if course has no attachments)`
|
|
3564
|
+
);
|
|
3565
|
+
}
|
|
3566
|
+
} else {
|
|
3567
|
+
attachmentsPath = `${staticPath}/attachments`;
|
|
3568
|
+
const attachmentsStats = await nodeFS.promises.stat(attachmentsPath);
|
|
3569
|
+
if (attachmentsStats.isDirectory()) {
|
|
3570
|
+
validation.attachmentsExist = true;
|
|
3571
|
+
}
|
|
3572
|
+
}
|
|
3573
|
+
} catch (error) {
|
|
3574
|
+
attachmentsPath = attachmentsPath || `${staticPath}/attachments`;
|
|
3575
|
+
const warningMessage = error instanceof FileSystemError ? error.message : `Attachments directory not found: ${attachmentsPath} (this is OK if course has no attachments)`;
|
|
3576
|
+
validation.warnings.push(warningMessage);
|
|
3577
|
+
}
|
|
3578
|
+
} catch (error) {
|
|
3579
|
+
validation.errors.push(
|
|
3580
|
+
`Failed to validate static course: ${error instanceof Error ? error.message : String(error)}`
|
|
3581
|
+
);
|
|
3582
|
+
validation.valid = false;
|
|
3583
|
+
}
|
|
3584
|
+
return validation;
|
|
3585
|
+
}
|
|
3586
|
+
async function validateMigration(targetDB, expectedCounts, manifest) {
|
|
3587
|
+
const validation = {
|
|
3588
|
+
valid: true,
|
|
3589
|
+
documentCountMatch: false,
|
|
3590
|
+
attachmentIntegrity: false,
|
|
3591
|
+
viewFunctionality: false,
|
|
3592
|
+
issues: []
|
|
3593
|
+
};
|
|
3594
|
+
try {
|
|
3595
|
+
logger.info("Starting migration validation...");
|
|
3596
|
+
const actualCounts = await getActualDocumentCounts(targetDB);
|
|
3597
|
+
validation.documentCountMatch = compareDocumentCounts(
|
|
3598
|
+
expectedCounts,
|
|
3599
|
+
actualCounts,
|
|
3600
|
+
validation.issues
|
|
3601
|
+
);
|
|
3602
|
+
await validateCourseConfig(targetDB, manifest, validation.issues);
|
|
3603
|
+
validation.viewFunctionality = await validateViews(targetDB, manifest, validation.issues);
|
|
3604
|
+
validation.attachmentIntegrity = await validateAttachmentIntegrity(targetDB, validation.issues);
|
|
3605
|
+
validation.valid = validation.documentCountMatch && validation.viewFunctionality && validation.attachmentIntegrity;
|
|
3606
|
+
logger.info(`Migration validation completed. Valid: ${validation.valid}`);
|
|
3607
|
+
if (validation.issues.length > 0) {
|
|
3608
|
+
logger.info(`Validation issues: ${validation.issues.length}`);
|
|
3609
|
+
validation.issues.forEach((issue) => {
|
|
3610
|
+
if (issue.type === "error") {
|
|
3611
|
+
logger.error(`${issue.category}: ${issue.message}`);
|
|
3612
|
+
} else {
|
|
3613
|
+
logger.warn(`${issue.category}: ${issue.message}`);
|
|
3614
|
+
}
|
|
3615
|
+
});
|
|
3616
|
+
}
|
|
3617
|
+
} catch (error) {
|
|
3618
|
+
validation.valid = false;
|
|
3619
|
+
validation.issues.push({
|
|
3620
|
+
type: "error",
|
|
3621
|
+
category: "metadata",
|
|
3622
|
+
message: `Validation failed: ${error instanceof Error ? error.message : String(error)}`
|
|
3623
|
+
});
|
|
3624
|
+
}
|
|
3625
|
+
return validation;
|
|
3626
|
+
}
|
|
3627
|
+
async function getActualDocumentCounts(db) {
|
|
3628
|
+
const counts = {};
|
|
3629
|
+
try {
|
|
3630
|
+
const allDocs = await db.allDocs({ include_docs: true });
|
|
3631
|
+
for (const row of allDocs.rows) {
|
|
3632
|
+
if (row.id.startsWith("_design/")) {
|
|
3633
|
+
counts["_design"] = (counts["_design"] || 0) + 1;
|
|
3634
|
+
continue;
|
|
3635
|
+
}
|
|
3636
|
+
const doc = row.doc;
|
|
3637
|
+
if (doc && doc.docType) {
|
|
3638
|
+
counts[doc.docType] = (counts[doc.docType] || 0) + 1;
|
|
3639
|
+
} else {
|
|
3640
|
+
counts["unknown"] = (counts["unknown"] || 0) + 1;
|
|
3641
|
+
}
|
|
3642
|
+
}
|
|
3643
|
+
} catch (error) {
|
|
3644
|
+
logger.error("Failed to get actual document counts:", error);
|
|
3645
|
+
}
|
|
3646
|
+
return counts;
|
|
3647
|
+
}
|
|
3648
|
+
function compareDocumentCounts(expected, actual, issues) {
|
|
3649
|
+
let countsMatch = true;
|
|
3650
|
+
for (const [docType, expectedCount] of Object.entries(expected)) {
|
|
3651
|
+
const actualCount = actual[docType] || 0;
|
|
3652
|
+
if (actualCount !== expectedCount) {
|
|
3653
|
+
countsMatch = false;
|
|
3654
|
+
issues.push({
|
|
3655
|
+
type: "error",
|
|
3656
|
+
category: "documents",
|
|
3657
|
+
message: `Document count mismatch for ${docType}: expected ${expectedCount}, got ${actualCount}`
|
|
3658
|
+
});
|
|
3659
|
+
}
|
|
3660
|
+
}
|
|
3661
|
+
for (const [docType, actualCount] of Object.entries(actual)) {
|
|
3662
|
+
if (!expected[docType] && docType !== "_design") {
|
|
3663
|
+
issues.push({
|
|
3664
|
+
type: "warning",
|
|
3665
|
+
category: "documents",
|
|
3666
|
+
message: `Unexpected document type found: ${docType} (${actualCount} documents)`
|
|
3667
|
+
});
|
|
3668
|
+
}
|
|
3669
|
+
}
|
|
3670
|
+
return countsMatch;
|
|
3671
|
+
}
|
|
3672
|
+
async function validateCourseConfig(db, manifest, issues) {
|
|
3673
|
+
try {
|
|
3674
|
+
const courseConfig = await db.get("CourseConfig");
|
|
3675
|
+
if (!courseConfig) {
|
|
3676
|
+
issues.push({
|
|
3677
|
+
type: "error",
|
|
3678
|
+
category: "course_config",
|
|
3679
|
+
message: "CourseConfig document not found after migration"
|
|
3680
|
+
});
|
|
3681
|
+
return;
|
|
3682
|
+
}
|
|
3683
|
+
if (!courseConfig.courseID) {
|
|
3684
|
+
issues.push({
|
|
3685
|
+
type: "warning",
|
|
3686
|
+
category: "course_config",
|
|
3687
|
+
message: "CourseConfig document missing courseID field"
|
|
3688
|
+
});
|
|
3689
|
+
}
|
|
3690
|
+
if (courseConfig.courseID !== manifest.courseId) {
|
|
3691
|
+
issues.push({
|
|
3692
|
+
type: "warning",
|
|
3693
|
+
category: "course_config",
|
|
3694
|
+
message: `CourseConfig courseID mismatch: expected ${manifest.courseId}, got ${courseConfig.courseID}`
|
|
3695
|
+
});
|
|
3696
|
+
}
|
|
3697
|
+
logger.debug("CourseConfig document validation passed");
|
|
3698
|
+
} catch (error) {
|
|
3699
|
+
if (error.status === 404) {
|
|
3700
|
+
issues.push({
|
|
3701
|
+
type: "error",
|
|
3702
|
+
category: "course_config",
|
|
3703
|
+
message: "CourseConfig document not found in database"
|
|
3704
|
+
});
|
|
3705
|
+
} else {
|
|
3706
|
+
issues.push({
|
|
3707
|
+
type: "error",
|
|
3708
|
+
category: "course_config",
|
|
3709
|
+
message: `Failed to validate CourseConfig document: ${error instanceof Error ? error.message : String(error)}`
|
|
3710
|
+
});
|
|
3711
|
+
}
|
|
3712
|
+
}
|
|
3713
|
+
}
|
|
3714
|
+
async function validateViews(db, manifest, issues) {
|
|
3715
|
+
let viewsValid = true;
|
|
3716
|
+
try {
|
|
3717
|
+
for (const designDoc of manifest.designDocs) {
|
|
3718
|
+
try {
|
|
3719
|
+
const doc = await db.get(designDoc._id);
|
|
3720
|
+
if (!doc) {
|
|
3721
|
+
viewsValid = false;
|
|
3722
|
+
issues.push({
|
|
3723
|
+
type: "error",
|
|
3724
|
+
category: "views",
|
|
3725
|
+
message: `Design document not found: ${designDoc._id}`
|
|
3726
|
+
});
|
|
3727
|
+
continue;
|
|
3728
|
+
}
|
|
3729
|
+
for (const viewName of Object.keys(designDoc.views)) {
|
|
3730
|
+
try {
|
|
3731
|
+
const viewPath = `${designDoc._id}/${viewName}`;
|
|
3732
|
+
await db.query(viewPath, { limit: 1 });
|
|
3733
|
+
} catch (viewError) {
|
|
3734
|
+
viewsValid = false;
|
|
3735
|
+
issues.push({
|
|
3736
|
+
type: "error",
|
|
3737
|
+
category: "views",
|
|
3738
|
+
message: `View not accessible: ${designDoc._id}/${viewName} - ${viewError}`
|
|
3739
|
+
});
|
|
3740
|
+
}
|
|
3741
|
+
}
|
|
3742
|
+
} catch (error) {
|
|
3743
|
+
viewsValid = false;
|
|
3744
|
+
issues.push({
|
|
3745
|
+
type: "error",
|
|
3746
|
+
category: "views",
|
|
3747
|
+
message: `Failed to validate design document ${designDoc._id}: ${error}`
|
|
3748
|
+
});
|
|
3749
|
+
}
|
|
3750
|
+
}
|
|
3751
|
+
} catch (error) {
|
|
3752
|
+
viewsValid = false;
|
|
3753
|
+
issues.push({
|
|
3754
|
+
type: "error",
|
|
3755
|
+
category: "views",
|
|
3756
|
+
message: `View validation failed: ${error instanceof Error ? error.message : String(error)}`
|
|
3757
|
+
});
|
|
3758
|
+
}
|
|
3759
|
+
return viewsValid;
|
|
3760
|
+
}
|
|
3761
|
+
async function validateAttachmentIntegrity(db, issues) {
|
|
3762
|
+
let attachmentsValid = true;
|
|
3763
|
+
try {
|
|
3764
|
+
const allDocs = await db.allDocs({
|
|
3765
|
+
include_docs: true,
|
|
3766
|
+
limit: 10
|
|
3767
|
+
// Sample first 10 documents for performance
|
|
3768
|
+
});
|
|
3769
|
+
let attachmentCount = 0;
|
|
3770
|
+
let validAttachments = 0;
|
|
3771
|
+
for (const row of allDocs.rows) {
|
|
3772
|
+
const doc = row.doc;
|
|
3773
|
+
if (doc && doc._attachments) {
|
|
3774
|
+
for (const [attachmentName, _attachmentMeta] of Object.entries(doc._attachments)) {
|
|
3775
|
+
attachmentCount++;
|
|
3776
|
+
try {
|
|
3777
|
+
const attachment = await db.getAttachment(doc._id, attachmentName);
|
|
3778
|
+
if (attachment) {
|
|
3779
|
+
validAttachments++;
|
|
3780
|
+
}
|
|
3781
|
+
} catch (attachmentError) {
|
|
3782
|
+
attachmentsValid = false;
|
|
3783
|
+
issues.push({
|
|
3784
|
+
type: "error",
|
|
3785
|
+
category: "attachments",
|
|
3786
|
+
message: `Attachment not accessible: ${doc._id}/${attachmentName} - ${attachmentError}`
|
|
3787
|
+
});
|
|
3788
|
+
}
|
|
3789
|
+
}
|
|
3790
|
+
}
|
|
3791
|
+
}
|
|
3792
|
+
if (attachmentCount === 0) {
|
|
3793
|
+
issues.push({
|
|
3794
|
+
type: "warning",
|
|
3795
|
+
category: "attachments",
|
|
3796
|
+
message: "No attachments found in sampled documents"
|
|
3797
|
+
});
|
|
3798
|
+
} else {
|
|
3799
|
+
logger.info(`Validated ${validAttachments}/${attachmentCount} sampled attachments`);
|
|
3800
|
+
}
|
|
3801
|
+
} catch (error) {
|
|
3802
|
+
attachmentsValid = false;
|
|
3803
|
+
issues.push({
|
|
3804
|
+
type: "error",
|
|
3805
|
+
category: "attachments",
|
|
3806
|
+
message: `Attachment validation failed: ${error instanceof Error ? error.message : String(error)}`
|
|
3807
|
+
});
|
|
3808
|
+
}
|
|
3809
|
+
return attachmentsValid;
|
|
3810
|
+
}
|
|
3811
|
+
var nodeFS;
|
|
3812
|
+
var init_validation = __esm({
|
|
3813
|
+
"src/util/migrator/validation.ts"() {
|
|
3814
|
+
"use strict";
|
|
3815
|
+
init_logger();
|
|
3816
|
+
init_FileSystemAdapter();
|
|
3817
|
+
nodeFS = null;
|
|
3818
|
+
try {
|
|
3819
|
+
if (typeof window === "undefined" && typeof process !== "undefined" && process.versions?.node) {
|
|
3820
|
+
nodeFS = eval("require")("fs");
|
|
3821
|
+
nodeFS.promises = nodeFS.promises || eval("require")("fs").promises;
|
|
3822
|
+
}
|
|
3823
|
+
} catch {
|
|
3824
|
+
}
|
|
3825
|
+
}
|
|
3826
|
+
});
|
|
3827
|
+
|
|
3828
|
+
// src/util/migrator/StaticToCouchDBMigrator.ts
|
|
3829
|
+
var nodeFS2, nodePath, StaticToCouchDBMigrator;
|
|
3830
|
+
var init_StaticToCouchDBMigrator = __esm({
|
|
3831
|
+
"src/util/migrator/StaticToCouchDBMigrator.ts"() {
|
|
3832
|
+
"use strict";
|
|
3833
|
+
init_logger();
|
|
3834
|
+
init_types4();
|
|
3835
|
+
init_validation();
|
|
3836
|
+
init_FileSystemAdapter();
|
|
3837
|
+
nodeFS2 = null;
|
|
3838
|
+
nodePath = null;
|
|
3839
|
+
try {
|
|
3840
|
+
if (typeof window === "undefined" && typeof process !== "undefined" && process.versions?.node) {
|
|
3841
|
+
nodeFS2 = eval("require")("fs");
|
|
3842
|
+
nodePath = eval("require")("path");
|
|
3843
|
+
nodeFS2.promises = nodeFS2.promises || eval("require")("fs").promises;
|
|
3844
|
+
}
|
|
3845
|
+
} catch {
|
|
3846
|
+
}
|
|
3847
|
+
StaticToCouchDBMigrator = class {
|
|
3848
|
+
options;
|
|
3849
|
+
progressCallback;
|
|
3850
|
+
fs;
|
|
3851
|
+
constructor(options = {}, fileSystemAdapter) {
|
|
3852
|
+
this.options = {
|
|
3853
|
+
...DEFAULT_MIGRATION_OPTIONS,
|
|
3854
|
+
...options
|
|
3855
|
+
};
|
|
3856
|
+
this.fs = fileSystemAdapter;
|
|
3857
|
+
}
|
|
3858
|
+
/**
|
|
3859
|
+
* Set a progress callback to receive updates during migration
|
|
3860
|
+
*/
|
|
3861
|
+
setProgressCallback(callback) {
|
|
3862
|
+
this.progressCallback = callback;
|
|
3863
|
+
}
|
|
3864
|
+
/**
|
|
3865
|
+
* Migrate a static course to CouchDB
|
|
3866
|
+
*/
|
|
3867
|
+
async migrateCourse(staticPath, targetDB) {
|
|
3868
|
+
const startTime = Date.now();
|
|
3869
|
+
const result = {
|
|
3870
|
+
success: false,
|
|
3871
|
+
documentsRestored: 0,
|
|
3872
|
+
attachmentsRestored: 0,
|
|
3873
|
+
designDocsRestored: 0,
|
|
3874
|
+
courseConfigRestored: 0,
|
|
3875
|
+
errors: [],
|
|
3876
|
+
warnings: [],
|
|
3877
|
+
migrationTime: 0
|
|
3878
|
+
};
|
|
3879
|
+
try {
|
|
3880
|
+
logger.info(`Starting migration from ${staticPath} to CouchDB`);
|
|
3881
|
+
this.reportProgress("manifest", 0, 1, "Validating static course...");
|
|
3882
|
+
const validation = await validateStaticCourse(staticPath, this.fs);
|
|
3883
|
+
if (!validation.valid) {
|
|
3884
|
+
result.errors.push(...validation.errors);
|
|
3885
|
+
throw new Error(`Static course validation failed: ${validation.errors.join(", ")}`);
|
|
3886
|
+
}
|
|
3887
|
+
result.warnings.push(...validation.warnings);
|
|
3888
|
+
this.reportProgress("manifest", 1, 1, "Loading course manifest...");
|
|
3889
|
+
const manifest = await this.loadManifest(staticPath);
|
|
3890
|
+
logger.info(`Loaded manifest for course: ${manifest.courseId} (${manifest.courseName})`);
|
|
3891
|
+
this.reportProgress(
|
|
3892
|
+
"design_docs",
|
|
3893
|
+
0,
|
|
3894
|
+
manifest.designDocs.length,
|
|
3895
|
+
"Restoring design documents..."
|
|
3896
|
+
);
|
|
3897
|
+
const designDocResults = await this.restoreDesignDocuments(manifest.designDocs, targetDB);
|
|
3898
|
+
result.designDocsRestored = designDocResults.restored;
|
|
3899
|
+
result.errors.push(...designDocResults.errors);
|
|
3900
|
+
result.warnings.push(...designDocResults.warnings);
|
|
3901
|
+
this.reportProgress("course_config", 0, 1, "Restoring CourseConfig document...");
|
|
3902
|
+
const courseConfigResults = await this.restoreCourseConfig(manifest, targetDB);
|
|
3903
|
+
result.courseConfigRestored = courseConfigResults.restored;
|
|
3904
|
+
result.errors.push(...courseConfigResults.errors);
|
|
3905
|
+
result.warnings.push(...courseConfigResults.warnings);
|
|
3906
|
+
this.reportProgress("course_config", 1, 1, "CourseConfig document restored");
|
|
3907
|
+
const expectedCounts = this.calculateExpectedCounts(manifest);
|
|
3908
|
+
this.reportProgress(
|
|
3909
|
+
"documents",
|
|
3910
|
+
0,
|
|
3911
|
+
manifest.documentCount,
|
|
3912
|
+
"Aggregating documents from chunks..."
|
|
3913
|
+
);
|
|
3914
|
+
const documents = await this.aggregateDocuments(staticPath, manifest);
|
|
3915
|
+
const filteredDocuments = documents.filter((doc) => doc._id !== "CourseConfig");
|
|
3916
|
+
if (documents.length !== filteredDocuments.length) {
|
|
3917
|
+
result.warnings.push(
|
|
3918
|
+
`Filtered out ${documents.length - filteredDocuments.length} CourseConfig document(s) from chunks to prevent conflicts`
|
|
3919
|
+
);
|
|
3920
|
+
}
|
|
3921
|
+
this.reportProgress(
|
|
3922
|
+
"documents",
|
|
3923
|
+
filteredDocuments.length,
|
|
3924
|
+
manifest.documentCount,
|
|
3925
|
+
"Uploading documents to CouchDB..."
|
|
3926
|
+
);
|
|
3927
|
+
const docResults = await this.uploadDocuments(filteredDocuments, targetDB);
|
|
3928
|
+
result.documentsRestored = docResults.restored;
|
|
3929
|
+
result.errors.push(...docResults.errors);
|
|
3930
|
+
result.warnings.push(...docResults.warnings);
|
|
3931
|
+
const docsWithAttachments = documents.filter(
|
|
3932
|
+
(doc) => doc._attachments && Object.keys(doc._attachments).length > 0
|
|
3933
|
+
);
|
|
3934
|
+
this.reportProgress("attachments", 0, docsWithAttachments.length, "Uploading attachments...");
|
|
3935
|
+
const attachmentResults = await this.uploadAttachments(
|
|
3936
|
+
staticPath,
|
|
3937
|
+
docsWithAttachments,
|
|
3938
|
+
targetDB
|
|
3939
|
+
);
|
|
3940
|
+
result.attachmentsRestored = attachmentResults.restored;
|
|
3941
|
+
result.errors.push(...attachmentResults.errors);
|
|
3942
|
+
result.warnings.push(...attachmentResults.warnings);
|
|
3943
|
+
if (this.options.validateRoundTrip) {
|
|
3944
|
+
this.reportProgress("validation", 0, 1, "Validating migration...");
|
|
3945
|
+
const validationResult = await validateMigration(targetDB, expectedCounts, manifest);
|
|
3946
|
+
if (!validationResult.valid) {
|
|
3947
|
+
result.warnings.push("Migration validation found issues");
|
|
3948
|
+
validationResult.issues.forEach((issue) => {
|
|
3949
|
+
if (issue.type === "error") {
|
|
3950
|
+
result.errors.push(`Validation: ${issue.message}`);
|
|
3951
|
+
} else {
|
|
3952
|
+
result.warnings.push(`Validation: ${issue.message}`);
|
|
3953
|
+
}
|
|
3954
|
+
});
|
|
3955
|
+
}
|
|
3956
|
+
this.reportProgress("validation", 1, 1, "Migration validation completed");
|
|
3957
|
+
}
|
|
3958
|
+
result.success = result.errors.length === 0;
|
|
3959
|
+
result.migrationTime = Date.now() - startTime;
|
|
3960
|
+
logger.info(`Migration completed in ${result.migrationTime}ms`);
|
|
3961
|
+
logger.info(`Documents restored: ${result.documentsRestored}`);
|
|
3962
|
+
logger.info(`Attachments restored: ${result.attachmentsRestored}`);
|
|
3963
|
+
logger.info(`Design docs restored: ${result.designDocsRestored}`);
|
|
3964
|
+
logger.info(`CourseConfig restored: ${result.courseConfigRestored}`);
|
|
3965
|
+
if (result.errors.length > 0) {
|
|
3966
|
+
logger.error(`Migration completed with ${result.errors.length} errors`);
|
|
3967
|
+
}
|
|
3968
|
+
if (result.warnings.length > 0) {
|
|
3969
|
+
logger.warn(`Migration completed with ${result.warnings.length} warnings`);
|
|
3970
|
+
}
|
|
3971
|
+
} catch (error) {
|
|
3972
|
+
result.success = false;
|
|
3973
|
+
result.migrationTime = Date.now() - startTime;
|
|
3974
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
3975
|
+
result.errors.push(`Migration failed: ${errorMessage}`);
|
|
3976
|
+
logger.error("Migration failed:", error);
|
|
3977
|
+
if (this.options.cleanupOnFailure) {
|
|
3978
|
+
try {
|
|
3979
|
+
await this.cleanupFailedMigration(targetDB);
|
|
3980
|
+
} catch (cleanupError) {
|
|
3981
|
+
logger.error("Failed to cleanup after migration failure:", cleanupError);
|
|
3982
|
+
result.warnings.push("Failed to cleanup after migration failure");
|
|
3983
|
+
}
|
|
3984
|
+
}
|
|
3985
|
+
}
|
|
3986
|
+
return result;
|
|
3987
|
+
}
|
|
3988
|
+
/**
|
|
3989
|
+
* Load and parse the manifest file
|
|
3990
|
+
*/
|
|
3991
|
+
async loadManifest(staticPath) {
|
|
3992
|
+
try {
|
|
3993
|
+
let manifestContent;
|
|
3994
|
+
let manifestPath;
|
|
3995
|
+
if (this.fs) {
|
|
3996
|
+
manifestPath = this.fs.joinPath(staticPath, "manifest.json");
|
|
3997
|
+
manifestContent = await this.fs.readFile(manifestPath);
|
|
3998
|
+
} else {
|
|
3999
|
+
manifestPath = nodeFS2 && nodePath ? nodePath.join(staticPath, "manifest.json") : `${staticPath}/manifest.json`;
|
|
4000
|
+
if (nodeFS2 && this.isLocalPath(staticPath)) {
|
|
4001
|
+
manifestContent = await nodeFS2.promises.readFile(manifestPath, "utf8");
|
|
4002
|
+
} else {
|
|
4003
|
+
const response = await fetch(manifestPath);
|
|
4004
|
+
if (!response.ok) {
|
|
4005
|
+
throw new Error(`Failed to fetch manifest: ${response.status} ${response.statusText}`);
|
|
4006
|
+
}
|
|
4007
|
+
manifestContent = await response.text();
|
|
4008
|
+
}
|
|
4009
|
+
}
|
|
4010
|
+
const manifest = JSON.parse(manifestContent);
|
|
4011
|
+
if (!manifest.version || !manifest.courseId || !manifest.chunks) {
|
|
4012
|
+
throw new Error("Invalid manifest structure");
|
|
4013
|
+
}
|
|
4014
|
+
return manifest;
|
|
4015
|
+
} catch (error) {
|
|
4016
|
+
const errorMessage = error instanceof FileSystemError ? error.message : `Failed to load manifest: ${error instanceof Error ? error.message : String(error)}`;
|
|
4017
|
+
throw new Error(errorMessage);
|
|
4018
|
+
}
|
|
4019
|
+
}
|
|
4020
|
+
/**
|
|
4021
|
+
* Restore design documents to CouchDB
|
|
4022
|
+
*/
|
|
4023
|
+
async restoreDesignDocuments(designDocs, db) {
|
|
4024
|
+
const result = { restored: 0, errors: [], warnings: [] };
|
|
4025
|
+
for (let i = 0; i < designDocs.length; i++) {
|
|
4026
|
+
const designDoc = designDocs[i];
|
|
4027
|
+
this.reportProgress("design_docs", i, designDocs.length, `Restoring ${designDoc._id}...`);
|
|
4028
|
+
try {
|
|
4029
|
+
let existingDoc;
|
|
4030
|
+
try {
|
|
4031
|
+
existingDoc = await db.get(designDoc._id);
|
|
4032
|
+
} catch {
|
|
4033
|
+
}
|
|
4034
|
+
const docToInsert = {
|
|
4035
|
+
_id: designDoc._id,
|
|
4036
|
+
views: designDoc.views
|
|
4037
|
+
};
|
|
4038
|
+
if (existingDoc) {
|
|
4039
|
+
docToInsert._rev = existingDoc._rev;
|
|
4040
|
+
logger.debug(`Updating existing design document: ${designDoc._id}`);
|
|
4041
|
+
} else {
|
|
4042
|
+
logger.debug(`Creating new design document: ${designDoc._id}`);
|
|
4043
|
+
}
|
|
4044
|
+
await db.put(docToInsert);
|
|
4045
|
+
result.restored++;
|
|
4046
|
+
} catch (error) {
|
|
4047
|
+
const errorMessage = `Failed to restore design document ${designDoc._id}: ${error instanceof Error ? error.message : String(error)}`;
|
|
4048
|
+
result.errors.push(errorMessage);
|
|
4049
|
+
logger.error(errorMessage);
|
|
4050
|
+
}
|
|
4051
|
+
}
|
|
4052
|
+
this.reportProgress(
|
|
4053
|
+
"design_docs",
|
|
4054
|
+
designDocs.length,
|
|
4055
|
+
designDocs.length,
|
|
4056
|
+
`Restored ${result.restored} design documents`
|
|
4057
|
+
);
|
|
4058
|
+
return result;
|
|
4059
|
+
}
|
|
4060
|
+
/**
|
|
4061
|
+
* Aggregate documents from all chunks
|
|
4062
|
+
*/
|
|
4063
|
+
async aggregateDocuments(staticPath, manifest) {
|
|
4064
|
+
const allDocuments = [];
|
|
4065
|
+
const documentMap = /* @__PURE__ */ new Map();
|
|
4066
|
+
for (let i = 0; i < manifest.chunks.length; i++) {
|
|
4067
|
+
const chunk = manifest.chunks[i];
|
|
4068
|
+
this.reportProgress(
|
|
4069
|
+
"documents",
|
|
4070
|
+
allDocuments.length,
|
|
4071
|
+
manifest.documentCount,
|
|
4072
|
+
`Loading chunk ${chunk.id}...`
|
|
4073
|
+
);
|
|
4074
|
+
try {
|
|
4075
|
+
const documents = await this.loadChunk(staticPath, chunk);
|
|
4076
|
+
for (const doc of documents) {
|
|
4077
|
+
if (!doc._id) {
|
|
4078
|
+
logger.warn(`Document without _id found in chunk ${chunk.id}, skipping`);
|
|
4079
|
+
continue;
|
|
4080
|
+
}
|
|
4081
|
+
if (documentMap.has(doc._id)) {
|
|
4082
|
+
logger.warn(`Duplicate document ID found: ${doc._id}, using latest version`);
|
|
4083
|
+
}
|
|
4084
|
+
documentMap.set(doc._id, doc);
|
|
4085
|
+
}
|
|
4086
|
+
} catch (error) {
|
|
4087
|
+
throw new Error(
|
|
4088
|
+
`Failed to load chunk ${chunk.id}: ${error instanceof Error ? error.message : String(error)}`
|
|
4089
|
+
);
|
|
4090
|
+
}
|
|
4091
|
+
}
|
|
4092
|
+
allDocuments.push(...documentMap.values());
|
|
4093
|
+
logger.info(
|
|
4094
|
+
`Aggregated ${allDocuments.length} unique documents from ${manifest.chunks.length} chunks`
|
|
4095
|
+
);
|
|
4096
|
+
return allDocuments;
|
|
4097
|
+
}
|
|
4098
|
+
/**
|
|
4099
|
+
* Load documents from a single chunk file
|
|
4100
|
+
*/
|
|
4101
|
+
async loadChunk(staticPath, chunk) {
|
|
4102
|
+
try {
|
|
4103
|
+
let chunkContent;
|
|
4104
|
+
let chunkPath;
|
|
4105
|
+
if (this.fs) {
|
|
4106
|
+
chunkPath = this.fs.joinPath(staticPath, chunk.path);
|
|
4107
|
+
chunkContent = await this.fs.readFile(chunkPath);
|
|
4108
|
+
} else {
|
|
4109
|
+
chunkPath = nodeFS2 && nodePath ? nodePath.join(staticPath, chunk.path) : `${staticPath}/${chunk.path}`;
|
|
4110
|
+
if (nodeFS2 && this.isLocalPath(staticPath)) {
|
|
4111
|
+
chunkContent = await nodeFS2.promises.readFile(chunkPath, "utf8");
|
|
4112
|
+
} else {
|
|
4113
|
+
const response = await fetch(chunkPath);
|
|
4114
|
+
if (!response.ok) {
|
|
4115
|
+
throw new Error(`Failed to fetch chunk: ${response.status} ${response.statusText}`);
|
|
4116
|
+
}
|
|
4117
|
+
chunkContent = await response.text();
|
|
4118
|
+
}
|
|
4119
|
+
}
|
|
4120
|
+
const documents = JSON.parse(chunkContent);
|
|
4121
|
+
if (!Array.isArray(documents)) {
|
|
4122
|
+
throw new Error("Chunk file does not contain an array of documents");
|
|
4123
|
+
}
|
|
4124
|
+
return documents;
|
|
4125
|
+
} catch (error) {
|
|
4126
|
+
const errorMessage = error instanceof FileSystemError ? error.message : `Failed to load chunk: ${error instanceof Error ? error.message : String(error)}`;
|
|
4127
|
+
throw new Error(errorMessage);
|
|
4128
|
+
}
|
|
4129
|
+
}
|
|
4130
|
+
/**
|
|
4131
|
+
* Upload documents to CouchDB in batches
|
|
4132
|
+
*/
|
|
4133
|
+
async uploadDocuments(documents, db) {
|
|
4134
|
+
const result = { restored: 0, errors: [], warnings: [] };
|
|
4135
|
+
const batchSize = this.options.chunkBatchSize;
|
|
4136
|
+
for (let i = 0; i < documents.length; i += batchSize) {
|
|
4137
|
+
const batch = documents.slice(i, i + batchSize);
|
|
4138
|
+
this.reportProgress(
|
|
4139
|
+
"documents",
|
|
4140
|
+
i,
|
|
4141
|
+
documents.length,
|
|
4142
|
+
`Uploading batch ${Math.floor(i / batchSize) + 1}...`
|
|
4143
|
+
);
|
|
4144
|
+
try {
|
|
4145
|
+
const docsToInsert = batch.map((doc) => {
|
|
4146
|
+
const cleanDoc = { ...doc };
|
|
4147
|
+
delete cleanDoc._rev;
|
|
4148
|
+
delete cleanDoc._attachments;
|
|
4149
|
+
return cleanDoc;
|
|
4150
|
+
});
|
|
4151
|
+
const bulkResult = await db.bulkDocs(docsToInsert);
|
|
4152
|
+
for (let j = 0; j < bulkResult.length; j++) {
|
|
4153
|
+
const docResult = bulkResult[j];
|
|
4154
|
+
const originalDoc = batch[j];
|
|
4155
|
+
if ("error" in docResult) {
|
|
4156
|
+
const errorMessage = `Failed to upload document ${originalDoc._id}: ${docResult.error} - ${docResult.reason}`;
|
|
4157
|
+
result.errors.push(errorMessage);
|
|
4158
|
+
logger.error(errorMessage);
|
|
4159
|
+
} else {
|
|
4160
|
+
result.restored++;
|
|
4161
|
+
}
|
|
4162
|
+
}
|
|
4163
|
+
} catch (error) {
|
|
4164
|
+
let errorMessage;
|
|
4165
|
+
if (error instanceof Error) {
|
|
4166
|
+
errorMessage = `Failed to upload document batch starting at index ${i}: ${error.message}`;
|
|
4167
|
+
} else if (error && typeof error === "object" && "message" in error) {
|
|
4168
|
+
errorMessage = `Failed to upload document batch starting at index ${i}: ${error.message}`;
|
|
4169
|
+
} else {
|
|
4170
|
+
errorMessage = `Failed to upload document batch starting at index ${i}: ${JSON.stringify(error)}`;
|
|
4171
|
+
}
|
|
4172
|
+
result.errors.push(errorMessage);
|
|
4173
|
+
logger.error(errorMessage);
|
|
4174
|
+
}
|
|
4175
|
+
}
|
|
4176
|
+
this.reportProgress(
|
|
4177
|
+
"documents",
|
|
4178
|
+
documents.length,
|
|
4179
|
+
documents.length,
|
|
4180
|
+
`Uploaded ${result.restored} documents`
|
|
4181
|
+
);
|
|
4182
|
+
return result;
|
|
4183
|
+
}
|
|
4184
|
+
/**
|
|
4185
|
+
* Upload attachments from filesystem to CouchDB
|
|
4186
|
+
*/
|
|
4187
|
+
async uploadAttachments(staticPath, documents, db) {
|
|
4188
|
+
const result = { restored: 0, errors: [], warnings: [] };
|
|
4189
|
+
let processedDocs = 0;
|
|
4190
|
+
for (const doc of documents) {
|
|
4191
|
+
this.reportProgress(
|
|
4192
|
+
"attachments",
|
|
4193
|
+
processedDocs,
|
|
4194
|
+
documents.length,
|
|
4195
|
+
`Processing attachments for ${doc._id}...`
|
|
4196
|
+
);
|
|
4197
|
+
processedDocs++;
|
|
4198
|
+
if (!doc._attachments) {
|
|
4199
|
+
continue;
|
|
4200
|
+
}
|
|
4201
|
+
for (const [attachmentName, attachmentMeta] of Object.entries(doc._attachments)) {
|
|
4202
|
+
try {
|
|
4203
|
+
const uploadResult = await this.uploadSingleAttachment(
|
|
4204
|
+
staticPath,
|
|
4205
|
+
doc._id,
|
|
4206
|
+
attachmentName,
|
|
4207
|
+
attachmentMeta,
|
|
4208
|
+
db
|
|
4209
|
+
);
|
|
4210
|
+
if (uploadResult.success) {
|
|
4211
|
+
result.restored++;
|
|
4212
|
+
} else {
|
|
4213
|
+
result.errors.push(uploadResult.error || "Unknown attachment upload error");
|
|
4214
|
+
}
|
|
4215
|
+
} catch (error) {
|
|
4216
|
+
const errorMessage = `Failed to upload attachment ${doc._id}/${attachmentName}: ${error instanceof Error ? error.message : String(error)}`;
|
|
4217
|
+
result.errors.push(errorMessage);
|
|
4218
|
+
logger.error(errorMessage);
|
|
4219
|
+
}
|
|
4220
|
+
}
|
|
4221
|
+
}
|
|
4222
|
+
this.reportProgress(
|
|
4223
|
+
"attachments",
|
|
4224
|
+
documents.length,
|
|
4225
|
+
documents.length,
|
|
4226
|
+
`Uploaded ${result.restored} attachments`
|
|
4227
|
+
);
|
|
4228
|
+
return result;
|
|
4229
|
+
}
|
|
4230
|
+
/**
|
|
4231
|
+
* Upload a single attachment file
|
|
4232
|
+
*/
|
|
4233
|
+
async uploadSingleAttachment(staticPath, docId, attachmentName, attachmentMeta, db) {
|
|
4234
|
+
const result = {
|
|
4235
|
+
success: false,
|
|
4236
|
+
attachmentName,
|
|
4237
|
+
docId
|
|
4238
|
+
};
|
|
4239
|
+
try {
|
|
4240
|
+
if (!attachmentMeta.path) {
|
|
4241
|
+
result.error = "Attachment metadata missing file path";
|
|
4242
|
+
return result;
|
|
4243
|
+
}
|
|
4244
|
+
let attachmentData;
|
|
4245
|
+
let attachmentPath;
|
|
4246
|
+
if (this.fs) {
|
|
4247
|
+
attachmentPath = this.fs.joinPath(staticPath, attachmentMeta.path);
|
|
4248
|
+
attachmentData = await this.fs.readBinary(attachmentPath);
|
|
4249
|
+
} else {
|
|
4250
|
+
attachmentPath = nodeFS2 && nodePath ? nodePath.join(staticPath, attachmentMeta.path) : `${staticPath}/${attachmentMeta.path}`;
|
|
4251
|
+
if (nodeFS2 && this.isLocalPath(staticPath)) {
|
|
4252
|
+
attachmentData = await nodeFS2.promises.readFile(attachmentPath);
|
|
4253
|
+
} else {
|
|
4254
|
+
const response = await fetch(attachmentPath);
|
|
4255
|
+
if (!response.ok) {
|
|
4256
|
+
result.error = `Failed to fetch attachment: ${response.status} ${response.statusText}`;
|
|
4257
|
+
return result;
|
|
4258
|
+
}
|
|
4259
|
+
attachmentData = await response.arrayBuffer();
|
|
4260
|
+
}
|
|
4261
|
+
}
|
|
4262
|
+
const doc = await db.get(docId);
|
|
4263
|
+
await db.putAttachment(
|
|
4264
|
+
docId,
|
|
4265
|
+
attachmentName,
|
|
4266
|
+
doc._rev,
|
|
4267
|
+
attachmentData,
|
|
4268
|
+
// PouchDB accepts both ArrayBuffer and Buffer
|
|
4269
|
+
attachmentMeta.content_type
|
|
4270
|
+
);
|
|
4271
|
+
result.success = true;
|
|
4272
|
+
} catch (error) {
|
|
4273
|
+
result.error = error instanceof Error ? error.message : String(error);
|
|
4274
|
+
}
|
|
4275
|
+
return result;
|
|
4276
|
+
}
|
|
4277
|
+
/**
|
|
4278
|
+
* Restore CourseConfig document from manifest
|
|
4279
|
+
*/
|
|
4280
|
+
async restoreCourseConfig(manifest, targetDB) {
|
|
4281
|
+
const results = {
|
|
4282
|
+
restored: 0,
|
|
4283
|
+
errors: [],
|
|
4284
|
+
warnings: []
|
|
4285
|
+
};
|
|
4286
|
+
try {
|
|
4287
|
+
if (!manifest.courseConfig) {
|
|
4288
|
+
results.warnings.push(
|
|
4289
|
+
"No courseConfig found in manifest, skipping CourseConfig document creation"
|
|
4290
|
+
);
|
|
4291
|
+
return results;
|
|
4292
|
+
}
|
|
4293
|
+
const courseConfigDoc = {
|
|
4294
|
+
_id: "CourseConfig",
|
|
4295
|
+
...manifest.courseConfig,
|
|
4296
|
+
courseID: manifest.courseId
|
|
4297
|
+
};
|
|
4298
|
+
delete courseConfigDoc._rev;
|
|
4299
|
+
await targetDB.put(courseConfigDoc);
|
|
4300
|
+
results.restored = 1;
|
|
4301
|
+
logger.info(`CourseConfig document created for course: ${manifest.courseId}`);
|
|
4302
|
+
} catch (error) {
|
|
4303
|
+
const errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
|
|
4304
|
+
results.errors.push(`Failed to restore CourseConfig: ${errorMessage}`);
|
|
4305
|
+
logger.error("CourseConfig restoration failed:", error);
|
|
4306
|
+
}
|
|
4307
|
+
return results;
|
|
4308
|
+
}
|
|
4309
|
+
/**
|
|
4310
|
+
* Calculate expected document counts from manifest
|
|
4311
|
+
*/
|
|
4312
|
+
calculateExpectedCounts(manifest) {
|
|
4313
|
+
const counts = {};
|
|
4314
|
+
for (const chunk of manifest.chunks) {
|
|
4315
|
+
counts[chunk.docType] = (counts[chunk.docType] || 0) + chunk.documentCount;
|
|
4316
|
+
}
|
|
4317
|
+
if (manifest.designDocs.length > 0) {
|
|
4318
|
+
counts["_design"] = manifest.designDocs.length;
|
|
4319
|
+
}
|
|
4320
|
+
return counts;
|
|
4321
|
+
}
|
|
4322
|
+
/**
|
|
4323
|
+
* Clean up database after failed migration
|
|
4324
|
+
*/
|
|
4325
|
+
async cleanupFailedMigration(db) {
|
|
4326
|
+
logger.info("Cleaning up failed migration...");
|
|
4327
|
+
try {
|
|
4328
|
+
const allDocs = await db.allDocs();
|
|
4329
|
+
const docsToDelete = allDocs.rows.map((row) => ({
|
|
4330
|
+
_id: row.id,
|
|
4331
|
+
_rev: row.value.rev,
|
|
4332
|
+
_deleted: true
|
|
4333
|
+
}));
|
|
4334
|
+
if (docsToDelete.length > 0) {
|
|
4335
|
+
await db.bulkDocs(docsToDelete);
|
|
4336
|
+
logger.info(`Cleaned up ${docsToDelete.length} documents from failed migration`);
|
|
4337
|
+
}
|
|
4338
|
+
} catch (error) {
|
|
4339
|
+
logger.error("Failed to cleanup documents:", error);
|
|
4340
|
+
throw error;
|
|
4341
|
+
}
|
|
4342
|
+
}
|
|
4343
|
+
/**
|
|
4344
|
+
* Report progress to callback if available
|
|
4345
|
+
*/
|
|
4346
|
+
reportProgress(phase, current, total, message) {
|
|
4347
|
+
if (this.progressCallback) {
|
|
4348
|
+
this.progressCallback({
|
|
4349
|
+
phase,
|
|
4350
|
+
current,
|
|
4351
|
+
total,
|
|
4352
|
+
message
|
|
4353
|
+
});
|
|
4354
|
+
}
|
|
4355
|
+
}
|
|
4356
|
+
/**
|
|
4357
|
+
* Check if a path is a local file path (vs URL)
|
|
4358
|
+
*/
|
|
4359
|
+
isLocalPath(path2) {
|
|
4360
|
+
return !path2.startsWith("http://") && !path2.startsWith("https://");
|
|
4361
|
+
}
|
|
4362
|
+
};
|
|
4363
|
+
}
|
|
4364
|
+
});
|
|
4365
|
+
|
|
4366
|
+
// src/util/migrator/index.ts
|
|
4367
|
+
var init_migrator = __esm({
|
|
4368
|
+
"src/util/migrator/index.ts"() {
|
|
4369
|
+
"use strict";
|
|
4370
|
+
init_StaticToCouchDBMigrator();
|
|
4371
|
+
init_validation();
|
|
4372
|
+
init_FileSystemAdapter();
|
|
4373
|
+
}
|
|
4374
|
+
});
|
|
4375
|
+
|
|
4376
|
+
// src/util/index.ts
|
|
4377
|
+
var init_util2 = __esm({
|
|
4378
|
+
"src/util/index.ts"() {
|
|
4379
|
+
"use strict";
|
|
4380
|
+
init_Loggable();
|
|
4381
|
+
init_packer();
|
|
4382
|
+
init_migrator();
|
|
4383
|
+
init_dataDirectory();
|
|
4384
|
+
}
|
|
4385
|
+
});
|
|
4386
|
+
|
|
4387
|
+
// src/study/SourceMixer.ts
|
|
4388
|
+
var init_SourceMixer = __esm({
|
|
4389
|
+
"src/study/SourceMixer.ts"() {
|
|
4390
|
+
"use strict";
|
|
4391
|
+
}
|
|
4392
|
+
});
|
|
4393
|
+
|
|
4394
|
+
// src/study/MixerDebugger.ts
|
|
4395
|
+
function printMixerSummary(run) {
|
|
4396
|
+
console.group(`\u{1F3A8} Mixer Run: ${run.mixerType}`);
|
|
4397
|
+
logger.info(`Run ID: ${run.runId}`);
|
|
4398
|
+
logger.info(`Time: ${run.timestamp.toISOString()}`);
|
|
4399
|
+
logger.info(
|
|
4400
|
+
`Config: limit=${run.requestedLimit}${run.quotaPerSource ? `, quota/source=${run.quotaPerSource}` : ""}`
|
|
4401
|
+
);
|
|
4402
|
+
console.group(`\u{1F4E5} Input: ${run.sourceSummaries.length} sources`);
|
|
4403
|
+
for (const src of run.sourceSummaries) {
|
|
4404
|
+
logger.info(
|
|
4405
|
+
` ${src.sourceName || src.sourceId}: ${src.totalCards} cards (${src.reviewCount} reviews, ${src.newCount} new)`
|
|
4406
|
+
);
|
|
4407
|
+
logger.info(` Score range: [${src.scoreRange[0].toFixed(2)}, ${src.scoreRange[1].toFixed(2)}], avg: ${src.avgScore.toFixed(2)}`);
|
|
4408
|
+
}
|
|
4409
|
+
console.groupEnd();
|
|
4410
|
+
console.group(`\u{1F4E4} Output: ${run.finalCount} cards selected (${run.reviewsSelected} reviews, ${run.newSelected} new)`);
|
|
4411
|
+
for (const breakdown of run.sourceBreakdowns) {
|
|
4412
|
+
const name = breakdown.sourceName || breakdown.sourceId;
|
|
4413
|
+
logger.info(
|
|
4414
|
+
` ${name}: ${breakdown.totalSelected} selected (${breakdown.reviewsSelected} reviews, ${breakdown.newSelected} new) - ${breakdown.selectionRate.toFixed(1)}% selection rate`
|
|
4415
|
+
);
|
|
4416
|
+
}
|
|
4417
|
+
console.groupEnd();
|
|
4418
|
+
console.groupEnd();
|
|
4419
|
+
}
|
|
4420
|
+
function mountMixerDebugger() {
|
|
4421
|
+
if (typeof window === "undefined") return;
|
|
4422
|
+
const win = window;
|
|
4423
|
+
win.skuilder = win.skuilder || {};
|
|
4424
|
+
win.skuilder.mixer = mixerDebugAPI;
|
|
4425
|
+
}
|
|
4426
|
+
var runHistory2, mixerDebugAPI;
|
|
4427
|
+
var init_MixerDebugger = __esm({
|
|
4428
|
+
"src/study/MixerDebugger.ts"() {
|
|
4429
|
+
"use strict";
|
|
4430
|
+
init_logger();
|
|
4431
|
+
init_navigators();
|
|
4432
|
+
runHistory2 = [];
|
|
4433
|
+
mixerDebugAPI = {
|
|
4434
|
+
/**
|
|
4435
|
+
* Get raw run history for programmatic access.
|
|
4436
|
+
*/
|
|
4437
|
+
get runs() {
|
|
4438
|
+
return [...runHistory2];
|
|
4439
|
+
},
|
|
4440
|
+
/**
|
|
4441
|
+
* Show summary of a specific mixer run.
|
|
4442
|
+
*/
|
|
4443
|
+
showRun(idOrIndex = 0) {
|
|
4444
|
+
if (runHistory2.length === 0) {
|
|
4445
|
+
logger.info("[Mixer Debug] No runs captured yet.");
|
|
4446
|
+
return;
|
|
4447
|
+
}
|
|
4448
|
+
let run;
|
|
4449
|
+
if (typeof idOrIndex === "number") {
|
|
4450
|
+
run = runHistory2[idOrIndex];
|
|
4451
|
+
if (!run) {
|
|
4452
|
+
logger.info(`[Mixer Debug] No run found at index ${idOrIndex}. History length: ${runHistory2.length}`);
|
|
4453
|
+
return;
|
|
4454
|
+
}
|
|
4455
|
+
} else {
|
|
4456
|
+
run = runHistory2.find((r) => r.runId.endsWith(idOrIndex));
|
|
4457
|
+
if (!run) {
|
|
4458
|
+
logger.info(`[Mixer Debug] No run found matching ID '${idOrIndex}'.`);
|
|
4459
|
+
return;
|
|
4460
|
+
}
|
|
4461
|
+
}
|
|
4462
|
+
printMixerSummary(run);
|
|
4463
|
+
},
|
|
4464
|
+
/**
|
|
4465
|
+
* Show summary of the last mixer run.
|
|
4466
|
+
*/
|
|
4467
|
+
showLastMix() {
|
|
4468
|
+
this.showRun(0);
|
|
4469
|
+
},
|
|
4470
|
+
/**
|
|
4471
|
+
* Explain source balance in the last run.
|
|
4472
|
+
*/
|
|
4473
|
+
explainSourceBalance() {
|
|
4474
|
+
if (runHistory2.length === 0) {
|
|
4475
|
+
logger.info("[Mixer Debug] No runs captured yet.");
|
|
4476
|
+
return;
|
|
4477
|
+
}
|
|
4478
|
+
const run = runHistory2[0];
|
|
4479
|
+
console.group("\u2696\uFE0F Source Balance Analysis");
|
|
4480
|
+
logger.info(`Mixer: ${run.mixerType}`);
|
|
4481
|
+
logger.info(`Requested limit: ${run.requestedLimit}`);
|
|
4482
|
+
if (run.quotaPerSource) {
|
|
4483
|
+
logger.info(`Quota per source: ${run.quotaPerSource}`);
|
|
4484
|
+
}
|
|
4485
|
+
console.group("Input Distribution:");
|
|
4486
|
+
for (const src of run.sourceSummaries) {
|
|
4487
|
+
const name = src.sourceName || src.sourceId;
|
|
4488
|
+
logger.info(`${name}:`);
|
|
4489
|
+
logger.info(` Provided: ${src.totalCards} cards (${src.reviewCount} reviews, ${src.newCount} new)`);
|
|
4490
|
+
logger.info(` Score range: [${src.scoreRange[0].toFixed(2)}, ${src.scoreRange[1].toFixed(2)}]`);
|
|
4491
|
+
}
|
|
4492
|
+
console.groupEnd();
|
|
4493
|
+
console.group("Selection Results:");
|
|
4494
|
+
for (const breakdown of run.sourceBreakdowns) {
|
|
4495
|
+
const name = breakdown.sourceName || breakdown.sourceId;
|
|
4496
|
+
logger.info(`${name}:`);
|
|
4497
|
+
logger.info(
|
|
4498
|
+
` Selected: ${breakdown.totalSelected}/${breakdown.reviewsProvided + breakdown.newProvided} (${breakdown.selectionRate.toFixed(1)}%)`
|
|
4499
|
+
);
|
|
4500
|
+
logger.info(` Reviews: ${breakdown.reviewsSelected}/${breakdown.reviewsProvided}`);
|
|
4501
|
+
logger.info(` New: ${breakdown.newSelected}/${breakdown.newProvided}`);
|
|
4502
|
+
if (breakdown.reviewsProvided > 0 && breakdown.reviewsSelected === 0) {
|
|
4503
|
+
logger.info(` \u26A0\uFE0F Had reviews but none selected!`);
|
|
4504
|
+
}
|
|
4505
|
+
if (breakdown.totalSelected === 0 && breakdown.reviewsProvided + breakdown.newProvided > 0) {
|
|
4506
|
+
logger.info(` \u26A0\uFE0F Had cards but none selected!`);
|
|
4507
|
+
}
|
|
4508
|
+
}
|
|
4509
|
+
console.groupEnd();
|
|
4510
|
+
const selectionRates = run.sourceBreakdowns.map((b) => b.selectionRate);
|
|
4511
|
+
const avgRate = selectionRates.reduce((a, b) => a + b, 0) / selectionRates.length;
|
|
4512
|
+
const maxDeviation = Math.max(...selectionRates.map((r) => Math.abs(r - avgRate)));
|
|
4513
|
+
if (maxDeviation > 20) {
|
|
4514
|
+
logger.info(`
|
|
4515
|
+
\u26A0\uFE0F Significant imbalance detected (max deviation: ${maxDeviation.toFixed(1)}%)`);
|
|
4516
|
+
logger.info("Possible causes:");
|
|
4517
|
+
logger.info(" - Score range differences between sources");
|
|
4518
|
+
logger.info(" - One source has much better quality cards");
|
|
4519
|
+
logger.info(" - Different card availability (reviews vs new)");
|
|
4520
|
+
}
|
|
4521
|
+
console.groupEnd();
|
|
4522
|
+
},
|
|
4523
|
+
/**
|
|
4524
|
+
* Compare score distributions across sources.
|
|
4525
|
+
*/
|
|
4526
|
+
compareScores() {
|
|
4527
|
+
if (runHistory2.length === 0) {
|
|
4528
|
+
logger.info("[Mixer Debug] No runs captured yet.");
|
|
4529
|
+
return;
|
|
4530
|
+
}
|
|
4531
|
+
const run = runHistory2[0];
|
|
4532
|
+
console.group("\u{1F4CA} Score Distribution Comparison");
|
|
4533
|
+
console.table(
|
|
4534
|
+
run.sourceSummaries.map((src) => ({
|
|
4535
|
+
source: src.sourceName || src.sourceId,
|
|
4536
|
+
cards: src.totalCards,
|
|
4537
|
+
min: src.bottomScore.toFixed(3),
|
|
4538
|
+
max: src.topScore.toFixed(3),
|
|
4539
|
+
avg: src.avgScore.toFixed(3),
|
|
4540
|
+
range: (src.topScore - src.bottomScore).toFixed(3)
|
|
4541
|
+
}))
|
|
4542
|
+
);
|
|
4543
|
+
const ranges = run.sourceSummaries.map((s) => s.topScore - s.bottomScore);
|
|
4544
|
+
const avgScores = run.sourceSummaries.map((s) => s.avgScore);
|
|
4545
|
+
const rangeDiff = Math.max(...ranges) - Math.min(...ranges);
|
|
4546
|
+
const avgDiff = Math.max(...avgScores) - Math.min(...avgScores);
|
|
4547
|
+
if (rangeDiff > 0.3 || avgDiff > 0.2) {
|
|
4548
|
+
logger.info("\n\u26A0\uFE0F Significant score distribution differences detected");
|
|
4549
|
+
logger.info(
|
|
4550
|
+
"This may cause one source to dominate selection if using global sorting (not quota-based)"
|
|
4551
|
+
);
|
|
4552
|
+
}
|
|
4553
|
+
console.groupEnd();
|
|
4554
|
+
},
|
|
4555
|
+
/**
|
|
4556
|
+
* Show detailed information for a specific card.
|
|
4557
|
+
*/
|
|
4558
|
+
showCard(cardId) {
|
|
4559
|
+
for (const run of runHistory2) {
|
|
4560
|
+
const card = run.cards.find((c) => c.cardId === cardId);
|
|
4561
|
+
if (card) {
|
|
4562
|
+
const source = run.sourceSummaries.find((s) => s.sourceIndex === card.sourceIndex);
|
|
4563
|
+
console.group(`\u{1F3B4} Card: ${cardId}`);
|
|
4564
|
+
logger.info(`Course: ${card.courseId}`);
|
|
4565
|
+
logger.info(`Source: ${source?.sourceName || source?.sourceId || "unknown"}`);
|
|
4566
|
+
logger.info(`Origin: ${card.origin}`);
|
|
4567
|
+
logger.info(`Score: ${card.score.toFixed(3)}`);
|
|
4568
|
+
if (card.rankInSource) {
|
|
4569
|
+
logger.info(`Rank in source: #${card.rankInSource}`);
|
|
4570
|
+
}
|
|
4571
|
+
if (card.rankInMix) {
|
|
4572
|
+
logger.info(`Rank in mixed results: #${card.rankInMix}`);
|
|
4573
|
+
}
|
|
4574
|
+
logger.info(`Selected: ${card.selected ? "Yes \u2705" : "No \u274C"}`);
|
|
4575
|
+
if (!card.selected && card.rankInSource) {
|
|
4576
|
+
logger.info("\nWhy not selected:");
|
|
4577
|
+
if (run.quotaPerSource && card.rankInSource > run.quotaPerSource) {
|
|
4578
|
+
logger.info(` - Ranked #${card.rankInSource} in source, but quota was ${run.quotaPerSource}`);
|
|
4579
|
+
}
|
|
4580
|
+
logger.info(" - Check score compared to selected cards using .showRun()");
|
|
4581
|
+
}
|
|
4582
|
+
console.groupEnd();
|
|
4583
|
+
return;
|
|
4584
|
+
}
|
|
4585
|
+
}
|
|
4586
|
+
logger.info(`[Mixer Debug] Card '${cardId}' not found in recent runs.`);
|
|
4587
|
+
},
|
|
4588
|
+
/**
|
|
4589
|
+
* Show all runs in compact format.
|
|
4590
|
+
*/
|
|
4591
|
+
listRuns() {
|
|
4592
|
+
if (runHistory2.length === 0) {
|
|
4593
|
+
logger.info("[Mixer Debug] No runs captured yet.");
|
|
4594
|
+
return;
|
|
4595
|
+
}
|
|
4596
|
+
console.table(
|
|
4597
|
+
runHistory2.map((r) => ({
|
|
4598
|
+
id: r.runId.slice(-8),
|
|
4599
|
+
time: r.timestamp.toLocaleTimeString(),
|
|
4600
|
+
mixer: r.mixerType,
|
|
4601
|
+
sources: r.sourceSummaries.length,
|
|
4602
|
+
selected: r.finalCount,
|
|
4603
|
+
reviews: r.reviewsSelected,
|
|
4604
|
+
new: r.newSelected
|
|
4605
|
+
}))
|
|
4606
|
+
);
|
|
4607
|
+
},
|
|
4608
|
+
/**
|
|
4609
|
+
* Export run history as JSON for bug reports.
|
|
4610
|
+
*/
|
|
4611
|
+
export() {
|
|
4612
|
+
const json = JSON.stringify(runHistory2, null, 2);
|
|
4613
|
+
logger.info("[Mixer Debug] Run history exported. Copy the returned string or use:");
|
|
4614
|
+
logger.info(" copy(window.skuilder.mixer.export())");
|
|
4615
|
+
return json;
|
|
4616
|
+
},
|
|
4617
|
+
/**
|
|
4618
|
+
* Clear run history.
|
|
4619
|
+
*/
|
|
4620
|
+
clear() {
|
|
4621
|
+
runHistory2.length = 0;
|
|
4622
|
+
logger.info("[Mixer Debug] Run history cleared.");
|
|
4623
|
+
},
|
|
4624
|
+
/**
|
|
4625
|
+
* Show help.
|
|
4626
|
+
*/
|
|
4627
|
+
help() {
|
|
4628
|
+
logger.info(`
|
|
4629
|
+
\u{1F3A8} Mixer Debug API
|
|
4630
|
+
|
|
4631
|
+
Commands:
|
|
4632
|
+
.showLastMix() Show summary of most recent mixer run
|
|
4633
|
+
.showRun(id|index) Show summary of a specific run (by index or ID suffix)
|
|
4634
|
+
.explainSourceBalance() Analyze source balance and selection patterns
|
|
4635
|
+
.compareScores() Compare score distributions across sources
|
|
4636
|
+
.showCard(cardId) Show mixer decisions for a specific card
|
|
4637
|
+
.listRuns() List all captured runs in table format
|
|
4638
|
+
.export() Export run history as JSON for bug reports
|
|
4639
|
+
.clear() Clear run history
|
|
4640
|
+
.runs Access raw run history array
|
|
4641
|
+
.help() Show this help message
|
|
4642
|
+
|
|
4643
|
+
Example:
|
|
4644
|
+
window.skuilder.mixer.showLastMix()
|
|
4645
|
+
window.skuilder.mixer.explainSourceBalance()
|
|
4646
|
+
window.skuilder.mixer.compareScores()
|
|
4647
|
+
`);
|
|
4648
|
+
}
|
|
4649
|
+
};
|
|
4650
|
+
mountMixerDebugger();
|
|
4651
|
+
}
|
|
4652
|
+
});
|
|
4653
|
+
|
|
4654
|
+
// src/study/SessionDebugger.ts
|
|
4655
|
+
function showCurrentQueue() {
|
|
4656
|
+
if (!activeSession) {
|
|
4657
|
+
logger.info("[Session Debug] No active session.");
|
|
4658
|
+
return;
|
|
2719
4659
|
}
|
|
2720
|
-
const
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
4660
|
+
const latest = activeSession.queueSnapshots[activeSession.queueSnapshots.length - 1] || activeSession.initialQueues;
|
|
4661
|
+
console.group("\u{1F4CA} Current Queue State");
|
|
4662
|
+
logger.info(`Review Queue: ${latest.reviewQLength} cards`);
|
|
4663
|
+
if (latest.reviewQNext3 && latest.reviewQNext3.length > 0) {
|
|
4664
|
+
logger.info(` Next: ${latest.reviewQNext3.join(", ")}`);
|
|
2725
4665
|
}
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
}
|
|
2729
|
-
function scoreAccuracyInZone(accuracy, target, tolerance) {
|
|
2730
|
-
const dist = Math.abs(accuracy - target);
|
|
2731
|
-
if (dist <= tolerance) {
|
|
2732
|
-
return 1;
|
|
4666
|
+
logger.info(`New Queue: ${latest.newQLength} cards`);
|
|
4667
|
+
if (latest.newQNext3 && latest.newQNext3.length > 0) {
|
|
4668
|
+
logger.info(` Next: ${latest.newQNext3.join(", ")}`);
|
|
2733
4669
|
}
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
return Math.max(0, 1 - excess * slope);
|
|
4670
|
+
logger.info(`Failed Queue: ${latest.failedQLength} cards`);
|
|
4671
|
+
console.groupEnd();
|
|
2737
4672
|
}
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
4673
|
+
function showPresentationHistory(sessionIndex = 0) {
|
|
4674
|
+
const session = sessionIndex === 0 && activeSession ? activeSession : sessionHistory[sessionIndex];
|
|
4675
|
+
if (!session) {
|
|
4676
|
+
logger.info(`[Session Debug] No session found at index ${sessionIndex}`);
|
|
4677
|
+
return;
|
|
2741
4678
|
}
|
|
2742
|
-
});
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
4679
|
+
console.group(`\u{1F4DC} Session History: ${session.sessionId}`);
|
|
4680
|
+
logger.info(`Started: ${session.startTime.toLocaleTimeString()}`);
|
|
4681
|
+
if (session.endTime) {
|
|
4682
|
+
logger.info(`Ended: ${session.endTime.toLocaleTimeString()}`);
|
|
4683
|
+
}
|
|
4684
|
+
logger.info(`Cards presented: ${session.presentations.length}`);
|
|
4685
|
+
if (session.presentations.length > 0) {
|
|
4686
|
+
console.table(
|
|
4687
|
+
session.presentations.map((p) => ({
|
|
4688
|
+
"#": p.sequenceNumber,
|
|
4689
|
+
course: p.courseName || p.courseId.slice(0, 8),
|
|
4690
|
+
origin: p.origin,
|
|
4691
|
+
queue: p.queueSource,
|
|
4692
|
+
score: p.score?.toFixed(3) || "-",
|
|
4693
|
+
time: p.timestamp.toLocaleTimeString()
|
|
4694
|
+
}))
|
|
2752
4695
|
);
|
|
4696
|
+
}
|
|
4697
|
+
console.groupEnd();
|
|
4698
|
+
}
|
|
4699
|
+
function showInterleaving(sessionIndex = 0) {
|
|
4700
|
+
const session = sessionIndex === 0 && activeSession ? activeSession : sessionHistory[sessionIndex];
|
|
4701
|
+
if (!session) {
|
|
4702
|
+
logger.info(`[Session Debug] No session found at index ${sessionIndex}`);
|
|
2753
4703
|
return;
|
|
2754
4704
|
}
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
4705
|
+
console.group("\u{1F500} Interleaving Analysis");
|
|
4706
|
+
const courseCounts = /* @__PURE__ */ new Map();
|
|
4707
|
+
const courseOrigins = /* @__PURE__ */ new Map();
|
|
4708
|
+
session.presentations.forEach((p) => {
|
|
4709
|
+
const name = p.courseName || p.courseId;
|
|
4710
|
+
courseCounts.set(name, (courseCounts.get(name) || 0) + 1);
|
|
4711
|
+
if (!courseOrigins.has(name)) {
|
|
4712
|
+
courseOrigins.set(name, { review: 0, new: 0, failed: 0 });
|
|
4713
|
+
}
|
|
4714
|
+
const origins = courseOrigins.get(name);
|
|
4715
|
+
origins[p.origin]++;
|
|
4716
|
+
});
|
|
4717
|
+
logger.info("Course distribution:");
|
|
4718
|
+
console.table(
|
|
4719
|
+
Array.from(courseCounts.entries()).map(([course, count]) => {
|
|
4720
|
+
const origins = courseOrigins.get(course);
|
|
4721
|
+
return {
|
|
4722
|
+
course,
|
|
4723
|
+
total: count,
|
|
4724
|
+
reviews: origins.review,
|
|
4725
|
+
new: origins.new,
|
|
4726
|
+
failed: origins.failed,
|
|
4727
|
+
percentage: (count / session.presentations.length * 100).toFixed(1) + "%"
|
|
4728
|
+
};
|
|
4729
|
+
})
|
|
4730
|
+
);
|
|
4731
|
+
if (session.presentations.length > 0) {
|
|
4732
|
+
logger.info("\nPresentation sequence (first 20):");
|
|
4733
|
+
const sequence = session.presentations.slice(0, 20).map((p, idx) => `${idx + 1}. ${p.courseName || p.courseId.slice(0, 8)} (${p.origin})`).join("\n");
|
|
4734
|
+
logger.info(sequence);
|
|
2758
4735
|
}
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
courseId
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
metadata: {
|
|
2770
|
-
sessionsCount: 1,
|
|
2771
|
-
// Assumes recording is triggered per-session currently
|
|
2772
|
-
cardsSeen: records.length,
|
|
2773
|
-
eloStart,
|
|
2774
|
-
eloEnd,
|
|
2775
|
-
signalType: "accuracy_in_zone"
|
|
4736
|
+
let maxCluster = 0;
|
|
4737
|
+
let currentCluster = 1;
|
|
4738
|
+
let currentCourse = session.presentations[0]?.courseId;
|
|
4739
|
+
for (let i = 1; i < session.presentations.length; i++) {
|
|
4740
|
+
if (session.presentations[i].courseId === currentCourse) {
|
|
4741
|
+
currentCluster++;
|
|
4742
|
+
maxCluster = Math.max(maxCluster, currentCluster);
|
|
4743
|
+
} else {
|
|
4744
|
+
currentCourse = session.presentations[i].courseId;
|
|
4745
|
+
currentCluster = 1;
|
|
2776
4746
|
}
|
|
2777
|
-
};
|
|
2778
|
-
try {
|
|
2779
|
-
await user.putUserOutcome(record);
|
|
2780
|
-
logger.debug(
|
|
2781
|
-
`[Orchestration] Recorded outcome ${outcomeValue.toFixed(3)} for ${userId} (doc: ${id})`
|
|
2782
|
-
);
|
|
2783
|
-
} catch (e) {
|
|
2784
|
-
logger.error(`[Orchestration] Failed to record outcome: ${e}`);
|
|
2785
4747
|
}
|
|
4748
|
+
if (maxCluster > 3) {
|
|
4749
|
+
logger.info(`
|
|
4750
|
+
\u26A0\uFE0F Detected clustering: max ${maxCluster} cards from same course in a row`);
|
|
4751
|
+
logger.info("This suggests cards are sorted by score rather than round-robin by course.");
|
|
4752
|
+
}
|
|
4753
|
+
console.groupEnd();
|
|
2786
4754
|
}
|
|
2787
|
-
|
|
2788
|
-
"
|
|
4755
|
+
function mountSessionDebugger() {
|
|
4756
|
+
if (typeof window === "undefined") return;
|
|
4757
|
+
const win = window;
|
|
4758
|
+
win.skuilder = win.skuilder || {};
|
|
4759
|
+
win.skuilder.session = sessionDebugAPI;
|
|
4760
|
+
}
|
|
4761
|
+
var activeSession, sessionHistory, sessionDebugAPI;
|
|
4762
|
+
var init_SessionDebugger = __esm({
|
|
4763
|
+
"src/study/SessionDebugger.ts"() {
|
|
2789
4764
|
"use strict";
|
|
2790
|
-
init_signal();
|
|
2791
|
-
init_types_legacy();
|
|
2792
4765
|
init_logger();
|
|
2793
|
-
|
|
2794
|
-
|
|
4766
|
+
activeSession = null;
|
|
4767
|
+
sessionHistory = [];
|
|
4768
|
+
sessionDebugAPI = {
|
|
4769
|
+
/**
|
|
4770
|
+
* Get raw session history for programmatic access.
|
|
4771
|
+
*/
|
|
4772
|
+
get sessions() {
|
|
4773
|
+
return [...sessionHistory];
|
|
4774
|
+
},
|
|
4775
|
+
/**
|
|
4776
|
+
* Get active session if any.
|
|
4777
|
+
*/
|
|
4778
|
+
get active() {
|
|
4779
|
+
return activeSession;
|
|
4780
|
+
},
|
|
4781
|
+
/**
|
|
4782
|
+
* Show current queue state.
|
|
4783
|
+
*/
|
|
4784
|
+
showQueue() {
|
|
4785
|
+
showCurrentQueue();
|
|
4786
|
+
},
|
|
4787
|
+
/**
|
|
4788
|
+
* Show presentation history for current or past session.
|
|
4789
|
+
*/
|
|
4790
|
+
showHistory(sessionIndex = 0) {
|
|
4791
|
+
showPresentationHistory(sessionIndex);
|
|
4792
|
+
},
|
|
4793
|
+
/**
|
|
4794
|
+
* Analyze course interleaving pattern.
|
|
4795
|
+
*/
|
|
4796
|
+
showInterleaving(sessionIndex = 0) {
|
|
4797
|
+
showInterleaving(sessionIndex);
|
|
4798
|
+
},
|
|
4799
|
+
/**
|
|
4800
|
+
* List all tracked sessions.
|
|
4801
|
+
*/
|
|
4802
|
+
listSessions() {
|
|
4803
|
+
if (activeSession) {
|
|
4804
|
+
logger.info(`Active session: ${activeSession.sessionId} (${activeSession.presentations.length} cards presented)`);
|
|
4805
|
+
}
|
|
4806
|
+
if (sessionHistory.length === 0) {
|
|
4807
|
+
logger.info("[Session Debug] No completed sessions in history.");
|
|
4808
|
+
return;
|
|
4809
|
+
}
|
|
4810
|
+
console.table(
|
|
4811
|
+
sessionHistory.map((s, idx) => ({
|
|
4812
|
+
index: idx,
|
|
4813
|
+
id: s.sessionId.slice(-8),
|
|
4814
|
+
started: s.startTime.toLocaleTimeString(),
|
|
4815
|
+
ended: s.endTime?.toLocaleTimeString() || "incomplete",
|
|
4816
|
+
cards: s.presentations.length
|
|
4817
|
+
}))
|
|
4818
|
+
);
|
|
4819
|
+
},
|
|
4820
|
+
/**
|
|
4821
|
+
* Export session history as JSON for bug reports.
|
|
4822
|
+
*/
|
|
4823
|
+
export() {
|
|
4824
|
+
const data = {
|
|
4825
|
+
active: activeSession,
|
|
4826
|
+
history: sessionHistory
|
|
4827
|
+
};
|
|
4828
|
+
const json = JSON.stringify(data, null, 2);
|
|
4829
|
+
logger.info("[Session Debug] Session data exported. Copy the returned string or use:");
|
|
4830
|
+
logger.info(" copy(window.skuilder.session.export())");
|
|
4831
|
+
return json;
|
|
4832
|
+
},
|
|
4833
|
+
/**
|
|
4834
|
+
* Clear session history.
|
|
4835
|
+
*/
|
|
4836
|
+
clear() {
|
|
4837
|
+
sessionHistory.length = 0;
|
|
4838
|
+
logger.info("[Session Debug] Session history cleared.");
|
|
4839
|
+
},
|
|
4840
|
+
/**
|
|
4841
|
+
* Show help.
|
|
4842
|
+
*/
|
|
4843
|
+
help() {
|
|
4844
|
+
logger.info(`
|
|
4845
|
+
\u{1F3AF} Session Debug API
|
|
2795
4846
|
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
return MAX_SPREAD - clampedConfidence * (MAX_SPREAD - MIN_SPREAD);
|
|
2814
|
-
}
|
|
2815
|
-
function computeEffectiveWeight(learnable, userId, strategyId, salt) {
|
|
2816
|
-
const deviation = computeDeviation(userId, strategyId, salt);
|
|
2817
|
-
const spread = computeSpread(learnable.confidence);
|
|
2818
|
-
const adjustment = deviation * spread * learnable.weight;
|
|
2819
|
-
const effective = learnable.weight + adjustment;
|
|
2820
|
-
return Math.max(MIN_WEIGHT, Math.min(MAX_WEIGHT, effective));
|
|
2821
|
-
}
|
|
2822
|
-
async function createOrchestrationContext(user, course) {
|
|
2823
|
-
let courseConfig;
|
|
2824
|
-
try {
|
|
2825
|
-
courseConfig = await course.getCourseConfig();
|
|
2826
|
-
} catch (e) {
|
|
2827
|
-
logger.error(`[Orchestration] Failed to load course config: ${e}`);
|
|
2828
|
-
courseConfig = {
|
|
2829
|
-
name: "Unknown",
|
|
2830
|
-
description: "",
|
|
2831
|
-
public: false,
|
|
2832
|
-
deleted: false,
|
|
2833
|
-
creator: "",
|
|
2834
|
-
admins: [],
|
|
2835
|
-
moderators: [],
|
|
2836
|
-
dataShapes: [],
|
|
2837
|
-
questionTypes: [],
|
|
2838
|
-
orchestration: { salt: "default" }
|
|
4847
|
+
Commands:
|
|
4848
|
+
.showQueue() Show current queue state (active session only)
|
|
4849
|
+
.showHistory(index?) Show presentation history (0=current/last, 1=previous, etc)
|
|
4850
|
+
.showInterleaving(index?) Analyze course interleaving pattern
|
|
4851
|
+
.listSessions() List all tracked sessions
|
|
4852
|
+
.export() Export session data as JSON for bug reports
|
|
4853
|
+
.clear() Clear session history
|
|
4854
|
+
.sessions Access raw session history array
|
|
4855
|
+
.active Access active session (if any)
|
|
4856
|
+
.help() Show this help message
|
|
4857
|
+
|
|
4858
|
+
Example:
|
|
4859
|
+
window.skuilder.session.showHistory()
|
|
4860
|
+
window.skuilder.session.showInterleaving()
|
|
4861
|
+
window.skuilder.session.showQueue()
|
|
4862
|
+
`);
|
|
4863
|
+
}
|
|
2839
4864
|
};
|
|
4865
|
+
mountSessionDebugger();
|
|
2840
4866
|
}
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
userId,
|
|
2847
|
-
courseConfig,
|
|
2848
|
-
getEffectiveWeight(strategyId, learnable) {
|
|
2849
|
-
return computeEffectiveWeight(learnable, userId, strategyId, salt);
|
|
2850
|
-
},
|
|
2851
|
-
getDeviation(strategyId) {
|
|
2852
|
-
return computeDeviation(userId, strategyId, salt);
|
|
2853
|
-
}
|
|
2854
|
-
};
|
|
2855
|
-
}
|
|
2856
|
-
var MIN_SPREAD, MAX_SPREAD, MIN_WEIGHT, MAX_WEIGHT;
|
|
2857
|
-
var init_orchestration = __esm({
|
|
2858
|
-
"src/core/orchestration/index.ts"() {
|
|
4867
|
+
});
|
|
4868
|
+
|
|
4869
|
+
// src/study/SessionController.ts
|
|
4870
|
+
var init_SessionController = __esm({
|
|
4871
|
+
"src/study/SessionController.ts"() {
|
|
2859
4872
|
"use strict";
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
4873
|
+
init_SrsService();
|
|
4874
|
+
init_EloService();
|
|
4875
|
+
init_ResponseProcessor();
|
|
4876
|
+
init_CardHydrationService();
|
|
4877
|
+
init_ItemQueue();
|
|
4878
|
+
init_couch();
|
|
2864
4879
|
init_recording();
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
4880
|
+
init_util2();
|
|
4881
|
+
init_navigators();
|
|
4882
|
+
init_SourceMixer();
|
|
4883
|
+
init_MixerDebugger();
|
|
4884
|
+
init_SessionDebugger();
|
|
4885
|
+
init_logger();
|
|
2869
4886
|
}
|
|
2870
4887
|
});
|
|
2871
4888
|
|
|
@@ -2874,7 +4891,7 @@ var Pipeline_exports = {};
|
|
|
2874
4891
|
__export(Pipeline_exports, {
|
|
2875
4892
|
Pipeline: () => Pipeline
|
|
2876
4893
|
});
|
|
2877
|
-
import { toCourseElo as
|
|
4894
|
+
import { toCourseElo as toCourseElo7 } from "@vue-skuilder/common";
|
|
2878
4895
|
function globToRegex(pattern) {
|
|
2879
4896
|
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
2880
4897
|
const withWildcards = escaped.replace(/\*/g, ".*");
|
|
@@ -2958,6 +4975,7 @@ var init_Pipeline = __esm({
|
|
|
2958
4975
|
init_logger();
|
|
2959
4976
|
init_orchestration();
|
|
2960
4977
|
init_PipelineDebugger();
|
|
4978
|
+
init_SessionController();
|
|
2961
4979
|
VERBOSE_RESULTS = true;
|
|
2962
4980
|
Pipeline = class extends ContentNavigator {
|
|
2963
4981
|
generator;
|
|
@@ -3117,8 +5135,9 @@ var init_Pipeline = __esm({
|
|
|
3117
5135
|
generatorSummaries,
|
|
3118
5136
|
generatedCount,
|
|
3119
5137
|
filterImpacts,
|
|
3120
|
-
|
|
3121
|
-
result
|
|
5138
|
+
cards,
|
|
5139
|
+
result,
|
|
5140
|
+
context.userElo
|
|
3122
5141
|
);
|
|
3123
5142
|
captureRun(report);
|
|
3124
5143
|
} catch (e) {
|
|
@@ -3201,7 +5220,7 @@ var init_Pipeline = __esm({
|
|
|
3201
5220
|
card.provenance.push({
|
|
3202
5221
|
strategy: "ephemeralHint",
|
|
3203
5222
|
strategyId: "ephemeral-hint",
|
|
3204
|
-
strategyName: "Replan Hint",
|
|
5223
|
+
strategyName: hints._label ? `Replan Hint (${hints._label})` : "Replan Hint",
|
|
3205
5224
|
action: "boosted",
|
|
3206
5225
|
score: card.score,
|
|
3207
5226
|
reason: `boostTag ${pattern} \xD7${factor}`
|
|
@@ -3218,7 +5237,7 @@ var init_Pipeline = __esm({
|
|
|
3218
5237
|
card.provenance.push({
|
|
3219
5238
|
strategy: "ephemeralHint",
|
|
3220
5239
|
strategyId: "ephemeral-hint",
|
|
3221
|
-
strategyName: "Replan Hint",
|
|
5240
|
+
strategyName: hints._label ? `Replan Hint (${hints._label})` : "Replan Hint",
|
|
3222
5241
|
action: "boosted",
|
|
3223
5242
|
score: card.score,
|
|
3224
5243
|
reason: `boostCard ${pattern} \xD7${factor}`
|
|
@@ -3228,6 +5247,7 @@ var init_Pipeline = __esm({
|
|
|
3228
5247
|
}
|
|
3229
5248
|
}
|
|
3230
5249
|
const cardIds = new Set(cards.map((c) => c.cardId));
|
|
5250
|
+
const hintLabel = hints._label ? `Replan Hint (${hints._label})` : "Replan Hint";
|
|
3231
5251
|
const inject = (card, reason) => {
|
|
3232
5252
|
if (!cardIds.has(card.cardId)) {
|
|
3233
5253
|
const floorScore = Math.max(card.score, 1);
|
|
@@ -3239,7 +5259,7 @@ var init_Pipeline = __esm({
|
|
|
3239
5259
|
{
|
|
3240
5260
|
strategy: "ephemeralHint",
|
|
3241
5261
|
strategyId: "ephemeral-hint",
|
|
3242
|
-
strategyName:
|
|
5262
|
+
strategyName: hintLabel,
|
|
3243
5263
|
action: "boosted",
|
|
3244
5264
|
score: floorScore,
|
|
3245
5265
|
reason
|
|
@@ -3278,7 +5298,7 @@ var init_Pipeline = __esm({
|
|
|
3278
5298
|
let userElo = 1e3;
|
|
3279
5299
|
try {
|
|
3280
5300
|
const courseReg = await this.user.getCourseRegDoc(this.course.getCourseID());
|
|
3281
|
-
const courseElo =
|
|
5301
|
+
const courseElo = toCourseElo7(courseReg.elo);
|
|
3282
5302
|
userElo = courseElo.global.score;
|
|
3283
5303
|
} catch (e) {
|
|
3284
5304
|
logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`);
|
|
@@ -3331,6 +5351,34 @@ var init_Pipeline = __esm({
|
|
|
3331
5351
|
return [...new Set(ids)];
|
|
3332
5352
|
}
|
|
3333
5353
|
// ---------------------------------------------------------------------------
|
|
5354
|
+
// Tag ELO diagnostic
|
|
5355
|
+
// ---------------------------------------------------------------------------
|
|
5356
|
+
/**
|
|
5357
|
+
* Get the user's per-tag ELO data for specified tags (or all tags).
|
|
5358
|
+
* Useful for diagnosing why hierarchy gates are open/closed.
|
|
5359
|
+
*/
|
|
5360
|
+
async getTagEloStatus(tagFilter) {
|
|
5361
|
+
const courseReg = await this.user.getCourseRegDoc(this.course.getCourseID());
|
|
5362
|
+
const courseElo = toCourseElo7(courseReg.elo);
|
|
5363
|
+
const result = {};
|
|
5364
|
+
if (!tagFilter) {
|
|
5365
|
+
for (const [tag, data] of Object.entries(courseElo.tags)) {
|
|
5366
|
+
result[tag] = { score: data.score, count: data.count };
|
|
5367
|
+
}
|
|
5368
|
+
} else {
|
|
5369
|
+
const patterns = Array.isArray(tagFilter) ? tagFilter : [tagFilter];
|
|
5370
|
+
for (const pattern of patterns) {
|
|
5371
|
+
const regex = globToRegex(pattern);
|
|
5372
|
+
for (const [tag, data] of Object.entries(courseElo.tags)) {
|
|
5373
|
+
if (regex.test(tag)) {
|
|
5374
|
+
result[tag] = { score: data.score, count: data.count };
|
|
5375
|
+
}
|
|
5376
|
+
}
|
|
5377
|
+
}
|
|
5378
|
+
}
|
|
5379
|
+
return result;
|
|
5380
|
+
}
|
|
5381
|
+
// ---------------------------------------------------------------------------
|
|
3334
5382
|
// Card-space diagnostic
|
|
3335
5383
|
// ---------------------------------------------------------------------------
|
|
3336
5384
|
/**
|
|
@@ -3913,7 +5961,7 @@ import {
|
|
|
3913
5961
|
EloToNumber,
|
|
3914
5962
|
Status,
|
|
3915
5963
|
blankCourseElo as blankCourseElo2,
|
|
3916
|
-
toCourseElo as
|
|
5964
|
+
toCourseElo as toCourseElo8
|
|
3917
5965
|
} from "@vue-skuilder/common";
|
|
3918
5966
|
function randIntWeightedTowardZero(n) {
|
|
3919
5967
|
return Math.floor(Math.random() * Math.random() * Math.random() * n);
|
|
@@ -4097,7 +6145,7 @@ var init_courseDB = __esm({
|
|
|
4097
6145
|
docs.rows.forEach((r) => {
|
|
4098
6146
|
if (isSuccessRow(r)) {
|
|
4099
6147
|
if (r.doc && r.doc.elo) {
|
|
4100
|
-
ret.push(
|
|
6148
|
+
ret.push(toCourseElo8(r.doc.elo));
|
|
4101
6149
|
} else {
|
|
4102
6150
|
logger.warn("no elo data for card: " + r.id);
|
|
4103
6151
|
ret.push(blankCourseElo2());
|
|
@@ -4448,10 +6496,18 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
4448
6496
|
* @param limit - Maximum number of cards to return
|
|
4449
6497
|
* @returns Cards sorted by score descending
|
|
4450
6498
|
*/
|
|
6499
|
+
_pendingHints = null;
|
|
6500
|
+
setEphemeralHints(hints) {
|
|
6501
|
+
this._pendingHints = hints;
|
|
6502
|
+
}
|
|
4451
6503
|
async getWeightedCards(limit) {
|
|
4452
6504
|
const u = await this._getCurrentUser();
|
|
4453
6505
|
try {
|
|
4454
6506
|
const navigator = await this.createNavigator(u);
|
|
6507
|
+
if (this._pendingHints) {
|
|
6508
|
+
navigator.setEphemeralHints(this._pendingHints);
|
|
6509
|
+
this._pendingHints = null;
|
|
6510
|
+
}
|
|
4455
6511
|
return navigator.getWeightedCards(limit);
|
|
4456
6512
|
} catch (e) {
|
|
4457
6513
|
logger.error(`[courseDB] Error getting weighted cards: ${e}`);
|
|
@@ -4594,7 +6650,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
4594
6650
|
});
|
|
4595
6651
|
|
|
4596
6652
|
// src/impl/couch/classroomDB.ts
|
|
4597
|
-
import
|
|
6653
|
+
import moment6 from "moment";
|
|
4598
6654
|
var CLASSROOM_CONFIG, ClassroomDBBase, StudentClassroomDB;
|
|
4599
6655
|
var init_classroomDB2 = __esm({
|
|
4600
6656
|
"src/impl/couch/classroomDB.ts"() {
|
|
@@ -4712,9 +6768,9 @@ var init_classroomDB2 = __esm({
|
|
|
4712
6768
|
}
|
|
4713
6769
|
const activeCards = await this._user.getActiveCards();
|
|
4714
6770
|
const activeCardIds = new Set(activeCards.map((ac) => ac.cardID));
|
|
4715
|
-
const now =
|
|
6771
|
+
const now = moment6.utc();
|
|
4716
6772
|
const assigned = await this.getAssignedContent();
|
|
4717
|
-
const due = assigned.filter((c) => now.isAfter(
|
|
6773
|
+
const due = assigned.filter((c) => now.isAfter(moment6.utc(c.activeOn, REVIEW_TIME_FORMAT2)));
|
|
4718
6774
|
logger.info(`[StudentClassroomDB] Due content: ${JSON.stringify(due)}`);
|
|
4719
6775
|
for (const content of due) {
|
|
4720
6776
|
if (content.type === "course") {
|
|
@@ -4812,7 +6868,7 @@ var init_CourseSyncService = __esm({
|
|
|
4812
6868
|
});
|
|
4813
6869
|
|
|
4814
6870
|
// src/impl/couch/auth.ts
|
|
4815
|
-
import
|
|
6871
|
+
import fetch2 from "cross-fetch";
|
|
4816
6872
|
var init_auth = __esm({
|
|
4817
6873
|
"src/impl/couch/auth.ts"() {
|
|
4818
6874
|
"use strict";
|
|
@@ -4837,8 +6893,8 @@ var init_CouchDBSyncStrategy = __esm({
|
|
|
4837
6893
|
});
|
|
4838
6894
|
|
|
4839
6895
|
// src/impl/couch/index.ts
|
|
4840
|
-
import
|
|
4841
|
-
import
|
|
6896
|
+
import fetch3 from "cross-fetch";
|
|
6897
|
+
import moment7 from "moment";
|
|
4842
6898
|
import process2 from "process";
|
|
4843
6899
|
function createPouchDBConfig() {
|
|
4844
6900
|
const hasExplicitCredentials = ENV.COUCHDB_USERNAME && ENV.COUCHDB_PASSWORD;
|
|
@@ -4915,7 +6971,7 @@ var init_couch = __esm({
|
|
|
4915
6971
|
|
|
4916
6972
|
// src/impl/common/BaseUserDB.ts
|
|
4917
6973
|
import { Status as Status3 } from "@vue-skuilder/common";
|
|
4918
|
-
import
|
|
6974
|
+
import moment8 from "moment";
|
|
4919
6975
|
async function getOrCreateClassroomRegistrationsDoc(user) {
|
|
4920
6976
|
let ret;
|
|
4921
6977
|
try {
|
|
@@ -5277,7 +7333,7 @@ Currently logged-in as ${this._username}.`
|
|
|
5277
7333
|
);
|
|
5278
7334
|
return reviews.rows.filter((r) => {
|
|
5279
7335
|
if (r.id.startsWith(DocTypePrefixes["SCHEDULED_CARD" /* SCHEDULED_CARD */])) {
|
|
5280
|
-
const date =
|
|
7336
|
+
const date = moment8.utc(
|
|
5281
7337
|
r.id.substr(DocTypePrefixes["SCHEDULED_CARD" /* SCHEDULED_CARD */].length),
|
|
5282
7338
|
REVIEW_TIME_FORMAT
|
|
5283
7339
|
);
|
|
@@ -5290,11 +7346,11 @@ Currently logged-in as ${this._username}.`
|
|
|
5290
7346
|
}).map((r) => r.doc);
|
|
5291
7347
|
}
|
|
5292
7348
|
async getReviewsForcast(daysCount) {
|
|
5293
|
-
const time =
|
|
7349
|
+
const time = moment8.utc().add(daysCount, "days");
|
|
5294
7350
|
return this.getReviewstoDate(time);
|
|
5295
7351
|
}
|
|
5296
7352
|
async getPendingReviews(course_id) {
|
|
5297
|
-
const now =
|
|
7353
|
+
const now = moment8.utc();
|
|
5298
7354
|
return this.getReviewstoDate(now, course_id);
|
|
5299
7355
|
}
|
|
5300
7356
|
async getScheduledReviewCount(course_id) {
|
|
@@ -5581,7 +7637,7 @@ Currently logged-in as ${this._username}.`
|
|
|
5581
7637
|
*/
|
|
5582
7638
|
async putCardRecord(record) {
|
|
5583
7639
|
const cardHistoryID = getCardHistoryID(record.courseID, record.cardID);
|
|
5584
|
-
record.timeStamp =
|
|
7640
|
+
record.timeStamp = moment8.utc(record.timeStamp).toString();
|
|
5585
7641
|
try {
|
|
5586
7642
|
const cardHistory = await this.update(
|
|
5587
7643
|
cardHistoryID,
|
|
@@ -5597,7 +7653,7 @@ Currently logged-in as ${this._username}.`
|
|
|
5597
7653
|
const ret = {
|
|
5598
7654
|
...record2
|
|
5599
7655
|
};
|
|
5600
|
-
ret.timeStamp =
|
|
7656
|
+
ret.timeStamp = moment8.utc(record2.timeStamp);
|
|
5601
7657
|
return ret;
|
|
5602
7658
|
});
|
|
5603
7659
|
return cardHistory;
|
|
@@ -6297,7 +8353,7 @@ var init_cardProcessor = __esm({
|
|
|
6297
8353
|
});
|
|
6298
8354
|
|
|
6299
8355
|
// src/core/bulkImport/types.ts
|
|
6300
|
-
var
|
|
8356
|
+
var init_types5 = __esm({
|
|
6301
8357
|
"src/core/bulkImport/types.ts"() {
|
|
6302
8358
|
"use strict";
|
|
6303
8359
|
}
|
|
@@ -6308,7 +8364,7 @@ var init_bulkImport = __esm({
|
|
|
6308
8364
|
"src/core/bulkImport/index.ts"() {
|
|
6309
8365
|
"use strict";
|
|
6310
8366
|
init_cardProcessor();
|
|
6311
|
-
|
|
8367
|
+
init_types5();
|
|
6312
8368
|
}
|
|
6313
8369
|
});
|
|
6314
8370
|
|