@vue-skuilder/db 0.1.20 → 0.1.21
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/CLAUDE.md +2 -2
- package/dist/{classroomDB-CZdMBiTU.d.ts → contentSource-BP9hznNV.d.ts} +150 -196
- package/dist/{classroomDB-PxDZTky3.d.cts → contentSource-DsJadoBU.d.cts} +150 -196
- package/dist/core/index.d.cts +3 -3
- package/dist/core/index.d.ts +3 -3
- package/dist/core/index.js +615 -1758
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +579 -1727
- package/dist/core/index.mjs.map +1 -1
- package/dist/{dataLayerProvider-D8o6ZnKW.d.ts → dataLayerProvider-CHYrQ5pB.d.cts} +1 -1
- package/dist/{dataLayerProvider-D0MoZMjH.d.cts → dataLayerProvider-MDTxXq2l.d.ts} +1 -1
- package/dist/impl/couch/index.d.cts +6 -22
- package/dist/impl/couch/index.d.ts +6 -22
- package/dist/impl/couch/index.js +598 -1769
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +579 -1755
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.d.cts +22 -6
- package/dist/impl/static/index.d.ts +22 -6
- package/dist/impl/static/index.js +617 -1629
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +607 -1624
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/index.d.cts +64 -56
- package/dist/index.d.ts +64 -56
- package/dist/index.js +1000 -2161
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +970 -2127
- package/dist/index.mjs.map +1 -1
- package/dist/pouch/index.js +3 -0
- package/dist/pouch/index.js.map +1 -1
- package/dist/pouch/index.mjs +3 -0
- package/dist/pouch/index.mjs.map +1 -1
- package/docs/navigators-architecture.md +2 -9
- package/package.json +3 -3
- package/src/core/interfaces/classroomDB.ts +5 -13
- package/src/core/interfaces/contentSource.ts +6 -66
- package/src/core/interfaces/courseDB.ts +2 -7
- package/src/core/navigators/Pipeline.ts +24 -53
- package/src/core/navigators/PipelineAssembler.ts +1 -1
- package/src/core/navigators/defaults.ts +84 -0
- package/src/core/navigators/{hierarchyDefinition.ts → filters/hierarchyDefinition.ts} +11 -25
- package/src/core/navigators/{interferenceMitigator.ts → filters/interferenceMitigator.ts} +10 -24
- package/src/core/navigators/{relativePriority.ts → filters/relativePriority.ts} +10 -24
- package/src/core/navigators/filters/userTagPreference.ts +1 -16
- package/src/core/navigators/{CompositeGenerator.ts → generators/CompositeGenerator.ts} +15 -64
- package/src/core/navigators/{elo.ts → generators/elo.ts} +13 -63
- package/src/core/navigators/{srs.ts → generators/srs.ts} +11 -40
- package/src/core/navigators/generators/types.ts +1 -1
- package/src/core/navigators/index.ts +36 -91
- package/src/impl/couch/classroomDB.ts +100 -103
- package/src/impl/couch/courseDB.ts +5 -81
- package/src/impl/couch/pouchdb-setup.ts +7 -0
- package/src/impl/static/StaticDataUnpacker.ts +50 -1
- package/src/impl/static/courseDB.ts +76 -37
- package/src/study/SessionController.ts +122 -202
- package/src/study/SourceMixer.ts +65 -0
- package/src/study/TagFilteredContentSource.ts +49 -92
- package/src/study/index.ts +1 -0
- package/src/study/services/CardHydrationService.ts +165 -81
- package/src/util/dataDirectory.ts +1 -1
- package/src/util/index.ts +0 -1
- package/tests/core/navigators/CompositeGenerator.test.ts +44 -168
- package/tests/core/navigators/Pipeline.test.ts +5 -72
- package/tests/core/navigators/PipelineAssembler.test.ts +8 -58
- package/tests/core/navigators/navigators.test.ts +118 -151
- package/src/core/navigators/hardcodedOrder.ts +0 -163
- package/src/util/tuiLogger.ts +0 -139
- /package/src/core/navigators/{inferredPreference.ts → filters/inferredPreferenceStub.ts} +0 -0
- /package/src/core/navigators/{userGoal.ts → filters/userGoalStub.ts} +0 -0
|
@@ -1,17 +1,7 @@
|
|
|
1
|
-
var __defProp = Object.defineProperty;
|
|
2
1
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
3
|
-
var __glob = (map) => (path2) => {
|
|
4
|
-
var fn = map[path2];
|
|
5
|
-
if (fn) return fn();
|
|
6
|
-
throw new Error("Module not found in bundle: " + path2);
|
|
7
|
-
};
|
|
8
2
|
var __esm = (fn, res) => function __init() {
|
|
9
3
|
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
10
4
|
};
|
|
11
|
-
var __export = (target, all) => {
|
|
12
|
-
for (var name in all)
|
|
13
|
-
__defProp(target, name, { get: all[name], enumerable: true });
|
|
14
|
-
};
|
|
15
5
|
|
|
16
6
|
// src/impl/common/SyncStrategy.ts
|
|
17
7
|
var init_SyncStrategy = __esm({
|
|
@@ -89,6 +79,9 @@ var init_pouchdb_setup = __esm({
|
|
|
89
79
|
"use strict";
|
|
90
80
|
PouchDB.plugin(PouchDBFind);
|
|
91
81
|
PouchDB.plugin(PouchDBAuth);
|
|
82
|
+
if (typeof PouchDB.debug !== "undefined") {
|
|
83
|
+
PouchDB.debug.disable();
|
|
84
|
+
}
|
|
92
85
|
PouchDB.defaults({
|
|
93
86
|
// ajax: {
|
|
94
87
|
// timeout: 60000,
|
|
@@ -600,195 +593,159 @@ var init_courseLookupDB = __esm({
|
|
|
600
593
|
}
|
|
601
594
|
});
|
|
602
595
|
|
|
603
|
-
// src/core/navigators/
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
var
|
|
611
|
-
|
|
596
|
+
// src/core/navigators/index.ts
|
|
597
|
+
function isGenerator(impl) {
|
|
598
|
+
return NavigatorRoles[impl] === "generator" /* GENERATOR */;
|
|
599
|
+
}
|
|
600
|
+
function isFilter(impl) {
|
|
601
|
+
return NavigatorRoles[impl] === "filter" /* FILTER */;
|
|
602
|
+
}
|
|
603
|
+
var NavigatorRoles, ContentNavigator;
|
|
604
|
+
var init_navigators = __esm({
|
|
605
|
+
"src/core/navigators/index.ts"() {
|
|
612
606
|
"use strict";
|
|
613
|
-
init_navigators();
|
|
614
607
|
init_logger();
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
/**
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
if (generators.length === 0) {
|
|
633
|
-
throw new Error("CompositeGenerator requires at least one generator");
|
|
634
|
-
}
|
|
635
|
-
logger.debug(
|
|
636
|
-
`[CompositeGenerator] Created with ${generators.length} generators, mode: ${aggregationMode}`
|
|
637
|
-
);
|
|
638
|
-
}
|
|
608
|
+
NavigatorRoles = {
|
|
609
|
+
["elo" /* ELO */]: "generator" /* GENERATOR */,
|
|
610
|
+
["srs" /* SRS */]: "generator" /* GENERATOR */,
|
|
611
|
+
["hierarchyDefinition" /* HIERARCHY */]: "filter" /* FILTER */,
|
|
612
|
+
["interferenceMitigator" /* INTERFERENCE */]: "filter" /* FILTER */,
|
|
613
|
+
["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */,
|
|
614
|
+
["userTagPreference" /* USER_TAG_PREFERENCE */]: "filter" /* FILTER */
|
|
615
|
+
};
|
|
616
|
+
ContentNavigator = class {
|
|
617
|
+
/** User interface for this navigation session */
|
|
618
|
+
user;
|
|
619
|
+
/** Course interface for this navigation session */
|
|
620
|
+
course;
|
|
621
|
+
/** Human-readable name for this strategy instance (from ContentNavigationStrategyData.name) */
|
|
622
|
+
strategyName;
|
|
623
|
+
/** Unique document ID for this strategy instance (from ContentNavigationStrategyData._id) */
|
|
624
|
+
strategyId;
|
|
639
625
|
/**
|
|
640
|
-
*
|
|
626
|
+
* Constructor for standard navigators.
|
|
627
|
+
* Call this from subclass constructors to initialize common fields.
|
|
641
628
|
*
|
|
642
|
-
*
|
|
629
|
+
* Note: CompositeGenerator and Pipeline call super() without args, then set
|
|
630
|
+
* user/course fields directly if needed.
|
|
643
631
|
*/
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
)
|
|
648
|
-
|
|
632
|
+
constructor(user, course, strategyData) {
|
|
633
|
+
this.user = user;
|
|
634
|
+
this.course = course;
|
|
635
|
+
if (strategyData) {
|
|
636
|
+
this.strategyName = strategyData.name;
|
|
637
|
+
this.strategyId = strategyData._id;
|
|
638
|
+
}
|
|
649
639
|
}
|
|
640
|
+
// ============================================================================
|
|
641
|
+
// STRATEGY STATE HELPERS
|
|
642
|
+
// ============================================================================
|
|
643
|
+
//
|
|
644
|
+
// These methods allow strategies to persist their own state (user preferences,
|
|
645
|
+
// learned patterns, temporal tracking) in the user database.
|
|
646
|
+
//
|
|
647
|
+
// ============================================================================
|
|
650
648
|
/**
|
|
651
|
-
*
|
|
652
|
-
*
|
|
653
|
-
* Cards appearing in multiple generators receive a score boost.
|
|
654
|
-
* Provenance tracks which generators produced each card and how scores were aggregated.
|
|
655
|
-
*
|
|
656
|
-
* This method supports both the legacy signature (limit only) and the
|
|
657
|
-
* CardGenerator interface signature (limit, context).
|
|
649
|
+
* Unique key identifying this strategy for state storage.
|
|
658
650
|
*
|
|
659
|
-
*
|
|
660
|
-
*
|
|
651
|
+
* Defaults to the constructor name (e.g., "UserTagPreferenceFilter").
|
|
652
|
+
* Override in subclasses if multiple instances of the same strategy type
|
|
653
|
+
* need separate state storage.
|
|
661
654
|
*/
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
this.generators.map((g) => g.getWeightedCards(limit, context))
|
|
665
|
-
);
|
|
666
|
-
const byCardId = /* @__PURE__ */ new Map();
|
|
667
|
-
for (const cards of results) {
|
|
668
|
-
for (const card of cards) {
|
|
669
|
-
const existing = byCardId.get(card.cardId) || [];
|
|
670
|
-
existing.push(card);
|
|
671
|
-
byCardId.set(card.cardId, existing);
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
const merged = [];
|
|
675
|
-
for (const [, cards] of byCardId) {
|
|
676
|
-
const aggregatedScore = this.aggregateScores(cards);
|
|
677
|
-
const finalScore = Math.min(1, aggregatedScore);
|
|
678
|
-
const mergedProvenance = cards.flatMap((c) => c.provenance);
|
|
679
|
-
const initialScore = cards[0].score;
|
|
680
|
-
const action = finalScore > initialScore ? "boosted" : finalScore < initialScore ? "penalized" : "passed";
|
|
681
|
-
const reason = this.buildAggregationReason(cards, finalScore);
|
|
682
|
-
merged.push({
|
|
683
|
-
...cards[0],
|
|
684
|
-
score: finalScore,
|
|
685
|
-
provenance: [
|
|
686
|
-
...mergedProvenance,
|
|
687
|
-
{
|
|
688
|
-
strategy: "composite",
|
|
689
|
-
strategyName: "Composite Generator",
|
|
690
|
-
strategyId: "COMPOSITE_GENERATOR",
|
|
691
|
-
action,
|
|
692
|
-
score: finalScore,
|
|
693
|
-
reason
|
|
694
|
-
}
|
|
695
|
-
]
|
|
696
|
-
});
|
|
697
|
-
}
|
|
698
|
-
return merged.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
655
|
+
get strategyKey() {
|
|
656
|
+
return this.constructor.name;
|
|
699
657
|
}
|
|
700
658
|
/**
|
|
701
|
-
*
|
|
659
|
+
* Get this strategy's persisted state for the current course.
|
|
660
|
+
*
|
|
661
|
+
* @returns The strategy's data payload, or null if no state exists
|
|
662
|
+
* @throws Error if user or course is not initialized
|
|
702
663
|
*/
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
}
|
|
709
|
-
const strategies = cards.map((c) => c.provenance[0]?.strategy || "unknown").join(", ");
|
|
710
|
-
switch (this.aggregationMode) {
|
|
711
|
-
case "max" /* MAX */:
|
|
712
|
-
return `Max of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
|
|
713
|
-
case "average" /* AVERAGE */:
|
|
714
|
-
return `Average of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
|
|
715
|
-
case "frequencyBoost" /* FREQUENCY_BOOST */: {
|
|
716
|
-
const avg = cards.reduce((sum, c) => sum + c.score, 0) / count;
|
|
717
|
-
const boost = 1 + FREQUENCY_BOOST_FACTOR * (count - 1);
|
|
718
|
-
return `Frequency boost from ${count} generators (${strategies}): avg ${avg.toFixed(2)} \xD7 ${boost.toFixed(2)} \u2192 ${finalScore.toFixed(2)}`;
|
|
719
|
-
}
|
|
720
|
-
default:
|
|
721
|
-
return `Aggregated from ${count} generators: ${finalScore.toFixed(2)}`;
|
|
664
|
+
async getStrategyState() {
|
|
665
|
+
if (!this.user || !this.course) {
|
|
666
|
+
throw new Error(
|
|
667
|
+
`Cannot get strategy state: navigator not properly initialized. Ensure user and course are provided to constructor.`
|
|
668
|
+
);
|
|
722
669
|
}
|
|
670
|
+
return this.user.getStrategyState(this.course.getCourseID(), this.strategyKey);
|
|
723
671
|
}
|
|
724
672
|
/**
|
|
725
|
-
*
|
|
673
|
+
* Persist this strategy's state for the current course.
|
|
674
|
+
*
|
|
675
|
+
* @param data - The strategy's data payload to store
|
|
676
|
+
* @throws Error if user or course is not initialized
|
|
726
677
|
*/
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
case "average" /* AVERAGE */:
|
|
733
|
-
return scores.reduce((sum, s) => sum + s, 0) / scores.length;
|
|
734
|
-
case "frequencyBoost" /* FREQUENCY_BOOST */: {
|
|
735
|
-
const avg = scores.reduce((sum, s) => sum + s, 0) / scores.length;
|
|
736
|
-
const frequencyBoost = 1 + FREQUENCY_BOOST_FACTOR * (cards.length - 1);
|
|
737
|
-
return avg * frequencyBoost;
|
|
738
|
-
}
|
|
739
|
-
default:
|
|
740
|
-
return scores[0];
|
|
678
|
+
async putStrategyState(data) {
|
|
679
|
+
if (!this.user || !this.course) {
|
|
680
|
+
throw new Error(
|
|
681
|
+
`Cannot put strategy state: navigator not properly initialized. Ensure user and course are provided to constructor.`
|
|
682
|
+
);
|
|
741
683
|
}
|
|
684
|
+
return this.user.putStrategyState(this.course.getCourseID(), this.strategyKey, data);
|
|
742
685
|
}
|
|
743
686
|
/**
|
|
744
|
-
*
|
|
687
|
+
* Factory method to create navigator instances dynamically.
|
|
688
|
+
*
|
|
689
|
+
* @param user - User interface
|
|
690
|
+
* @param course - Course interface
|
|
691
|
+
* @param strategyData - Strategy configuration document
|
|
692
|
+
* @returns the runtime object used to steer a study session.
|
|
745
693
|
*/
|
|
746
|
-
async
|
|
747
|
-
const
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
const
|
|
751
|
-
const
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
694
|
+
static async create(user, course, strategyData) {
|
|
695
|
+
const implementingClass = strategyData.implementingClass;
|
|
696
|
+
let NavigatorImpl;
|
|
697
|
+
const variations = [".ts", ".js", ""];
|
|
698
|
+
const dirs = ["filters", "generators"];
|
|
699
|
+
for (const ext of variations) {
|
|
700
|
+
for (const dir of dirs) {
|
|
701
|
+
const loadFrom = `./${dir}/${implementingClass}${ext}`;
|
|
702
|
+
try {
|
|
703
|
+
const module = await import(loadFrom);
|
|
704
|
+
NavigatorImpl = module.default;
|
|
705
|
+
break;
|
|
706
|
+
} catch (e) {
|
|
707
|
+
logger.debug(`Failed to load extension from ${loadFrom}:`, e);
|
|
758
708
|
}
|
|
759
709
|
}
|
|
760
710
|
}
|
|
761
|
-
|
|
711
|
+
if (!NavigatorImpl) {
|
|
712
|
+
throw new Error(`Could not load navigator implementation for: ${implementingClass}`);
|
|
713
|
+
}
|
|
714
|
+
return new NavigatorImpl(user, course, strategyData);
|
|
762
715
|
}
|
|
763
716
|
/**
|
|
764
|
-
* Get
|
|
717
|
+
* Get cards with suitability scores and provenance trails.
|
|
718
|
+
*
|
|
719
|
+
* **This is the PRIMARY API for navigation strategies.**
|
|
720
|
+
*
|
|
721
|
+
* Returns cards ranked by suitability score (0-1). Higher scores indicate
|
|
722
|
+
* better candidates for presentation. Each card includes a provenance trail
|
|
723
|
+
* documenting how strategies contributed to the final score.
|
|
724
|
+
*
|
|
725
|
+
* ## Implementation Required
|
|
726
|
+
* All navigation strategies MUST override this method. The base class does
|
|
727
|
+
* not provide a default implementation.
|
|
728
|
+
*
|
|
729
|
+
* ## For Generators
|
|
730
|
+
* Override this method to generate candidates and compute scores based on
|
|
731
|
+
* your strategy's logic (e.g., ELO proximity, review urgency). Create the
|
|
732
|
+
* initial provenance entry with action='generated'.
|
|
733
|
+
*
|
|
734
|
+
* ## For Filters
|
|
735
|
+
* Filters should implement the CardFilter interface instead and be composed
|
|
736
|
+
* via Pipeline. Filters do not directly implement getWeightedCards().
|
|
737
|
+
*
|
|
738
|
+
* @param limit - Maximum cards to return
|
|
739
|
+
* @returns Cards sorted by score descending, with provenance trails
|
|
765
740
|
*/
|
|
766
|
-
async
|
|
767
|
-
|
|
768
|
-
(g) => g instanceof ContentNavigator
|
|
769
|
-
);
|
|
770
|
-
const results = await Promise.all(legacyGenerators.map((g) => g.getPendingReviews()));
|
|
771
|
-
const seen = /* @__PURE__ */ new Set();
|
|
772
|
-
const merged = [];
|
|
773
|
-
for (const reviews of results) {
|
|
774
|
-
for (const review of reviews) {
|
|
775
|
-
if (!seen.has(review.cardID)) {
|
|
776
|
-
seen.add(review.cardID);
|
|
777
|
-
merged.push(review);
|
|
778
|
-
}
|
|
779
|
-
}
|
|
780
|
-
}
|
|
781
|
-
return merged;
|
|
741
|
+
async getWeightedCards(_limit) {
|
|
742
|
+
throw new Error(`${this.constructor.name} must implement getWeightedCards(). `);
|
|
782
743
|
}
|
|
783
744
|
};
|
|
784
745
|
}
|
|
785
746
|
});
|
|
786
747
|
|
|
787
748
|
// src/core/navigators/Pipeline.ts
|
|
788
|
-
var Pipeline_exports = {};
|
|
789
|
-
__export(Pipeline_exports, {
|
|
790
|
-
Pipeline: () => Pipeline
|
|
791
|
-
});
|
|
792
749
|
import { toCourseElo as toCourseElo2 } from "@vue-skuilder/common";
|
|
793
750
|
function logPipelineConfig(generator, filters) {
|
|
794
751
|
const filterList = filters.length > 0 ? "\n - " + filters.map((f) => f.name).join("\n - ") : " none";
|
|
@@ -848,6 +805,11 @@ var init_Pipeline = __esm({
|
|
|
848
805
|
this.filters = filters;
|
|
849
806
|
this.user = user;
|
|
850
807
|
this.course = course;
|
|
808
|
+
course.getCourseConfig().then((cfg) => {
|
|
809
|
+
logger.debug(`[pipeline] Crated pipeline for ${cfg.name}`);
|
|
810
|
+
}).catch((e) => {
|
|
811
|
+
logger.error(`[pipeline] Failed to lookup courseCfg: ${e}`);
|
|
812
|
+
});
|
|
851
813
|
logPipelineConfig(generator, filters);
|
|
852
814
|
}
|
|
853
815
|
/**
|
|
@@ -884,7 +846,13 @@ var init_Pipeline = __esm({
|
|
|
884
846
|
cards.sort((a, b) => b.score - a.score);
|
|
885
847
|
const result = cards.slice(0, limit);
|
|
886
848
|
const topScores = result.slice(0, 3).map((c) => c.score);
|
|
887
|
-
logExecutionSummary(
|
|
849
|
+
logExecutionSummary(
|
|
850
|
+
this.generator.name,
|
|
851
|
+
generatedCount,
|
|
852
|
+
this.filters.length,
|
|
853
|
+
result.length,
|
|
854
|
+
topScores
|
|
855
|
+
);
|
|
888
856
|
logCardProvenance(result, 3);
|
|
889
857
|
return result;
|
|
890
858
|
}
|
|
@@ -933,33 +901,6 @@ var init_Pipeline = __esm({
|
|
|
933
901
|
userElo
|
|
934
902
|
};
|
|
935
903
|
}
|
|
936
|
-
// ===========================================================================
|
|
937
|
-
// Legacy StudyContentSource methods
|
|
938
|
-
// ===========================================================================
|
|
939
|
-
//
|
|
940
|
-
// These delegate to the generator for backward compatibility.
|
|
941
|
-
// Eventually SessionController will use getWeightedCards() exclusively.
|
|
942
|
-
//
|
|
943
|
-
/**
|
|
944
|
-
* Get new cards via legacy API.
|
|
945
|
-
* Delegates to the generator if it supports the legacy interface.
|
|
946
|
-
*/
|
|
947
|
-
async getNewCards(n) {
|
|
948
|
-
if ("getNewCards" in this.generator && typeof this.generator.getNewCards === "function") {
|
|
949
|
-
return this.generator.getNewCards(n);
|
|
950
|
-
}
|
|
951
|
-
return [];
|
|
952
|
-
}
|
|
953
|
-
/**
|
|
954
|
-
* Get pending reviews via legacy API.
|
|
955
|
-
* Delegates to the generator if it supports the legacy interface.
|
|
956
|
-
*/
|
|
957
|
-
async getPendingReviews() {
|
|
958
|
-
if ("getPendingReviews" in this.generator && typeof this.generator.getPendingReviews === "function") {
|
|
959
|
-
return this.generator.getPendingReviews();
|
|
960
|
-
}
|
|
961
|
-
return [];
|
|
962
|
-
}
|
|
963
904
|
/**
|
|
964
905
|
* Get the course ID for this pipeline.
|
|
965
906
|
*/
|
|
@@ -970,28 +911,162 @@ var init_Pipeline = __esm({
|
|
|
970
911
|
}
|
|
971
912
|
});
|
|
972
913
|
|
|
973
|
-
// src/core/navigators/
|
|
974
|
-
var
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
});
|
|
978
|
-
var PipelineAssembler;
|
|
979
|
-
var init_PipelineAssembler = __esm({
|
|
980
|
-
"src/core/navigators/PipelineAssembler.ts"() {
|
|
914
|
+
// src/core/navigators/generators/CompositeGenerator.ts
|
|
915
|
+
var DEFAULT_AGGREGATION_MODE, FREQUENCY_BOOST_FACTOR, CompositeGenerator;
|
|
916
|
+
var init_CompositeGenerator = __esm({
|
|
917
|
+
"src/core/navigators/generators/CompositeGenerator.ts"() {
|
|
981
918
|
"use strict";
|
|
982
919
|
init_navigators();
|
|
983
|
-
init_Pipeline();
|
|
984
|
-
init_types_legacy();
|
|
985
920
|
init_logger();
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
921
|
+
DEFAULT_AGGREGATION_MODE = "frequencyBoost" /* FREQUENCY_BOOST */;
|
|
922
|
+
FREQUENCY_BOOST_FACTOR = 0.1;
|
|
923
|
+
CompositeGenerator = class _CompositeGenerator extends ContentNavigator {
|
|
924
|
+
/** Human-readable name for CardGenerator interface */
|
|
925
|
+
name = "Composite Generator";
|
|
926
|
+
generators;
|
|
927
|
+
aggregationMode;
|
|
928
|
+
constructor(generators, aggregationMode = DEFAULT_AGGREGATION_MODE) {
|
|
929
|
+
super();
|
|
930
|
+
this.generators = generators;
|
|
931
|
+
this.aggregationMode = aggregationMode;
|
|
932
|
+
if (generators.length === 0) {
|
|
933
|
+
throw new Error("CompositeGenerator requires at least one generator");
|
|
934
|
+
}
|
|
935
|
+
logger.debug(
|
|
936
|
+
`[CompositeGenerator] Created with ${generators.length} generators, mode: ${aggregationMode}`
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
/**
|
|
940
|
+
* Creates a CompositeGenerator from strategy data.
|
|
941
|
+
*
|
|
942
|
+
* This is a convenience factory for use by PipelineAssembler.
|
|
943
|
+
*/
|
|
944
|
+
static async fromStrategies(user, course, strategies, aggregationMode = DEFAULT_AGGREGATION_MODE) {
|
|
945
|
+
const generators = await Promise.all(
|
|
946
|
+
strategies.map((s) => ContentNavigator.create(user, course, s))
|
|
947
|
+
);
|
|
948
|
+
return new _CompositeGenerator(generators, aggregationMode);
|
|
949
|
+
}
|
|
950
|
+
/**
|
|
951
|
+
* Get weighted cards from all generators, merge and deduplicate.
|
|
952
|
+
*
|
|
953
|
+
* Cards appearing in multiple generators receive a score boost.
|
|
954
|
+
* Provenance tracks which generators produced each card and how scores were aggregated.
|
|
955
|
+
*
|
|
956
|
+
* This method supports both the legacy signature (limit only) and the
|
|
957
|
+
* CardGenerator interface signature (limit, context).
|
|
958
|
+
*
|
|
959
|
+
* @param limit - Maximum number of cards to return
|
|
960
|
+
* @param context - GeneratorContext passed to child generators (required when called via Pipeline)
|
|
961
|
+
*/
|
|
962
|
+
async getWeightedCards(limit, context) {
|
|
963
|
+
if (!context) {
|
|
964
|
+
throw new Error(
|
|
965
|
+
"CompositeGenerator.getWeightedCards requires a GeneratorContext. It should be called via Pipeline, not directly."
|
|
966
|
+
);
|
|
967
|
+
}
|
|
968
|
+
const results = await Promise.all(
|
|
969
|
+
this.generators.map((g) => g.getWeightedCards(limit, context))
|
|
970
|
+
);
|
|
971
|
+
const byCardId = /* @__PURE__ */ new Map();
|
|
972
|
+
for (const cards of results) {
|
|
973
|
+
for (const card of cards) {
|
|
974
|
+
const existing = byCardId.get(card.cardId) || [];
|
|
975
|
+
existing.push(card);
|
|
976
|
+
byCardId.set(card.cardId, existing);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
const merged = [];
|
|
980
|
+
for (const [, cards] of byCardId) {
|
|
981
|
+
const aggregatedScore = this.aggregateScores(cards);
|
|
982
|
+
const finalScore = Math.min(1, aggregatedScore);
|
|
983
|
+
const mergedProvenance = cards.flatMap((c) => c.provenance);
|
|
984
|
+
const initialScore = cards[0].score;
|
|
985
|
+
const action = finalScore > initialScore ? "boosted" : finalScore < initialScore ? "penalized" : "passed";
|
|
986
|
+
const reason = this.buildAggregationReason(cards, finalScore);
|
|
987
|
+
merged.push({
|
|
988
|
+
...cards[0],
|
|
989
|
+
score: finalScore,
|
|
990
|
+
provenance: [
|
|
991
|
+
...mergedProvenance,
|
|
992
|
+
{
|
|
993
|
+
strategy: "composite",
|
|
994
|
+
strategyName: "Composite Generator",
|
|
995
|
+
strategyId: "COMPOSITE_GENERATOR",
|
|
996
|
+
action,
|
|
997
|
+
score: finalScore,
|
|
998
|
+
reason
|
|
999
|
+
}
|
|
1000
|
+
]
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
return merged.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* Build human-readable reason for score aggregation.
|
|
1007
|
+
*/
|
|
1008
|
+
buildAggregationReason(cards, finalScore) {
|
|
1009
|
+
const count = cards.length;
|
|
1010
|
+
const scores = cards.map((c) => c.score.toFixed(2)).join(", ");
|
|
1011
|
+
if (count === 1) {
|
|
1012
|
+
return `Single generator, score ${finalScore.toFixed(2)}`;
|
|
1013
|
+
}
|
|
1014
|
+
const strategies = cards.map((c) => c.provenance[0]?.strategy || "unknown").join(", ");
|
|
1015
|
+
switch (this.aggregationMode) {
|
|
1016
|
+
case "max" /* MAX */:
|
|
1017
|
+
return `Max of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
|
|
1018
|
+
case "average" /* AVERAGE */:
|
|
1019
|
+
return `Average of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
|
|
1020
|
+
case "frequencyBoost" /* FREQUENCY_BOOST */: {
|
|
1021
|
+
const avg = cards.reduce((sum, c) => sum + c.score, 0) / count;
|
|
1022
|
+
const boost = 1 + FREQUENCY_BOOST_FACTOR * (count - 1);
|
|
1023
|
+
return `Frequency boost from ${count} generators (${strategies}): avg ${avg.toFixed(2)} \xD7 ${boost.toFixed(2)} \u2192 ${finalScore.toFixed(2)}`;
|
|
1024
|
+
}
|
|
1025
|
+
default:
|
|
1026
|
+
return `Aggregated from ${count} generators: ${finalScore.toFixed(2)}`;
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
/**
|
|
1030
|
+
* Aggregate scores from multiple generators for the same card.
|
|
1031
|
+
*/
|
|
1032
|
+
aggregateScores(cards) {
|
|
1033
|
+
const scores = cards.map((c) => c.score);
|
|
1034
|
+
switch (this.aggregationMode) {
|
|
1035
|
+
case "max" /* MAX */:
|
|
1036
|
+
return Math.max(...scores);
|
|
1037
|
+
case "average" /* AVERAGE */:
|
|
1038
|
+
return scores.reduce((sum, s) => sum + s, 0) / scores.length;
|
|
1039
|
+
case "frequencyBoost" /* FREQUENCY_BOOST */: {
|
|
1040
|
+
const avg = scores.reduce((sum, s) => sum + s, 0) / scores.length;
|
|
1041
|
+
const frequencyBoost = 1 + FREQUENCY_BOOST_FACTOR * (cards.length - 1);
|
|
1042
|
+
return avg * frequencyBoost;
|
|
1043
|
+
}
|
|
1044
|
+
default:
|
|
1045
|
+
return scores[0];
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
// src/core/navigators/PipelineAssembler.ts
|
|
1053
|
+
var PipelineAssembler;
|
|
1054
|
+
var init_PipelineAssembler = __esm({
|
|
1055
|
+
"src/core/navigators/PipelineAssembler.ts"() {
|
|
1056
|
+
"use strict";
|
|
1057
|
+
init_navigators();
|
|
1058
|
+
init_Pipeline();
|
|
1059
|
+
init_types_legacy();
|
|
1060
|
+
init_logger();
|
|
1061
|
+
init_CompositeGenerator();
|
|
1062
|
+
PipelineAssembler = class {
|
|
1063
|
+
/**
|
|
1064
|
+
* Assembles a navigation pipeline from strategy documents.
|
|
1065
|
+
*
|
|
1066
|
+
* 1. Separates into generators and filters by role
|
|
1067
|
+
* 2. Validates at least one generator exists (or creates default ELO)
|
|
1068
|
+
* 3. Instantiates generators - wraps multiple in CompositeGenerator
|
|
1069
|
+
* 4. Instantiates filters
|
|
995
1070
|
* 5. Returns Pipeline(generator, filters)
|
|
996
1071
|
*
|
|
997
1072
|
* @param input - Strategy documents plus user/course interfaces
|
|
@@ -1095,15 +1170,11 @@ var init_PipelineAssembler = __esm({
|
|
|
1095
1170
|
}
|
|
1096
1171
|
});
|
|
1097
1172
|
|
|
1098
|
-
// src/core/navigators/elo.ts
|
|
1099
|
-
var elo_exports = {};
|
|
1100
|
-
__export(elo_exports, {
|
|
1101
|
-
default: () => ELONavigator
|
|
1102
|
-
});
|
|
1173
|
+
// src/core/navigators/generators/elo.ts
|
|
1103
1174
|
import { toCourseElo as toCourseElo3 } from "@vue-skuilder/common";
|
|
1104
1175
|
var ELONavigator;
|
|
1105
1176
|
var init_elo = __esm({
|
|
1106
|
-
"src/core/navigators/elo.ts"() {
|
|
1177
|
+
"src/core/navigators/generators/elo.ts"() {
|
|
1107
1178
|
"use strict";
|
|
1108
1179
|
init_navigators();
|
|
1109
1180
|
ELONavigator = class extends ContentNavigator {
|
|
@@ -1113,50 +1184,6 @@ var init_elo = __esm({
|
|
|
1113
1184
|
super(user, course, strategyData);
|
|
1114
1185
|
this.name = strategyData?.name || "ELO";
|
|
1115
1186
|
}
|
|
1116
|
-
async getPendingReviews() {
|
|
1117
|
-
const reviews = await this.user.getPendingReviews(this.course.getCourseID());
|
|
1118
|
-
const elo = await this.course.getCardEloData(reviews.map((r) => r.cardId));
|
|
1119
|
-
const ratedReviews = reviews.map((r, i) => {
|
|
1120
|
-
const ratedR = {
|
|
1121
|
-
...r,
|
|
1122
|
-
...elo[i]
|
|
1123
|
-
};
|
|
1124
|
-
return ratedR;
|
|
1125
|
-
});
|
|
1126
|
-
ratedReviews.sort((a, b) => {
|
|
1127
|
-
return a.global.score - b.global.score;
|
|
1128
|
-
});
|
|
1129
|
-
return ratedReviews.map((r) => {
|
|
1130
|
-
return {
|
|
1131
|
-
...r,
|
|
1132
|
-
contentSourceType: "course",
|
|
1133
|
-
contentSourceID: this.course.getCourseID(),
|
|
1134
|
-
cardID: r.cardId,
|
|
1135
|
-
courseID: r.courseId,
|
|
1136
|
-
qualifiedID: `${r.courseId}-${r.cardId}`,
|
|
1137
|
-
reviewID: r._id,
|
|
1138
|
-
status: "review"
|
|
1139
|
-
};
|
|
1140
|
-
});
|
|
1141
|
-
}
|
|
1142
|
-
async getNewCards(limit = 99) {
|
|
1143
|
-
const activeCards = await this.user.getActiveCards();
|
|
1144
|
-
return (await this.course.getCardsCenteredAtELO(
|
|
1145
|
-
{ limit, elo: "user" },
|
|
1146
|
-
(c) => {
|
|
1147
|
-
if (activeCards.some((ac) => c.cardID === ac.cardID)) {
|
|
1148
|
-
return false;
|
|
1149
|
-
} else {
|
|
1150
|
-
return true;
|
|
1151
|
-
}
|
|
1152
|
-
}
|
|
1153
|
-
)).map((c) => {
|
|
1154
|
-
return {
|
|
1155
|
-
...c,
|
|
1156
|
-
status: "new"
|
|
1157
|
-
};
|
|
1158
|
-
});
|
|
1159
|
-
}
|
|
1160
1187
|
/**
|
|
1161
1188
|
* Get new cards with suitability scores based on ELO distance.
|
|
1162
1189
|
*
|
|
@@ -1181,7 +1208,11 @@ var init_elo = __esm({
|
|
|
1181
1208
|
const userElo = toCourseElo3(courseReg.elo);
|
|
1182
1209
|
userGlobalElo = userElo.global.score;
|
|
1183
1210
|
}
|
|
1184
|
-
const
|
|
1211
|
+
const activeCards = await this.user.getActiveCards();
|
|
1212
|
+
const newCards = (await this.course.getCardsCenteredAtELO(
|
|
1213
|
+
{ limit, elo: "user" },
|
|
1214
|
+
(c) => !activeCards.some((ac) => c.cardID === ac.cardID)
|
|
1215
|
+
)).map((c) => ({ ...c, status: "new" }));
|
|
1185
1216
|
const cardIds = newCards.map((c) => c.cardID);
|
|
1186
1217
|
const cardEloData = await this.course.getCardEloData(cardIds);
|
|
1187
1218
|
const scored = newCards.map((c, i) => {
|
|
@@ -1211,946 +1242,35 @@ var init_elo = __esm({
|
|
|
1211
1242
|
}
|
|
1212
1243
|
});
|
|
1213
1244
|
|
|
1214
|
-
// src/core/navigators/
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
DEFAULT_MIN_MULTIPLIER: () => DEFAULT_MIN_MULTIPLIER,
|
|
1220
|
-
createEloDistanceFilter: () => createEloDistanceFilter
|
|
1221
|
-
});
|
|
1222
|
-
function computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier) {
|
|
1223
|
-
const normalizedDistance = distance / halfLife;
|
|
1224
|
-
const decay = Math.exp(-(normalizedDistance * normalizedDistance));
|
|
1225
|
-
return minMultiplier + (maxMultiplier - minMultiplier) * decay;
|
|
1226
|
-
}
|
|
1227
|
-
function createEloDistanceFilter(config) {
|
|
1228
|
-
const halfLife = config?.halfLife ?? DEFAULT_HALF_LIFE;
|
|
1229
|
-
const minMultiplier = config?.minMultiplier ?? DEFAULT_MIN_MULTIPLIER;
|
|
1230
|
-
const maxMultiplier = config?.maxMultiplier ?? DEFAULT_MAX_MULTIPLIER;
|
|
1231
|
-
return {
|
|
1232
|
-
name: "ELO Distance Filter",
|
|
1233
|
-
async transform(cards, context) {
|
|
1234
|
-
const { course, userElo } = context;
|
|
1235
|
-
const cardIds = cards.map((c) => c.cardId);
|
|
1236
|
-
const cardElos = await course.getCardEloData(cardIds);
|
|
1237
|
-
return cards.map((card, i) => {
|
|
1238
|
-
const cardElo = cardElos[i]?.global?.score ?? 1e3;
|
|
1239
|
-
const distance = Math.abs(cardElo - userElo);
|
|
1240
|
-
const multiplier = computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier);
|
|
1241
|
-
const newScore = card.score * multiplier;
|
|
1242
|
-
const action = multiplier < maxMultiplier - 0.01 ? "penalized" : "passed";
|
|
1243
|
-
return {
|
|
1244
|
-
...card,
|
|
1245
|
-
score: newScore,
|
|
1246
|
-
provenance: [
|
|
1247
|
-
...card.provenance,
|
|
1248
|
-
{
|
|
1249
|
-
strategy: "eloDistance",
|
|
1250
|
-
strategyName: "ELO Distance Filter",
|
|
1251
|
-
strategyId: "ELO_DISTANCE_FILTER",
|
|
1252
|
-
action,
|
|
1253
|
-
score: newScore,
|
|
1254
|
-
reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userElo)}) \u2192 ${multiplier.toFixed(2)}x`
|
|
1255
|
-
}
|
|
1256
|
-
]
|
|
1257
|
-
};
|
|
1258
|
-
});
|
|
1259
|
-
}
|
|
1260
|
-
};
|
|
1261
|
-
}
|
|
1262
|
-
var DEFAULT_HALF_LIFE, DEFAULT_MIN_MULTIPLIER, DEFAULT_MAX_MULTIPLIER;
|
|
1263
|
-
var init_eloDistance = __esm({
|
|
1264
|
-
"src/core/navigators/filters/eloDistance.ts"() {
|
|
1265
|
-
"use strict";
|
|
1266
|
-
DEFAULT_HALF_LIFE = 200;
|
|
1267
|
-
DEFAULT_MIN_MULTIPLIER = 0.3;
|
|
1268
|
-
DEFAULT_MAX_MULTIPLIER = 1;
|
|
1269
|
-
}
|
|
1270
|
-
});
|
|
1271
|
-
|
|
1272
|
-
// src/core/navigators/filters/userTagPreference.ts
|
|
1273
|
-
var userTagPreference_exports = {};
|
|
1274
|
-
__export(userTagPreference_exports, {
|
|
1275
|
-
default: () => UserTagPreferenceFilter
|
|
1276
|
-
});
|
|
1277
|
-
var UserTagPreferenceFilter;
|
|
1278
|
-
var init_userTagPreference = __esm({
|
|
1279
|
-
"src/core/navigators/filters/userTagPreference.ts"() {
|
|
1245
|
+
// src/core/navigators/generators/srs.ts
|
|
1246
|
+
import moment from "moment";
|
|
1247
|
+
var SRSNavigator;
|
|
1248
|
+
var init_srs = __esm({
|
|
1249
|
+
"src/core/navigators/generators/srs.ts"() {
|
|
1280
1250
|
"use strict";
|
|
1281
1251
|
init_navigators();
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
/** Human-readable name for
|
|
1252
|
+
init_logger();
|
|
1253
|
+
SRSNavigator = class extends ContentNavigator {
|
|
1254
|
+
/** Human-readable name for CardGenerator interface */
|
|
1285
1255
|
name;
|
|
1286
1256
|
constructor(user, course, strategyData) {
|
|
1287
1257
|
super(user, course, strategyData);
|
|
1288
|
-
this.
|
|
1289
|
-
this.name = strategyData.name || "User Tag Preferences";
|
|
1290
|
-
}
|
|
1291
|
-
/**
|
|
1292
|
-
* Compute multiplier for a card based on its tags and user preferences.
|
|
1293
|
-
* Returns the maximum multiplier among all matching tags, or 1.0 if no matches.
|
|
1294
|
-
*/
|
|
1295
|
-
computeMultiplier(cardTags, boostMap) {
|
|
1296
|
-
const multipliers = cardTags.map((tag) => boostMap[tag]).filter((val) => val !== void 0);
|
|
1297
|
-
if (multipliers.length === 0) {
|
|
1298
|
-
return 1;
|
|
1299
|
-
}
|
|
1300
|
-
return Math.max(...multipliers);
|
|
1301
|
-
}
|
|
1302
|
-
/**
|
|
1303
|
-
* Build human-readable reason for the filter's decision.
|
|
1304
|
-
*/
|
|
1305
|
-
buildReason(cardTags, boostMap, multiplier) {
|
|
1306
|
-
const matchingTags = cardTags.filter((tag) => boostMap[tag] === multiplier);
|
|
1307
|
-
if (multiplier === 0) {
|
|
1308
|
-
return `Excluded by user preference: ${matchingTags.join(", ")} (${multiplier}x)`;
|
|
1309
|
-
}
|
|
1310
|
-
if (multiplier < 1) {
|
|
1311
|
-
return `Penalized by user preference: ${matchingTags.join(", ")} (${multiplier.toFixed(2)}x)`;
|
|
1312
|
-
}
|
|
1313
|
-
if (multiplier > 1) {
|
|
1314
|
-
return `Boosted by user preference: ${matchingTags.join(", ")} (${multiplier.toFixed(2)}x)`;
|
|
1315
|
-
}
|
|
1316
|
-
return "No matching user preferences";
|
|
1258
|
+
this.name = strategyData?.name || "SRS";
|
|
1317
1259
|
}
|
|
1318
1260
|
/**
|
|
1319
|
-
*
|
|
1261
|
+
* Get review cards scored by urgency.
|
|
1262
|
+
*
|
|
1263
|
+
* Score formula combines:
|
|
1264
|
+
* - Relative overdueness: hoursOverdue / intervalHours
|
|
1265
|
+
* - Interval recency: exponential decay favoring shorter intervals
|
|
1266
|
+
*
|
|
1267
|
+
* Cards not yet due are excluded (not scored as 0).
|
|
1268
|
+
*
|
|
1269
|
+
* This method supports both the legacy signature (limit only) and the
|
|
1270
|
+
* CardGenerator interface signature (limit, context).
|
|
1320
1271
|
*
|
|
1321
|
-
*
|
|
1322
|
-
*
|
|
1323
|
-
* 2. If no preferences, pass through unchanged
|
|
1324
|
-
* 3. For each card:
|
|
1325
|
-
* - Look up tag in boost record
|
|
1326
|
-
* - If tag found: apply multiplier (0 = exclude, 1 = neutral, >1 = boost)
|
|
1327
|
-
* - If multiple tags match: use max multiplier
|
|
1328
|
-
* - Append provenance with clear reason
|
|
1329
|
-
*/
|
|
1330
|
-
async transform(cards, _context) {
|
|
1331
|
-
const prefs = await this.getStrategyState();
|
|
1332
|
-
if (!prefs || Object.keys(prefs.boost).length === 0) {
|
|
1333
|
-
return cards.map((card) => ({
|
|
1334
|
-
...card,
|
|
1335
|
-
provenance: [
|
|
1336
|
-
...card.provenance,
|
|
1337
|
-
{
|
|
1338
|
-
strategy: "userTagPreference",
|
|
1339
|
-
strategyName: this.strategyName || this.name,
|
|
1340
|
-
strategyId: this.strategyId || this._strategyData._id,
|
|
1341
|
-
action: "passed",
|
|
1342
|
-
score: card.score,
|
|
1343
|
-
reason: "No user tag preferences configured"
|
|
1344
|
-
}
|
|
1345
|
-
]
|
|
1346
|
-
}));
|
|
1347
|
-
}
|
|
1348
|
-
const adjusted = await Promise.all(
|
|
1349
|
-
cards.map(async (card) => {
|
|
1350
|
-
const cardTags = card.tags ?? [];
|
|
1351
|
-
const multiplier = this.computeMultiplier(cardTags, prefs.boost);
|
|
1352
|
-
const finalScore = Math.min(1, card.score * multiplier);
|
|
1353
|
-
let action;
|
|
1354
|
-
if (multiplier === 0 || multiplier < 1) {
|
|
1355
|
-
action = "penalized";
|
|
1356
|
-
} else if (multiplier > 1) {
|
|
1357
|
-
action = "boosted";
|
|
1358
|
-
} else {
|
|
1359
|
-
action = "passed";
|
|
1360
|
-
}
|
|
1361
|
-
return {
|
|
1362
|
-
...card,
|
|
1363
|
-
score: finalScore,
|
|
1364
|
-
provenance: [
|
|
1365
|
-
...card.provenance,
|
|
1366
|
-
{
|
|
1367
|
-
strategy: "userTagPreference",
|
|
1368
|
-
strategyName: this.strategyName || this.name,
|
|
1369
|
-
strategyId: this.strategyId || this._strategyData._id,
|
|
1370
|
-
action,
|
|
1371
|
-
score: finalScore,
|
|
1372
|
-
reason: this.buildReason(cardTags, prefs.boost, multiplier)
|
|
1373
|
-
}
|
|
1374
|
-
]
|
|
1375
|
-
};
|
|
1376
|
-
})
|
|
1377
|
-
);
|
|
1378
|
-
return adjusted;
|
|
1379
|
-
}
|
|
1380
|
-
/**
|
|
1381
|
-
* Legacy getWeightedCards - throws as filters should not be used as generators.
|
|
1382
|
-
*/
|
|
1383
|
-
async getWeightedCards(_limit) {
|
|
1384
|
-
throw new Error(
|
|
1385
|
-
"UserTagPreferenceFilter is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
|
|
1386
|
-
);
|
|
1387
|
-
}
|
|
1388
|
-
// Legacy methods - stub implementations since filters don't generate cards
|
|
1389
|
-
async getNewCards(_n) {
|
|
1390
|
-
return [];
|
|
1391
|
-
}
|
|
1392
|
-
async getPendingReviews() {
|
|
1393
|
-
return [];
|
|
1394
|
-
}
|
|
1395
|
-
};
|
|
1396
|
-
}
|
|
1397
|
-
});
|
|
1398
|
-
|
|
1399
|
-
// src/core/navigators/filters/index.ts
|
|
1400
|
-
var filters_exports = {};
|
|
1401
|
-
__export(filters_exports, {
|
|
1402
|
-
UserTagPreferenceFilter: () => UserTagPreferenceFilter,
|
|
1403
|
-
createEloDistanceFilter: () => createEloDistanceFilter
|
|
1404
|
-
});
|
|
1405
|
-
var init_filters = __esm({
|
|
1406
|
-
"src/core/navigators/filters/index.ts"() {
|
|
1407
|
-
"use strict";
|
|
1408
|
-
init_eloDistance();
|
|
1409
|
-
init_userTagPreference();
|
|
1410
|
-
}
|
|
1411
|
-
});
|
|
1412
|
-
|
|
1413
|
-
// src/core/navigators/filters/types.ts
|
|
1414
|
-
var types_exports = {};
|
|
1415
|
-
var init_types = __esm({
|
|
1416
|
-
"src/core/navigators/filters/types.ts"() {
|
|
1417
|
-
"use strict";
|
|
1418
|
-
}
|
|
1419
|
-
});
|
|
1420
|
-
|
|
1421
|
-
// src/core/navigators/generators/index.ts
|
|
1422
|
-
var generators_exports = {};
|
|
1423
|
-
var init_generators = __esm({
|
|
1424
|
-
"src/core/navigators/generators/index.ts"() {
|
|
1425
|
-
"use strict";
|
|
1426
|
-
}
|
|
1427
|
-
});
|
|
1428
|
-
|
|
1429
|
-
// src/core/navigators/generators/types.ts
|
|
1430
|
-
var types_exports2 = {};
|
|
1431
|
-
var init_types2 = __esm({
|
|
1432
|
-
"src/core/navigators/generators/types.ts"() {
|
|
1433
|
-
"use strict";
|
|
1434
|
-
}
|
|
1435
|
-
});
|
|
1436
|
-
|
|
1437
|
-
// src/core/navigators/hardcodedOrder.ts
|
|
1438
|
-
var hardcodedOrder_exports = {};
|
|
1439
|
-
__export(hardcodedOrder_exports, {
|
|
1440
|
-
default: () => HardcodedOrderNavigator
|
|
1441
|
-
});
|
|
1442
|
-
var HardcodedOrderNavigator;
|
|
1443
|
-
var init_hardcodedOrder = __esm({
|
|
1444
|
-
"src/core/navigators/hardcodedOrder.ts"() {
|
|
1445
|
-
"use strict";
|
|
1446
|
-
init_navigators();
|
|
1447
|
-
init_logger();
|
|
1448
|
-
HardcodedOrderNavigator = class extends ContentNavigator {
|
|
1449
|
-
/** Human-readable name for CardGenerator interface */
|
|
1450
|
-
name;
|
|
1451
|
-
orderedCardIds = [];
|
|
1452
|
-
constructor(user, course, strategyData) {
|
|
1453
|
-
super(user, course, strategyData);
|
|
1454
|
-
this.name = strategyData.name || "Hardcoded Order";
|
|
1455
|
-
if (strategyData.serializedData) {
|
|
1456
|
-
try {
|
|
1457
|
-
this.orderedCardIds = JSON.parse(strategyData.serializedData);
|
|
1458
|
-
} catch (e) {
|
|
1459
|
-
logger.error("Failed to parse serializedData for HardcodedOrderNavigator", e);
|
|
1460
|
-
}
|
|
1461
|
-
}
|
|
1462
|
-
}
|
|
1463
|
-
async getPendingReviews() {
|
|
1464
|
-
const reviews = await this.user.getPendingReviews(this.course.getCourseID());
|
|
1465
|
-
return reviews.map((r) => {
|
|
1466
|
-
return {
|
|
1467
|
-
...r,
|
|
1468
|
-
contentSourceType: "course",
|
|
1469
|
-
contentSourceID: this.course.getCourseID(),
|
|
1470
|
-
cardID: r.cardId,
|
|
1471
|
-
courseID: r.courseId,
|
|
1472
|
-
reviewID: r._id,
|
|
1473
|
-
status: "review"
|
|
1474
|
-
};
|
|
1475
|
-
});
|
|
1476
|
-
}
|
|
1477
|
-
async getNewCards(limit = 99) {
|
|
1478
|
-
const activeCardIds = (await this.user.getActiveCards()).map((c) => c.cardID);
|
|
1479
|
-
const newCardIds = this.orderedCardIds.filter((cardId) => !activeCardIds.includes(cardId));
|
|
1480
|
-
const cardsToReturn = newCardIds.slice(0, limit);
|
|
1481
|
-
return cardsToReturn.map((cardId) => {
|
|
1482
|
-
return {
|
|
1483
|
-
cardID: cardId,
|
|
1484
|
-
courseID: this.course.getCourseID(),
|
|
1485
|
-
contentSourceType: "course",
|
|
1486
|
-
contentSourceID: this.course.getCourseID(),
|
|
1487
|
-
status: "new"
|
|
1488
|
-
};
|
|
1489
|
-
});
|
|
1490
|
-
}
|
|
1491
|
-
/**
|
|
1492
|
-
* Get cards in hardcoded order with scores based on position.
|
|
1493
|
-
*
|
|
1494
|
-
* Earlier cards in the sequence get higher scores.
|
|
1495
|
-
* Score formula: 1.0 - (position / totalCards) * 0.5
|
|
1496
|
-
* This ensures scores range from 1.0 (first card) to 0.5+ (last card).
|
|
1497
|
-
*
|
|
1498
|
-
* This method supports both the legacy signature (limit only) and the
|
|
1499
|
-
* CardGenerator interface signature (limit, context).
|
|
1500
|
-
*
|
|
1501
|
-
* @param limit - Maximum number of cards to return
|
|
1502
|
-
* @param _context - Optional GeneratorContext (currently unused, but required for interface)
|
|
1503
|
-
*/
|
|
1504
|
-
async getWeightedCards(limit, _context) {
|
|
1505
|
-
const activeCardIds = (await this.user.getActiveCards()).map((c) => c.cardID);
|
|
1506
|
-
const reviews = await this.getPendingReviews();
|
|
1507
|
-
const newCardIds = this.orderedCardIds.filter((cardId) => !activeCardIds.includes(cardId));
|
|
1508
|
-
const totalCards = newCardIds.length;
|
|
1509
|
-
const scoredNew = newCardIds.slice(0, limit).map((cardId, index) => {
|
|
1510
|
-
const position = index + 1;
|
|
1511
|
-
const score = Math.max(0.5, 1 - index / totalCards * 0.5);
|
|
1512
|
-
return {
|
|
1513
|
-
cardId,
|
|
1514
|
-
courseId: this.course.getCourseID(),
|
|
1515
|
-
score,
|
|
1516
|
-
provenance: [
|
|
1517
|
-
{
|
|
1518
|
-
strategy: "hardcodedOrder",
|
|
1519
|
-
strategyName: this.strategyName || this.name,
|
|
1520
|
-
strategyId: this.strategyId || "NAVIGATION_STRATEGY-hardcoded",
|
|
1521
|
-
action: "generated",
|
|
1522
|
-
score,
|
|
1523
|
-
reason: `Position ${position} of ${totalCards} in fixed sequence, new card`
|
|
1524
|
-
}
|
|
1525
|
-
]
|
|
1526
|
-
};
|
|
1527
|
-
});
|
|
1528
|
-
const scoredReviews = reviews.map((r) => ({
|
|
1529
|
-
cardId: r.cardID,
|
|
1530
|
-
courseId: r.courseID,
|
|
1531
|
-
score: 1,
|
|
1532
|
-
provenance: [
|
|
1533
|
-
{
|
|
1534
|
-
strategy: "hardcodedOrder",
|
|
1535
|
-
strategyName: this.strategyName || this.name,
|
|
1536
|
-
strategyId: this.strategyId || "NAVIGATION_STRATEGY-hardcoded",
|
|
1537
|
-
action: "generated",
|
|
1538
|
-
score: 1,
|
|
1539
|
-
reason: "Scheduled review, highest priority"
|
|
1540
|
-
}
|
|
1541
|
-
]
|
|
1542
|
-
}));
|
|
1543
|
-
const all = [...scoredReviews, ...scoredNew];
|
|
1544
|
-
all.sort((a, b) => b.score - a.score);
|
|
1545
|
-
return all.slice(0, limit);
|
|
1546
|
-
}
|
|
1547
|
-
};
|
|
1548
|
-
}
|
|
1549
|
-
});
|
|
1550
|
-
|
|
1551
|
-
// src/core/navigators/hierarchyDefinition.ts
|
|
1552
|
-
var hierarchyDefinition_exports = {};
|
|
1553
|
-
__export(hierarchyDefinition_exports, {
|
|
1554
|
-
default: () => HierarchyDefinitionNavigator
|
|
1555
|
-
});
|
|
1556
|
-
import { toCourseElo as toCourseElo4 } from "@vue-skuilder/common";
|
|
1557
|
-
var DEFAULT_MIN_COUNT, HierarchyDefinitionNavigator;
|
|
1558
|
-
var init_hierarchyDefinition = __esm({
|
|
1559
|
-
"src/core/navigators/hierarchyDefinition.ts"() {
|
|
1560
|
-
"use strict";
|
|
1561
|
-
init_navigators();
|
|
1562
|
-
DEFAULT_MIN_COUNT = 3;
|
|
1563
|
-
HierarchyDefinitionNavigator = class extends ContentNavigator {
|
|
1564
|
-
config;
|
|
1565
|
-
_strategyData;
|
|
1566
|
-
/** Human-readable name for CardFilter interface */
|
|
1567
|
-
name;
|
|
1568
|
-
constructor(user, course, _strategyData) {
|
|
1569
|
-
super(user, course, _strategyData);
|
|
1570
|
-
this._strategyData = _strategyData;
|
|
1571
|
-
this.config = this.parseConfig(_strategyData.serializedData);
|
|
1572
|
-
this.name = _strategyData.name || "Hierarchy Definition";
|
|
1573
|
-
}
|
|
1574
|
-
parseConfig(serializedData) {
|
|
1575
|
-
try {
|
|
1576
|
-
const parsed = JSON.parse(serializedData);
|
|
1577
|
-
return {
|
|
1578
|
-
prerequisites: parsed.prerequisites || {}
|
|
1579
|
-
};
|
|
1580
|
-
} catch {
|
|
1581
|
-
return {
|
|
1582
|
-
prerequisites: {}
|
|
1583
|
-
};
|
|
1584
|
-
}
|
|
1585
|
-
}
|
|
1586
|
-
/**
|
|
1587
|
-
* Check if a specific prerequisite is satisfied
|
|
1588
|
-
*/
|
|
1589
|
-
isPrerequisiteMet(prereq, userTagElo, userGlobalElo) {
|
|
1590
|
-
if (!userTagElo) return false;
|
|
1591
|
-
const minCount = prereq.masteryThreshold?.minCount ?? DEFAULT_MIN_COUNT;
|
|
1592
|
-
if (userTagElo.count < minCount) return false;
|
|
1593
|
-
if (prereq.masteryThreshold?.minElo !== void 0) {
|
|
1594
|
-
return userTagElo.score >= prereq.masteryThreshold.minElo;
|
|
1595
|
-
} else {
|
|
1596
|
-
return userTagElo.score >= userGlobalElo;
|
|
1597
|
-
}
|
|
1598
|
-
}
|
|
1599
|
-
/**
|
|
1600
|
-
* Get the set of tags the user has mastered.
|
|
1601
|
-
* A tag is "mastered" if it appears as a prerequisite somewhere and meets its threshold.
|
|
1602
|
-
*/
|
|
1603
|
-
async getMasteredTags(context) {
|
|
1604
|
-
const mastered = /* @__PURE__ */ new Set();
|
|
1605
|
-
try {
|
|
1606
|
-
const courseReg = await context.user.getCourseRegDoc(context.course.getCourseID());
|
|
1607
|
-
const userElo = toCourseElo4(courseReg.elo);
|
|
1608
|
-
for (const prereqs of Object.values(this.config.prerequisites)) {
|
|
1609
|
-
for (const prereq of prereqs) {
|
|
1610
|
-
const tagElo = userElo.tags[prereq.tag];
|
|
1611
|
-
if (this.isPrerequisiteMet(prereq, tagElo, userElo.global.score)) {
|
|
1612
|
-
mastered.add(prereq.tag);
|
|
1613
|
-
}
|
|
1614
|
-
}
|
|
1615
|
-
}
|
|
1616
|
-
} catch {
|
|
1617
|
-
}
|
|
1618
|
-
return mastered;
|
|
1619
|
-
}
|
|
1620
|
-
/**
|
|
1621
|
-
* Get the set of tags that are unlocked (prerequisites met)
|
|
1622
|
-
*/
|
|
1623
|
-
getUnlockedTags(masteredTags) {
|
|
1624
|
-
const unlocked = /* @__PURE__ */ new Set();
|
|
1625
|
-
for (const [tagId, prereqs] of Object.entries(this.config.prerequisites)) {
|
|
1626
|
-
const allPrereqsMet = prereqs.every((prereq) => masteredTags.has(prereq.tag));
|
|
1627
|
-
if (allPrereqsMet) {
|
|
1628
|
-
unlocked.add(tagId);
|
|
1629
|
-
}
|
|
1630
|
-
}
|
|
1631
|
-
return unlocked;
|
|
1632
|
-
}
|
|
1633
|
-
/**
|
|
1634
|
-
* Check if a tag has prerequisites defined in config
|
|
1635
|
-
*/
|
|
1636
|
-
hasPrerequisites(tagId) {
|
|
1637
|
-
return tagId in this.config.prerequisites;
|
|
1638
|
-
}
|
|
1639
|
-
/**
|
|
1640
|
-
* Check if a card is unlocked and generate reason.
|
|
1641
|
-
*/
|
|
1642
|
-
async checkCardUnlock(card, course, unlockedTags, masteredTags) {
|
|
1643
|
-
try {
|
|
1644
|
-
const cardTags = card.tags ?? [];
|
|
1645
|
-
const lockedTags = cardTags.filter(
|
|
1646
|
-
(tag) => this.hasPrerequisites(tag) && !unlockedTags.has(tag)
|
|
1647
|
-
);
|
|
1648
|
-
if (lockedTags.length === 0) {
|
|
1649
|
-
const tagList = cardTags.length > 0 ? cardTags.join(", ") : "none";
|
|
1650
|
-
return {
|
|
1651
|
-
isUnlocked: true,
|
|
1652
|
-
reason: `Prerequisites met, tags: ${tagList}`
|
|
1653
|
-
};
|
|
1654
|
-
}
|
|
1655
|
-
const missingPrereqs = lockedTags.flatMap((tag) => {
|
|
1656
|
-
const prereqs = this.config.prerequisites[tag] || [];
|
|
1657
|
-
return prereqs.filter((p) => !masteredTags.has(p.tag)).map((p) => p.tag);
|
|
1658
|
-
});
|
|
1659
|
-
return {
|
|
1660
|
-
isUnlocked: false,
|
|
1661
|
-
reason: `Blocked: missing prerequisites ${missingPrereqs.join(", ")} for tags ${lockedTags.join(", ")}`
|
|
1662
|
-
};
|
|
1663
|
-
} catch {
|
|
1664
|
-
return {
|
|
1665
|
-
isUnlocked: true,
|
|
1666
|
-
reason: "Prerequisites check skipped (tag lookup failed)"
|
|
1667
|
-
};
|
|
1668
|
-
}
|
|
1669
|
-
}
|
|
1670
|
-
/**
|
|
1671
|
-
* CardFilter.transform implementation.
|
|
1672
|
-
*
|
|
1673
|
-
* Apply prerequisite gating to cards. Cards with locked tags receive score: 0.
|
|
1674
|
-
*/
|
|
1675
|
-
async transform(cards, context) {
|
|
1676
|
-
const masteredTags = await this.getMasteredTags(context);
|
|
1677
|
-
const unlockedTags = this.getUnlockedTags(masteredTags);
|
|
1678
|
-
const gated = [];
|
|
1679
|
-
for (const card of cards) {
|
|
1680
|
-
const { isUnlocked, reason } = await this.checkCardUnlock(
|
|
1681
|
-
card,
|
|
1682
|
-
context.course,
|
|
1683
|
-
unlockedTags,
|
|
1684
|
-
masteredTags
|
|
1685
|
-
);
|
|
1686
|
-
const finalScore = isUnlocked ? card.score : 0;
|
|
1687
|
-
const action = isUnlocked ? "passed" : "penalized";
|
|
1688
|
-
gated.push({
|
|
1689
|
-
...card,
|
|
1690
|
-
score: finalScore,
|
|
1691
|
-
provenance: [
|
|
1692
|
-
...card.provenance,
|
|
1693
|
-
{
|
|
1694
|
-
strategy: "hierarchyDefinition",
|
|
1695
|
-
strategyName: this.strategyName || this.name,
|
|
1696
|
-
strategyId: this.strategyId || "NAVIGATION_STRATEGY-hierarchy",
|
|
1697
|
-
action,
|
|
1698
|
-
score: finalScore,
|
|
1699
|
-
reason
|
|
1700
|
-
}
|
|
1701
|
-
]
|
|
1702
|
-
});
|
|
1703
|
-
}
|
|
1704
|
-
return gated;
|
|
1705
|
-
}
|
|
1706
|
-
/**
|
|
1707
|
-
* Legacy getWeightedCards - now throws as filters should not be used as generators.
|
|
1708
|
-
*
|
|
1709
|
-
* Use transform() via Pipeline instead.
|
|
1710
|
-
*/
|
|
1711
|
-
async getWeightedCards(_limit) {
|
|
1712
|
-
throw new Error(
|
|
1713
|
-
"HierarchyDefinitionNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
|
|
1714
|
-
);
|
|
1715
|
-
}
|
|
1716
|
-
// Legacy methods - stub implementations since filters don't generate cards
|
|
1717
|
-
async getNewCards(_n) {
|
|
1718
|
-
return [];
|
|
1719
|
-
}
|
|
1720
|
-
async getPendingReviews() {
|
|
1721
|
-
return [];
|
|
1722
|
-
}
|
|
1723
|
-
};
|
|
1724
|
-
}
|
|
1725
|
-
});
|
|
1726
|
-
|
|
1727
|
-
// src/core/navigators/inferredPreference.ts
|
|
1728
|
-
var inferredPreference_exports = {};
|
|
1729
|
-
__export(inferredPreference_exports, {
|
|
1730
|
-
INFERRED_PREFERENCE_NAVIGATOR_STUB: () => INFERRED_PREFERENCE_NAVIGATOR_STUB
|
|
1731
|
-
});
|
|
1732
|
-
var INFERRED_PREFERENCE_NAVIGATOR_STUB;
|
|
1733
|
-
var init_inferredPreference = __esm({
|
|
1734
|
-
"src/core/navigators/inferredPreference.ts"() {
|
|
1735
|
-
"use strict";
|
|
1736
|
-
INFERRED_PREFERENCE_NAVIGATOR_STUB = true;
|
|
1737
|
-
}
|
|
1738
|
-
});
|
|
1739
|
-
|
|
1740
|
-
// src/core/navigators/interferenceMitigator.ts
|
|
1741
|
-
var interferenceMitigator_exports = {};
|
|
1742
|
-
__export(interferenceMitigator_exports, {
|
|
1743
|
-
default: () => InterferenceMitigatorNavigator
|
|
1744
|
-
});
|
|
1745
|
-
import { toCourseElo as toCourseElo5 } from "@vue-skuilder/common";
|
|
1746
|
-
var DEFAULT_MIN_COUNT2, DEFAULT_MIN_ELAPSED_DAYS, DEFAULT_INTERFERENCE_DECAY, InterferenceMitigatorNavigator;
|
|
1747
|
-
var init_interferenceMitigator = __esm({
|
|
1748
|
-
"src/core/navigators/interferenceMitigator.ts"() {
|
|
1749
|
-
"use strict";
|
|
1750
|
-
init_navigators();
|
|
1751
|
-
DEFAULT_MIN_COUNT2 = 10;
|
|
1752
|
-
DEFAULT_MIN_ELAPSED_DAYS = 3;
|
|
1753
|
-
DEFAULT_INTERFERENCE_DECAY = 0.8;
|
|
1754
|
-
InterferenceMitigatorNavigator = class extends ContentNavigator {
|
|
1755
|
-
config;
|
|
1756
|
-
_strategyData;
|
|
1757
|
-
/** Human-readable name for CardFilter interface */
|
|
1758
|
-
name;
|
|
1759
|
-
/** Precomputed map: tag -> set of { partner, decay } it interferes with */
|
|
1760
|
-
interferenceMap;
|
|
1761
|
-
constructor(user, course, _strategyData) {
|
|
1762
|
-
super(user, course, _strategyData);
|
|
1763
|
-
this._strategyData = _strategyData;
|
|
1764
|
-
this.config = this.parseConfig(_strategyData.serializedData);
|
|
1765
|
-
this.interferenceMap = this.buildInterferenceMap();
|
|
1766
|
-
this.name = _strategyData.name || "Interference Mitigator";
|
|
1767
|
-
}
|
|
1768
|
-
parseConfig(serializedData) {
|
|
1769
|
-
try {
|
|
1770
|
-
const parsed = JSON.parse(serializedData);
|
|
1771
|
-
let sets = parsed.interferenceSets || [];
|
|
1772
|
-
if (sets.length > 0 && Array.isArray(sets[0])) {
|
|
1773
|
-
sets = sets.map((tags) => ({ tags }));
|
|
1774
|
-
}
|
|
1775
|
-
return {
|
|
1776
|
-
interferenceSets: sets,
|
|
1777
|
-
maturityThreshold: {
|
|
1778
|
-
minCount: parsed.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT2,
|
|
1779
|
-
minElo: parsed.maturityThreshold?.minElo,
|
|
1780
|
-
minElapsedDays: parsed.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS
|
|
1781
|
-
},
|
|
1782
|
-
defaultDecay: parsed.defaultDecay ?? DEFAULT_INTERFERENCE_DECAY
|
|
1783
|
-
};
|
|
1784
|
-
} catch {
|
|
1785
|
-
return {
|
|
1786
|
-
interferenceSets: [],
|
|
1787
|
-
maturityThreshold: {
|
|
1788
|
-
minCount: DEFAULT_MIN_COUNT2,
|
|
1789
|
-
minElapsedDays: DEFAULT_MIN_ELAPSED_DAYS
|
|
1790
|
-
},
|
|
1791
|
-
defaultDecay: DEFAULT_INTERFERENCE_DECAY
|
|
1792
|
-
};
|
|
1793
|
-
}
|
|
1794
|
-
}
|
|
1795
|
-
/**
|
|
1796
|
-
* Build a map from each tag to its interference partners with decay coefficients.
|
|
1797
|
-
* If tags A, B, C are in an interference group with decay 0.8, then:
|
|
1798
|
-
* - A interferes with B (decay 0.8) and C (decay 0.8)
|
|
1799
|
-
* - B interferes with A (decay 0.8) and C (decay 0.8)
|
|
1800
|
-
* - etc.
|
|
1801
|
-
*/
|
|
1802
|
-
buildInterferenceMap() {
|
|
1803
|
-
const map = /* @__PURE__ */ new Map();
|
|
1804
|
-
for (const group of this.config.interferenceSets) {
|
|
1805
|
-
const decay = group.decay ?? this.config.defaultDecay ?? DEFAULT_INTERFERENCE_DECAY;
|
|
1806
|
-
for (const tag of group.tags) {
|
|
1807
|
-
if (!map.has(tag)) {
|
|
1808
|
-
map.set(tag, []);
|
|
1809
|
-
}
|
|
1810
|
-
const partners = map.get(tag);
|
|
1811
|
-
for (const other of group.tags) {
|
|
1812
|
-
if (other !== tag) {
|
|
1813
|
-
const existing = partners.find((p) => p.partner === other);
|
|
1814
|
-
if (existing) {
|
|
1815
|
-
existing.decay = Math.max(existing.decay, decay);
|
|
1816
|
-
} else {
|
|
1817
|
-
partners.push({ partner: other, decay });
|
|
1818
|
-
}
|
|
1819
|
-
}
|
|
1820
|
-
}
|
|
1821
|
-
}
|
|
1822
|
-
}
|
|
1823
|
-
return map;
|
|
1824
|
-
}
|
|
1825
|
-
/**
|
|
1826
|
-
* Get the set of tags that are currently immature for this user.
|
|
1827
|
-
* A tag is immature if the user has interacted with it but hasn't
|
|
1828
|
-
* reached the maturity threshold.
|
|
1829
|
-
*/
|
|
1830
|
-
async getImmatureTags(context) {
|
|
1831
|
-
const immature = /* @__PURE__ */ new Set();
|
|
1832
|
-
try {
|
|
1833
|
-
const courseReg = await context.user.getCourseRegDoc(context.course.getCourseID());
|
|
1834
|
-
const userElo = toCourseElo5(courseReg.elo);
|
|
1835
|
-
const minCount = this.config.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT2;
|
|
1836
|
-
const minElo = this.config.maturityThreshold?.minElo;
|
|
1837
|
-
const minElapsedDays = this.config.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS;
|
|
1838
|
-
const minCountForElapsed = minElapsedDays * 2;
|
|
1839
|
-
for (const [tagId, tagElo] of Object.entries(userElo.tags)) {
|
|
1840
|
-
if (tagElo.count === 0) continue;
|
|
1841
|
-
const belowCount = tagElo.count < minCount;
|
|
1842
|
-
const belowElo = minElo !== void 0 && tagElo.score < minElo;
|
|
1843
|
-
const belowElapsed = tagElo.count < minCountForElapsed;
|
|
1844
|
-
if (belowCount || belowElo || belowElapsed) {
|
|
1845
|
-
immature.add(tagId);
|
|
1846
|
-
}
|
|
1847
|
-
}
|
|
1848
|
-
} catch {
|
|
1849
|
-
}
|
|
1850
|
-
return immature;
|
|
1851
|
-
}
|
|
1852
|
-
/**
|
|
1853
|
-
* Get all tags that interfere with any immature tag, along with their decay coefficients.
|
|
1854
|
-
* These are the tags we want to avoid introducing.
|
|
1855
|
-
*/
|
|
1856
|
-
getTagsToAvoid(immatureTags) {
|
|
1857
|
-
const avoid = /* @__PURE__ */ new Map();
|
|
1858
|
-
for (const immatureTag of immatureTags) {
|
|
1859
|
-
const partners = this.interferenceMap.get(immatureTag);
|
|
1860
|
-
if (partners) {
|
|
1861
|
-
for (const { partner, decay } of partners) {
|
|
1862
|
-
if (!immatureTags.has(partner)) {
|
|
1863
|
-
const existing = avoid.get(partner) ?? 0;
|
|
1864
|
-
avoid.set(partner, Math.max(existing, decay));
|
|
1865
|
-
}
|
|
1866
|
-
}
|
|
1867
|
-
}
|
|
1868
|
-
}
|
|
1869
|
-
return avoid;
|
|
1870
|
-
}
|
|
1871
|
-
/**
|
|
1872
|
-
* Compute interference score reduction for a card.
|
|
1873
|
-
* Returns: { multiplier, interfering tags, reason }
|
|
1874
|
-
*/
|
|
1875
|
-
computeInterferenceEffect(cardTags, tagsToAvoid, immatureTags) {
|
|
1876
|
-
if (tagsToAvoid.size === 0) {
|
|
1877
|
-
return {
|
|
1878
|
-
multiplier: 1,
|
|
1879
|
-
interferingTags: [],
|
|
1880
|
-
reason: "No interference detected"
|
|
1881
|
-
};
|
|
1882
|
-
}
|
|
1883
|
-
let multiplier = 1;
|
|
1884
|
-
const interferingTags = [];
|
|
1885
|
-
for (const tag of cardTags) {
|
|
1886
|
-
const decay = tagsToAvoid.get(tag);
|
|
1887
|
-
if (decay !== void 0) {
|
|
1888
|
-
interferingTags.push(tag);
|
|
1889
|
-
multiplier *= 1 - decay;
|
|
1890
|
-
}
|
|
1891
|
-
}
|
|
1892
|
-
if (interferingTags.length === 0) {
|
|
1893
|
-
return {
|
|
1894
|
-
multiplier: 1,
|
|
1895
|
-
interferingTags: [],
|
|
1896
|
-
reason: "No interference detected"
|
|
1897
|
-
};
|
|
1898
|
-
}
|
|
1899
|
-
const causingTags = /* @__PURE__ */ new Set();
|
|
1900
|
-
for (const tag of interferingTags) {
|
|
1901
|
-
for (const immatureTag of immatureTags) {
|
|
1902
|
-
const partners = this.interferenceMap.get(immatureTag);
|
|
1903
|
-
if (partners?.some((p) => p.partner === tag)) {
|
|
1904
|
-
causingTags.add(immatureTag);
|
|
1905
|
-
}
|
|
1906
|
-
}
|
|
1907
|
-
}
|
|
1908
|
-
const reason = `Interferes with immature tags ${Array.from(causingTags).join(", ")} (tags: ${interferingTags.join(", ")}, multiplier: ${multiplier.toFixed(2)})`;
|
|
1909
|
-
return { multiplier, interferingTags, reason };
|
|
1910
|
-
}
|
|
1911
|
-
/**
|
|
1912
|
-
* CardFilter.transform implementation.
|
|
1913
|
-
*
|
|
1914
|
-
* Apply interference-aware scoring. Cards with tags that interfere with
|
|
1915
|
-
* immature learnings get reduced scores.
|
|
1916
|
-
*/
|
|
1917
|
-
async transform(cards, context) {
|
|
1918
|
-
const immatureTags = await this.getImmatureTags(context);
|
|
1919
|
-
const tagsToAvoid = this.getTagsToAvoid(immatureTags);
|
|
1920
|
-
const adjusted = [];
|
|
1921
|
-
for (const card of cards) {
|
|
1922
|
-
const cardTags = card.tags ?? [];
|
|
1923
|
-
const { multiplier, reason } = this.computeInterferenceEffect(
|
|
1924
|
-
cardTags,
|
|
1925
|
-
tagsToAvoid,
|
|
1926
|
-
immatureTags
|
|
1927
|
-
);
|
|
1928
|
-
const finalScore = card.score * multiplier;
|
|
1929
|
-
const action = multiplier < 1 ? "penalized" : multiplier > 1 ? "boosted" : "passed";
|
|
1930
|
-
adjusted.push({
|
|
1931
|
-
...card,
|
|
1932
|
-
score: finalScore,
|
|
1933
|
-
provenance: [
|
|
1934
|
-
...card.provenance,
|
|
1935
|
-
{
|
|
1936
|
-
strategy: "interferenceMitigator",
|
|
1937
|
-
strategyName: this.strategyName || this.name,
|
|
1938
|
-
strategyId: this.strategyId || "NAVIGATION_STRATEGY-interference",
|
|
1939
|
-
action,
|
|
1940
|
-
score: finalScore,
|
|
1941
|
-
reason
|
|
1942
|
-
}
|
|
1943
|
-
]
|
|
1944
|
-
});
|
|
1945
|
-
}
|
|
1946
|
-
return adjusted;
|
|
1947
|
-
}
|
|
1948
|
-
/**
|
|
1949
|
-
* Legacy getWeightedCards - now throws as filters should not be used as generators.
|
|
1950
|
-
*
|
|
1951
|
-
* Use transform() via Pipeline instead.
|
|
1952
|
-
*/
|
|
1953
|
-
async getWeightedCards(_limit) {
|
|
1954
|
-
throw new Error(
|
|
1955
|
-
"InterferenceMitigatorNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
|
|
1956
|
-
);
|
|
1957
|
-
}
|
|
1958
|
-
// Legacy methods - stub implementations since filters don't generate cards
|
|
1959
|
-
async getNewCards(_n) {
|
|
1960
|
-
return [];
|
|
1961
|
-
}
|
|
1962
|
-
async getPendingReviews() {
|
|
1963
|
-
return [];
|
|
1964
|
-
}
|
|
1965
|
-
};
|
|
1966
|
-
}
|
|
1967
|
-
});
|
|
1968
|
-
|
|
1969
|
-
// src/core/navigators/relativePriority.ts
|
|
1970
|
-
var relativePriority_exports = {};
|
|
1971
|
-
__export(relativePriority_exports, {
|
|
1972
|
-
default: () => RelativePriorityNavigator
|
|
1973
|
-
});
|
|
1974
|
-
var DEFAULT_PRIORITY, DEFAULT_PRIORITY_INFLUENCE, DEFAULT_COMBINE_MODE, RelativePriorityNavigator;
|
|
1975
|
-
var init_relativePriority = __esm({
|
|
1976
|
-
"src/core/navigators/relativePriority.ts"() {
|
|
1977
|
-
"use strict";
|
|
1978
|
-
init_navigators();
|
|
1979
|
-
DEFAULT_PRIORITY = 0.5;
|
|
1980
|
-
DEFAULT_PRIORITY_INFLUENCE = 0.5;
|
|
1981
|
-
DEFAULT_COMBINE_MODE = "max";
|
|
1982
|
-
RelativePriorityNavigator = class extends ContentNavigator {
|
|
1983
|
-
config;
|
|
1984
|
-
_strategyData;
|
|
1985
|
-
/** Human-readable name for CardFilter interface */
|
|
1986
|
-
name;
|
|
1987
|
-
constructor(user, course, _strategyData) {
|
|
1988
|
-
super(user, course, _strategyData);
|
|
1989
|
-
this._strategyData = _strategyData;
|
|
1990
|
-
this.config = this.parseConfig(_strategyData.serializedData);
|
|
1991
|
-
this.name = _strategyData.name || "Relative Priority";
|
|
1992
|
-
}
|
|
1993
|
-
parseConfig(serializedData) {
|
|
1994
|
-
try {
|
|
1995
|
-
const parsed = JSON.parse(serializedData);
|
|
1996
|
-
return {
|
|
1997
|
-
tagPriorities: parsed.tagPriorities || {},
|
|
1998
|
-
defaultPriority: parsed.defaultPriority ?? DEFAULT_PRIORITY,
|
|
1999
|
-
combineMode: parsed.combineMode ?? DEFAULT_COMBINE_MODE,
|
|
2000
|
-
priorityInfluence: parsed.priorityInfluence ?? DEFAULT_PRIORITY_INFLUENCE
|
|
2001
|
-
};
|
|
2002
|
-
} catch {
|
|
2003
|
-
return {
|
|
2004
|
-
tagPriorities: {},
|
|
2005
|
-
defaultPriority: DEFAULT_PRIORITY,
|
|
2006
|
-
combineMode: DEFAULT_COMBINE_MODE,
|
|
2007
|
-
priorityInfluence: DEFAULT_PRIORITY_INFLUENCE
|
|
2008
|
-
};
|
|
2009
|
-
}
|
|
2010
|
-
}
|
|
2011
|
-
/**
|
|
2012
|
-
* Look up the priority for a tag.
|
|
2013
|
-
*/
|
|
2014
|
-
getTagPriority(tagId) {
|
|
2015
|
-
return this.config.tagPriorities[tagId] ?? this.config.defaultPriority ?? DEFAULT_PRIORITY;
|
|
2016
|
-
}
|
|
2017
|
-
/**
|
|
2018
|
-
* Compute combined priority for a card based on its tags.
|
|
2019
|
-
*/
|
|
2020
|
-
computeCardPriority(cardTags) {
|
|
2021
|
-
if (cardTags.length === 0) {
|
|
2022
|
-
return this.config.defaultPriority ?? DEFAULT_PRIORITY;
|
|
2023
|
-
}
|
|
2024
|
-
const priorities = cardTags.map((tag) => this.getTagPriority(tag));
|
|
2025
|
-
switch (this.config.combineMode) {
|
|
2026
|
-
case "max":
|
|
2027
|
-
return Math.max(...priorities);
|
|
2028
|
-
case "min":
|
|
2029
|
-
return Math.min(...priorities);
|
|
2030
|
-
case "average":
|
|
2031
|
-
return priorities.reduce((sum, p) => sum + p, 0) / priorities.length;
|
|
2032
|
-
default:
|
|
2033
|
-
return Math.max(...priorities);
|
|
2034
|
-
}
|
|
2035
|
-
}
|
|
2036
|
-
/**
|
|
2037
|
-
* Compute boost factor based on priority.
|
|
2038
|
-
*
|
|
2039
|
-
* The formula: 1 + (priority - 0.5) * priorityInfluence
|
|
2040
|
-
*
|
|
2041
|
-
* This creates a multiplier centered around 1.0:
|
|
2042
|
-
* - Priority 1.0 with influence 0.5 → 1.25 (25% boost)
|
|
2043
|
-
* - Priority 0.5 with any influence → 1.00 (neutral)
|
|
2044
|
-
* - Priority 0.0 with influence 0.5 → 0.75 (25% reduction)
|
|
2045
|
-
*/
|
|
2046
|
-
computeBoostFactor(priority) {
|
|
2047
|
-
const influence = this.config.priorityInfluence ?? DEFAULT_PRIORITY_INFLUENCE;
|
|
2048
|
-
return 1 + (priority - 0.5) * influence;
|
|
2049
|
-
}
|
|
2050
|
-
/**
|
|
2051
|
-
* Build human-readable reason for priority adjustment.
|
|
2052
|
-
*/
|
|
2053
|
-
buildPriorityReason(cardTags, priority, boostFactor, finalScore) {
|
|
2054
|
-
if (cardTags.length === 0) {
|
|
2055
|
-
return `No tags, neutral priority (${priority.toFixed(2)})`;
|
|
2056
|
-
}
|
|
2057
|
-
const tagList = cardTags.slice(0, 3).join(", ");
|
|
2058
|
-
const more = cardTags.length > 3 ? ` (+${cardTags.length - 3} more)` : "";
|
|
2059
|
-
if (boostFactor === 1) {
|
|
2060
|
-
return `Neutral priority (${priority.toFixed(2)}) for tags: ${tagList}${more}`;
|
|
2061
|
-
} else if (boostFactor > 1) {
|
|
2062
|
-
return `High-priority tags: ${tagList}${more} (priority ${priority.toFixed(2)} \u2192 boost ${boostFactor.toFixed(2)}x \u2192 ${finalScore.toFixed(2)})`;
|
|
2063
|
-
} else {
|
|
2064
|
-
return `Low-priority tags: ${tagList}${more} (priority ${priority.toFixed(2)} \u2192 reduce ${boostFactor.toFixed(2)}x \u2192 ${finalScore.toFixed(2)})`;
|
|
2065
|
-
}
|
|
2066
|
-
}
|
|
2067
|
-
/**
|
|
2068
|
-
* CardFilter.transform implementation.
|
|
2069
|
-
*
|
|
2070
|
-
* Apply priority-adjusted scoring. Cards with high-priority tags get boosted,
|
|
2071
|
-
* cards with low-priority tags get reduced scores.
|
|
2072
|
-
*/
|
|
2073
|
-
async transform(cards, _context) {
|
|
2074
|
-
const adjusted = await Promise.all(
|
|
2075
|
-
cards.map(async (card) => {
|
|
2076
|
-
const cardTags = card.tags ?? [];
|
|
2077
|
-
const priority = this.computeCardPriority(cardTags);
|
|
2078
|
-
const boostFactor = this.computeBoostFactor(priority);
|
|
2079
|
-
const finalScore = Math.max(0, Math.min(1, card.score * boostFactor));
|
|
2080
|
-
const action = boostFactor > 1 ? "boosted" : boostFactor < 1 ? "penalized" : "passed";
|
|
2081
|
-
const reason = this.buildPriorityReason(cardTags, priority, boostFactor, finalScore);
|
|
2082
|
-
return {
|
|
2083
|
-
...card,
|
|
2084
|
-
score: finalScore,
|
|
2085
|
-
provenance: [
|
|
2086
|
-
...card.provenance,
|
|
2087
|
-
{
|
|
2088
|
-
strategy: "relativePriority",
|
|
2089
|
-
strategyName: this.strategyName || this.name,
|
|
2090
|
-
strategyId: this.strategyId || "NAVIGATION_STRATEGY-priority",
|
|
2091
|
-
action,
|
|
2092
|
-
score: finalScore,
|
|
2093
|
-
reason
|
|
2094
|
-
}
|
|
2095
|
-
]
|
|
2096
|
-
};
|
|
2097
|
-
})
|
|
2098
|
-
);
|
|
2099
|
-
return adjusted;
|
|
2100
|
-
}
|
|
2101
|
-
/**
|
|
2102
|
-
* Legacy getWeightedCards - now throws as filters should not be used as generators.
|
|
2103
|
-
*
|
|
2104
|
-
* Use transform() via Pipeline instead.
|
|
2105
|
-
*/
|
|
2106
|
-
async getWeightedCards(_limit) {
|
|
2107
|
-
throw new Error(
|
|
2108
|
-
"RelativePriorityNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
|
|
2109
|
-
);
|
|
2110
|
-
}
|
|
2111
|
-
// Legacy methods - stub implementations since filters don't generate cards
|
|
2112
|
-
async getNewCards(_n) {
|
|
2113
|
-
return [];
|
|
2114
|
-
}
|
|
2115
|
-
async getPendingReviews() {
|
|
2116
|
-
return [];
|
|
2117
|
-
}
|
|
2118
|
-
};
|
|
2119
|
-
}
|
|
2120
|
-
});
|
|
2121
|
-
|
|
2122
|
-
// src/core/navigators/srs.ts
|
|
2123
|
-
var srs_exports = {};
|
|
2124
|
-
__export(srs_exports, {
|
|
2125
|
-
default: () => SRSNavigator
|
|
2126
|
-
});
|
|
2127
|
-
import moment from "moment";
|
|
2128
|
-
var SRSNavigator;
|
|
2129
|
-
var init_srs = __esm({
|
|
2130
|
-
"src/core/navigators/srs.ts"() {
|
|
2131
|
-
"use strict";
|
|
2132
|
-
init_navigators();
|
|
2133
|
-
SRSNavigator = class extends ContentNavigator {
|
|
2134
|
-
/** Human-readable name for CardGenerator interface */
|
|
2135
|
-
name;
|
|
2136
|
-
constructor(user, course, strategyData) {
|
|
2137
|
-
super(user, course, strategyData);
|
|
2138
|
-
this.name = strategyData?.name || "SRS";
|
|
2139
|
-
}
|
|
2140
|
-
/**
|
|
2141
|
-
* Get review cards scored by urgency.
|
|
2142
|
-
*
|
|
2143
|
-
* Score formula combines:
|
|
2144
|
-
* - Relative overdueness: hoursOverdue / intervalHours
|
|
2145
|
-
* - Interval recency: exponential decay favoring shorter intervals
|
|
2146
|
-
*
|
|
2147
|
-
* Cards not yet due are excluded (not scored as 0).
|
|
2148
|
-
*
|
|
2149
|
-
* This method supports both the legacy signature (limit only) and the
|
|
2150
|
-
* CardGenerator interface signature (limit, context).
|
|
2151
|
-
*
|
|
2152
|
-
* @param limit - Maximum number of cards to return
|
|
2153
|
-
* @param _context - Optional GeneratorContext (currently unused, but required for interface)
|
|
1272
|
+
* @param limit - Maximum number of cards to return
|
|
1273
|
+
* @param _context - Optional GeneratorContext (currently unused, but required for interface)
|
|
2154
1274
|
*/
|
|
2155
1275
|
async getWeightedCards(limit, _context) {
|
|
2156
1276
|
if (!this.user || !this.course) {
|
|
@@ -2165,6 +1285,7 @@ var init_srs = __esm({
|
|
|
2165
1285
|
cardId: review.cardId,
|
|
2166
1286
|
courseId: review.courseId,
|
|
2167
1287
|
score,
|
|
1288
|
+
reviewID: review._id,
|
|
2168
1289
|
provenance: [
|
|
2169
1290
|
{
|
|
2170
1291
|
strategy: "srs",
|
|
@@ -2177,339 +1298,143 @@ var init_srs = __esm({
|
|
|
2177
1298
|
]
|
|
2178
1299
|
};
|
|
2179
1300
|
});
|
|
1301
|
+
logger.debug(`[srsNav] got ${scored.length} weighted cards`);
|
|
2180
1302
|
return scored.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
2181
1303
|
}
|
|
2182
1304
|
/**
|
|
2183
1305
|
* Compute urgency score for a review card.
|
|
2184
1306
|
*
|
|
2185
|
-
* Two factors:
|
|
2186
|
-
* 1. Relative overdueness = hoursOverdue / intervalHours
|
|
2187
|
-
* - 2 days overdue on 3-day interval = 0.67 (urgent)
|
|
2188
|
-
* - 2 days overdue on 180-day interval = 0.01 (not urgent)
|
|
2189
|
-
*
|
|
2190
|
-
* 2. Interval recency factor = 0.3 + 0.7 * exp(-intervalHours / 720)
|
|
2191
|
-
* - 24h interval → ~1.0 (very recent learning)
|
|
2192
|
-
* - 30 days (720h) → ~0.56
|
|
2193
|
-
* - 180 days → ~0.30
|
|
2194
|
-
*
|
|
2195
|
-
* Combined: base 0.5 + weighted average of factors * 0.45
|
|
2196
|
-
* Result range: approximately 0.5 to 0.95
|
|
2197
|
-
*/
|
|
2198
|
-
computeUrgencyScore(review, now) {
|
|
2199
|
-
const scheduledAt = moment.utc(review.scheduledAt);
|
|
2200
|
-
const due = moment.utc(review.reviewTime);
|
|
2201
|
-
const intervalHours = Math.max(1, due.diff(scheduledAt, "hours"));
|
|
2202
|
-
const hoursOverdue = now.diff(due, "hours");
|
|
2203
|
-
const relativeOverdue = hoursOverdue / intervalHours;
|
|
2204
|
-
const recencyFactor = 0.3 + 0.7 * Math.exp(-intervalHours / 720);
|
|
2205
|
-
const overdueContribution = Math.min(1, Math.max(0, relativeOverdue));
|
|
2206
|
-
const urgency = overdueContribution * 0.5 + recencyFactor * 0.5;
|
|
2207
|
-
const score = Math.min(0.95, 0.5 + urgency * 0.45);
|
|
2208
|
-
const reason = `${Math.round(hoursOverdue)}h overdue (interval: ${Math.round(intervalHours)}h, relative: ${relativeOverdue.toFixed(2)}), recency: ${recencyFactor.toFixed(2)}, review`;
|
|
2209
|
-
return { score, reason };
|
|
2210
|
-
}
|
|
2211
|
-
/**
|
|
2212
|
-
* Get pending reviews in legacy format.
|
|
2213
|
-
*
|
|
2214
|
-
* Returns all pending reviews for the course, enriched with session item fields.
|
|
2215
|
-
*/
|
|
2216
|
-
async getPendingReviews() {
|
|
2217
|
-
if (!this.user || !this.course) {
|
|
2218
|
-
throw new Error("SRSNavigator requires user and course to be set");
|
|
2219
|
-
}
|
|
2220
|
-
const reviews = await this.user.getPendingReviews(this.course.getCourseID());
|
|
2221
|
-
return reviews.map((r) => ({
|
|
2222
|
-
...r,
|
|
2223
|
-
contentSourceType: "course",
|
|
2224
|
-
contentSourceID: this.course.getCourseID(),
|
|
2225
|
-
cardID: r.cardId,
|
|
2226
|
-
courseID: r.courseId,
|
|
2227
|
-
qualifiedID: `${r.courseId}-${r.cardId}`,
|
|
2228
|
-
reviewID: r._id,
|
|
2229
|
-
status: "review"
|
|
2230
|
-
}));
|
|
2231
|
-
}
|
|
2232
|
-
/**
|
|
2233
|
-
* SRS does not generate new cards.
|
|
2234
|
-
* Use ELONavigator or another generator for new cards.
|
|
2235
|
-
*/
|
|
2236
|
-
async getNewCards(_n) {
|
|
2237
|
-
return [];
|
|
2238
|
-
}
|
|
2239
|
-
};
|
|
2240
|
-
}
|
|
2241
|
-
});
|
|
2242
|
-
|
|
2243
|
-
// src/core/navigators/userGoal.ts
|
|
2244
|
-
var userGoal_exports = {};
|
|
2245
|
-
__export(userGoal_exports, {
|
|
2246
|
-
USER_GOAL_NAVIGATOR_STUB: () => USER_GOAL_NAVIGATOR_STUB
|
|
2247
|
-
});
|
|
2248
|
-
var USER_GOAL_NAVIGATOR_STUB;
|
|
2249
|
-
var init_userGoal = __esm({
|
|
2250
|
-
"src/core/navigators/userGoal.ts"() {
|
|
2251
|
-
"use strict";
|
|
2252
|
-
USER_GOAL_NAVIGATOR_STUB = true;
|
|
2253
|
-
}
|
|
2254
|
-
});
|
|
2255
|
-
|
|
2256
|
-
// import("./**/*") in src/core/navigators/index.ts
|
|
2257
|
-
var globImport;
|
|
2258
|
-
var init_ = __esm({
|
|
2259
|
-
'import("./**/*") in src/core/navigators/index.ts'() {
|
|
2260
|
-
globImport = __glob({
|
|
2261
|
-
"./CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
|
|
2262
|
-
"./Pipeline.ts": () => Promise.resolve().then(() => (init_Pipeline(), Pipeline_exports)),
|
|
2263
|
-
"./PipelineAssembler.ts": () => Promise.resolve().then(() => (init_PipelineAssembler(), PipelineAssembler_exports)),
|
|
2264
|
-
"./elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
|
|
2265
|
-
"./filters/eloDistance.ts": () => Promise.resolve().then(() => (init_eloDistance(), eloDistance_exports)),
|
|
2266
|
-
"./filters/index.ts": () => Promise.resolve().then(() => (init_filters(), filters_exports)),
|
|
2267
|
-
"./filters/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports)),
|
|
2268
|
-
"./filters/userTagPreference.ts": () => Promise.resolve().then(() => (init_userTagPreference(), userTagPreference_exports)),
|
|
2269
|
-
"./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
|
|
2270
|
-
"./generators/types.ts": () => Promise.resolve().then(() => (init_types2(), types_exports2)),
|
|
2271
|
-
"./hardcodedOrder.ts": () => Promise.resolve().then(() => (init_hardcodedOrder(), hardcodedOrder_exports)),
|
|
2272
|
-
"./hierarchyDefinition.ts": () => Promise.resolve().then(() => (init_hierarchyDefinition(), hierarchyDefinition_exports)),
|
|
2273
|
-
"./index.ts": () => Promise.resolve().then(() => (init_navigators(), navigators_exports)),
|
|
2274
|
-
"./inferredPreference.ts": () => Promise.resolve().then(() => (init_inferredPreference(), inferredPreference_exports)),
|
|
2275
|
-
"./interferenceMitigator.ts": () => Promise.resolve().then(() => (init_interferenceMitigator(), interferenceMitigator_exports)),
|
|
2276
|
-
"./relativePriority.ts": () => Promise.resolve().then(() => (init_relativePriority(), relativePriority_exports)),
|
|
2277
|
-
"./srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
|
|
2278
|
-
"./userGoal.ts": () => Promise.resolve().then(() => (init_userGoal(), userGoal_exports))
|
|
2279
|
-
});
|
|
2280
|
-
}
|
|
2281
|
-
});
|
|
2282
|
-
|
|
2283
|
-
// src/core/navigators/index.ts
|
|
2284
|
-
var navigators_exports = {};
|
|
2285
|
-
__export(navigators_exports, {
|
|
2286
|
-
ContentNavigator: () => ContentNavigator,
|
|
2287
|
-
NavigatorRole: () => NavigatorRole,
|
|
2288
|
-
NavigatorRoles: () => NavigatorRoles,
|
|
2289
|
-
Navigators: () => Navigators,
|
|
2290
|
-
getCardOrigin: () => getCardOrigin,
|
|
2291
|
-
isFilter: () => isFilter,
|
|
2292
|
-
isGenerator: () => isGenerator
|
|
2293
|
-
});
|
|
2294
|
-
function getCardOrigin(card) {
|
|
2295
|
-
if (card.provenance.length === 0) {
|
|
2296
|
-
throw new Error("Card has no provenance - cannot determine origin");
|
|
2297
|
-
}
|
|
2298
|
-
const firstEntry = card.provenance[0];
|
|
2299
|
-
const reason = firstEntry.reason.toLowerCase();
|
|
2300
|
-
if (reason.includes("failed")) {
|
|
2301
|
-
return "failed";
|
|
2302
|
-
}
|
|
2303
|
-
if (reason.includes("review")) {
|
|
2304
|
-
return "review";
|
|
2305
|
-
}
|
|
2306
|
-
return "new";
|
|
2307
|
-
}
|
|
2308
|
-
function isGenerator(impl) {
|
|
2309
|
-
return NavigatorRoles[impl] === "generator" /* GENERATOR */;
|
|
2310
|
-
}
|
|
2311
|
-
function isFilter(impl) {
|
|
2312
|
-
return NavigatorRoles[impl] === "filter" /* FILTER */;
|
|
2313
|
-
}
|
|
2314
|
-
var Navigators, NavigatorRole, NavigatorRoles, ContentNavigator;
|
|
2315
|
-
var init_navigators = __esm({
|
|
2316
|
-
"src/core/navigators/index.ts"() {
|
|
2317
|
-
"use strict";
|
|
2318
|
-
init_logger();
|
|
2319
|
-
init_();
|
|
2320
|
-
Navigators = /* @__PURE__ */ ((Navigators2) => {
|
|
2321
|
-
Navigators2["ELO"] = "elo";
|
|
2322
|
-
Navigators2["SRS"] = "srs";
|
|
2323
|
-
Navigators2["HARDCODED"] = "hardcodedOrder";
|
|
2324
|
-
Navigators2["HIERARCHY"] = "hierarchyDefinition";
|
|
2325
|
-
Navigators2["INTERFERENCE"] = "interferenceMitigator";
|
|
2326
|
-
Navigators2["RELATIVE_PRIORITY"] = "relativePriority";
|
|
2327
|
-
Navigators2["USER_TAG_PREFERENCE"] = "userTagPreference";
|
|
2328
|
-
return Navigators2;
|
|
2329
|
-
})(Navigators || {});
|
|
2330
|
-
NavigatorRole = /* @__PURE__ */ ((NavigatorRole2) => {
|
|
2331
|
-
NavigatorRole2["GENERATOR"] = "generator";
|
|
2332
|
-
NavigatorRole2["FILTER"] = "filter";
|
|
2333
|
-
return NavigatorRole2;
|
|
2334
|
-
})(NavigatorRole || {});
|
|
2335
|
-
NavigatorRoles = {
|
|
2336
|
-
["elo" /* ELO */]: "generator" /* GENERATOR */,
|
|
2337
|
-
["srs" /* SRS */]: "generator" /* GENERATOR */,
|
|
2338
|
-
["hardcodedOrder" /* HARDCODED */]: "generator" /* GENERATOR */,
|
|
2339
|
-
["hierarchyDefinition" /* HIERARCHY */]: "filter" /* FILTER */,
|
|
2340
|
-
["interferenceMitigator" /* INTERFERENCE */]: "filter" /* FILTER */,
|
|
2341
|
-
["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */,
|
|
2342
|
-
["userTagPreference" /* USER_TAG_PREFERENCE */]: "filter" /* FILTER */
|
|
2343
|
-
};
|
|
2344
|
-
ContentNavigator = class {
|
|
2345
|
-
/** User interface for this navigation session */
|
|
2346
|
-
user;
|
|
2347
|
-
/** Course interface for this navigation session */
|
|
2348
|
-
course;
|
|
2349
|
-
/** Human-readable name for this strategy instance (from ContentNavigationStrategyData.name) */
|
|
2350
|
-
strategyName;
|
|
2351
|
-
/** Unique document ID for this strategy instance (from ContentNavigationStrategyData._id) */
|
|
2352
|
-
strategyId;
|
|
2353
|
-
/**
|
|
2354
|
-
* Constructor for standard navigators.
|
|
2355
|
-
* Call this from subclass constructors to initialize common fields.
|
|
2356
|
-
*
|
|
2357
|
-
* Note: CompositeGenerator doesn't use this pattern and should call super() without args.
|
|
2358
|
-
*/
|
|
2359
|
-
constructor(user, course, strategyData) {
|
|
2360
|
-
if (user && course && strategyData) {
|
|
2361
|
-
this.user = user;
|
|
2362
|
-
this.course = course;
|
|
2363
|
-
this.strategyName = strategyData.name;
|
|
2364
|
-
this.strategyId = strategyData._id;
|
|
2365
|
-
}
|
|
2366
|
-
}
|
|
2367
|
-
// ============================================================================
|
|
2368
|
-
// STRATEGY STATE HELPERS
|
|
2369
|
-
// ============================================================================
|
|
2370
|
-
//
|
|
2371
|
-
// These methods allow strategies to persist their own state (user preferences,
|
|
2372
|
-
// learned patterns, temporal tracking) in the user database.
|
|
2373
|
-
//
|
|
2374
|
-
// ============================================================================
|
|
2375
|
-
/**
|
|
2376
|
-
* Unique key identifying this strategy for state storage.
|
|
2377
|
-
*
|
|
2378
|
-
* Defaults to the constructor name (e.g., "UserTagPreferenceFilter").
|
|
2379
|
-
* Override in subclasses if multiple instances of the same strategy type
|
|
2380
|
-
* need separate state storage.
|
|
2381
|
-
*/
|
|
2382
|
-
get strategyKey() {
|
|
2383
|
-
return this.constructor.name;
|
|
2384
|
-
}
|
|
2385
|
-
/**
|
|
2386
|
-
* Get this strategy's persisted state for the current course.
|
|
2387
|
-
*
|
|
2388
|
-
* @returns The strategy's data payload, or null if no state exists
|
|
2389
|
-
* @throws Error if user or course is not initialized
|
|
2390
|
-
*/
|
|
2391
|
-
async getStrategyState() {
|
|
2392
|
-
if (!this.user || !this.course) {
|
|
2393
|
-
throw new Error(
|
|
2394
|
-
`Cannot get strategy state: navigator not properly initialized. Ensure user and course are provided to constructor.`
|
|
2395
|
-
);
|
|
2396
|
-
}
|
|
2397
|
-
return this.user.getStrategyState(this.course.getCourseID(), this.strategyKey);
|
|
2398
|
-
}
|
|
2399
|
-
/**
|
|
2400
|
-
* Persist this strategy's state for the current course.
|
|
2401
|
-
*
|
|
2402
|
-
* @param data - The strategy's data payload to store
|
|
2403
|
-
* @throws Error if user or course is not initialized
|
|
2404
|
-
*/
|
|
2405
|
-
async putStrategyState(data) {
|
|
2406
|
-
if (!this.user || !this.course) {
|
|
2407
|
-
throw new Error(
|
|
2408
|
-
`Cannot put strategy state: navigator not properly initialized. Ensure user and course are provided to constructor.`
|
|
2409
|
-
);
|
|
2410
|
-
}
|
|
2411
|
-
return this.user.putStrategyState(this.course.getCourseID(), this.strategyKey, data);
|
|
2412
|
-
}
|
|
2413
|
-
/**
|
|
2414
|
-
* Factory method to create navigator instances dynamically.
|
|
2415
|
-
*
|
|
2416
|
-
* @param user - User interface
|
|
2417
|
-
* @param course - Course interface
|
|
2418
|
-
* @param strategyData - Strategy configuration document
|
|
2419
|
-
* @returns the runtime object used to steer a study session.
|
|
2420
|
-
*/
|
|
2421
|
-
static async create(user, course, strategyData) {
|
|
2422
|
-
const implementingClass = strategyData.implementingClass;
|
|
2423
|
-
let NavigatorImpl;
|
|
2424
|
-
const variations = [".ts", ".js", ""];
|
|
2425
|
-
for (const ext of variations) {
|
|
2426
|
-
try {
|
|
2427
|
-
const module = await globImport(`./${implementingClass}${ext}`);
|
|
2428
|
-
NavigatorImpl = module.default;
|
|
2429
|
-
break;
|
|
2430
|
-
} catch (e) {
|
|
2431
|
-
logger.debug(`Failed to load with extension ${ext}:`, e);
|
|
2432
|
-
}
|
|
2433
|
-
}
|
|
2434
|
-
if (!NavigatorImpl) {
|
|
2435
|
-
throw new Error(`Could not load navigator implementation for: ${implementingClass}`);
|
|
2436
|
-
}
|
|
2437
|
-
return new NavigatorImpl(user, course, strategyData);
|
|
2438
|
-
}
|
|
2439
|
-
/**
|
|
2440
|
-
* Get cards with suitability scores and provenance trails.
|
|
2441
|
-
*
|
|
2442
|
-
* **This is the PRIMARY API for navigation strategies.**
|
|
2443
|
-
*
|
|
2444
|
-
* Returns cards ranked by suitability score (0-1). Higher scores indicate
|
|
2445
|
-
* better candidates for presentation. Each card includes a provenance trail
|
|
2446
|
-
* documenting how strategies contributed to the final score.
|
|
2447
|
-
*
|
|
2448
|
-
* ## For Generators
|
|
2449
|
-
* Override this method to generate candidates and compute scores based on
|
|
2450
|
-
* your strategy's logic (e.g., ELO proximity, review urgency). Create the
|
|
2451
|
-
* initial provenance entry with action='generated'.
|
|
2452
|
-
*
|
|
2453
|
-
* ## Default Implementation
|
|
2454
|
-
* The base class provides a backward-compatible default that:
|
|
2455
|
-
* 1. Calls legacy getNewCards() and getPendingReviews()
|
|
2456
|
-
* 2. Assigns score=1.0 to all cards
|
|
2457
|
-
* 3. Creates minimal provenance from legacy methods
|
|
2458
|
-
* 4. Returns combined results up to limit
|
|
2459
|
-
*
|
|
2460
|
-
* This allows existing strategies to work without modification while
|
|
2461
|
-
* new strategies can override with proper scoring and provenance.
|
|
2462
|
-
*
|
|
2463
|
-
* @param limit - Maximum cards to return
|
|
2464
|
-
* @returns Cards sorted by score descending, with provenance trails
|
|
2465
|
-
*/
|
|
2466
|
-
async getWeightedCards(limit) {
|
|
2467
|
-
const newCards = await this.getNewCards(limit);
|
|
2468
|
-
const reviews = await this.getPendingReviews();
|
|
2469
|
-
const weighted = [
|
|
2470
|
-
...newCards.map((c) => ({
|
|
2471
|
-
cardId: c.cardID,
|
|
2472
|
-
courseId: c.courseID,
|
|
2473
|
-
score: 1,
|
|
2474
|
-
provenance: [
|
|
2475
|
-
{
|
|
2476
|
-
strategy: "legacy",
|
|
2477
|
-
strategyName: this.strategyName || "Legacy API",
|
|
2478
|
-
strategyId: this.strategyId || "legacy-fallback",
|
|
2479
|
-
action: "generated",
|
|
2480
|
-
score: 1,
|
|
2481
|
-
reason: "Generated via legacy getNewCards(), new card"
|
|
2482
|
-
}
|
|
2483
|
-
]
|
|
2484
|
-
})),
|
|
2485
|
-
...reviews.map((r) => ({
|
|
2486
|
-
cardId: r.cardID,
|
|
2487
|
-
courseId: r.courseID,
|
|
2488
|
-
score: 1,
|
|
2489
|
-
provenance: [
|
|
2490
|
-
{
|
|
2491
|
-
strategy: "legacy",
|
|
2492
|
-
strategyName: this.strategyName || "Legacy API",
|
|
2493
|
-
strategyId: this.strategyId || "legacy-fallback",
|
|
2494
|
-
action: "generated",
|
|
2495
|
-
score: 1,
|
|
2496
|
-
reason: "Generated via legacy getPendingReviews(), review"
|
|
2497
|
-
}
|
|
2498
|
-
]
|
|
2499
|
-
}))
|
|
2500
|
-
];
|
|
2501
|
-
return weighted.slice(0, limit);
|
|
1307
|
+
* Two factors:
|
|
1308
|
+
* 1. Relative overdueness = hoursOverdue / intervalHours
|
|
1309
|
+
* - 2 days overdue on 3-day interval = 0.67 (urgent)
|
|
1310
|
+
* - 2 days overdue on 180-day interval = 0.01 (not urgent)
|
|
1311
|
+
*
|
|
1312
|
+
* 2. Interval recency factor = 0.3 + 0.7 * exp(-intervalHours / 720)
|
|
1313
|
+
* - 24h interval → ~1.0 (very recent learning)
|
|
1314
|
+
* - 30 days (720h) → ~0.56
|
|
1315
|
+
* - 180 days → ~0.30
|
|
1316
|
+
*
|
|
1317
|
+
* Combined: base 0.5 + weighted average of factors * 0.45
|
|
1318
|
+
* Result range: approximately 0.5 to 0.95
|
|
1319
|
+
*/
|
|
1320
|
+
computeUrgencyScore(review, now) {
|
|
1321
|
+
const scheduledAt = moment.utc(review.scheduledAt);
|
|
1322
|
+
const due = moment.utc(review.reviewTime);
|
|
1323
|
+
const intervalHours = Math.max(1, due.diff(scheduledAt, "hours"));
|
|
1324
|
+
const hoursOverdue = now.diff(due, "hours");
|
|
1325
|
+
const relativeOverdue = hoursOverdue / intervalHours;
|
|
1326
|
+
const recencyFactor = 0.3 + 0.7 * Math.exp(-intervalHours / 720);
|
|
1327
|
+
const overdueContribution = Math.min(1, Math.max(0, relativeOverdue));
|
|
1328
|
+
const urgency = overdueContribution * 0.5 + recencyFactor * 0.5;
|
|
1329
|
+
const score = Math.min(0.95, 0.5 + urgency * 0.45);
|
|
1330
|
+
const reason = `${Math.round(hoursOverdue)}h overdue (interval: ${Math.round(intervalHours)}h, relative: ${relativeOverdue.toFixed(2)}), recency: ${recencyFactor.toFixed(2)}, review`;
|
|
1331
|
+
return { score, reason };
|
|
2502
1332
|
}
|
|
2503
1333
|
};
|
|
2504
1334
|
}
|
|
2505
1335
|
});
|
|
2506
1336
|
|
|
1337
|
+
// src/core/navigators/filters/eloDistance.ts
|
|
1338
|
+
function computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier) {
|
|
1339
|
+
const normalizedDistance = distance / halfLife;
|
|
1340
|
+
const decay = Math.exp(-(normalizedDistance * normalizedDistance));
|
|
1341
|
+
return minMultiplier + (maxMultiplier - minMultiplier) * decay;
|
|
1342
|
+
}
|
|
1343
|
+
function createEloDistanceFilter(config) {
|
|
1344
|
+
const halfLife = config?.halfLife ?? DEFAULT_HALF_LIFE;
|
|
1345
|
+
const minMultiplier = config?.minMultiplier ?? DEFAULT_MIN_MULTIPLIER;
|
|
1346
|
+
const maxMultiplier = config?.maxMultiplier ?? DEFAULT_MAX_MULTIPLIER;
|
|
1347
|
+
return {
|
|
1348
|
+
name: "ELO Distance Filter",
|
|
1349
|
+
async transform(cards, context) {
|
|
1350
|
+
const { course, userElo } = context;
|
|
1351
|
+
const cardIds = cards.map((c) => c.cardId);
|
|
1352
|
+
const cardElos = await course.getCardEloData(cardIds);
|
|
1353
|
+
return cards.map((card, i) => {
|
|
1354
|
+
const cardElo = cardElos[i]?.global?.score ?? 1e3;
|
|
1355
|
+
const distance = Math.abs(cardElo - userElo);
|
|
1356
|
+
const multiplier = computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier);
|
|
1357
|
+
const newScore = card.score * multiplier;
|
|
1358
|
+
const action = multiplier < maxMultiplier - 0.01 ? "penalized" : "passed";
|
|
1359
|
+
return {
|
|
1360
|
+
...card,
|
|
1361
|
+
score: newScore,
|
|
1362
|
+
provenance: [
|
|
1363
|
+
...card.provenance,
|
|
1364
|
+
{
|
|
1365
|
+
strategy: "eloDistance",
|
|
1366
|
+
strategyName: "ELO Distance Filter",
|
|
1367
|
+
strategyId: "ELO_DISTANCE_FILTER",
|
|
1368
|
+
action,
|
|
1369
|
+
score: newScore,
|
|
1370
|
+
reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userElo)}) \u2192 ${multiplier.toFixed(2)}x`
|
|
1371
|
+
}
|
|
1372
|
+
]
|
|
1373
|
+
};
|
|
1374
|
+
});
|
|
1375
|
+
}
|
|
1376
|
+
};
|
|
1377
|
+
}
|
|
1378
|
+
var DEFAULT_HALF_LIFE, DEFAULT_MIN_MULTIPLIER, DEFAULT_MAX_MULTIPLIER;
|
|
1379
|
+
var init_eloDistance = __esm({
|
|
1380
|
+
"src/core/navigators/filters/eloDistance.ts"() {
|
|
1381
|
+
"use strict";
|
|
1382
|
+
DEFAULT_HALF_LIFE = 200;
|
|
1383
|
+
DEFAULT_MIN_MULTIPLIER = 0.3;
|
|
1384
|
+
DEFAULT_MAX_MULTIPLIER = 1;
|
|
1385
|
+
}
|
|
1386
|
+
});
|
|
1387
|
+
|
|
1388
|
+
// src/core/navigators/defaults.ts
|
|
1389
|
+
function createDefaultEloStrategy(courseId) {
|
|
1390
|
+
return {
|
|
1391
|
+
_id: "NAVIGATION_STRATEGY-ELO-default",
|
|
1392
|
+
docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
|
|
1393
|
+
name: "ELO (default)",
|
|
1394
|
+
description: "Default ELO-based navigation strategy for new cards",
|
|
1395
|
+
implementingClass: "elo" /* ELO */,
|
|
1396
|
+
course: courseId,
|
|
1397
|
+
serializedData: ""
|
|
1398
|
+
};
|
|
1399
|
+
}
|
|
1400
|
+
function createDefaultSrsStrategy(courseId) {
|
|
1401
|
+
return {
|
|
1402
|
+
_id: "NAVIGATION_STRATEGY-SRS-default",
|
|
1403
|
+
docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
|
|
1404
|
+
name: "SRS (default)",
|
|
1405
|
+
description: "Default SRS-based navigation strategy for reviews",
|
|
1406
|
+
implementingClass: "srs" /* SRS */,
|
|
1407
|
+
course: courseId,
|
|
1408
|
+
serializedData: ""
|
|
1409
|
+
};
|
|
1410
|
+
}
|
|
1411
|
+
function createDefaultPipeline(user, course) {
|
|
1412
|
+
const courseId = course.getCourseID();
|
|
1413
|
+
const eloNavigator = new ELONavigator(user, course, createDefaultEloStrategy(courseId));
|
|
1414
|
+
const srsNavigator = new SRSNavigator(user, course, createDefaultSrsStrategy(courseId));
|
|
1415
|
+
const compositeGenerator = new CompositeGenerator([eloNavigator, srsNavigator]);
|
|
1416
|
+
const eloDistanceFilter = createEloDistanceFilter();
|
|
1417
|
+
return new Pipeline(compositeGenerator, [eloDistanceFilter], user, course);
|
|
1418
|
+
}
|
|
1419
|
+
var init_defaults = __esm({
|
|
1420
|
+
"src/core/navigators/defaults.ts"() {
|
|
1421
|
+
"use strict";
|
|
1422
|
+
init_navigators();
|
|
1423
|
+
init_Pipeline();
|
|
1424
|
+
init_CompositeGenerator();
|
|
1425
|
+
init_elo();
|
|
1426
|
+
init_srs();
|
|
1427
|
+
init_eloDistance();
|
|
1428
|
+
init_types_legacy();
|
|
1429
|
+
}
|
|
1430
|
+
});
|
|
1431
|
+
|
|
2507
1432
|
// src/impl/couch/courseDB.ts
|
|
2508
1433
|
import {
|
|
2509
1434
|
EloToNumber,
|
|
2510
1435
|
Status,
|
|
2511
1436
|
blankCourseElo as blankCourseElo2,
|
|
2512
|
-
toCourseElo as
|
|
1437
|
+
toCourseElo as toCourseElo4
|
|
2513
1438
|
} from "@vue-skuilder/common";
|
|
2514
1439
|
function randIntWeightedTowardZero(n) {
|
|
2515
1440
|
return Math.floor(Math.random() * Math.random() * Math.random() * n);
|
|
@@ -2640,12 +1565,8 @@ var init_courseDB = __esm({
|
|
|
2640
1565
|
init_courseAPI();
|
|
2641
1566
|
init_courseLookupDB();
|
|
2642
1567
|
init_navigators();
|
|
2643
|
-
init_Pipeline();
|
|
2644
1568
|
init_PipelineAssembler();
|
|
2645
|
-
|
|
2646
|
-
init_elo();
|
|
2647
|
-
init_srs();
|
|
2648
|
-
init_eloDistance();
|
|
1569
|
+
init_defaults();
|
|
2649
1570
|
CoursesDB = class {
|
|
2650
1571
|
_courseIDs;
|
|
2651
1572
|
constructor(courseIDs) {
|
|
@@ -2757,7 +1678,7 @@ var init_courseDB = __esm({
|
|
|
2757
1678
|
docs.rows.forEach((r) => {
|
|
2758
1679
|
if (isSuccessRow(r)) {
|
|
2759
1680
|
if (r.doc && r.doc.elo) {
|
|
2760
|
-
ret.push(
|
|
1681
|
+
ret.push(toCourseElo4(r.doc.elo));
|
|
2761
1682
|
} else {
|
|
2762
1683
|
logger.warn("no elo data for card: " + r.id);
|
|
2763
1684
|
ret.push(blankCourseElo2());
|
|
@@ -3059,7 +1980,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3059
1980
|
logger.debug(
|
|
3060
1981
|
"[courseDB] No strategy documents found, using default Pipeline(Composite(ELO, SRS), [eloDistanceFilter])"
|
|
3061
1982
|
);
|
|
3062
|
-
return
|
|
1983
|
+
return createDefaultPipeline(user, this);
|
|
3063
1984
|
}
|
|
3064
1985
|
const assembler = new PipelineAssembler();
|
|
3065
1986
|
const { pipeline, generatorStrategies, filterStrategies, warnings } = await assembler.assemble({
|
|
@@ -3072,7 +1993,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3072
1993
|
}
|
|
3073
1994
|
if (!pipeline) {
|
|
3074
1995
|
logger.debug("[courseDB] Pipeline assembly failed, using default pipeline");
|
|
3075
|
-
return
|
|
1996
|
+
return createDefaultPipeline(user, this);
|
|
3076
1997
|
}
|
|
3077
1998
|
logger.debug(
|
|
3078
1999
|
`[courseDB] Using assembled pipeline with ${generatorStrategies.length} generator(s) and ${filterStrategies.length} filter(s)`
|
|
@@ -3083,69 +2004,12 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3083
2004
|
throw e;
|
|
3084
2005
|
}
|
|
3085
2006
|
}
|
|
3086
|
-
makeDefaultEloStrategy() {
|
|
3087
|
-
return {
|
|
3088
|
-
_id: "NAVIGATION_STRATEGY-ELO-default",
|
|
3089
|
-
docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
|
|
3090
|
-
name: "ELO (default)",
|
|
3091
|
-
description: "Default ELO-based navigation strategy for new cards",
|
|
3092
|
-
implementingClass: "elo" /* ELO */,
|
|
3093
|
-
course: this.id,
|
|
3094
|
-
serializedData: ""
|
|
3095
|
-
};
|
|
3096
|
-
}
|
|
3097
|
-
makeDefaultSrsStrategy() {
|
|
3098
|
-
return {
|
|
3099
|
-
_id: "NAVIGATION_STRATEGY-SRS-default",
|
|
3100
|
-
docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
|
|
3101
|
-
name: "SRS (default)",
|
|
3102
|
-
description: "Default SRS-based navigation strategy for reviews",
|
|
3103
|
-
implementingClass: "srs" /* SRS */,
|
|
3104
|
-
course: this.id,
|
|
3105
|
-
serializedData: ""
|
|
3106
|
-
};
|
|
3107
|
-
}
|
|
3108
|
-
/**
|
|
3109
|
-
* Creates the default navigation pipeline for courses with no configured strategies.
|
|
3110
|
-
*
|
|
3111
|
-
* Default: Pipeline(Composite(ELO, SRS), [eloDistanceFilter])
|
|
3112
|
-
* - ELO generator: scores new cards by skill proximity
|
|
3113
|
-
* - SRS generator: scores reviews by overdueness and interval recency
|
|
3114
|
-
* - ELO distance filter: penalizes cards far from user's current level
|
|
3115
|
-
*/
|
|
3116
|
-
createDefaultPipeline(user) {
|
|
3117
|
-
const eloNavigator = new ELONavigator(user, this, this.makeDefaultEloStrategy());
|
|
3118
|
-
const srsNavigator = new SRSNavigator(user, this, this.makeDefaultSrsStrategy());
|
|
3119
|
-
const compositeGenerator = new CompositeGenerator([eloNavigator, srsNavigator]);
|
|
3120
|
-
const eloDistanceFilter = createEloDistanceFilter();
|
|
3121
|
-
return new Pipeline(compositeGenerator, [eloDistanceFilter], user, this);
|
|
3122
|
-
}
|
|
3123
2007
|
////////////////////////////////////
|
|
3124
2008
|
// END NavigationStrategyManager implementation
|
|
3125
2009
|
////////////////////////////////////
|
|
3126
2010
|
////////////////////////////////////
|
|
3127
2011
|
// StudyContentSource implementation
|
|
3128
2012
|
////////////////////////////////////
|
|
3129
|
-
async getNewCards(limit = 99) {
|
|
3130
|
-
const u = await this._getCurrentUser();
|
|
3131
|
-
try {
|
|
3132
|
-
const navigator = await this.createNavigator(u);
|
|
3133
|
-
return navigator.getNewCards(limit);
|
|
3134
|
-
} catch (e) {
|
|
3135
|
-
logger.error(`[courseDB] Error in getNewCards: ${e}`);
|
|
3136
|
-
throw e;
|
|
3137
|
-
}
|
|
3138
|
-
}
|
|
3139
|
-
async getPendingReviews() {
|
|
3140
|
-
const u = await this._getCurrentUser();
|
|
3141
|
-
try {
|
|
3142
|
-
const navigator = await this.createNavigator(u);
|
|
3143
|
-
return navigator.getPendingReviews();
|
|
3144
|
-
} catch (e) {
|
|
3145
|
-
logger.error(`[courseDB] Error in getPendingReviews: ${e}`);
|
|
3146
|
-
throw e;
|
|
3147
|
-
}
|
|
3148
|
-
}
|
|
3149
2013
|
/**
|
|
3150
2014
|
* Get cards with suitability scores for presentation.
|
|
3151
2015
|
*
|
|
@@ -3396,79 +2260,27 @@ var init_classroomDB2 = __esm({
|
|
|
3396
2260
|
setChangeFcn(f) {
|
|
3397
2261
|
void this.userMessages.on("change", f);
|
|
3398
2262
|
}
|
|
3399
|
-
async getPendingReviews() {
|
|
3400
|
-
const u = this._user;
|
|
3401
|
-
return (await u.getPendingReviews()).filter((r) => r.scheduledFor === "classroom" && r.schedulingAgentId === this._id).map((r) => {
|
|
3402
|
-
return {
|
|
3403
|
-
...r,
|
|
3404
|
-
qualifiedID: `${r.courseId}-${r.cardId}`,
|
|
3405
|
-
courseID: r.courseId,
|
|
3406
|
-
cardID: r.cardId,
|
|
3407
|
-
contentSourceType: "classroom",
|
|
3408
|
-
contentSourceID: this._id,
|
|
3409
|
-
reviewID: r._id,
|
|
3410
|
-
status: "review"
|
|
3411
|
-
};
|
|
3412
|
-
});
|
|
3413
|
-
}
|
|
3414
|
-
async getNewCards() {
|
|
3415
|
-
const activeCards = await this._user.getActiveCards();
|
|
3416
|
-
const now = moment2.utc();
|
|
3417
|
-
const assigned = await this.getAssignedContent();
|
|
3418
|
-
const due = assigned.filter((c) => now.isAfter(moment2.utc(c.activeOn, REVIEW_TIME_FORMAT)));
|
|
3419
|
-
logger.info(`Due content: ${JSON.stringify(due)}`);
|
|
3420
|
-
let ret = [];
|
|
3421
|
-
for (let i = 0; i < due.length; i++) {
|
|
3422
|
-
const content = due[i];
|
|
3423
|
-
if (content.type === "course") {
|
|
3424
|
-
const db = new CourseDB(content.courseID, async () => this._user);
|
|
3425
|
-
ret = ret.concat(await db.getNewCards());
|
|
3426
|
-
} else if (content.type === "tag") {
|
|
3427
|
-
const tagDoc = await getTag(content.courseID, content.tagID);
|
|
3428
|
-
ret = ret.concat(
|
|
3429
|
-
tagDoc.taggedCards.map((c) => {
|
|
3430
|
-
return {
|
|
3431
|
-
courseID: content.courseID,
|
|
3432
|
-
cardID: c,
|
|
3433
|
-
qualifiedID: `${content.courseID}-${c}`,
|
|
3434
|
-
contentSourceType: "classroom",
|
|
3435
|
-
contentSourceID: this._id,
|
|
3436
|
-
status: "new"
|
|
3437
|
-
};
|
|
3438
|
-
})
|
|
3439
|
-
);
|
|
3440
|
-
} else if (content.type === "card") {
|
|
3441
|
-
ret.push(await getCourseDB2(content.courseID).get(content.cardID));
|
|
3442
|
-
}
|
|
3443
|
-
}
|
|
3444
|
-
logger.info(
|
|
3445
|
-
`New Cards from classroom ${this._cfg.name}: ${ret.map((c) => `${c.courseID}-${c.cardID}`)}`
|
|
3446
|
-
);
|
|
3447
|
-
return ret.filter((c) => {
|
|
3448
|
-
if (activeCards.some((ac) => c.cardID === ac.cardID)) {
|
|
3449
|
-
return false;
|
|
3450
|
-
} else {
|
|
3451
|
-
return true;
|
|
3452
|
-
}
|
|
3453
|
-
});
|
|
3454
|
-
}
|
|
3455
2263
|
/**
|
|
3456
2264
|
* Get cards with suitability scores for presentation.
|
|
3457
2265
|
*
|
|
3458
|
-
*
|
|
3459
|
-
*
|
|
3460
|
-
* support pluggable navigation strategies.
|
|
2266
|
+
* Gathers new cards from assigned content (courses, tags, cards) and
|
|
2267
|
+
* pending reviews scheduled for this classroom. Assigns score=1.0 to all.
|
|
3461
2268
|
*
|
|
3462
2269
|
* @param limit - Maximum number of cards to return
|
|
3463
2270
|
* @returns Cards sorted by score descending (all scores = 1.0)
|
|
3464
2271
|
*/
|
|
3465
2272
|
async getWeightedCards(limit) {
|
|
3466
|
-
const
|
|
3467
|
-
const
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
2273
|
+
const weighted = [];
|
|
2274
|
+
const allUserReviews = await this._user.getPendingReviews();
|
|
2275
|
+
const classroomReviews = allUserReviews.filter(
|
|
2276
|
+
(r) => r.scheduledFor === "classroom" && r.schedulingAgentId === this._id
|
|
2277
|
+
);
|
|
2278
|
+
for (const r of classroomReviews) {
|
|
2279
|
+
weighted.push({
|
|
2280
|
+
cardId: r.cardId,
|
|
2281
|
+
courseId: r.courseId,
|
|
3471
2282
|
score: 1,
|
|
2283
|
+
reviewID: r._id,
|
|
3472
2284
|
provenance: [
|
|
3473
2285
|
{
|
|
3474
2286
|
strategy: "classroom",
|
|
@@ -3476,27 +2288,84 @@ var init_classroomDB2 = __esm({
|
|
|
3476
2288
|
strategyId: "CLASSROOM",
|
|
3477
2289
|
action: "generated",
|
|
3478
2290
|
score: 1,
|
|
3479
|
-
reason: "Classroom
|
|
2291
|
+
reason: "Classroom scheduled review"
|
|
3480
2292
|
}
|
|
3481
2293
|
]
|
|
3482
|
-
})
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
2294
|
+
});
|
|
2295
|
+
}
|
|
2296
|
+
const activeCards = await this._user.getActiveCards();
|
|
2297
|
+
const activeCardIds = new Set(activeCards.map((ac) => ac.cardID));
|
|
2298
|
+
const now = moment2.utc();
|
|
2299
|
+
const assigned = await this.getAssignedContent();
|
|
2300
|
+
const due = assigned.filter((c) => now.isAfter(moment2.utc(c.activeOn, REVIEW_TIME_FORMAT)));
|
|
2301
|
+
logger.info(`[StudentClassroomDB] Due content: ${JSON.stringify(due)}`);
|
|
2302
|
+
for (const content of due) {
|
|
2303
|
+
if (content.type === "course") {
|
|
2304
|
+
const db = new CourseDB(content.courseID, async () => this._user);
|
|
2305
|
+
const courseCards = await db.getWeightedCards(limit);
|
|
2306
|
+
for (const card of courseCards) {
|
|
2307
|
+
if (!activeCardIds.has(card.cardId)) {
|
|
2308
|
+
weighted.push({
|
|
2309
|
+
...card,
|
|
2310
|
+
provenance: [
|
|
2311
|
+
...card.provenance,
|
|
2312
|
+
{
|
|
2313
|
+
strategy: "classroom",
|
|
2314
|
+
strategyName: "Classroom",
|
|
2315
|
+
strategyId: "CLASSROOM",
|
|
2316
|
+
action: "passed",
|
|
2317
|
+
score: card.score,
|
|
2318
|
+
reason: `Assigned via classroom from course ${content.courseID}`
|
|
2319
|
+
}
|
|
2320
|
+
]
|
|
2321
|
+
});
|
|
3495
2322
|
}
|
|
3496
|
-
|
|
3497
|
-
})
|
|
3498
|
-
|
|
3499
|
-
|
|
2323
|
+
}
|
|
2324
|
+
} else if (content.type === "tag") {
|
|
2325
|
+
const tagDoc = await getTag(content.courseID, content.tagID);
|
|
2326
|
+
for (const cardId of tagDoc.taggedCards) {
|
|
2327
|
+
if (!activeCardIds.has(cardId)) {
|
|
2328
|
+
weighted.push({
|
|
2329
|
+
cardId,
|
|
2330
|
+
courseId: content.courseID,
|
|
2331
|
+
score: 1,
|
|
2332
|
+
provenance: [
|
|
2333
|
+
{
|
|
2334
|
+
strategy: "classroom",
|
|
2335
|
+
strategyName: "Classroom",
|
|
2336
|
+
strategyId: "CLASSROOM",
|
|
2337
|
+
action: "generated",
|
|
2338
|
+
score: 1,
|
|
2339
|
+
reason: `Classroom assigned tag: ${content.tagID}, new card`
|
|
2340
|
+
}
|
|
2341
|
+
]
|
|
2342
|
+
});
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
} else if (content.type === "card") {
|
|
2346
|
+
if (!activeCardIds.has(content.cardID)) {
|
|
2347
|
+
weighted.push({
|
|
2348
|
+
cardId: content.cardID,
|
|
2349
|
+
courseId: content.courseID,
|
|
2350
|
+
score: 1,
|
|
2351
|
+
provenance: [
|
|
2352
|
+
{
|
|
2353
|
+
strategy: "classroom",
|
|
2354
|
+
strategyName: "Classroom",
|
|
2355
|
+
strategyId: "CLASSROOM",
|
|
2356
|
+
action: "generated",
|
|
2357
|
+
score: 1,
|
|
2358
|
+
reason: "Classroom assigned card, new card"
|
|
2359
|
+
}
|
|
2360
|
+
]
|
|
2361
|
+
});
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
logger.info(
|
|
2366
|
+
`[StudentClassroomDB] New cards from classroom ${this._cfg.name}: ${weighted.length} total (reviews + new)`
|
|
2367
|
+
);
|
|
2368
|
+
return weighted.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
3500
2369
|
}
|
|
3501
2370
|
};
|
|
3502
2371
|
TeacherClassroomDB = class _TeacherClassroomDB extends ClassroomDBBase {
|
|
@@ -3664,108 +2533,71 @@ var init_TagFilteredContentSource = __esm({
|
|
|
3664
2533
|
return finalCardIds;
|
|
3665
2534
|
}
|
|
3666
2535
|
/**
|
|
3667
|
-
*
|
|
2536
|
+
* Get cards with suitability scores for presentation.
|
|
2537
|
+
*
|
|
2538
|
+
* Filters cards by tag inclusion/exclusion and assigns score=1.0 to all.
|
|
2539
|
+
* TagFilteredContentSource does not currently support pluggable navigation
|
|
2540
|
+
* strategies - it returns flat-scored candidates.
|
|
2541
|
+
*
|
|
2542
|
+
* @param limit - Maximum number of cards to return
|
|
2543
|
+
* @returns Cards sorted by score descending (all scores = 1.0)
|
|
3668
2544
|
*/
|
|
3669
|
-
async
|
|
2545
|
+
async getWeightedCards(limit) {
|
|
3670
2546
|
if (!hasActiveFilter(this.filter)) {
|
|
3671
|
-
logger.warn("[TagFilteredContentSource]
|
|
2547
|
+
logger.warn("[TagFilteredContentSource] getWeightedCards called with no active filter");
|
|
3672
2548
|
return [];
|
|
3673
2549
|
}
|
|
3674
2550
|
const eligibleCardIds = await this.resolveFilteredCardIds();
|
|
3675
2551
|
const activeCards = await this.user.getActiveCards();
|
|
3676
2552
|
const activeCardIds = new Set(activeCards.map((c) => c.cardID));
|
|
3677
|
-
const
|
|
2553
|
+
const newCardWeighted = [];
|
|
3678
2554
|
for (const cardId of eligibleCardIds) {
|
|
3679
2555
|
if (!activeCardIds.has(cardId)) {
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
2556
|
+
newCardWeighted.push({
|
|
2557
|
+
cardId,
|
|
2558
|
+
courseId: this.courseId,
|
|
2559
|
+
score: 1,
|
|
2560
|
+
provenance: [
|
|
2561
|
+
{
|
|
2562
|
+
strategy: "tagFilter",
|
|
2563
|
+
strategyName: "Tag Filter",
|
|
2564
|
+
strategyId: "TAG_FILTER",
|
|
2565
|
+
action: "generated",
|
|
2566
|
+
score: 1,
|
|
2567
|
+
reason: `Tag-filtered new card (tags: ${this.filter.include.join(", ")})`
|
|
2568
|
+
}
|
|
2569
|
+
]
|
|
3686
2570
|
});
|
|
3687
2571
|
}
|
|
3688
|
-
if (
|
|
2572
|
+
if (newCardWeighted.length >= limit) {
|
|
3689
2573
|
break;
|
|
3690
2574
|
}
|
|
3691
2575
|
}
|
|
3692
|
-
logger.info(
|
|
3693
|
-
|
|
3694
|
-
|
|
3695
|
-
/**
|
|
3696
|
-
* Gets pending reviews, filtered to only include cards that match the tag filter.
|
|
3697
|
-
*/
|
|
3698
|
-
async getPendingReviews() {
|
|
3699
|
-
if (!hasActiveFilter(this.filter)) {
|
|
3700
|
-
logger.warn("[TagFilteredContentSource] getPendingReviews called with no active filter");
|
|
3701
|
-
return [];
|
|
3702
|
-
}
|
|
3703
|
-
const eligibleCardIds = await this.resolveFilteredCardIds();
|
|
2576
|
+
logger.info(
|
|
2577
|
+
`[TagFilteredContentSource] Found ${newCardWeighted.length} new cards matching filter`
|
|
2578
|
+
);
|
|
3704
2579
|
const allReviews = await this.user.getPendingReviews(this.courseId);
|
|
3705
|
-
const filteredReviews = allReviews.filter((review) =>
|
|
3706
|
-
return eligibleCardIds.has(review.cardId);
|
|
3707
|
-
});
|
|
2580
|
+
const filteredReviews = allReviews.filter((review) => eligibleCardIds.has(review.cardId));
|
|
3708
2581
|
logger.info(
|
|
3709
2582
|
`[TagFilteredContentSource] Found ${filteredReviews.length} pending reviews matching filter (of ${allReviews.length} total)`
|
|
3710
2583
|
);
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
contentSourceType: "course",
|
|
3716
|
-
contentSourceID: this.courseId,
|
|
2584
|
+
const reviewWeighted = filteredReviews.map((r) => ({
|
|
2585
|
+
cardId: r.cardId,
|
|
2586
|
+
courseId: r.courseId,
|
|
2587
|
+
score: 1,
|
|
3717
2588
|
reviewID: r._id,
|
|
3718
|
-
|
|
2589
|
+
provenance: [
|
|
2590
|
+
{
|
|
2591
|
+
strategy: "tagFilter",
|
|
2592
|
+
strategyName: "Tag Filter",
|
|
2593
|
+
strategyId: "TAG_FILTER",
|
|
2594
|
+
action: "generated",
|
|
2595
|
+
score: 1,
|
|
2596
|
+
reason: `Tag-filtered review (tags: ${this.filter.include.join(", ")})`
|
|
2597
|
+
}
|
|
2598
|
+
]
|
|
3719
2599
|
}));
|
|
3720
|
-
|
|
3721
|
-
/**
|
|
3722
|
-
* Get cards with suitability scores for presentation.
|
|
3723
|
-
*
|
|
3724
|
-
* This implementation wraps the legacy getNewCards/getPendingReviews methods,
|
|
3725
|
-
* assigning score=1.0 to all cards. TagFilteredContentSource does not currently
|
|
3726
|
-
* support pluggable navigation strategies - it returns flat-scored candidates.
|
|
3727
|
-
*
|
|
3728
|
-
* @param limit - Maximum number of cards to return
|
|
3729
|
-
* @returns Cards sorted by score descending (all scores = 1.0)
|
|
3730
|
-
*/
|
|
3731
|
-
async getWeightedCards(limit) {
|
|
3732
|
-
const [newCards, reviews] = await Promise.all([
|
|
3733
|
-
this.getNewCards(limit),
|
|
3734
|
-
this.getPendingReviews()
|
|
3735
|
-
]);
|
|
3736
|
-
const weighted = [
|
|
3737
|
-
...reviews.map((r) => ({
|
|
3738
|
-
cardId: r.cardID,
|
|
3739
|
-
courseId: r.courseID,
|
|
3740
|
-
score: 1,
|
|
3741
|
-
provenance: [
|
|
3742
|
-
{
|
|
3743
|
-
strategy: "tagFilter",
|
|
3744
|
-
strategyName: "Tag Filter",
|
|
3745
|
-
strategyId: "TAG_FILTER",
|
|
3746
|
-
action: "generated",
|
|
3747
|
-
score: 1,
|
|
3748
|
-
reason: `Tag-filtered review (tags: ${this.filter.include.join(", ")})`
|
|
3749
|
-
}
|
|
3750
|
-
]
|
|
3751
|
-
})),
|
|
3752
|
-
...newCards.map((c) => ({
|
|
3753
|
-
cardId: c.cardID,
|
|
3754
|
-
courseId: c.courseID,
|
|
3755
|
-
score: 1,
|
|
3756
|
-
provenance: [
|
|
3757
|
-
{
|
|
3758
|
-
strategy: "tagFilter",
|
|
3759
|
-
strategyName: "Tag Filter",
|
|
3760
|
-
strategyId: "TAG_FILTER",
|
|
3761
|
-
action: "generated",
|
|
3762
|
-
score: 1,
|
|
3763
|
-
reason: `Tag-filtered new card (tags: ${this.filter.include.join(", ")})`
|
|
3764
|
-
}
|
|
3765
|
-
]
|
|
3766
|
-
}))
|
|
3767
|
-
];
|
|
3768
|
-
return weighted.slice(0, limit);
|
|
2600
|
+
return [...reviewWeighted, ...newCardWeighted].slice(0, limit);
|
|
3769
2601
|
}
|
|
3770
2602
|
/**
|
|
3771
2603
|
* Clears the cached resolved card IDs.
|
|
@@ -3887,7 +2719,7 @@ var init_cardProcessor = __esm({
|
|
|
3887
2719
|
});
|
|
3888
2720
|
|
|
3889
2721
|
// src/core/bulkImport/types.ts
|
|
3890
|
-
var
|
|
2722
|
+
var init_types = __esm({
|
|
3891
2723
|
"src/core/bulkImport/types.ts"() {
|
|
3892
2724
|
"use strict";
|
|
3893
2725
|
}
|
|
@@ -3898,7 +2730,7 @@ var init_bulkImport = __esm({
|
|
|
3898
2730
|
"src/core/bulkImport/index.ts"() {
|
|
3899
2731
|
"use strict";
|
|
3900
2732
|
init_cardProcessor();
|
|
3901
|
-
|
|
2733
|
+
init_types();
|
|
3902
2734
|
}
|
|
3903
2735
|
});
|
|
3904
2736
|
|
|
@@ -3917,14 +2749,6 @@ var init_core = __esm({
|
|
|
3917
2749
|
}
|
|
3918
2750
|
});
|
|
3919
2751
|
|
|
3920
|
-
// src/util/tuiLogger.ts
|
|
3921
|
-
var init_tuiLogger = __esm({
|
|
3922
|
-
"src/util/tuiLogger.ts"() {
|
|
3923
|
-
"use strict";
|
|
3924
|
-
init_dataDirectory();
|
|
3925
|
-
}
|
|
3926
|
-
});
|
|
3927
|
-
|
|
3928
2752
|
// src/util/dataDirectory.ts
|
|
3929
2753
|
import * as path from "path";
|
|
3930
2754
|
import * as os from "os";
|
|
@@ -3941,7 +2765,7 @@ function getDbPath(dbName) {
|
|
|
3941
2765
|
var init_dataDirectory = __esm({
|
|
3942
2766
|
"src/util/dataDirectory.ts"() {
|
|
3943
2767
|
"use strict";
|
|
3944
|
-
|
|
2768
|
+
init_logger();
|
|
3945
2769
|
init_factory();
|
|
3946
2770
|
}
|
|
3947
2771
|
});
|